상세 컨텐츠

본문 제목

MyBatis Mapper vs JPA — 뭐가 다르고, 어떻게 같이 쓸까

공부/Database 공부

by hongga 2026. 10. 25. 15:15

본문

반응형

이전 글에서 Repository 계층의 선택지로 JPA와 MyBatis를 가볍게 비교했는데, 실무에서는 "둘 중 하나만 고르는 것"보다 둘을 나눠서 같이 쓰는 경우가 더 자주 보입니다. 특히 "조회는 Mapper, 나머지는 JPA"라는 말을 한 번쯤 들어보셨을 텐데요. 이번 글에서는 두 기술의 장단점을 비교하고, 왜 이렇게 역할을 나누는지, 그리고 코드량이 실제로 얼마나 달라지는지를 샘플 코드로 확인해봅니다.

 

1. 사고방식이 다르다 — SQL 중심 vs 객체 중심

두 기술의 가장 큰 차이는 "누가 SQL을 만드는가"입니다.

  • MyBatis: 개발자가 SQL을 직접 쓰고, MyBatis는 그 결과를 자바 객체에 담아줍니다. (SQL Mapper)
  • JPA: 개발자는 객체(Entity)를 다루고, SQL은 JPA가 대신 만들어 실행합니다. (ORM)

같은 "id로 사용자 한 명 조회"를 비교하면 이렇습니다.

// MyBatis: 메서드 선언 + XML에 SQL 작성이 짝으로 필요
User user = userMapper.findById(1L);
 
// JPA: 기본 제공 메서드를 그대로 사용
User user = userRepository.findById(1L).orElseThrow();

호출하는 코드는 비슷해 보여도, 그 뒤에 준비해야 하는 코드의 양이 다릅니다. 

 

2. 장단점 비교 분석

MyBatis Mapper vs JPA 장단점 비교

MyBatis Mapper

장점

  • 실행되는 SQL을 내가 쓴 그대로 확인하고 통제할 수 있어 성능 튜닝이 직관적입니다.
  • 여러 테이블 JOIN, 집계, 통계처럼 복잡한 조회를 SQL로 바로 풀 수 있습니다.
  • <if>, <where> 같은 태그로 동적 쿼리(조건이 바뀌는 검색)를 쉽게 만듭니다.
  • SQL만 알면 시작할 수 있어 학습 부담이 작습니다.
  • 복합키나 정규화되지 않은 레거시 테이블 구조에도 유연하게 맞출 수 있습니다.

단점

  • 단순한 등록·수정·삭제도 SQL을 매번 직접 작성해야 합니다.
  • 컬럼이 바뀌면 VO와 여러 SQL을 함께 수정해야 해서 누락 위험이 있습니다.
  • SQL이 특정 DB 문법에 묶이기 쉬워 DB를 바꿀 때 손볼 곳이 많습니다.

JPA

장점

  • save(), findById(), delete() 같은 기본 CRUD가 자동 제공되어 코드가 크게 줄어듭니다.
  • 엔티티 필드를 바꾸면 되므로 컬럼 변경 대응이 단순합니다.
  • **변경 감지(Dirty Checking)**로 수정 시 update() 호출 없이도 UPDATE가 실행됩니다.
  • Dialect가 DB 간 차이를 일부 흡수해 DB 이식성이 상대적으로 좋습니다.

단점

  • 영속성 컨텍스트, 지연 로딩, 연관관계 매핑 등 알아야 할 개념이 많습니다.
  • 복잡한 조회는 JPQL·QueryDSL을 따로 익혀야 하고, N+1 문제를 신경 써야 합니다.
  • 내부에서 만들어지는 SQL을 로그로 확인하지 않으면 성능 문제를 놓치기 쉽습니다.
  • 복합키나 레거시 테이블 구조는 매핑이 까다로울 수 있습니다.

정리하면, "반복적인 단순 작업"은 JPA가, "복잡하고 세밀한 SQL"은 MyBatis가 강합니다. 그래서 둘의 강점을 나눠 쓰는 구조가 나옵니다.

 

3. 조회는 Mapper, 나머지는 JPA — 왜 나눠 쓸까

조회는 Mapper, 변경은 JPA 역할 분담

