본문으로 건너뛰기

왜 CQRS인가

금요일 오후, 기획자가 슬랙 메시지를 보냅니다: “고객별 주문 이력 필터를 추가해주세요.”

OrderRepository를 열어봅니다. 이미 GetByCustomer, GetRecent, GetSummaries, SearchByKeyword가 있습니다. 하나 더 추가하면 되겠지? 그런데 돌아보면 3개월 전에도 같은 작업을 했고, 그때도 “하나만 추가”했습니다. 지금 Repository 메서드는 15개. 다음 분기엔 25개가 될 겁니다.

“이게 정말 Repository인가?” 라는 질문이 떠오르는 순간, 이 튜토리얼이 시작됩니다.

이 튜토리얼은 Command와 Query의 책임 분리(CQRS)로 그 문제를 해결합니다. 도메인 엔티티 기초에서 시작하여 Repository 패턴, Query 어댑터, Usecase 통합까지, 22개의 실습 프로젝트를 통해 CQRS 패턴의 모든 측면을 단계별로 학습합니다.


하나의 모델로 모든 것을 처리하는 문제

섹션 제목: “하나의 모델로 모든 것을 처리하는 문제”

대부분의 애플리케이션은 하나의 모델로 읽기와 쓰기를 모두 처리합니다. 다음 코드를 보세요.

// ❌ 하나의 Repository가 모든 책임을 짊어짐
public interface IOrderRepository
{
// 쓰기 (Command)
Task<Order> CreateAsync(Order order);
Task UpdateAsync(Order order);
Task DeleteAsync(Guid id);
// 읽기 (Query)
Task<Order> GetByIdAsync(Guid id);
Task<List<Order>> GetByCustomerAsync(Guid customerId);
Task<List<Order>> GetRecentOrdersAsync(int count);
Task<List<OrderSummary>> GetOrderSummariesAsync(int page, int size);
Task<List<Order>> SearchAsync(string keyword, DateTime? from, DateTime? to);
// ... 조회 조건이 늘어날 때마다 메서드 추가
}

하나의 인터페이스에 쓰기 4개, 읽기 5개 메서드가 뒤섞여 있고, 새 조회 조건이 생길 때마다 메서드가 추가됩니다. 다음 표는 이 방식이 만들어내는 구체적인 문제를 정리합니다.

문제설명
읽기/쓰기 요구사항 충돌쓰기는 도메인 불변식 검증이 필요하고, 읽기는 빠른 프로젝션이 필요
모델 비대화읽기 전용 필드와 쓰기 전용 로직이 하나의 클래스에 혼재
성능 최적화 어려움읽기와 쓰기의 성능 특성이 다르지만 동일 경로를 사용
메서드 폭발조회 조건 조합마다 새로운 메서드가 필요
테스트 복잡도하나의 Repository에 대한 테스트가 비대해짐

CQRS(Command Query Responsibility Segregation)는 쓰기 모델과 읽기 모델을 분리하여 각각의 요구사항에 맞게 최적화합니다. 쓰기는 Aggregate Root 단위로, 읽기는 Specification 기반 동적 검색으로 접근하면 위 문제가 모두 해소됩니다.

// ✅ Command 측: Aggregate Root 단위 영속화
public interface IRepository<TAggregate, TId>
{
FinT<IO, TAggregate> Create(TAggregate aggregate);
FinT<IO, TAggregate> GetById(TId id);
FinT<IO, TAggregate> Update(TAggregate aggregate);
FinT<IO, int> Delete(TId id);
}
// ✅ Query 측: DTO 프로젝션 + 페이지네이션
public interface IQueryPort<TEntity, TDto>
{
FinT<IO, PagedResult<TDto>> Search(
Specification<TEntity> spec,
PageRequest page,
SortExpression sort);
}

다음 표는 앞서 나열한 각 문제가 CQRS에서 어떻게 해결되는지 대응시킵니다.

문제CQRS 해결 방식
읽기/쓰기 충돌Command(IRepository)와 Query(IQueryPort)를 분리
모델 비대화Command는 도메인 모델, Query는 DTO로 분리
성능 최적화읽기와 쓰기를 독립적으로 최적화 가능
메서드 폭발Specification 기반 동적 검색으로 해결
테스트 복잡도Command와 Query를 독립적으로 테스트

