본문 바로가기
Research Notes (개인)

Spring Boot에서 CQRS + (MyBatis, JPA) 하이브리드로 개발하기

“쓰기(도메인)에는 JPA, 읽기(조회/리포팅)에는 MyBatis.”
복잡한 비즈니스 로직과 고성능 조회를 둘 다 잡기 위한, 실전형 CQRS 아키텍처 가이드


0. TL;DR

  • Command(쓰기): JPA로 Aggregate/Entity 중심의 도메인 모델 + 트랜잭션/영속성 컨텍스트의 이점 활용
  • Query(읽기): MyBatis로 복잡한 조인/집계/검색/리포트 SQL을 명시적으로 최적화
  • DB는 하나로 시작해도 CQRS 가능(논리적 분리). 성장하면 Read Replica, 별도 스키마/DB로 확장
  • 트랜잭션은 보통 JPA 중심(JpaTransactionManager) 으로 두고 MyBatis가 같은 커넥션을 공유하도록 구성
  • 핵심은 “컨트롤러”가 아니라 애플리케이션 서비스 레이어에서 Command/Query를 분리하는 것

1. 왜 CQRS + 하이브리드(MyBatis + JPA)인가?

실무에서 자주 만나는 문제:

  • 쓰기 로직은 도메인 규칙/불변식이 중요 → Entity 중심 모델링과 변경 감지가 편한 JPA가 유리
  • 읽기 로직은 조인/필터/정렬/집계/페이징이 지옥 → 성능/튜닝/명시성이 좋은 MyBatis가 유리

“JPA로 조회까지 다 하려다”

  • N+1, 복잡한 fetch join, DTO projection 지옥, JPQL 유지보수, 성능 튜닝 난이도 ↑

“MyBatis로 쓰기까지 다 하려다”

  • 도메인 규칙이 흩어지고, 변경 추적/라이프사이클/연관관계 관리가 어려워짐

결론:
쓰기 모델과 읽기 모델의 목적이 다르니, 저장 기술도 목적에 맞게 분리하는 게 합리적입니다.


2. CQRS의 “진짜” 의미: DB를 나누는 게 아니라 “책임을 나누는 것”

CQRS는 크게 3단계로 진화합니다.

(1) 논리적 CQRS (가장 현실적인 출발점)

  • DB는 하나
  • 코드에서 Command/Query 경로만 분리
  • 일관성은 “즉시 일관성(강일관)”에 가깝게 유지 가능

(2) 물리적 CQRS (성능/확장 목적)

  • Read Replica(복제) 도입
  • Query는 Replica로, Command는 Primary로
  • 복제 지연으로 “준-최종 일관성” 고려 필요

(3) 완전 분리 CQRS (대규모/이벤트 기반)

  • Read DB/인덱스를 별도로 운영 (예: Elasticsearch/OpenSearch, Redis Materialized View, 별도 RDB)
  • 이벤트(outbox/CDC)로 읽기 모델을 비동기 갱신
  • 설계 난이도↑ 운영 난이도↑ 대신 조회 확장성↑

이 문서는 (1) 논리적 CQRS를 기본으로 설명하고, 확장 경로까지 안내합니다.


3. 추천 아키텍처: “JPA는 Command, MyBatis는 Query”

diagram rendering...

역할 분담 원칙

  • Command Side
    • 비즈니스 규칙(불변식) 검증
    • 상태 변경(생성/수정/삭제)
    • 트랜잭션 경계의 중심
  • Query Side
    • 화면/리포트/검색/관리자 목록 등 “조회 전용”
    • DTO로 바로 반환(엔티티 반환 금지)
    • SQL 최적화/인덱스 활용 극대화

4. 프로젝트 구조(패키지/모듈) 예시

모놀리식 레포에서 깔끔한 구조:

com.yourapp
 ├─ api
 │   ├─ command
 │   │   └─ OrderCommandController
 │   └─ query
 │       └─ OrderQueryController
 ├─ application
 │   ├─ command
 │   │   ├─ OrderCommandService
 │   │   ├─ dto (CreateOrderCommand ...)
 │   │   └─ handler(optional)
 │   └─ query
 │       ├─ OrderQueryService
 │       └─ dto (OrderSummaryView ...)
 ├─ domain
 │   ├─ order
 │   │   ├─ Order (Aggregate Root, @Entity)
 │   │   ├─ OrderItem (@Entity)
 │   │   ├─ OrderStatus (enum)
 │   │   └─ policy/spec(optional)
 │   └─ common (ValueObject 등)
 ├─ infrastructure
 │   ├─ persistence
 │   │   ├─ jpa
 │   │   │   └─ OrderJpaRepository
 │   │   └─ mybatis
 │   │       ├─ mapper (OrderQueryMapper.java)
 │   │       └─ xml (order-query-mapper.xml)
 │   └─ config (MyBatisConfig, JpaConfig 등)
 └─ support (logging, tracing, exception 등)