"조회는 Mapper, 등록·수정·삭제는 JPA"라는 구분은 실무에서 실제로 많이 쓰는 혼용 패턴이 맞습니다. 다만 짚어둘 점이 있습니다. 이건 기술적인 규칙이 아니라 선택한 설계입니다. MyBatis로도 CRUD 전부를 처리할 수 있고, JPA로도 복잡한 조회를 할 수 있습니다. 각자 잘하는 영역에 맞춰 나눠 쓰는 것뿐입니다.

변경(C·U·D)에 JPA가 잘 맞는 이유

  • save() 한 줄로 등록, 변경 감지로 수정, delete()로 삭제가 끝납니다.
  • 연관된 엔티티를 함께 저장·삭제하는 cascade 처리가 편합니다.
  • 도메인 규칙을 엔티티 안에 담아 객체지향적으로 설계하기 좋습니다.

조회(R)에 Mapper가 잘 맞는 이유

화면이나 API 응답에 필요한 건 보통 엔티티 전체가 아니라 **여러 테이블에서 필요한 컬럼만 모은 결과(DTO)**입니다. 이런 조회는 JOIN, 집계, 페이징, 동적 조건이 섞이기 때문에 SQL을 직접 쓰는 쪽이 더 직관적입니다. JPA로 같은 걸 하려면 fetch join, QueryDSL, DTO projection을 조합해야 합니다.

 

이렇게 쓰기 모델과 조회 모델을 나누는 방식은 CQRS(명령-조회 책임 분리)를 가볍게 적용한 형태라고 볼 수 있습니다.

 

실제 코드로 보기

 
java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository; // JPA: 쓰기 담당
    private final UserMapper userMapper;         // MyBatis: 읽기 담당

    @Transactional
    public Long create(UserCreateRequest request) {
        User user = new User(request.getEmail(), request.getNickname());
        return userRepository.save(user).getId();
    }

    @Transactional
    public void changeNickname(Long id, String nickname) {
        User user = userRepository.findById(id).orElseThrow();
        user.setNickname(nickname); // 변경 감지로 UPDATE 자동 실행
    }

    @Transactional(readOnly = true)
    public List<UserSearchResponse> search(UserSearchCondition condition) {
        return userMapper.search(condition); // 엔티티가 아닌 DTO 반환
    }
}

조회 쪽 Mapper는 조건에 따라 SQL이 바뀌는 검색을 이렇게 표현합니다.

 
xml
<select id="search" resultType="UserSearchResponse">
    SELECT u.id, u.nickname, COUNT(p.id) AS post_count
    FROM users u
    LEFT JOIN posts p ON p.user_id = u.id
    <where>
        <if test="nickname != null">
            AND u.nickname LIKE CONCAT('%', #{nickname}, '%')
        </if>
        <if test="createdFrom != null">
            AND u.created_at >= #{createdFrom}
        </if>
    </where>
    GROUP BY u.id, u.nickname
    ORDER BY post_count DESC
</select>

JOIN과 집계, 동적 조건이 한 번에 보이는 게 SQL 중심 조회의 장점입니다.

같이 쓸 때 주의할 점

  1. flush 타이밍: JPA의 변경 사항은 flush 시점에 DB로 나갑니다. 같은 트랜잭션에서 JPA로 저장한 직후 MyBatis로 조회하면 아직 반영되지 않은 데이터를 읽을 수 있습니다. 이럴 땐 saveAndFlush()나 entityManager.flush()로 먼저 DB에 반영해야 합니다.
  2. 트랜잭션 설정: 같은 DataSource를 쓰면 보통 하나의 @Transactional 안에서 함께 동작하지만, 프로젝트의 트랜잭션 매니저 설정은 한 번 확인해두는 게 안전합니다.
  3. 테이블 변경 시 두 곳 확인: 컬럼이 바뀌면 Entity와 Mapper XML 양쪽을 점검해야 합니다.
  4. 조회 결과는 DTO로: Mapper는 엔티티가 아니라 조회 전용 DTO를 반환하게 두는 편이 역할이 깔끔합니다.
  5. 팀의 부담: 두 기술을 모두 이해하는 팀이어야 유지보수가 편합니다.

단순 조회(findById 같은)까지 굳이 Mapper로 만들 필요는 없습니다. **"조회는 무조건 Mapper"가 아니라 "복잡한 조회는 Mapper"**로 기준을 잡는 게 현실적입니다.

 

4. 코드량 비교 — 같은 CRUD를 만들어보면

users 테이블에 대해 조회(단건·목록), 등록, 수정, 삭제 5개 기능을 양쪽으로 만들어 비교했습니다.

같은 User CRUD의 코드량 비교

 

MyBatis

// User.java (VO)
@Getter
@Setter
public class User {
    private Long id;
    private String email;
    private String nickname;
    private LocalDateTime createdAt;
}
// UserMapper.java
@Mapper
public interface UserMapper {
    User findById(Long id);
    List<User> findAll();
    void insert(User user);
    void update(User user);
    void delete(Long id);
}
<!-- UserMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.UserMapper">

    <select id="findById" resultType="User">
        SELECT id, email, nickname, created_at
        FROM users
        WHERE id = #{id}
    </select>

    <select id="findAll" resultType="User">
        SELECT id, email, nickname, created_at
        FROM users
    </select>

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO users (email, nickname)
        VALUES (#{email}, #{nickname})
    </insert>

    <update id="update">
        UPDATE users
        SET email = #{email}, nickname = #{nickname}
        WHERE id = #{id}
    </update>

    <delete id="delete">
        DELETE FROM users
        WHERE id = #{id}
    </delete>
</mapper>

 

 

JPA

// User.java (Entity)
@Entity
@Table(name = "users")
@Getter
@Setter
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;
    private String nickname;
    private LocalDateTime createdAt;
}
// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
}