여러분의 경험 수준에 따라 학습 범위를 선택할 수 있습니다.

수준대상권장 학습 범위
초급C# 기본 문법을 알고 CQRS 패턴에 입문하려는 개발자Part 1
중급패턴을 이해하고 실전 적용을 원하는 개발자Part 1~3
고급아키텍처 설계와 도메인 모델링에 관심 있는 개발자Part 4~5 + 부록

이 튜토리얼을 효과적으로 학습하려면 C# 기본 문법(클래스, 인터페이스, 제네릭)과 객체지향 프로그래밍 기초 개념을 이해하고 있어야 하며, .NET 프로젝트를 실행해 본 경험이 필요합니다.

LINQ 기본 문법, 단위 테스트 경험, 도메인 주도 설계(DDD) 기초 개념(Entity, Aggregate Root), Entity Framework Core 기본 사용 경험이 있으면 학습이 더 수월합니다. 다만 이들은 필수가 아니므로 튜토리얼을 진행하면서 익혀도 됩니다.


이 튜토리얼을 완료하면 다음을 할 수 있습니다:

1. Aggregate Root 단위 Repository로 쓰기 작업 구현

섹션 제목: “1. Aggregate Root 단위 Repository로 쓰기 작업 구현”

IRepository를 통해 도메인 모델의 불변식을 보장하면서 영속화할 수 있습니다.

// IRepository로 Aggregate 단위 영속화
public class CreateOrderUsecase(IRepository<Order, OrderId> repository)
: ICommandUsecase<CreateOrderCommand, OrderId>
{
public async ValueTask<FinResponse<OrderId>> Handle(
CreateOrderCommand command, CancellationToken ct)
{
var order = Order.Create(OrderId.New(), command.CustomerId);
var fin = await repository.Create(order).RunAsync();
return fin.ToFinResponse(o => o.Id);
}
}

2. Query 어댑터로 읽기 전용 최적화 조회 구현

섹션 제목: “2. Query 어댑터로 읽기 전용 최적화 조회 구현”

IQueryPort와 Specification을 조합하면 조회 조건이 늘어나도 메서드를 추가할 필요가 없습니다.

// IQueryPort로 DTO 프로젝션 + 페이지네이션
public class SearchOrdersUsecase(IQueryPort<Order, OrderDto> query)
: IQueryUsecase<SearchOrdersQuery, PagedResult<OrderDto>>
{
public async ValueTask<FinResponse<PagedResult<OrderDto>>> Handle(
SearchOrdersQuery request, CancellationToken ct)
{
var spec = new OrderByCustomerSpec(request.CustomerId);
var fin = await query.Search(spec, request.Page, request.Sort).RunAsync();
return fin.ToFinResponse();
}
}

3. FinT 모나드로 함수형 파이프라인 구성

섹션 제목: “3. FinT 모나드로 함수형 파이프라인 구성”

여러 Repository 호출을 from...select 구문으로 연결하면 에러 처리가 자동으로 전파됩니다.

// from...select 구문으로 모나딕 합성
var pipeline =
from order in repository.GetById(orderId)
from _ in guard(order.CanCancel(), Error.New("취소 불가"))
from __ in repository.Update(order.Cancel())
select order.Id;
var fin = await pipeline.RunAsync();
return fin.ToFinResponse();

4. 트랜잭션 파이프라인으로 일관성 보장

섹션 제목: “4. 트랜잭션 파이프라인으로 일관성 보장”

Command Usecase는 트랜잭션 파이프라인을 자동으로 통과하므로, SaveChanges와 도메인 이벤트 발행을 직접 호출할 필요가 없습니다.

// Command는 자동으로 트랜잭션 파이프라인을 통과
// SaveChanges + 도메인 이벤트 발행이 자동 처리됨
ICommandRequest<TSuccess> -> UsecaseTransactionPipeline -> ICommandUsecase

