Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
Tags
- UTF-8
- 비동기 처리
- 백준
- Producer
- 플로이드워셜
- cqrs
- 소프트웨어개발
- consumer
- 유니코드
- 최소신장트리
- command
- BFS
- 비트
- prim
- 슬라이딩윈도우
- 아스키 코드
- 2의 보수
- 메시지 큐
- 바이트
- kruskal
- MSA
- 투포인터
- 고정 소수점
- query
- MST
- 부동 소수점
- 정처기
- DECIMAL
Archives
- Today
- Total
munjji 님의 블로그
[MSA] CQRS 패턴 (feat. Spring Boot) 본문
CQRS란?
CQRS는 Command Query Responsibility Segregation의 약자
- Command: 데이터를 변경하는 작업
- Query: 데이터를 조회하는 작업
이 둘의 책임을 분리하는 패턴이다.
보통 우리가 처음 만드는 서비스는 이런 형태이다.
@PostMapping("/orders")
public Order createOrder(...) { ... }
@GetMapping("/orders/{id}")
public Order getOrder(...) { ... }
즉, 같은 도메인 모델과 같은 저장소를 기준으로 "쓰기"와 "읽기"를 같이 처리한다.
그런데 서비스가 커지면 문제가 발생한다.
- 쓰기 로직은 검증, 트랜잭션, 정합성이 중요
- 읽기 로직은 속도, 조인 최적화, 캐싱이 중요
둘이 요구사항이 완전히 다른데도 같은 모델, 같은 구조로 처리하려고 하니 점점 복잡해진다.
이때 쓰는 것이 CQRS
CQRS의 핵심 개념
Command
데이터를 변경하는 책임만 가진다.
예시
- 회원 가입
- 주문 생성
- 결제 승인
- 예약 취소
즉, 상태를 바꾸는 작업을 한다.
Query
데이터를 조회하는 책임만 가진다.
예시
- 주문 상세 조회
- 예약 목록 조회
- 대시보드 통계 조회
즉, 읽기 전용 작업이다.
왜 굳이 나누는가?
일반적인 CRUD 방식의 한계
예를 들어 주문 서비스가 있다고 생각해보자.
쓰기 로직에서 중요한 것
- 주문 생성 시 재고 차감
- 결제 처리
- 트랜잭션 보장
- 비즈니스 규칙 검증
조회 로직에서 중요한 것
- 주문 목록 빠르게 보여주기
- 여러 테이블 조인
- 정렬, 검색, 페이징
- API 응답용 DTO 최적화
이 둘은 관심사가 다르다.
그런데 CRUD 방식에서는 보통 같은 엔티티와 같은 리포지토리를 공유한다.
그러면 다음과 같은 문제가 발생할 수 있다.
조회 때문에 복잡한 fetch join이 늘어남
쓰기 모델이 조회 요구사항에 끌려다님
조회 성능 때문에 엔티티 구조가 왜곡됨
한 모델에 책임이 너무 많아짐
CQRS는 이걸 끊어낸다!
CQRS의 구조
[Client]
|
+---- Command API ----> Command Service ----> Write DB
|
+---- Query API ------> Query Service ------> Read DB
쓰기용 모델과 읽기용 모델을 분리한다.
Spring Boot에서 이해하는 가장 쉬운 예시
주문 시스템을 예로 들어보자면,
요구사항
- 주문 생성
- 주문 상세 조회
- 주문 목록 조회
여기서 CQRS를 적용하면,
Command 쪽
- 주문 생성
- 주문 취소
- 주문 상태 변경
Query 쪽
- 주문 상세 조회
- 주문 목록 조회
- 사용자별 최근 주문 조회
이렇게 나눌 수 있다.
Command 쪽 예제
Order 엔티티
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long userId;
private String productName;
private int quantity;
private String status;
protected Order() {}
public Order(Long userId, String productName, int quantity) {
this.userId = userId;
this.productName = productName;
this.quantity = quantity;
this.status = "CREATED";
}
public void cancel() {
if ("CANCELLED".equals(this.status)) {
throw new IllegalStateException("이미 취소된 주문입니다.");
}
this.status = "CANCELLED";
}
public Long getId() {
return id;
}
}
여기서는 중요한 것이 비즈니스 규칙과 상태 변경이다.
Command DTO
public record CreateOrderCommand(
Long userId,
String productName,
int quantity
) {}
Command Repository
public interface OrderCommandRepository extends JpaRepository<Order, Long> {
}
Command Service
@Service
@Transactional
public class OrderCommandService {
private final OrderCommandRepository orderCommandRepository;
public OrderCommandService(OrderCommandRepository orderCommandRepository) {
this.orderCommandRepository = orderCommandRepository;
}
public Long createOrder(CreateOrderCommand command) {
Order order = new Order(
command.userId(),
command.productName(),
command.quantity()
);
Order saved = orderCommandRepository.save(order);
return saved.getId();
}
public void cancelOrder(Long orderId) {
Order order = orderCommandRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문이 존재하지 않습니다."));
order.cancel();
}
}
여기서 핵심은 상태를 안전하게 바꾸는 것
Command Controller
@RestController
@RequestMapping("/commands/orders")
public class OrderCommandController {
private final OrderCommandService orderCommandService;
public OrderCommandController(OrderCommandService orderCommandService) {
this.orderCommandService = orderCommandService;
}
@PostMapping
public ResponseEntity<Long> createOrder(@RequestBody CreateOrderCommand command) {
Long orderId = orderCommandService.createOrder(command);
return ResponseEntity.ok(orderId);
}
@PostMapping("/{orderId}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long orderId) {
orderCommandService.cancelOrder(orderId);
return ResponseEntity.noContent().build();
}
}
2. Query 쪽 예제
조회는 아예 따로 생각한다.
조회는 엔티티를 굳이 그대로 반환할 필요가 없다.
Query DTO
public record OrderDetailResponse(
Long orderId,
Long userId,
String productName,
int quantity,
String status
) {}
Query Repository
@Repository
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public OrderQueryRepository(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
public OrderDetailResponse findOrderDetail(Long orderId) {
QOrder order = QOrder.order;
return queryFactory
.select(Projections.constructor(
OrderDetailResponse.class,
order.id,
order.userId,
order.productName,
order.quantity,
order.status
))
.from(order)
.where(order.id.eq(orderId))
.fetchOne();
}
public List<OrderDetailResponse> findOrdersByUserId(Long userId) {
QOrder order = QOrder.order;
return queryFactory
.select(Projections.constructor(
OrderDetailResponse.class,
order.id,
order.userId,
order.productName,
order.quantity,
order.status
))
.from(order)
.where(order.userId.eq(userId))
.orderBy(order.id.desc())
.fetch();
}
}
조회는 이렇게 읽기 최적화된 DTO 반환 중심으로 설계한다.
Query Service
@Service
@Transactional(readOnly = true)
public class OrderQueryService {
private final OrderQueryRepository orderQueryRepository;
public OrderQueryService(OrderQueryRepository orderQueryRepository) {
this.orderQueryRepository = orderQueryRepository;
}
public OrderDetailResponse getOrderDetail(Long orderId) {
return orderQueryRepository.findOrderDetail(orderId);
}
public List<OrderDetailResponse> getUserOrders(Long userId) {
return orderQueryRepository.findOrdersByUserId(userId);
}
}
Query Controller
@RestController
@RequestMapping("/queries/orders")
public class OrderQueryController {
private final OrderQueryService orderQueryService;
public OrderQueryController(OrderQueryService orderQueryService) {
this.orderQueryService = orderQueryService;
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDetailResponse> getOrder(@PathVariable Long orderId) {
return ResponseEntity.ok(orderQueryService.getOrderDetail(orderId));
}
@GetMapping("/users/{userId}")
public ResponseEntity<List<OrderDetailResponse>> getUserOrders(@PathVariable Long userId) {
return ResponseEntity.ok(orderQueryService.getUserOrders(userId));
}
}
발전된 CQRS 형태: 읽기/쓰기 DB 분리
[Command Service] ---> Write DB
|
| 이벤트 발행
v
Message Broker (Kafka/RabbitMQ)
|
v
[Query Model Updater] ---> Read DB
- Command 서비스가 Write DB에 저장
- 이벤트 발행
- 이벤트를 받아 Read DB를 갱신
- Query 서비스는 Read DB만 조회
CQRS 패턴의 장단점
CQRS의 장점
- 읽기와 쓰기의 관심사를 분리할 수 있다
- 조회 성능 최적화가 쉬워진다
- 복잡한 도메인에서 모델이 깔끔해진다
- MSA와 궁합이 좋다
- 읽기 확장이 쉬워진다
CQRS의 단점
- 구조가 복잡해진다
- 데이터가 즉시 일치하지 않을 수 있다
- 운영 포인트가 늘어난다
그럼 언제 CQRS를 쓰는 것이 좋은가?
- 조회와 쓰기 패턴이 매우 다를 때
- 도메인 로직이 복잡할 때
- 조회 트래픽이 많을 때
- 이벤트 기반 MSA를 구성할 때
- 대시보드/통계/검색 등 조회 요구가 많은 경우
예시
- 주문 시스템
- 예약 시스템
- 결제 시스템
- 티켓팅 시스템
- 알림/통계 시스템
Spring Boot에서 실무적으로 적용하는 방법
1단계 코드 레벨 CQRS
- CommandService 분리
- QueryService 분리
- QueryRepository에서 DTO 직접 조회
2단계 읽기 모델 분리
- 조회 전용 테이블
- 통계 전용 테이블
- 캐시 활용
3단계 이벤트 기반 CQRS
- Kafka / RabbitMQ
- 이벤트 소비 후 Read Model 갱신
- Read DB 분리