Command와 Query를 분리하여 성능과 유지보수성을 향상시키는 패턴을 학습합니다.
CQRS (Command Query Responsibility Segregation)
명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴입니다. 쓰기와 읽기의 요구사항이 다르므로 각각 최적화할 수 있습니다.
┌─────────────────────────────────────────────────────────────┐
│ 전통적인 CRUD │
├─────────────────────────────────────────────────────────────┤
│ │
│ Controller → Service → Repository → Database │
│ │ │
│ └─ 하나의 모델로 읽기/쓰기 모두 처리 │
│ │
│ 문제점: │
│ • 조회 최적화가 어려움 (엔티티 중심) │
│ • 복잡한 조회 로직이 도메인 로직과 섞임 │
│ • N+1 문제 발생 가능성 높음 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CQRS │
├─────────────────────────────────────────────────────────────┤
│ │
│ Command (쓰기) │
│ Controller → CommandService → Repository → Database │
│ │ │
│ └─ 도메인 로직, 엔티티 중심 │
│ │
│ Query (읽기) │
│ Controller → QueryService → QueryRepository → Database │
│ │ │
│ └─ 조회 최적화, DTO 중심 │
│ │
└─────────────────────────────────────────────────────────────┘Command (쓰기)
Query (읽기)
// 패키지 구조
com.example.order
├── command/ # 쓰기 작업
│ ├── OrderService.java # Command Service
│ └── OrderCreateRequest.java
├── query/ # 읽기 작업
│ ├── OrderQueryService.java # Query Service
│ ├── OrderQueryRepository.java
│ └── OrderDto.java
├── domain/ # 도메인 모델
│ ├── Order.java
│ └── OrderRepository.java
└── api/
└── OrderController.java핵심 정리
@Service
@RequiredArgsConstructor
@Transactional // 쓰기 작업이므로 기본 트랜잭션
public class OrderService {
private final OrderRepository orderRepository;
private final MemberRepository memberRepository;
private final ProductRepository productRepository;
/**
* 주문 생성 - 도메인 로직 중심
*/
public Long createOrder(OrderCreateRequest request) {
// 1. 엔티티 조회
Member member = memberRepository.findById(request.getMemberId())
.orElseThrow(() -> new EntityNotFoundException("회원을 찾을 수 없습니다."));
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));
// 2. 도메인 로직 실행 (엔티티의 생성 메서드 사용)
Order order = Order.create(member, product, request.getQuantity());
// 3. 저장
orderRepository.save(order);
return order.getId();
}
/**
* 주문 취소 - 도메인 로직 위임
*/
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
order.cancel(); // 도메인 로직은 엔티티에서 처리
}
/**
* 배송 상태 변경
*/
public void updateDeliveryStatus(Long orderId, DeliveryStatus status) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
order.updateDeliveryStatus(status);
}
}@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime orderDate;
// === 생성 메서드 (정적 팩토리) ===
public static Order create(Member member, Product product, int quantity) {
Order order = new Order();
order.member = member;
order.status = OrderStatus.ORDER;
order.orderDate = LocalDateTime.now();
OrderItem orderItem = OrderItem.create(product, quantity);
order.addOrderItem(orderItem);
return order;
}
// === 비즈니스 로직 ===
public void cancel() {
if (this.status == OrderStatus.DELIVERED) {
throw new IllegalStateException("배송 완료된 상품은 취소할 수 없습니다.");
}
this.status = OrderStatus.CANCELLED;
// 재고 복구
for (OrderItem item : orderItems) {
item.cancel();
}
}
public void updateDeliveryStatus(DeliveryStatus status) {
if (this.status == OrderStatus.CANCELLED) {
throw new IllegalStateException("취소된 주문입니다.");
}
// 배송 상태 업데이트 로직
}
// === 연관관계 편의 메서드 ===
private void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
}Command Service 핵심 원칙
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 읽기 전용 최적화
public class OrderQueryService {
private final OrderQueryRepository orderQueryRepository;
/**
* 주문 단건 조회
*/
public OrderDto findOrder(Long orderId) {
return orderQueryRepository.findOrderDto(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
}
/**
* 주문 목록 조회 (검색 조건)
*/
public Page<OrderDto> searchOrders(OrderSearchCondition condition,
Pageable pageable) {
return orderQueryRepository.searchOrders(condition, pageable);
}
/**
* 회원별 주문 목록
*/
public List<OrderDto> findOrdersByMember(Long memberId) {
return orderQueryRepository.findOrdersByMemberId(memberId);
}
}@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public Optional<OrderDto> findOrderDto(Long orderId) {
OrderDto result = queryFactory
.select(new QOrderDto(
order.id,
member.name,
order.orderDate,
order.status
))
.from(order)
.join(order.member, member)
.where(order.id.eq(orderId))
.fetchOne();
return Optional.ofNullable(result);
}
public Page<OrderDto> searchOrders(OrderSearchCondition condition,
Pageable pageable) {
List<OrderDto> content = queryFactory
.select(new QOrderDto(
order.id,
member.name,
order.orderDate,
order.status
))
.from(order)
.join(order.member, member)
.where(
memberNameLike(condition.getMemberName()),
statusEq(condition.getStatus())
)
.orderBy(order.orderDate.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(order.count())
.from(order)
.join(order.member, member)
.where(
memberNameLike(condition.getMemberName()),
statusEq(condition.getStatus())
);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
// 동적 조건 메서드
private BooleanExpression memberNameLike(String memberName) {
return hasText(memberName) ? member.name.contains(memberName) : null;
}
private BooleanExpression statusEq(OrderStatus status) {
return status != null ? order.status.eq(status) : null;
}
}// QueryDSL @QueryProjection 사용
@Getter
public class OrderDto {
private Long orderId;
private String memberName;
private LocalDateTime orderDate;
private OrderStatus status;
@QueryProjection // Q클래스 생성
public OrderDto(Long orderId, String memberName,
LocalDateTime orderDate, OrderStatus status) {
this.orderId = orderId;
this.memberName = memberName;
this.orderDate = orderDate;
this.status = status;
}
}
// 검색 조건 DTO
@Getter @Setter
public class OrderSearchCondition {
private String memberName;
private OrderStatus status;
private LocalDate fromDate;
private LocalDate toDate;
}Query Service 핵심 원칙
com.example.order
├── command/ # 쓰기 작업
│ ├── OrderService.java # Command Service
│ ├── OrderCreateRequest.java
│ └── OrderUpdateRequest.java
├── query/ # 읽기 작업
│ ├── OrderQueryService.java # Query Service
│ ├── OrderQueryRepository.java
│ ├── OrderDto.java
│ └── OrderSearchCondition.java
├── domain/ # 도메인 모델
│ ├── Order.java
│ ├── OrderItem.java
│ └── OrderRepository.java
└── api/ # API 계층
└── OrderController.java@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final MemberRepository memberRepository;
private final ProductRepository productRepository;
/**
* 주문 생성
*/
public Long createOrder(OrderCreateRequest request) {
// 엔티티 조회
Member member = memberRepository.findById(request.getMemberId())
.orElseThrow(() -> new EntityNotFoundException("회원을 찾을 수 없습니다."));
Product product = productRepository.findById(request.getProductId())
.orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));
// 주문 생성 (도메인 로직)
Order order = Order.create(member, product, request.getQuantity());
// 저장
orderRepository.save(order);
return order.getId();
}
/**
* 주문 취소
*/
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
order.cancel(); // 도메인 로직
}
/**
* 배송 상태 변경
*/
public void updateDeliveryStatus(Long orderId, DeliveryStatus status) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
order.updateDeliveryStatus(status);
}
}@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 읽기 전용
public class OrderQueryService {
private final OrderQueryRepository orderQueryRepository;
/**
* 주문 단건 조회
*/
public OrderDto findOrder(Long orderId) {
return orderQueryRepository.findOrderDto(orderId)
.orElseThrow(() -> new EntityNotFoundException("주문을 찾을 수 없습니다."));
}
/**
* 주문 목록 조회 (검색 조건)
*/
public Page<OrderDto> searchOrders(OrderSearchCondition condition,
Pageable pageable) {
return orderQueryRepository.searchOrders(condition, pageable);
}
/**
* 회원별 주문 목록
*/
public List<OrderDto> findOrdersByMember(Long memberId) {
return orderQueryRepository.findOrdersByMemberId(memberId);
}
/**
* 주문 통계
*/
public OrderStatisticsDto getOrderStatistics(LocalDate from, LocalDate to) {
return orderQueryRepository.getStatistics(from, to);
}
}@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final JPAQueryFactory queryFactory;
public Optional<OrderDto> findOrderDto(Long orderId) {
OrderDto result = queryFactory
.select(new QOrderDto(
order.id,
member.name,
order.orderDate,
order.status,
delivery.address
))
.from(order)
.join(order.member, member)
.join(order.delivery, delivery)
.where(order.id.eq(orderId))
.fetchOne();
return Optional.ofNullable(result);
}
public Page<OrderDto> searchOrders(OrderSearchCondition condition,
Pageable pageable) {
List<OrderDto> content = queryFactory
.select(new QOrderDto(
order.id,
member.name,
order.orderDate,
order.status,
delivery.address
))
.from(order)
.join(order.member, member)
.join(order.delivery, delivery)
.where(
memberNameLike(condition.getMemberName()),
statusEq(condition.getStatus()),
orderDateBetween(condition.getFromDate(), condition.getToDate())
)
.orderBy(order.orderDate.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(order.count())
.from(order)
.join(order.member, member)
.where(
memberNameLike(condition.getMemberName()),
statusEq(condition.getStatus()),
orderDateBetween(condition.getFromDate(), condition.getToDate())
);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
private BooleanExpression memberNameLike(String memberName) {
return hasText(memberName) ? member.name.contains(memberName) : null;
}
private BooleanExpression statusEq(OrderStatus status) {
return status != null ? order.status.eq(status) : null;
}
private BooleanExpression orderDateBetween(LocalDate from, LocalDate to) {
if (from == null && to == null) return null;
if (from == null) return order.orderDate.loe(to.atTime(23, 59, 59));
if (to == null) return order.orderDate.goe(from.atStartOfDay());
return order.orderDate.between(from.atStartOfDay(), to.atTime(23, 59, 59));
}
}@RestController
@RequiredArgsConstructor
@RequestMapping("/api/orders")
public class OrderController {
// Command와 Query 서비스 분리
private final OrderService orderService; // Command
private final OrderQueryService orderQueryService; // Query
// === Command (쓰기) ===
@PostMapping
public ResponseEntity<Long> createOrder(@RequestBody @Valid OrderCreateRequest request) {
Long orderId = orderService.createOrder(request);
return ResponseEntity.created(URI.create("/api/orders/" + orderId)).body(orderId);
}
@PostMapping("/{orderId}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long orderId) {
orderService.cancelOrder(orderId);
return ResponseEntity.ok().build();
}
// === Query (읽기) ===
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long orderId) {
return ResponseEntity.ok(orderQueryService.findOrder(orderId));
}
@GetMapping
public ResponseEntity<Page<OrderDto>> searchOrders(
@ModelAttribute OrderSearchCondition condition,
@PageableDefault(size = 20, sort = "orderDate", direction = DESC) Pageable pageable) {
return ResponseEntity.ok(orderQueryService.searchOrders(condition, pageable));
}
}CQRS 적용 효과