핵심 규칙

  • Query는 domain 엔티티를 응답으로 내보내지 않기
  • Command는 조회 최적화 DTO를 알 필요 없음

5. 의존성(Gradle) 예시

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.springframework.boot:spring-boot-starter-validation'

  // JPA (Command)
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

  // MyBatis (Query)
  implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'

  runtimeOnly 'com.mysql:mysql-connector-j'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

6. 설정 포인트 1) 트랜잭션: “JPA 트랜잭션에서 MyBatis가 같이 놀게 하자”

운영이 쉬운 목표:

  • @Transactional을 Command Service에 걸면
  • JPA가 쓰는 커넥션/트랜잭션과 MyBatis가 쓰는 커넥션/트랜잭션이 동일하게 묶이도록

일반적으로 Spring Boot에서:

  • JpaTransactionManager가 활성 트랜잭션을 만들면
  • MyBatis-Spring은 DataSourceUtils를 통해 같은 커넥션을 받아와 같은 트랜잭션에 참여합니다.

실전 팁

  • Command 경로는 “조회가 섞여도” 대부분 JPA 트랜잭션으로 묶는 게 편합니다.
  • Query 전용 API는 @Transactional(readOnly = true) 또는 트랜잭션 없이도 가능(요구 일관성에 따라)

7. 설정 포인트 2) MyBatis 매퍼 스캔 & 리소스

7.1 Java Config

@Configuration
@MapperScan(basePackages = "com.yourapp.infrastructure.persistence.mybatis.mapper")
public class MyBatisConfig {
  // mybatis-spring-boot-starter 사용 시 대부분 자동 구성됨
}

7.2 application.yml 예시

mybatis:
  mapper-locations: classpath:/mybatis/**/*.xml
  type-aliases-package: com.yourapp.application.query.dto
  configuration:
    map-underscore-to-camel-case: true

8. Command Side 구현 예시 (JPA)

8.1 Entity(Aggregate Root)

@Entity
@Table(name = "orders")
public class Order {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Enumerated(EnumType.STRING)
  private OrderStatus status;

  protected Order() {}

  public static Order create() {
    Order o = new Order();
    o.status = OrderStatus.CREATED;
    return o;
  }

  public void pay() {
    if (this.status != OrderStatus.CREATED) {
      throw new IllegalStateException("Only CREATED order can be paid.");
    }
    this.status = OrderStatus.PAID;
  }
}

8.2 Command Service

@Service
@RequiredArgsConstructor
public class OrderCommandService {
  private final OrderJpaRepository orderJpaRepository;

  @Transactional
  public Long createOrder() {
    Order order = Order.create();
    orderJpaRepository.save(order);
    return order.getId();
  }

  @Transactional
  public void pay(Long orderId) {
    Order order = orderJpaRepository.findById(orderId)
        .orElseThrow(() -> new NoSuchElementException("order not found"));
    order.pay();
  }
}

9. Query Side 구현 예시 (MyBatis)

9.1 조회 DTO(View Model)

public record OrderSummaryView(
    Long orderId,
    String status,
    Instant createdAt
) {}

9.2 Mapper 인터페이스

@Mapper
public interface OrderQueryMapper {
  List<OrderSummaryView> findOrders(@Param("status") String status,
                                    @Param("limit") int limit,
                                    @Param("offset") int offset);
}

9.3 XML SQL (튜닝/명시성의 핵심)

