Session 6JPA Workshop

CQRS 패턴

Command와 Query를 분리하여 성능과 유지보수성을 향상시키는 패턴을 학습합니다.

학습 목표

  • CQRS 패턴의 개념과 장점을 이해한다
  • Command와 Query 서비스를 분리할 수 있다
  • Projection을 활용하여 DTO를 직접 조회할 수 있다
  • readOnly 트랜잭션의 최적화 효과를 이해한다
  • 실제 프로젝트에 CQRS 패턴을 적용할 수 있다

1. CQRS 패턴이란?

CQRS (Command Query Responsibility Segregation)

명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴입니다. 쓰기와 읽기의 요구사항이 다르므로 각각 최적화할 수 있습니다.

1.1 전통적인 CRUD vs CQRS

┌─────────────────────────────────────────────────────────────┐
│                    전통적인 CRUD                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Controller → Service → Repository → Database               │
│                  │                                          │
│                  └─ 하나의 모델로 읽기/쓰기 모두 처리          │
│                                                             │
│  문제점:                                                     │
│  • 조회 최적화가 어려움 (엔티티 중심)                         │
│  • 복잡한 조회 로직이 도메인 로직과 섞임                      │
│  • N+1 문제 발생 가능성 높음                                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                       CQRS                                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Command (쓰기)                                              │
│  Controller → CommandService → Repository → Database        │
│                     │                                       │
│                     └─ 도메인 로직, 엔티티 중심               │
│                                                             │
│  Query (읽기)                                                │
│  Controller → QueryService → QueryRepository → Database     │
│                    │                                        │
│                    └─ 조회 최적화, DTO 중심                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.2 CQRS의 장점

Command (쓰기)

  • • 도메인 로직에 집중
  • • 엔티티의 무결성 보장
  • • 비즈니스 규칙 검증
  • • 트랜잭션 관리

Query (읽기)

  • • 조회 성능 최적화
  • • DTO 직접 조회
  • • 복잡한 조인 쿼리
  • • 캐싱 적용 용이

1.3 JPA에서의 CQRS 적용

// 패키지 구조
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

핵심 정리

  • • Command: 상태 변경, 도메인 로직, 엔티티 중심
  • • Query: 데이터 조회, 성능 최적화, DTO 중심
  • • 각각 독립적으로 최적화 가능
  • • 코드 가독성과 유지보수성 향상

2. Command Service 구현

2.1 Command Service 패턴

@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);
    }
}

2.2 도메인 엔티티 설계

@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 핵심 원칙

  • • @Transactional 기본 적용 (쓰기 작업)
  • • 도메인 로직은 엔티티에 위임
  • • 서비스는 흐름 제어와 트랜잭션 관리
  • • 정적 팩토리 메서드로 엔티티 생성

3. Query Service 구현

3.1 Query 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);
    }
}

3.2 Query Repository (QueryDSL)

@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;
    }
}

3.3 DTO 정의

// 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 핵심 원칙

  • • @Transactional(readOnly = true) 필수
  • • DTO 직접 조회로 성능 최적화
  • • QueryDSL로 타입 안전한 동적 쿼리
  • • BooleanExpression으로 조건 재사용

4. CQRS 실전 적용

4.1 계층 구조 설계

CQRS 패키지 구조
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

4.2 Command Service 구현

OrderService (Command)
@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);
    }
}

4.3 Query Service 구현

OrderQueryService (Query)
@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);
    }
}

4.4 Query Repository 구현

OrderQueryRepository (QueryDSL)
@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));
    }
}

4.5 Controller 구현

OrderController
@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 적용 효과

  • • Command: 도메인 로직에 집중, 엔티티 중심
  • • Query: 성능 최적화에 집중, DTO 중심
  • • 각각 독립적으로 최적화 가능
  • • 코드 가독성과 유지보수성 향상