JPA는 findById, findAll, save(등록·수정), deleteById를 JpaRepository가 이미 제공하기 때문에 Repository에 메서드를 하나도 쓰지 않아도 됩니다.

 

수정(UPDATE)할 때 서비스 코드도 달라진다

// MyBatis: 조회 → 값 변경 → update 호출까지 직접
@Transactional
public void changeNickname(Long id, String nickname) {
    User user = userMapper.findById(id);
    user.setNickname(nickname);
    userMapper.update(user);
}
// JPA: 변경 감지가 UPDATE를 대신 실행
@Transactional
public void changeNickname(Long id, String nickname) {
    User user = userRepository.findById(id).orElseThrow();
    user.setNickname(nickname);
}

 

phone 컬럼을 추가한다고 하면 수정할 곳이 이렇게 갈립니다.

수정 지점
MyBatis VO 필드 1 + SELECT 2곳 + INSERT 2곳(컬럼·값) + UPDATE 1곳 = 6곳
JPA Entity 필드 1곳

단, 이 비교에는 전제가 있습니다

이 숫자는 기본 CRUD 기준입니다. 복잡한 조회로 가면 이야기가 달라집니다. 앞서 본 "JOIN + 집계 + 동적 조건" 검색은 MyBatis XML 한 덩어리로 끝나지만, JPA로 같은 걸 만들면 QueryDSL 설정, DTO projection, fetch 전략까지 챙겨야 해서 오히려 코드와 개념이 늘어날 수 있습니다. 코드가 줄어드는 영역은 단순 CRUD라는 점을 함께 기억해두면 좋습니다.

 

5. 그래서 어떤 기준으로 선택할까

상황선택하기 좋은 방식
등록·수정·삭제가 중심인 일반 서비스 JPA
단순 조회 (id, 목록 등) JPA로 충분
여러 테이블 JOIN·집계·통계 조회 MyBatis Mapper
조건이 자주 바뀌는 검색·목록 화면 MyBatis Mapper
기존 SQL 자산이 많은 레거시 시스템 MyBatis (필요 시 JPA 병행)
신규 서비스 + 복잡한 조회도 많음 쓰기는 JPA, 복잡한 읽기는 Mapper

마무리

JPA는 "반복되는 변경 작업을 줄여주는 도구", MyBatis Mapper는 **"복잡한 조회를 정확하게 다루는 도구"**라고 정리할 수 있습니다. 조회는 Mapper, 변경은 JPA로 나눠 쓰는 건 이 둘의 강점을 합친 현실적인 선택이지만, 반드시 따라야 하는 규칙은 아닙니다. 프로젝트의 쿼리 복잡도와 팀이 익숙한 기술에 맞춰 기준을 정하고, 같이 쓴다면 flush 타이밍과 테이블 변경 시 두 곳을 챙기는 것만 기억해두면 됩니다.

 

반응형

관련글 더보기