CQRS 패턴 이해하기 (Spring Boot 예제)

mocaccino·2025년 2월 3일

백엔드로드맵

목록 보기
19/19

CQRS(Command Query Responsibility Segregation) 패턴에서 Read Model과 Write Model을 분리한다는 것은 단순히 DAO(Data Access Object)와 VO(Value Object)를 나누는 것이 아니라, 데이터 저장 및 조회 로직을 명확히 분리하는 것을 의미함.

즉:

Write Model: 데이터를 변경하는 역할 (Command) → @Transactional을 적극 활용
Read Model: 데이터를 조회하는 역할 (Query) → 캐싱, Read 전용 DB 등을 활용 가능
아래 예제는 Spring Boot에서 CQRS 패턴을 적용하는 기본적인 구조를 보여줌.

CQRS 예제 (Spring Boot)

1. 엔티티 (Entity)

import jakarta.persistence.*;
import lombok.Getter;

@Entity
@Getter
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int price;

    protected Product() {}

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    public void updatePrice(int newPrice) {
        this.price = newPrice;
    }
}

2. Write Model (Command)

2-1. Command 객체 (DTO)

import lombok.Getter;

@Getter
public class CreateProductCommand {
    private String name;
    private int price;

    public CreateProductCommand(String name, int price) {
        this.name = name;
        this.price = price;
    }
}
import lombok.Getter;

@Getter
public class UpdateProductPriceCommand {
    private int newPrice;

    public UpdateProductPriceCommand(int newPrice) {
        this.newPrice = newPrice;
    }
}

2-2. Command Repository

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

2-3. Command Service (Write Model)

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class ProductCommandService {
    private final ProductRepository productRepository;

    public ProductCommandService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public Product createProduct(CreateProductCommand command) {
        Product product = new Product(command.getName(), command.getPrice());
        return productRepository.save(product);
    }

    public void updatePrice(Long productId, UpdateProductPriceCommand command) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new IllegalArgumentException("Product not found"));
        product.updatePrice(command.getNewPrice());
    }
}

2-4. Command Controller

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/products")
public class ProductCommandController {
    private final ProductCommandService productCommandService;

    public ProductCommandController(ProductCommandService productCommandService) {
        this.productCommandService = productCommandService;
    }

    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody CreateProductCommand command) {
        return ResponseEntity.ok(productCommandService.createProduct(command));
    }

    @PutMapping("/{id}/price")
    public ResponseEntity<Void> updateProductPrice(@PathVariable Long id, @RequestBody UpdateProductPriceCommand command) {
        productCommandService.updatePrice(id, command);
        return ResponseEntity.noContent().build();
    }
}

3. Read Model (Query)

3-1. Query 객체 (DTO)

import lombok.Getter;

@Getter
public class ProductView {
    private Long id;
    private String name;
    private int price;

    public ProductView(Long id, String name, int price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }
}

3-2. Query Repository (Read 전용)

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;

public interface ProductQueryRepository extends JpaRepository<Product, Long> {
    @Query("SELECT new com.example.dto.ProductView(p.id, p.name, p.price) FROM Product p")
    List<ProductView> findAllProducts();
}

3-3. Query Service


import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ProductQueryService {
    private final ProductQueryRepository productQueryRepository;

    public ProductQueryService(ProductQueryRepository productQueryRepository) {
        this.productQueryRepository = productQueryRepository;
    }

    public List<ProductView> getProducts() {
        return productQueryRepository.findAllProducts();
    }
}

3-4. Query Controller


import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/products")
public class ProductQueryController {
    private final ProductQueryService productQueryService;

    public ProductQueryController(ProductQueryService productQueryService) {
        this.productQueryService = productQueryService;
    }

    @GetMapping
    public List<ProductView> getProducts() {
        return productQueryService.getProducts();
    }
}

핵심 정리

Write Model

  • ProductCommandService → @Transactional을 사용하여 데이터 변경 관리
  • ProductCommandController → /products POST, PUT 요청 처리
  • ProductRepository → JPA 사용

Read Model

  • ProductQueryService → 별도 조회 서비스
  • ProductQueryRepository → @Query를 활용한 조회 전용 리포지토리
  • ProductQueryController → /products GET 요청 처리

장점

조회와 변경 로직을 완전히 분리하여 유지보수성이 높아짐
읽기 모델을 최적화하여 성능 개선 가능 (예: 캐싱, Read Replica DB 활용)

profile
레거시문서를 줄이자. 계속 업데이트해서 최신화한다.

0개의 댓글