JPA + MyBatis 하이브리드 — 한 프로젝트에서 두 기술을 공존시키는 방법
"JPA 쓰면 편하다는데 통계 쿼리가 너무 복잡하고, MyBatis는 SQL 직접 쓰니까 좋은데 단순 CRUD가 귀찮다." 둘 다 쓰면 안 될까요?
결론부터 말하면, 됩니다. 실제로 많은 실무 프로젝트에서 JPA와 MyBatis를 한 프로젝트에서 함께 사용합니다. 엔티티 중심 CRUD는 JPA에게, 복잡한 조인/통계/동적 SQL은 MyBatis에게 맡기는 하이브리드 전략입니다.
다만 "그냥 둘 다 의존성 넣으면 되는 거 아니야?"라고 생각하면 큰일납니다. 영속성 컨텍스트 불일치, 트랜잭션 분리, 패키지 구조 혼란 등 신경 써야 할 포인트가 꽤 있습니다. 하나씩 정리해보겠습니다.
왜 하이브리드가 필요한가
현실의 프로젝트는 깔끔하게 나뉘지 않습니다.
- ** 단순 CRUD** — 회원 가입, 게시글 작성, 댓글 등록. JPA의
save(),findById()한 줄이면 끝 - ** 복잡한 조회** — 월별 매출 통계, 다중 조인 리포트, 조건이 10개 이상인 동적 검색. JPQL이나 Querydsl로도 한계가 있음
- ** 레거시 연동** — 이미 만들어진 프로시저 호출, 비정규화된 뷰 조회
JPA만 쓰면 복잡한 쿼리에서 고통받고, MyBatis만 쓰면 단순 CRUD에서 보일러플레이트가 쌓입니다. 두 기술의 장점만 취하는 것이 하이브리드의 핵심입니다.
실무에서는 "JPA 80% + MyBatis 20%" 정도의 비율로 운영하는 팀이 많습니다. 대부분의 도메인 로직은 JPA로 처리하고, 통계/리포트/배치 쿼리만 MyBatis로 빼는 패턴입니다.
Spring Boot 설정 — 공존을 위한 의존성
build.gradle에 두 스타터를 함께 추가합니다.
dependencies {
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
// DB 드라이버
runtimeOnly 'com.mysql:mysql-connector-j'
}
application.yml 설정도 특별한 건 없습니다. ** 두 기술이 같은 DataSource를 공유 **하는 것이 핵심입니다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: secret
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
open-in-view: false # OSIV 끄는 걸 권장
# MyBatis 설정
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
Spring Boot의 자동 설정이 DataSource를 하나만 감지하면, JPA의 EntityManagerFactory와 MyBatis의 SqlSessionFactory 모두 같은 DataSource 를 사용합니다. 별도 설정 없이도 공존이 가능한 이유입니다.
트랜잭션 관리 — 하나의 트랜잭션으로 묶이는 원리
가장 많이 받는 질문이 "트랜잭션은 어떻게 되나요?"입니다.
같은 DataSource를 쓰면 Spring의
PlatformTransactionManager가 두 기술을 하나의 트랜잭션으로 관리합니다. JTA 같은 분산 트랜잭션이 필요하지 않습니다.
Spring Boot는 기본적으로 JpaTransactionManager를 생성합니다. 이 트랜잭션 매니저는 내부적으로 JDBC Connection을 관리하는데, MyBatis도 같은 DataSource에서 Connection을 가져오므로 동일한 트랜잭션 범위 안에 들어갑니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository; // JPA
private final OrderStatMapper orderStatMapper; // MyBatis
@Transactional
public void createOrderWithStats(OrderCreateRequest request) {
// 1. JPA로 주문 저장
Order order = Order.from(request);
orderRepository.save(order);
// 2. MyBatis로 통계 테이블 갱신
orderStatMapper.incrementDailyStat(order.getCreatedDate());
// 둘 다 같은 트랜잭션 — 하나라도 실패하면 둘 다 롤백
}
}
단, DataSource가 여러 개인 경우(예: 읽기 전용 DB 분리)에는 트랜잭션 매니저를 명시적으로 지정해야 합니다.
패키지 구조 — 역할별 분리 전략
하이브리드에서 가장 중요한 건 "이 쿼리가 JPA인지 MyBatis인지 한눈에 알 수 있는 구조" 입니다.
src/main/java/com/example/order/
├── domain/
│ ├── Order.java # JPA 엔티티
│ └── OrderItem.java
├── repository/
│ └── OrderRepository.java # Spring Data JPA Repository
├── mapper/
│ └── OrderStatMapper.java # MyBatis Mapper 인터페이스
├── dto/
│ ├── OrderCreateRequest.java
│ └── DailyStatDto.java # MyBatis 조회 결과 DTO
└── service/
└── OrderService.java # JPA + MyBatis 조합
src/main/resources/
└── mapper/
└── order/
└── OrderStatMapper.xml # MyBatis XML
핵심 규칙 세 가지:
- **
repository/는 JPA 전용 **,mapper/는 MyBatis 전용 — 패키지명만 봐도 기술이 구분됨 - JPA 엔티티를 MyBatis 반환 타입으로 쓰지 않기 — MyBatis는 별도 DTO로 매핑
- ** 서비스 계층에서 조합** — Repository와 Mapper를 함께 주입받아 비즈니스 로직 처리
영속성 컨텍스트 불일치 — 가장 위험한 함정
하이브리드의 최대 난관입니다. JPA는 영속성 컨텍스트(1차 캐시)를 통해 엔티티를 관리하는데, MyBatis는 이 캐시를 완전히 무시하고 DB에 직접 SQL을 날립니다.
@Transactional
public void dangerousUpdate() {
// 1. JPA로 주문 조회 → 영속성 컨텍스트에 캐싱됨
Order order = orderRepository.findById(1L).orElseThrow();
// order.status = "PENDING" (1차 캐시)
// 2. MyBatis로 상태 직접 변경
orderStatMapper.updateOrderStatus(1L, "COMPLETED");
// DB는 "COMPLETED"로 바뀜
// 3. JPA로 다시 조회
Order sameOrder = orderRepository.findById(1L).orElseThrow();
// sameOrder.status = "PENDING" ← 1차 캐시에서 가져오므로 옛날 값!
}
DB에는 COMPLETED인데 자바 객체는 PENDING입니다. 이걸 모르고 로직을 이어가면 데이터 정합성이 깨집니다.
flush/clear 패턴 — 불일치를 해결하는 방법
해결 방법은 크게 두 가지입니다.
방법 1: MyBatis 실행 전에 flush
JPA가 아직 DB에 반영하지 않은 변경사항이 있을 수 있으므로, MyBatis 쿼리 전에 flush()를 호출합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OrderStatMapper orderStatMapper;
private final EntityManager em;
@Transactional
public DailyStatDto getStatAfterUpdate(Long orderId) {
// JPA로 엔티티 수정
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete(); // 상태 변경 (더티 체킹 대상)
// MyBatis로 통계 조회 전에 flush — JPA 변경분을 DB에 반영
em.flush();
// 이제 MyBatis 쿼리가 최신 데이터를 볼 수 있음
return orderStatMapper.getDailyStat(order.getCreatedDate());
}
}
방법 2: MyBatis 실행 후에 clear
MyBatis가 DB를 직접 수정한 뒤에는 JPA 1차 캐시를 초기화합니다.
@Transactional
public void updateViaMyBatisAndContinueWithJpa(Long orderId) {
// MyBatis로 대량 업데이트
orderStatMapper.bulkUpdateExpiredOrders();
// 1차 캐시 초기화 — 이후 JPA 조회는 DB에서 새로 가져옴
em.clear();
// 최신 데이터로 조회
Order order = orderRepository.findById(orderId).orElseThrow();
// 이제 DB 최신 상태가 반영됨
}
flush()는 "JPA → DB 동기화",clear()는 "캐시 버리기"입니다. JPA 먼저 쓰고 MyBatis 가면flush(), MyBatis 먼저 쓰고 JPA 가면clear()로 기억하면 됩니다.
방법 3: 아예 섞지 않기 (권장)
가능하다면 하나의 메서드에서 JPA와 MyBatis를 섞지 않는 것이 가장 안전합니다.
// 좋은 예: 기술별로 메서드를 분리
@Transactional
public void processOrder(Long orderId) {
completeOrder(orderId); // JPA만 사용
updateStatistics(orderId); // MyBatis만 사용 (별도 메서드)
}
private void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
// 트랜잭션 커밋 시 자동 flush
}
private void updateStatistics(Long orderId) {
orderStatMapper.incrementCompletedCount(orderId);
}
JPA vs MyBatis — 어떤 상황에 무엇을 쓸까
| 상황 | 선택 | 이유 |
|---|---|---|
| 단순 CRUD | JPA | save(), findById() 한 줄이면 끝 |
| 엔티티 연관관계가 있는 저장/수정 | JPA | 영속성 컨텍스트가 변경 감지 처리 |
| 복잡한 조인 (3개 테이블 이상) | MyBatis | 네이티브 SQL이 훨씬 직관적 |
| 동적 검색 조건 (10개 이상) | MyBatis | <if>, <choose> 동적 SQL |
| 통계/리포트 | MyBatis | 윈도우 함수, PIVOT 등 자유롭게 |
| 배치 대량 처리 | MyBatis | SQL 한 방으로 처리, 영속성 컨텍스트 부담 없음 |
| 프로시저 호출 | MyBatis | {call procedure_name} 직접 호출 |
| 페이징 조회 | 둘 다 가능 | JPA Pageable도 좋고, MyBatis도 가능 |
판단 기준은 단순합니다. "엔티티 단위의 작업이면 JPA, SQL 단위의 작업이면 MyBatis." 이 원칙만 지키면 대부분의 상황에서 올바른 선택을 할 수 있습니다.
MyBatis Mapper 예시
MyBatis 쪽 코드를 좀 더 구체적으로 살펴보겠습니다.
Mapper 인터페이스
@Mapper
public interface OrderStatMapper {
// 일별 통계 조회
DailyStatDto getDailyStat(@Param("date") LocalDate date);
// 월별 매출 리포트 (복잡한 조인 + 집계)
List<MonthlyRevenueDto> getMonthlyRevenue(
@Param("year") int year,
@Param("month") int month
);
// 대량 상태 업데이트
int bulkUpdateExpiredOrders();
// 일별 통계 카운트 증가
void incrementDailyStat(@Param("date") LocalDate date);
}
XML Mapper
<?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.order.mapper.OrderStatMapper">
<!-- 월별 매출 리포트 — 다중 조인 + 윈도우 함수 -->
<select id="getMonthlyRevenue" resultType="com.example.order.dto.MonthlyRevenueDto">
SELECT
p.category_name,
COUNT(o.id) AS order_count,
SUM(o.total_amount) AS total_revenue,
/* 전월 대비 증감률 */
ROUND(
(SUM(o.total_amount) - LAG(SUM(o.total_amount))
OVER (ORDER BY p.category_name))
/ NULLIF(LAG(SUM(o.total_amount))
OVER (ORDER BY p.category_name), 0) * 100
, 2) AS growth_rate
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE YEAR(o.created_at) = #{year}
AND MONTH(o.created_at) = #{month}
AND o.status = 'COMPLETED'
GROUP BY p.category_name
ORDER BY total_revenue DESC
</select>
<!-- 만료 주문 일괄 업데이트 -->
<update id="bulkUpdateExpiredOrders">
UPDATE orders
SET status = 'EXPIRED',
updated_at = NOW()
WHERE status = 'PENDING'
AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
</update>
<!-- 일별 통계 증가 — UPSERT 패턴 -->
<insert id="incrementDailyStat">
INSERT INTO daily_order_stats (stat_date, order_count)
VALUES (#{date}, 1)
ON DUPLICATE KEY UPDATE
order_count = order_count + 1
</insert>
</mapper>
이런 쿼리를 JPQL이나 Querydsl로 작성한다고 상상해보면, MyBatis가 왜 필요한지 바로 체감이 됩니다.
실전 팁 정리
1. MyBatis에서 JPA 엔티티를 직접 반환하지 마세요
// 나쁜 예 — MyBatis가 JPA 엔티티를 반환
@Mapper
public interface BadMapper {
Order findOrderById(Long id); // JPA 엔티티를 반환 → 비영속 상태
}
// 좋은 예 — 별도 DTO 사용
@Mapper
public interface GoodMapper {
OrderDetailDto findOrderDetail(Long id); // 전용 DTO 반환
}
MyBatis가 반환한 엔티티는 비영속(detached) 상태 입니다. 더티 체킹도 안 되고, 지연 로딩도 안 됩니다. 혼란만 생기므로 DTO를 쓰는 게 맞습니다.
2. 테스트에서도 구분하세요
// JPA Repository 테스트
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
// ...
}
// MyBatis Mapper 테스트
@MybatisTest // mybatis-spring-boot-starter-test 의존성 필요
class OrderStatMapperTest {
@Autowired
private OrderStatMapper orderStatMapper;
// ...
}
3. 설정 클래스가 꼭 필요한 경우
대부분은 자동 설정으로 충분하지만, 스캔 경로를 명시해야 할 때가 있습니다.
@Configuration
@EnableJpaRepositories(basePackages = "com.example.**.repository")
@MapperScan(basePackages = "com.example.**.mapper")
public class DataAccessConfig {
// 대부분 빈 클래스로 충분 — 스캔 경로만 명시
}
@EnableJpaRepositories와 @MapperScan의 basePackages가 겹치지 않도록 주의합니다. repository와 mapper 패키지를 분리한 이유가 여기에도 있습니다.
4. 로깅으로 어떤 기술이 실행되는지 확인
logging:
level:
# JPA SQL 로깅
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
# MyBatis SQL 로깅
com.example.order.mapper: DEBUG
개발 중에 콘솔에서 "이 쿼리가 JPA에서 나온 건지, MyBatis에서 나온 건지" 구분할 수 있어야 디버깅이 수월합니다.
정리
| 항목 | 핵심 |
|---|---|
| 공존 가능 여부 | 같은 DataSource만 쓰면 바로 가능 |
| 트랜잭션 | @Transactional 하나로 두 기술 모두 관리됨 |
| 최대 위험 | 영속성 컨텍스트 불일치 (1차 캐시 ≠ DB) |
| 해결 패턴 | JPA→MyBatis는 flush(), MyBatis→JPA는 clear() |
| 구조 원칙 | repository/는 JPA, mapper/는 MyBatis, DTO 분리 |
| 선택 기준 | 엔티티 작업은 JPA, SQL 작업은 MyBatis |
하이브리드는 "어중간한 타협"이 아니라 ** 각 기술의 강점을 극대화하는 실용적인 전략 **입니다. 다만 영속성 컨텍스트 불일치라는 함정만 제대로 이해하고 있으면, 실무에서 매우 효과적으로 활용할 수 있습니다.