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 경로에만
- Command 서비스에서
함정 4) 같은 테이블을 JPA/MyBatis가 둘 다 “수정”
- 원칙: “쓰기 테이블의 변경은 Command만”
- Query는 읽기만. (운영에서 디버깅 난이도 차이가 큼)
13. 테스트 전략
- Command 테스트(JPA)
- 도메인 규칙/상태 전이 테스트
@DataJpaTest또는 통합 테스트(Testcontainers)
- Query 테스트(MyBatis)
- 복잡 SQL이면 통합 테스트로 실제 쿼리 검증
- 페이징/정렬/인덱스 등 회귀 방지
- E2E
- “POST로 생성 → GET으로 조회” 시나리오 1~2개라도 고정해두면 운영 안정성↑
14. 확장 로드맵(현실적인 순서)
- 단일 DB, 논리 CQRS로 시작
- Query 트래픽이 늘면 Read Replica 도입
- 검색/추천/대시보드가 커지면 Read 모델을 별도 저장소로 분리(OpenSearch/Redis MV 등)
- Outbox + 이벤트로 최종 분리 CQRS 완성
15. 언제 이 패턴이 특히 좋나?
- 관리자 화면/리포트/통계처럼 조회 요구가 복잡한 서비스
- 주문/결제/정산처럼 쓰기 규칙이 중요한 도메인
- “쓰기 규칙은 깔끔하게, 조회는 빠르고 명확하게”를 동시에 원할 때
16. 결론
CQRS는 거창한 분산 시스템부터 시작하는 패턴이 아니라,
코드 책임을 분리해서 유지보수성과 성능을 같이 잡는 실전 패턴입니다.
- JPA로 도메인/쓰기를 안정적으로 만들고
- MyBatis로 조회/성능을 확실히 챙기면
- Spring Boot에서 가장 현실적인 균형점을 얻을 수 있습니다.
Appendix) 체크리스트
- Query는 DTO만 반환한다
- Command는 엔티티/도메인 규칙 중심이다
- 트랜잭션 매니저 전략(JPA 중심)을 정했다
- Mapper SQL은 리소스/네이밍 규칙을 갖췄다
- POST→GET E2E 회귀 테스트가 있다
- 확장 시(Read Replica / Outbox) 경로가 문서화돼 있다