Kim WooSup

CQRS 작게 시작하기

2026-05-07

AI 요약

DDD 맥락에서 CQRS를 꼭 인프라 분리로 시작할 필요는 없다는 이야기입니다 — 쓰기와 조회의 관심사가 달라지며 서비스가 비대해질 때 같은 DB를 쓰더라도 CommandService와 QueryService로 책임을 분리해 코드를 더 명확하게 만들면 된다는 결론입니다.

DDD를 공부하다 보면 CQRS라는 개념을 자주 만나게 된다.


처음에는 단순히 '쓰기와 읽기를 나누는 패턴'정도로 이해했다.
하지만 실제로 코드를 작성해보면 궁금한 점이 생긴다.

'그럼 모든 CRUD Service를 Command와 Query로 나눠야 할까?'
'단순 조회도 전부 QueryService로 빼야 할까?'
'DB도 Command용, Query용으로 나눠야 CQRS라고 할 수 있을까?'

이번 글에서는 CQRS를 거창한 아키텍처로 보기보다,
하나의 Service가 커질 때 쓰기와 조회의 책임을 어떻게 나눌 수 있는지 정리해보려고 한다.

 

단일 Service

처음 시작하면 하나의 Service에서 CRUD를 모두 처리한다.

이 구조는 단순하다.

클래스도 적고 흐름도 한눈에 보인다.

ProductService
Copied
01@Service02@Transactional(readOnly = true)03@RequiredArgsConstructor04public class ProductService {05 06    private final ProductRepository productRepository;07 08    @Transactional09    public ProductResponse create(ProductCreateCommand request) {10        Product product = Product.create(request.name(), request.price(), request.type());11 12        Product savedProduct = productRepository.save(product);13        return ProductResponse.from(savedProduct);14    }15 16    public ProductResponse findById(Long id) {17        Product product = productRepository.findById(id)18                .orElseThrow(ProductNotFound::new);19        return ProductResponse.from(product);20    }21 22    public List<ProductResponse> findAll() {23        return productRepository.findAll().stream()24                .map(ProductResponse::from)25                .toList();26    }27 28    @Transactional29    public ProductResponse update(Long id, ProductUpdateCommand request) {30        Product product = productRepository.findById(id)31                .orElseThrow(ProductNotFound::new);32 33        Product updatedProduct = product.update(request.name(), request.price(), request.type());34        Product savedProduct = productRepository.save(updatedProduct);35 36        return ProductResponse.from(savedProduct);37    }38 39    @Transactional40    public void deleteById(Long id) {41        Product product = productRepository.findById(id)42                .orElseThrow(ProductNotFound::new);43 44        productRepository.delete(product);45    }46 47}

 

작은 프로젝트라면 이 방식이 더 낫다고 생각한다.

패턴을 적용하기 위해 억지로 클래스를 나누면 오히려 가독성이 떨어지는 코드가 될 수 있다고 본다.

 

 

쓰기와 읽기의 관심사

문제는 기능이 늘어나면서 시작됐다.

 

쓰기 기능에는 도메인 규칙이 중요했다.

  • 상품 가격은 음수일 수 없다.

  • 상품 타입에 따라 수정 가능한 값이 다르다.

  • 재고는 0보다 작아질 수 없다.

  • 상태 변경은 트랜잭션 안에서 처리되어야 한다.

 

반면 읽기에서는 다른 것들이 중요하다.

  • 화면에 필요한 필드만 내려주기

  • 검색 조건 처리

  • Entity를 그대로 노출하지 않기

  • 조회 성능

 

이렇게 보면 쓰기와 읽기는 같은 데이터를 다루지만 관심사가 꽤 다르다.

 

 

세 가지 선택지

이 상황에서 선택지는 크게 세 가지였다.

Copied
011. 기존 CRUD Service 유지02기능이 작고 조회 요구사항이 복잡하지 않다면 이 방식이 최고다.03 042. 같은 DB를 사용하되 Service 분리05쓰기와 읽기의 책임을 애플리케이션 레벨에서 분리한다.06이번 글에서 다루는 방식이다.07 083. Primary-Replica로 읽기/쓰기 DB를 분리09쓰기와 읽기를 인프라 레벨에서 분리한다.

 

현재 단계에서는 인프라까지 분리할 이유가 없다고 생각했다.

다만 쓰기와 조회의 관심사를 분리하면 코드가 더 명확해질 것 같았다.

 

 

Command

CommandService는 상태를 바꾸는 작업만 담당하게 했다.