Part 0: 서론
├── CQRS 패턴의 개념과 필요성
├── 환경 설정
└── CQRS 아키텍처 개요
Part 1: 도메인 엔티티 기초
├── Entity<TId>와 IEntityId
├── AggregateRoot<TId>
├── 도메인 이벤트
└── 엔티티 인터페이스 (IAuditable, ISoftDeletable)
Part 2: Command 측 -- Repository 패턴
├── IRepository<TAggregate, TId> 인터페이스
├── InMemory Repository 구현
├── EF Core Repository 구현
└── Unit of Work 패턴
Part 3: Query 측 -- 읽기 전용 패턴
├── IQueryPort<TEntity, TDto> 인터페이스
├── Command DTO vs Query DTO 분리
├── 페이지네이션과 정렬
├── InMemory Query 어댑터
└── Dapper Query 어댑터
Part 4: CQRS Usecase 통합
├── Command/Query Usecase
├── FinT -> FinResponse 변환
├── 도메인 이벤트 흐름
└── 트랜잭션 파이프라인
Part 5: 도메인별 실전 예제
├── 주문 관리
├── 고객 관리
├── 재고 관리
└── 카탈로그 검색

다음 표는 빠른 실습 중심의 Tutorial과 이 튜토리얼의 접근 방식 차이를 비교합니다.

구분Tutorial이 튜토리얼
목적빠른 실습과 결과 확인개념 이해와 설계 원리 학습
깊이핵심 사용법 중심내부 구현과 원리 심화
범위CQRS 기본 사용Repository, Query 어댑터, 트랜잭션, 이벤트
대상바로 적용하려는 개발자패턴을 깊이 이해하려는 개발자

초급 (Part 1)
├── Entity와 Identity 구현
├── Aggregate Root와 도메인 불변식
├── 도메인 이벤트
└── 엔티티 인터페이스
중급 (Part 2~3)
├── Repository 인터페이스와 구현
├── Unit of Work 패턴
├── Query 어댑터와 DTO 분리
└── 페이지네이션과 정렬
고급 (Part 4~5 + 부록)
├── Command/Query Usecase 통합
├── FinT 모나딕 합성
├── 트랜잭션 파이프라인
└── 도메인별 실전 예제

Q1: CQRS는 모든 프로젝트에 적용해야 하나요?

섹션 제목: “Q1: CQRS는 모든 프로젝트에 적용해야 하나요?”

A: 아닙니다. CQRS는 읽기와 쓰기의 요구사항이 크게 다를 때 가치를 발휘합니다. 단순 CRUD 위주의 애플리케이션에서는 오히려 복잡도만 증가시킬 수 있습니다. 조회 조건이 다양하고 성능 최적화가 필요한 도메인부터 점진적으로 도입하는 것을 권장합니다.

Q2: FinT<IO, T>Task<T>보다 나은 점은 무엇인가요?

섹션 제목: “Q2: FinT<IO, T>가 Task<T>보다 나은 점은 무엇인가요?”

A: Task<T>는 예외를 던져 실패를 표현하지만, FinT<IO, T>는 성공과 실패를 값으로 표현합니다. 이를 통해 from...select LINQ 구문으로 여러 Repository 호출을 합성할 수 있고, 예외 기반 제어 흐름 없이 Railway-oriented programming을 구현할 수 있습니다.

Q3: IRepositoryIQueryPort를 분리하면 코드가 더 많아지지 않나요?

섹션 제목: “Q3: IRepository와 IQueryPort를 분리하면 코드가 더 많아지지 않나요?”

A: 초기에는 인터페이스와 DTO가 늘어나지만, 조회 조건이 추가될 때 Repository에 메서드를 추가하는 “메서드 폭발” 문제가 사라집니다. IQueryPort는 Specification 기반 동적 검색을 지원하므로 새 필터 조건을 추가해도 인터페이스 변경이 필요 없습니다.

Q4: 이 튜토리얼은 어떤 순서로 학습하면 좋을까요?

섹션 제목: “Q4: 이 튜토리얼은 어떤 순서로 학습하면 좋을까요?”

A: 초급자는 Part 1(도메인 엔티티 기초)부터 순서대로 진행하세요. CQRS 개념을 이미 알고 있다면 Part 2(Repository 패턴)이나 Part 4(Usecase 통합)부터 시작해도 됩니다. 각 Part는 독립적으로 빌드/테스트할 수 있습니다.


CQRS가 왜 필요한지 확인했으니, 이제 개발 환경을 준비할 차례입니다. 다음 장에서는 .NET SDK 설치, VS Code 설정, 튜토리얼 프로젝트 클론까지 환경 설정 전 과정을 안내합니다.

0.2장: 환경 설정