<select id="findOrders" resultType="com.yourapp.application.query.dto.OrderSummaryView">
  SELECT
    o.id AS orderId,
    o.status AS status,
    o.created_at AS createdAt
  FROM orders o
  WHERE (#{status} IS NULL OR o.status = #{status})
  ORDER BY o.id DESC
  LIMIT #{limit} OFFSET #{offset}
</select>

9.4 Query Service

@Service
@RequiredArgsConstructor
public class OrderQueryService {
  private final OrderQueryMapper orderQueryMapper;

  @Transactional(readOnly = true)
  public List<OrderSummaryView> list(String status, int limit, int offset) {
    return orderQueryMapper.findOrders(status, limit, offset);
  }
}

10. API 레이어: Command/Query 컨트롤러 분리

10.1 Command Controller

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderCommandController {
  private final OrderCommandService commandService;

  @PostMapping
  public Map<String, Object> create() {
    Long id = commandService.createOrder();
    return Map.of("orderId", id);
  }

  @PostMapping("/{id}/pay")
  public void pay(@PathVariable Long id) {
    commandService.pay(id);
  }
}

10.2 Query Controller

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderQueryController {
  private final OrderQueryService queryService;

  @GetMapping
  public List<OrderSummaryView> list(
      @RequestParam(required = false) String status,
      @RequestParam(defaultValue = "20") int limit,
      @RequestParam(defaultValue = "0") int offset
  ) {
    return queryService.list(status, limit, offset);
  }
}

같은 리소스(/api/orders)를 공유해도
메서드 책임(POST/PUT vs GET)을 기준으로 분리하면 유지보수가 좋아집니다.


11. 데이터 일관성 전략(운영 포인트)

11.1 단일 DB(논리 CQRS)

  • Command 직후 Query가 같은 DB를 보면 최신 데이터가 보임(대부분 즉시 일관성)
  • 트랜잭션 내 “쓰기 후 조회”가 필요하면 MyBatis가 같은 트랜잭션에 참여하도록 구성

11.2 Read Replica 도입(물리 CQRS)

  • Query를 Replica로 보내면 조회 부하 분산
  • 단, 복제 지연으로 “방금 결제했는데 목록에서 안 보임” 같은 UX가 생길 수 있음
  • 해결책:
    • 특정 사용자/특정 시나리오는 Primary로 읽기
    • UI에서 “처리중” 상태 노출
    • 캐시/세션 스코프 보정

11.3 완전 분리(이벤트 기반)

  • Outbox 테이블 + CDC(Kafka)로 Read 모델 갱신
  • Query는 별도 DB/인덱스(OpenSearch 등)로 조회
  • 장점: 조회 확장성 최고
  • 단점: 설계/운영 복잡도 증가

12. “하이브리드”에서 자주 터지는 함정과 해결

함정 1) 엔티티를 Query 응답으로 반환

  • 문제: Lazy 로딩, 순환 참조, DTO 변환 비용, API 스펙 변동 리스크
  • 해결: Query는 무조건 DTO(View Model) 로만 반환

함정 2) Command 로직이 Query SQL에 의존

  • 문제: 도메인 규칙이 조회 모델에 종속됨
  • 해결: Command는 “도메인 규칙 중심” / Query는 “표현/화면 중심”으로 분리

함정 3) 트랜잭션 경계 혼선

  • 문제: JPA flush 타이밍, MyBatis 조회 시점, 격리 수준에 따른 착시
  • 해결:
    • Command 서비스에서 @Transactional을 명확히
    • “쓰기 후 조회”가 필요하면 flush 전략을 의식 (saveAndFlush 또는 명시적 flush)
    • readOnly 트랜잭션은 Query 경로에만

함정 4) 같은 테이블을 JPA/MyBatis가 둘 다 “수정”

  • 원칙: “쓰기 테이블의 변경은 Command만”
  • Query는 읽기만. (운영에서 디버깅 난이도 차이가 큼)

13. 테스트 전략

  • Command 테스트(JPA)
    • 도메인 규칙/상태 전이 테스트
    • @DataJpaTest 또는 통합 테스트(Testcontainers)
  • Query 테스트(MyBatis)
    • 복잡 SQL이면 통합 테스트로 실제 쿼리 검증
    • 페이징/정렬/인덱스 등 회귀 방지
  • E2E
    • “POST로 생성 → GET으로 조회” 시나리오 1~2개라도 고정해두면 운영 안정성↑

14. 확장 로드맵(현실적인 순서)

  1. 단일 DB, 논리 CQRS로 시작
  2. Query 트래픽이 늘면 Read Replica 도입
  3. 검색/추천/대시보드가 커지면 Read 모델을 별도 저장소로 분리(OpenSearch/Redis MV 등)
  4. Outbox + 이벤트로 최종 분리 CQRS 완성

15. 언제 이 패턴이 특히 좋나?

  • 관리자 화면/리포트/통계처럼 조회 요구가 복잡한 서비스
  • 주문/결제/정산처럼 쓰기 규칙이 중요한 도메인
  • “쓰기 규칙은 깔끔하게, 조회는 빠르고 명확하게”를 동시에 원할 때

16. 결론

CQRS는 거창한 분산 시스템부터 시작하는 패턴이 아니라,
코드 책임을 분리해서 유지보수성과 성능을 같이 잡는 실전 패턴입니다.

  • JPA로 도메인/쓰기를 안정적으로 만들고
  • MyBatis로 조회/성능을 확실히 챙기면
  • Spring Boot에서 가장 현실적인 균형점을 얻을 수 있습니다.

Appendix) 체크리스트

  • Query는 DTO만 반환한다
  • Command는 엔티티/도메인 규칙 중심이다
  • 트랜잭션 매니저 전략(JPA 중심)을 정했다
  • Mapper SQL은 리소스/네이밍 규칙을 갖췄다
  • POST→GET E2E 회귀 테스트가 있다
  • 확장 시(Read Replica / Outbox) 경로가 문서화돼 있다