"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에 두 스타터를 함께 추가합니다.

GROOVY
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를 공유 **하는 것이 핵심입니다.

YAML
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을 가져오므로 동일한 트랜잭션 범위 안에 들어갑니다.

JAVA
@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인지 한눈에 알 수 있는 구조" 입니다.

PLAINTEXT
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

핵심 규칙 세 가지:

  1. **repository/는 JPA 전용 **, mapper/는 MyBatis 전용 — 패키지명만 봐도 기술이 구분됨
  2. JPA 엔티티를 MyBatis 반환 타입으로 쓰지 않기 — MyBatis는 별도 DTO로 매핑
  3. ** 서비스 계층에서 조합** — Repository와 Mapper를 함께 주입받아 비즈니스 로직 처리

영속성 컨텍스트 불일치 — 가장 위험한 함정

하이브리드의 최대 난관입니다. JPA는 영속성 컨텍스트(1차 캐시)를 통해 엔티티를 관리하는데, MyBatis는 이 캐시를 완전히 무시하고 DB에 직접 SQL을 날립니다.

JAVA
@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()를 호출합니다.

JAVA
@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차 캐시를 초기화합니다.

JAVA
@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를 섞지 않는 것이 가장 안전합니다.

JAVA
// 좋은 예: 기술별로 메서드를 분리
@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 — 어떤 상황에 무엇을 쓸까

상황선택이유
단순 CRUDJPAsave(), findById() 한 줄이면 끝
엔티티 연관관계가 있는 저장/수정JPA영속성 컨텍스트가 변경 감지 처리
복잡한 조인 (3개 테이블 이상)MyBatis네이티브 SQL이 훨씬 직관적
동적 검색 조건 (10개 이상)MyBatis<if>, <choose> 동적 SQL
통계/리포트MyBatis윈도우 함수, PIVOT 등 자유롭게
배치 대량 처리MyBatisSQL 한 방으로 처리, 영속성 컨텍스트 부담 없음
프로시저 호출MyBatis{call procedure_name} 직접 호출
페이징 조회둘 다 가능JPA Pageable도 좋고, MyBatis도 가능

판단 기준은 단순합니다. "엔티티 단위의 작업이면 JPA, SQL 단위의 작업이면 MyBatis." 이 원칙만 지키면 대부분의 상황에서 올바른 선택을 할 수 있습니다.

MyBatis Mapper 예시

MyBatis 쪽 코드를 좀 더 구체적으로 살펴보겠습니다.

Mapper 인터페이스

JAVA
@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
<?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 &lt; 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 엔티티를 직접 반환하지 마세요

JAVA
// 나쁜 예 — 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. 테스트에서도 구분하세요

JAVA
// JPA Repository 테스트
@DataJpaTest
class OrderRepositoryTest {
    @Autowired
    private OrderRepository orderRepository;
    // ...
}

// MyBatis Mapper 테스트
@MybatisTest  // mybatis-spring-boot-starter-test 의존성 필요
class OrderStatMapperTest {
    @Autowired
    private OrderStatMapper orderStatMapper;
    // ...
}

3. 설정 클래스가 꼭 필요한 경우

대부분은 자동 설정으로 충분하지만, 스캔 경로를 명시해야 할 때가 있습니다.

JAVA
@Configuration
@EnableJpaRepositories(basePackages = "com.example.**.repository")
@MapperScan(basePackages = "com.example.**.mapper")
public class DataAccessConfig {
    // 대부분 빈 클래스로 충분 — 스캔 경로만 명시
}

@EnableJpaRepositories@MapperScan의 basePackages가 겹치지 않도록 주의합니다. repositorymapper 패키지를 분리한 이유가 여기에도 있습니다.

4. 로깅으로 어떤 기술이 실행되는지 확인

YAML
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

하이브리드는 "어중간한 타협"이 아니라 ** 각 기술의 강점을 극대화하는 실용적인 전략 **입니다. 다만 영속성 컨텍스트 불일치라는 함정만 제대로 이해하고 있으면, 실무에서 매우 효과적으로 활용할 수 있습니다.

댓글 로딩 중...