MyBatis 사용 이유가 궁금하신가요? “JPA 쓰면 SQL 안 써도 되는데 왜 굳이 MyBatis를?” 이런 질문, 한 번쯤 해보셨을 겁니다. JPA는 반복적인 CRUD를 자동화해주고 객체 중심으로 개발할 수 있어 생산성이 높습니다. 그런데도 국내 금융권·공공기관·대형 커머스 현장에서는 여전히 MyBatis가 압도적으로 많이 쓰입니다. 단순히 “예전부터 써왔으니까”가 아닙니다. SQL 직접 제어, 복잡한 동적 쿼리, 성능 튜닝, 레거시 환경 대응 등 MyBatis만이 줄 수 있는 명확한 이유가 존재합니다. 이 글에서는 그 이유를 하나씩 짚어드립니다.
목차
- JPA와 MyBatis – 두 기술의 기초 개념 정리
- JPA와 MyBatis의 핵심 작동 원리 차이
- MyBatis가 유리한 환경과 실무 강점
- JPA의 한계 – 실무에서 MyBatis로 돌아오는 이유
- 실무 프로젝트별 기술 선택 가이드
- 전문가 관점 – 공존 전략과 추천 조합
1. JPA와 MyBatis – 두 기술의 기초 개념 정리
백엔드 개발에서 데이터베이스와 애플리케이션을 연결하는 계층을 **데이터 접근 계층(Data Access Layer)**이라고 합니다. Java 생태계에서 이 계층을 담당하는 대표적인 두 가지 기술이 바로 JPA와 MyBatis입니다. 둘 다 같은 역할을 하는 것처럼 보이지만, 철학과 작동 방식이 근본적으로 다릅니다.
JPA란 무엇인가
**JPA(Java Persistence API)**는 Java 표준 ORM(Object-Relational Mapping) 명세입니다. ORM이란 객체(Object)와 관계형 데이터베이스의 테이블(Relational)을 자동으로 매핑해주는 기술입니다. JPA 자체는 명세(인터페이스)이며, 실제 구현체는 Hibernate가 가장 널리 사용됩니다. Spring Boot 환경에서는 Spring Data JPA라는 추상화 레이어가 추가로 얹혀 더욱 쉽게 사용할 수 있습니다.
java
// Spring Data JPA 예시 – SQL 없이 메서드 이름만으로 쿼리 생성
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByNameAndAgeGreaterThan(String name, int age);
// → SELECT * FROM user WHERE name = ? AND age > ? 자동 생성
}
JPA의 핵심 가치는 **”SQL을 직접 쓰지 않아도 된다”**는 것입니다. 개발자는 객체와 메서드로 사고하고, SQL 생성은 프레임워크에 위임합니다.
MyBatis란 무엇인가
MyBatis는 SQL을 직접 작성하고 Java 객체와 매핑해주는 SQL Mapper 프레임워크입니다. 이전 이름은 iBatis였으며, 2010년 Apache에서 Google Code로 이관되면서 MyBatis로 개명했습니다. ORM과 달리 SQL의 작성과 제어권을 온전히 개발자가 가집니다.
xml
<!-- MyBatis Mapper XML 예시 -->
<select id="findActiveUsers" resultType="User">
SELECT
u.user_id,
u.user_name,
u.email,
d.dept_name
FROM users u
LEFT JOIN departments d ON u.dept_id = d.dept_id
WHERE u.status = 'ACTIVE'
AND u.created_at >= #{startDate}
ORDER BY u.created_at DESC
</select>
MyBatis의 핵심 가치는 **”SQL을 완전히 내 손으로 제어한다”**는 것입니다. 어떤 SQL이 실행되는지 항상 명확하고, 원하는 대로 최적화할 수 있습니다.
두 기술의 포지셔닝 한눈에 보기
| 구분 | JPA (Hibernate) | MyBatis |
|---|---|---|
| 분류 | ORM 프레임워크 | SQL Mapper 프레임워크 |
| SQL 작성 | 자동 생성 (JPQL·Criteria) | 개발자가 직접 작성 |
| 학습 난이도 | 높음 (개념 많음) | 낮음 (SQL 알면 바로 가능) |
| 생산성(단순 CRUD) | 매우 높음 | 보통 |
| 복잡한 쿼리 | 어렵고 불편함 | 자연스럽고 강력함 |
| SQL 투명성 | 낮음 (자동 생성) | 높음 (명시적) |
| 국내 사용 비중 | 증가 추세 | 여전히 압도적 |
| 주요 적용 분야 | 스타트업, 신규 서비스 | 금융, 공공, 대형 레거시 |
2. JPA와 MyBatis의 핵심 작동 원리 차이
두 기술을 단순 비교표로만 이해하면 실무 선택에서 오판하기 쉽습니다. 각각이 내부에서 어떻게 작동하는지 원리를 이해해야 언제 어떤 기술이 맞는지 판단할 수 있습니다.
JPA의 작동 원리 – 영속성 컨텍스트
JPA의 핵심 메커니즘은 **영속성 컨텍스트(Persistence Context)**입니다. 쉽게 말해 데이터베이스와 애플리케이션 사이에 존재하는 1차 캐시이자 변경 감지 공간입니다.
[JPA 작동 흐름]
애플리케이션
↓ em.find(User.class, 1L)
영속성 컨텍스트 (1차 캐시)
├─ 캐시에 있으면 → DB 조회 없이 즉시 반환
└─ 캐시에 없으면 → DB SELECT 실행 후 캐시에 저장
↓
객체 변경 감지 (Dirty Checking)
└─ 트랜잭션 커밋 시 변경된 필드만 자동 UPDATE
이 덕분에 개발자는 entity.setName("새이름")처럼 객체를 수정하기만 해도 JPA가 알아서 UPDATE 쿼리를 생성하고 실행합니다.
JPA 연관관계 로딩 전략:
- 즉시 로딩(EAGER): 연관 엔티티를 즉시 함께 조회
- 지연 로딩(LAZY): 연관 엔티티를 실제로 사용할 때만 조회
이 로딩 전략의 잘못된 설정이 악명 높은 N+1 문제를 만들어냅니다. 예를 들어 주문 목록 100개를 조회할 때 각 주문의 회원 정보를 지연 로딩으로 가져오면, 주문 1번 SELECT + 회원 100번 SELECT = 총 101번의 쿼리가 실행되는 상황이 발생합니다.
MyBatis의 작동 원리 – SQL Mapping
MyBatis는 훨씬 단순하고 직관적입니다. Mapper XML(또는 Annotation)에 작성된 SQL을 그대로 실행하고, 결과를 Java 객체에 매핑합니다.
[MyBatis 작동 흐름]
애플리케이션
↓ userMapper.findById(1L)
MyBatis
├─ Mapper XML에서 해당 SQL 조회
├─ 파라미터 바인딩 (#{id} → 1)
├─ DB에 SQL 직접 실행
└─ ResultSet → Java 객체 매핑 후 반환
영속성 컨텍스트가 없으므로 변경 감지, 1차 캐시 같은 개념이 없습니다. 모든 데이터 변경은 명시적으로 UPDATE SQL을 작성해야 합니다. 복잡한 내부 동작이 없어 예측 가능성이 높습니다.
트랜잭션 관리 방식 비교
두 기술 모두 Spring의 @Transactional을 활용하지만, 내부 동작에 차이가 있습니다.
java
// JPA – 트랜잭션 커밋 시점에 Dirty Checking으로 자동 UPDATE
@Transactional
public void updateUserName(Long id, String newName) {
User user = userRepository.findById(id).orElseThrow();
user.setName(newName); // UPDATE 쿼리 자동 생성·실행
}
// MyBatis – 명시적 UPDATE 메서드 호출 필수
@Transactional
public void updateUserName(Long id, String newName) {
userMapper.updateName(id, newName); // UPDATE 쿼리 직접 실행
}
3. MyBatis가 유리한 환경과 실무 강점
이제 핵심입니다. 실무에서 MyBatis 사용 이유가 되는 구체적인 상황과 강점을 하나씩 살펴보겠습니다.
① 복잡한 SQL이 필수인 환경
JPA는 단순한 CRUD에서는 매우 강력합니다. 하지만 실무 데이터베이스는 생각보다 훨씬 복잡합니다. 다음과 같은 쿼리가 필요한 순간 JPA는 급격히 불편해집니다.
sql
-- 실무에서 자주 등장하는 복잡한 쿼리 예시
-- 월별 매출 집계 + 전월 대비 증감률 + 순위 계산
SELECT
sale_month,
total_amount,
LAG(total_amount) OVER (ORDER BY sale_month) AS prev_amount,
ROUND(
(total_amount - LAG(total_amount) OVER (ORDER BY sale_month))
/ LAG(total_amount) OVER (ORDER BY sale_month) * 100, 2
) AS growth_rate,
RANK() OVER (ORDER BY total_amount DESC) AS sales_rank
FROM monthly_sales
WHERE branch_id = #{branchId}
AND sale_month BETWEEN #{startMonth} AND #{endMonth}
이런 쿼리를 JPA의 JPQL이나 Criteria API로 표현하려면 가독성이 극도로 낮아지거나 아예 네이티브 쿼리(@Query(nativeQuery=true))를 써야 합니다. 네이티브 쿼리를 쓰는 순간 JPA의 주요 장점(타입 안정성, 자동 매핑)이 상당 부분 사라집니다. MyBatis는 이런 SQL을 XML에 그대로 작성하면 끝입니다.
② 동적 쿼리 – MyBatis의 가장 강력한 무기
실무에서는 검색 조건이 상황에 따라 달라지는 동적 쿼리가 매우 빈번합니다. 예를 들어 이름, 나이, 지역, 상태를 선택적으로 조합하는 검색 화면이 있다고 가정해 봅시다.
xml
<!-- MyBatis 동적 쿼리 – <where>와 <if> 태그로 우아하게 처리 -->
<select id="searchUsers" resultType="User">
SELECT user_id, user_name, age, region, status
FROM users
<where>
<if test="name != null and name != ''">
AND user_name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="minAge != null">
AND age >= #{minAge}
</if>
<if test="maxAge != null">
AND age <= #{maxAge}
</if>
<if test="region != null">
AND region = #{region}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
ORDER BY created_at DESC
</select>
MyBatis의 <where>, <if>, <choose>, <foreach> 태그를 활용하면 조건에 따라 자동으로 WHERE 절을 조립합니다. JPA에서 같은 기능을 구현하려면 QueryDSL을 추가 도입하거나 Criteria API를 사용해야 하는데, 코드 복잡도가 크게 높아집니다.
java
// JPA + QueryDSL로 같은 동적 쿼리 구현 – 상대적으로 복잡
public List<User> searchUsers(UserSearchCondition cond) {
QUser user = QUser.user;
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(cond.getName()))
builder.and(user.name.contains(cond.getName()));
if (cond.getMinAge() != null)
builder.and(user.age.goe(cond.getMinAge()));
if (cond.getMaxAge() != null)
builder.and(user.age.loe(cond.getMaxAge()));
if (cond.getRegion() != null)
builder.and(user.region.eq(cond.getRegion()));
return queryFactory
.selectFrom(user)
.where(builder)
.orderBy(user.createdAt.desc())
.fetch();
}
MyBatis XML이 더 직관적이고 SQL을 아는 사람이라면 바로 읽고 수정할 수 있습니다.
③ SQL 튜닝과 실행 계획 제어
금융권, 대용량 트래픽 환경에서는 쿼리 한 줄의 성능이 전체 서비스에 직결됩니다. DBA(데이터베이스 관리자)와 협업할 때, JPA가 자동 생성한 쿼리를 튜닝하는 것은 쉽지 않습니다. JPA는 SQL을 추상화했기 때문에 실행되는 SQL이 항상 예측 가능하지 않습니다.
MyBatis는 반대입니다. DBA가 최적화한 SQL을 XML에 그대로 넣으면 됩니다. 인덱스 힌트, 실행 계획 강제, 파티션 접근 방식 지정 등 DB 수준의 세밀한 제어가 가능합니다.
xml
<!-- Oracle 인덱스 힌트 적용 – MyBatis에서 자유롭게 작성 가능 -->
<select id="findOrdersFast" resultType="Order">
SELECT /*+ INDEX(o IDX_ORDER_DATE) */
o.order_id,
o.order_date,
o.total_amount
FROM orders o
WHERE o.order_date >= #{startDate}
AND o.status = 'COMPLETED'
</select>
④ 레거시 DB 구조와의 호환성
국내 기업 시스템 중 상당수는 10~20년 된 데이터베이스를 그대로 사용합니다. 이런 레거시 DB는 JPA의 엔티티 매핑 규칙(기본 키 전략, 연관관계, 네이밍 컨벤션 등)과 충돌하는 경우가 많습니다.
레거시 DB에서 JPA가 어려운 대표적 상황:
- 복합 기본 키(Composite Primary Key)가 3개 이상인 테이블
- 기본 키가 없는 집계 테이블
- 뷰(View) 기반 조회 로직
- 프로시저(Stored Procedure) 중심 아키텍처
- 언더스코어·한글 컬럼명 혼재
MyBatis는 이런 상황에서 SQL을 그대로 쓰면 되므로 DB 구조에 관계없이 자유롭게 대응합니다.
⑤ 팀 내 SQL 역량이 중심인 조직
DBA와 개발자 사이의 협업 방식이 SQL 중심으로 정착된 팀에서는 MyBatis가 훨씬 자연스럽습니다. DBA가 작성한 쿼리를 개발자가 Mapper XML에 넣고, 코드 리뷰도 SQL 단위로 진행합니다. JPA를 도입하면 DBA는 어떤 SQL이 실행되는지 파악하기 어렵고, 개발자는 JPA 내부 동작을 충분히 이해해야 하는 이중 학습 부담이 생깁니다.
4. JPA의 한계 – 실무에서 MyBatis로 돌아오는 이유
JPA를 도입했다가 특정 상황에서 MyBatis로 전환하거나 병행 사용을 결정하는 팀이 많습니다. JPA가 실무에서 부딪히는 대표적인 한계를 짚어봅니다.
N+1 문제 – JPA의 가장 유명한 함정
N+1 문제는 JPA를 사용하는 팀이 가장 자주 겪는 성능 이슈입니다. 연관관계가 있는 엔티티를 조회할 때 예상보다 훨씬 많은 쿼리가 실행되는 현상입니다.
java
// N+1 문제 발생 시나리오
List<Order> orders = orderRepository.findAll(); // SELECT * FROM orders (1번)
for (Order order : orders) {
// 지연 로딩 → 주문마다 회원 정보 별도 조회
String userName = order.getUser().getName(); // SELECT * FROM users WHERE id = ? (N번)
}
// 주문이 100개면 → 총 101번 쿼리 실행!
해결책(FETCH JOIN, @EntityGraph, Batch Size)이 존재하지만, 이를 제대로 이해하고 적용하려면 JPA 내부 동작에 대한 깊은 이해가 필요합니다. 해결하지 못하면 운영 환경에서 심각한 성능 저하로 이어집니다.
학습 곡선이 매우 가파름
JPA를 제대로 사용하려면 아래의 개념들을 모두 이해해야 합니다.
| 필수 학습 개념 | 난이도 |
|---|---|
| 영속성 컨텍스트 생명주기 | ★★★★☆ |
| 즉시·지연 로딩 전략 | ★★★☆☆ |
| 양방향 연관관계 관리 | ★★★★☆ |
| OSIV(Open Session In View) | ★★★★☆ |
| Dirty Checking 메커니즘 | ★★★☆☆ |
| N+1 문제 해결 전략 | ★★★★★ |
| 상속 관계 매핑 전략 | ★★★★☆ |
| 트랜잭션 전파(Propagation) | ★★★★☆ |
이 개념들을 제대로 이해하지 못한 상태에서 JPA를 사용하면, 오히려 예측 불가능한 버그와 성능 문제를 유발할 수 있습니다. **”JPA는 알고 쓰면 강력하고, 모르고 쓰면 위험하다”**는 말이 나오는 이유입니다.
통계·배치 쿼리에서의 한계
대량 데이터 배치 처리나 복잡한 통계 쿼리에서 JPA는 어색합니다.
java
// JPA로 대량 업데이트 – 엔티티를 하나씩 로딩 후 변경 → 심각한 성능 저하
List<User> users = userRepository.findByStatus("INACTIVE");
users.forEach(u -> u.setStatus("DELETED")); // N번 UPDATE
// MyBatis – 단 한 번의 SQL로 처리
<update id="bulkUpdateStatus">
UPDATE users
SET status = 'DELETED',
updated_at = NOW()
WHERE status = 'INACTIVE'
AND last_login_at < #{cutoffDate}
</update>
JPA의 @Modifying @Query로 벌크 업데이트가 가능하지만, 영속성 컨텍스트와 동기화 문제가 발생해 추가 처리(clearAutomatically = true)가 필요합니다. MyBatis는 이런 복잡함 없이 SQL 그대로 실행됩니다.
5. 실무 프로젝트별 기술 선택 가이드
JPA가 좋다, MyBatis가 좋다는 이분법적 결론은 없습니다. 프로젝트 특성에 따라 최적의 선택이 다릅니다. 다음 가이드를 참고해 현재 상황에 맞는 판단을 내려보세요.
프로젝트 유형별 추천 기술
✅ JPA(Spring Data JPA)를 추천하는 경우
- 스타트업 신규 서비스 – 빠른 개발 속도와 반복 배포가 중요한 환경
- 도메인 모델이 명확하고 객체 간 관계가 단순한 서비스
- 팀원 전원이 JPA에 익숙하고 학습 비용을 감당할 수 있는 경우
- 단순 CRUD 비중이 높고 복잡한 통계 쿼리가 거의 없는 서비스
- MSA(마이크로서비스) 환경에서 서비스 범위가 명확히 분리된 경우
✅ MyBatis를 추천하는 경우
- 금융·공공기관 – 쿼리 감사, SQL 투명성, DBA 협업이 필수인 환경
- 복잡한 통계·집계·리포트 쿼리가 비즈니스 핵심인 서비스
- 레거시 DB 구조를 그대로 유지해야 하는 유지보수 프로젝트
- DBA가 주도적으로 쿼리를 설계하고 개발자가 연동하는 조직 문화
- 대용량 배치 처리가 핵심 기능인 시스템
✅ JPA + MyBatis 혼합을 추천하는 경우
- 단순 CRUD는 JPA로 빠르게 처리하고 복잡한 조회는 MyBatis로 처리
- 신규 기능은 JPA, 레거시 기능은 MyBatis로 역할 분리
- 성능이 중요한 특정 API만 MyBatis 쿼리로 최적화
[혼합 전략 아키텍처 예시]
Service Layer
├─ UserService → UserRepository (JPA) → 회원 CRUD
├─ OrderService → OrderRepository (JPA) → 주문 CRUD
└─ ReportService → ReportMapper (MyBatis) → 복잡한 통계 조회
기술 선택 체크리스트
다음 항목 중 해당되는 쪽이 많은 기술을 선택하세요.
| 체크 항목 | JPA | MyBatis |
|---|---|---|
| 단순 CRUD가 전체 쿼리의 70% 이상 | ✅ | |
| 5개 이상 테이블 조인이 빈번함 | ✅ | |
| DB 스키마가 안정적이고 정규화됨 | ✅ | |
| 레거시 DB로 스키마 변경 불가 | ✅ | |
| DBA와 SQL 단위 협업 필수 | ✅ | |
| 팀 내 JPA 숙련자 없음 | ✅ | |
| 빠른 기능 개발 속도가 최우선 | ✅ | |
| 쿼리 실행 내역 감사 필수(금융) | ✅ | |
| 통계·집계 리포트 기능이 핵심 | ✅ | |
| 객체 모델 중심의 도메인 설계 | ✅ |
6. 전문가 관점 – 공존 전략과 추천 조합
실무 경험이 풍부한 시니어 개발자들은 “JPA냐 MyBatis냐”보다 “언제 무엇을 쓰느냐”를 더 중요하게 봅니다. 두 기술은 경쟁 관계가 아니라 상호 보완 관계입니다.
Spring Boot에서 JPA + MyBatis 공존 설정
두 기술은 Spring Boot 하나의 프로젝트에서 동시에 사용할 수 있습니다.
xml
<!-- pom.xml – 두 의존성 함께 추가 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
yaml
# application.yml – MyBatis 설정
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
default-fetch-size: 100
default-statement-timeout: 30
역할 분리 권장 패턴
도메인/패키지 구조 예시:
com.example.project
├── domain
│ ├── user
│ │ ├── User.java (JPA Entity)
│ │ ├── UserRepository.java (Spring Data JPA)
│ │ └── UserService.java
│ └── order
│ ├── Order.java (JPA Entity)
│ └── OrderRepository.java (Spring Data JPA)
└── report
├── ReportMapper.java (MyBatis Mapper Interface)
├── ReportMapper.xml (MyBatis SQL XML)
└── ReportService.java
국내 기술 생태계 현황과 전망
국내 취업 시장과 실무 환경을 현실적으로 봤을 때, MyBatis 사용 이유는 단순히 기술적 우열이 아닌 생태계와 조직 문화의 문제이기도 합니다.
- 금융·공공·SI 업계: MyBatis 압도적 주류, 단기간 내 변화 가능성 낮음
- 스타트업·IT 기업: JPA + QueryDSL 도입 증가, 신규 프로젝트는 JPA 우세
- 대형 IT 플랫폼: JPA와 MyBatis 혼용, 서비스 특성별로 선택
Spring Data JPA 창시자인 올리버 드로트붐(Oliver Drotbohm)을 포함한 많은 전문가들이 강조하는 공통 메시지는 명확합니다.
“ORM은 모든 문제를 해결하는 은탄환이 아니다. 복잡한 쿼리가 필요한 곳에서는 SQL을 직접 쓰는 것이 올바른 선택이다.”
결국 두 기술 모두 깊이 이해하고 상황에 맞게 사용하는 것이 최고의 전략입니다.
추천 학습 로드맵
| 단계 | 학습 내용 | 추천 자료 |
|---|---|---|
| 1단계 | SQL 기초 + MyBatis 기본 | MyBatis 공식 문서 |
| 2단계 | JPA 핵심 개념 (영속성·연관관계) | 김영한 JPA 강의 |
| 3단계 | QueryDSL 동적 쿼리 | QueryDSL 공식 문서 |
| 4단계 | 두 기술 혼합 프로젝트 실습 | GitHub 오픈소스 분석 |
| 5단계 | 성능 튜닝 및 모니터링 | p6spy, Hibernate Statistics |
결론
MyBatis 사용 이유는 결코 “JPA가 어려워서” 또는 “예전 습관” 때문만이 아닙니다. 복잡한 SQL의 직접 제어, 강력한 동적 쿼리, DB 수준의 성능 튜닝, 레거시 환경 대응, DBA와의 협업 문화라는 실질적이고 합리적인 이유가 존재합니다. JPA는 단순 CRUD와 객체 중심 도메인 설계에서 생산성을 극대화하고, MyBatis는 복잡한 쿼리와 성능이 중요한 영역에서 빛을 발합니다. 두 기술을 적재적소에 함께 사용하는 것이 현실적으로 가장 강력한 전략입니다.
지금 바로 본인 프로젝트의 체크리스트를 점검해보고, JPA와 MyBatis 중 어떤 기술이 더 적합한지, 혹은 어떻게 혼합할지 팀과 함께 논의해 보세요.
답글 남기기