ProductCommandService
Copied
01@Service02@Transactional03@RequiredArgsConstructor04public class ProductCommandService {05 06    private final ProductRepository productRepository;07 08    public Long create(ProductCreateCommand request) {09        Product product = Product.create(request.name(), request.price(), request.type());10 11        Product savedProduct = productRepository.save(product);12        return savedProduct.getId();13    }14 15    public void update(Long id, ProductUpdateCommand request) {16        Product product = productRepository.findById(id)17                .orElseThrow(ProductNotFound::new);18        Product updatedProduct = product.update(request.name(), request.price(), request.type());19        productRepository.save(updatedProduct);20    }21 22    public void deleteById(Long id) {23        Product product = productRepository.findById(id)24                .orElseThrow(ProductNotFound::new);25 26        productRepository.delete(product);27    }28    29}

도메인 규칙을 지키고 트랜잭션 안에서 상태를 안전하게 변경하는 것이 중요하다.

 

Command의 반환값도 고민했다.

DTO를 반환할 수도 있지만, 나는 우선 id 정도만 반환하는 방식이 더 명확하다고 봤다.

상태를 변경하는 책임과 응답을 만드는 책임을 섞지 않기 위해서다.

 

 

Query

QueryService는 조회에 필요한 응답 형태를 만드는 데 집중하게 했다.

ProductQueryResponse
Copied
01public record ProductQueryResponse(02        Long id,03        String name,04        BigDecimal price,05        ProductType type06) {07}
ProductQueryRepository
Copied
01public interface ProductQueryRepository extends Repository<ProductEntity, Long> {02 03    @Query("""04            select new hello.spring.practice.cqrs.application.dto.ProductQueryResponse(05            p.id,06            p.name,07            p.price,08            p.type09            )10            from ProductEntity p11            where p.id = :id12            """)13    Optional<ProductQueryResponse> findProduct(Long id);14 15    @Query("""16            select new hello.spring.practice.cqrs.application.dto.ProductQueryResponse(17            p.id,18            p.name,19            p.price,20            p.type21            )22            from ProductEntity p23            """)24    List<ProductQueryResponse> findAllProducts();25}26 

Entity를 그대로 조회해서 Response로 변환할 수도 있지만, 화면에 필요한 필드가 정해져 있다면 DTO Projection을 사용할 수 있다.

조회 쪽에서는 도메인 객체의 상태 변경이 필요하지 않다.
오히려 필요한 데이터를 필요한 모양으로 빠르게 가져오는 것이 더 중요하다.

 

ProductQueryRepository는 JpaRepository가 아니라 Repository를 상속했다.
조회 전용 Repository에서 save, delete 같은 메서드가 열려 있을 필요가 없다고 생각했기 때문이다.

 

 

CQRS와 Primary-Replica

처음에는 CQRS와 Primary-Replica가 비슷하게 느껴졌다.
둘 다 쓰기와 읽기를 나누기 때문이다.

 

하지만 둘은 관점이 다르다.

 

CQRS는 애플리케이션 설계 관점에서 Command 모델과 Query 모델을 나누는 것이다.
Primary-Replica는 DB 인프라 관점에서 쓰기 DB와 읽기 DB를 나누는 것이다.

둘은 함께 사용할 수 있지만 같은 개념은 아니다.

즉, CommandService와 QueryService를 나눴다고 해서 반드시 DB까지 나눠야 하는 것은 아니다.

 

 

정리

이번에 CQRS를 공부하면서 느낀 점은 CQRS는 처음부터 거창하게 적용할 패턴이 아니라는 것이다.

 

작은 기능에서는 하나의 CRUD Service가 더 단순할 수 있다.
하지만 시간이 지나면서 쓰기와 읽기가 서로 다른 이유로 변경되기 시작하면,
그때 Command와 Query를 나누는 것을 고민해볼 수 있다.

 

내 기준은 다음과 같다.

  • 쓰기 로직에 도메인 규칙과 트랜잭션이 많아진다

  • 조회 로직에 검색, 정렬, 페이징, join이 많아진다

  • Entity를 그대로 조회하기보다 화면 별 DTO가 필요해진다

  • 하나의 Service가 너무 많은 변경 이유를 갖게 된다

 

이런 상황이라면 같은 DB를 사용하더라도
CommandService와 QueryService를 나누는 것부터 시작할 수 있다.

 

반대로 단순 CRUD라면 굳이 CQRS를 적용하지 않아도 된다.
패턴은 구조를 복잡하게 만들기 위해 쓰는 것이 아니라,
이미 생긴 복잡도를 분리하기 위해 쓰는 것이기 때문이다.

  1. 단일 Service
  2. 쓰기와 읽기의 관심사
  3. 세 가지 선택지
  4. Command
  5. Query
  6. CQRS와 Primary-Replica
  7. 정리

'개발' 카테고리의 다른 글

  • 좋은 설계란 무엇일까?→
  • SQL Server Logical Reads 기반 조회 쿼리 개선기→
목록으로