이전 글에서 Repository 계층의 선택지로 JPA와 MyBatis를 가볍게 비교했는데, 실무에서는 "둘 중 하나만 고르는 것"보다 둘을 나눠서 같이 쓰는 경우가 더 자주 보입니다. 특히 "조회는 Mapper, 나머지는 JPA"라는 말을 한 번쯤 들어보셨을 텐데요. 이번 글에서는 두 기술의 장단점을 비교하고, 왜 이렇게 역할을 나누는지, 그리고 코드량이 실제로 얼마나 달라지는지를 샘플 코드로 확인해봅니다.
두 기술의 가장 큰 차이는 "누가 SQL을 만드는가"입니다.
같은 "id로 사용자 한 명 조회"를 비교하면 이렇습니다.
// MyBatis: 메서드 선언 + XML에 SQL 작성이 짝으로 필요
User user = userMapper.findById(1L);
// JPA: 기본 제공 메서드를 그대로 사용
User user = userRepository.findById(1L).orElseThrow();
호출하는 코드는 비슷해 보여도, 그 뒤에 준비해야 하는 코드의 양이 다릅니다.
MyBatis Mapper vs JPA 장단점 비교
장점
단점
장점
단점
정리하면, "반복적인 단순 작업"은 JPA가, "복잡하고 세밀한 SQL"은 MyBatis가 강합니다. 그래서 둘의 강점을 나눠 쓰는 구조가 나옵니다.
조회는 Mapper, 변경은 JPA 역할 분담
"조회는 Mapper, 등록·수정·삭제는 JPA"라는 구분은 실무에서 실제로 많이 쓰는 혼용 패턴이 맞습니다. 다만 짚어둘 점이 있습니다. 이건 기술적인 규칙이 아니라 선택한 설계입니다. MyBatis로도 CRUD 전부를 처리할 수 있고, JPA로도 복잡한 조회를 할 수 있습니다. 각자 잘하는 영역에 맞춰 나눠 쓰는 것뿐입니다.
화면이나 API 응답에 필요한 건 보통 엔티티 전체가 아니라 **여러 테이블에서 필요한 컬럼만 모은 결과(DTO)**입니다. 이런 조회는 JOIN, 집계, 페이징, 동적 조건이 섞이기 때문에 SQL을 직접 쓰는 쪽이 더 직관적입니다. JPA로 같은 걸 하려면 fetch join, QueryDSL, DTO projection을 조합해야 합니다.
이렇게 쓰기 모델과 조회 모델을 나누는 방식은 CQRS(명령-조회 책임 분리)를 가볍게 적용한 형태라고 볼 수 있습니다.
@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이 바뀌는 검색을 이렇게 표현합니다.
<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 중심 조회의 장점입니다.
단순 조회(findById 같은)까지 굳이 Mapper로 만들 필요는 없습니다. **"조회는 무조건 Mapper"가 아니라 "복잡한 조회는 Mapper"**로 기준을 잡는 게 현실적입니다.
users 테이블에 대해 조회(단건·목록), 등록, 수정, 삭제 5개 기능을 양쪽으로 만들어 비교했습니다.
같은 User CRUD의 코드량 비교
// 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>
// 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에 메서드를 하나도 쓰지 않아도 됩니다.
// 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라는 점을 함께 기억해두면 좋습니다.
| 등록·수정·삭제가 중심인 일반 서비스 | JPA |
| 단순 조회 (id, 목록 등) | JPA로 충분 |
| 여러 테이블 JOIN·집계·통계 조회 | MyBatis Mapper |
| 조건이 자주 바뀌는 검색·목록 화면 | MyBatis Mapper |
| 기존 SQL 자산이 많은 레거시 시스템 | MyBatis (필요 시 JPA 병행) |
| 신규 서비스 + 복잡한 조회도 많음 | 쓰기는 JPA, 복잡한 읽기는 Mapper |
JPA는 "반복되는 변경 작업을 줄여주는 도구", MyBatis Mapper는 **"복잡한 조회를 정확하게 다루는 도구"**라고 정리할 수 있습니다. 조회는 Mapper, 변경은 JPA로 나눠 쓰는 건 이 둘의 강점을 합친 현실적인 선택이지만, 반드시 따라야 하는 규칙은 아닙니다. 프로젝트의 쿼리 복잡도와 팀이 익숙한 기술에 맞춰 기준을 정하고, 같이 쓴다면 flush 타이밍과 테이블 변경 시 두 곳을 챙기는 것만 기억해두면 됩니다.
| MariaDB vs PostgreSQL vs Oracle — 실무에서 뭘 기준으로 선택할까 (0) | 2026.09.19 |
|---|---|
| PostgreSQL 기본 구조와 SQL 쿼리 정리하기 (0) | 2026.09.11 |
| DBeaver 설치 및 기본 세팅 방법 - mac기준 (0) | 2026.08.30 |