본문으로 건너뛰기

Query Usecase 예제

앞 장에서 Command Usecase를 구현했습니다. 이번에는 Query Usecase를 다룹니다. 이 장에서는 Functorium의 IQueryRequest<TSuccess> 인터페이스를 활용하여 Query Usecase의 완전한 구현 예제를 작성합니다. Query는 Command와 달리 데이터를 읽기만 하므로 Transaction Pipeline이 적용되지 않으며, ICacheable을 구현하여 캐싱 최적화를 적용할 수 있습니다.

Query Usecase 구조:
GetProductQuery (최상위 클래스)
├── Request : IQueryRequest<Response>, ICacheable ← 읽기 전용 요청 + 캐싱
├── Response ← 조회 결과
└── Handler : IQueryUsecase<Request, Response> ← 조회 로직

이 장을 완료하면 다음을 할 수 있습니다:

  1. IQueryRequest<TSuccess>IQueryUsecase<TQuery, TSuccess> 인터페이스의 역할과 Command와의 차이를 설명할 수 있습니다
  2. ICacheable 인터페이스를 구현하여 Query에 캐싱 최적화를 적용할 수 있습니다
  3. Query Handler가 읽기 전용으로 동작하는 패턴을 이해할 수 있습니다
  4. Pipeline이 Command/Query를 타입 수준에서 구분하는 방식을 설명할 수 있습니다

IQueryRequest<TSuccess>는 Mediator의 IQuery<FinResponse<TSuccess>>를 상속합니다. Pipeline은 이 인터페이스를 통해 요청이 Query임을 인식합니다.

// Functorium 정의
public interface IQueryRequest<TSuccess> : IQuery<FinResponse<TSuccess>> { }

Handler는 IQueryUsecase<TQuery, TSuccess>를 구현합니다. 이 인터페이스는 IQueryHandler<TQuery, FinResponse<TSuccess>>를 상속하므로, Handler가 이를 구현하면 Mediator가 자동으로 Pipeline 체인에 등록합니다:

// Functorium 정의
public interface IQueryUsecase<in TQuery, TSuccess>
: IQueryHandler<TQuery, FinResponse<TSuccess>>
where TQuery : IQueryRequest<TSuccess> { }

Command와 Query를 인터페이스로 구분하면, Pipeline의 where 제약 조건을 통해 컴파일 타임에 적용 대상이 결정됩니다:

  • ICommandRequestICommand<TResponse>를 상속 → Transaction Pipeline(where TRequest : ICommand<TResponse>) 적용
  • IQueryRequestIQuery<TResponse>를 상속 → Caching Pipeline(where TRequest : IQuery<TResponse>) 적용 가능

두 패턴의 핵심 차이를 정리하면 다음과 같습니다.

항목CommandQuery
인터페이스ICommandRequest<T>IQueryRequest<T>
데이터 변경O (생성/수정/삭제)X (읽기만)
Transaction적용미적용
Caching일반적으로 미적용ICacheable 구현 시 적용
반환 타입FinResponse<TSuccess>FinResponse<TSuccess>

Query Request가 ICacheable을 구현하면 Caching Pipeline이 자동으로 캐시를 적용합니다.

// ICacheable 인터페이스 (Functorium 정의)
public interface ICacheable
{
string CacheKey { get; }
TimeSpan? Duration { get; }
}

이 예제의 GetProductQuery.Request가 실제로 ICacheable을 구현합니다:

public sealed record Request(string ProductId)
: IQueryRequest<Response>, ICacheable
{
public string CacheKey => $"product:{ProductId}";
public TimeSpan? Duration => TimeSpan.FromMinutes(5);
}

Caching Pipeline은 request is ICacheable로 조건부 캐싱을 수행하므로, ICacheable을 구현하지 않은 Query는 캐싱을 건너뜁니다.

Query Handler는 상태를 변경하지 않으므로, 의존성이 읽기 전용 저장소(Repository의 Query 부분)에만 의존합니다.

public sealed class Handler : IQueryUsecase<Request, Response>
{
private readonly Dictionary<string, Response> _products = new() { ... };
public ValueTask<FinResponse<Response>> Handle(Request query, CancellationToken cancellationToken)
{
FinResponse<Response> result = _products.TryGetValue(query.ProductId, out var product)
? product
: Error.New($"Product not found: {query.ProductId}");
return new ValueTask<FinResponse<Response>>(result);
}
}

Q1: Query Usecase에 Validator가 없는 이유는 무엇인가요?

섹션 제목: “Q1: Query Usecase에 Validator가 없는 이유는 무엇인가요?”

A: 이 예제에서는 간결성을 위해 Validator를 생략했습니다. 실제 프로젝트에서는 Query에도 Validator를 추가할 수 있습니다. 예를 들어 ProductId가 빈 문자열인지 검사하는 것은 유효한 검증입니다. Validator 추가 여부는 비즈니스 요구사항에 따라 결정합니다.

Q2: IQueryRequestICommandRequest를 분리하면 Pipeline에서 어떤 이점이 있나요?

섹션 제목: “Q2: IQueryRequest와 ICommandRequest를 분리하면 Pipeline에서 어떤 이점이 있나요?”

A: Pipeline의 where 제약 조건을 통해 컴파일 타임에 적용 대상이 결정됩니다. Transaction Pipeline은 where TRequest : ICommand<TResponse> 제약으로 Command에만, Caching Pipeline은 where TRequest : IQuery<TResponse> 제약으로 Query에만 등록됩니다. Mediator 소스 제너레이터가 이 제약을 확인하여 해당 타입에만 Pipeline을 적용하므로, 런타임 타입 검사 없이 인터페이스 제약만으로 분기가 결정됩니다.

Q3: ICacheableDurationnull이면 어떻게 되나요?

섹션 제목: “Q3: ICacheable의 Duration이 null이면 어떻게 되나요?”

A: Durationnull이면 Caching Pipeline이 기본 캐시 만료 시간을 적용합니다. 이를 통해 대부분의 Query에는 기본값을 사용하고, 특정 Query에만 커스텀 만료 시간을 설정할 수 있습니다.

Q4: Query Handler가 Dictionary를 사용하는 것은 실전에서도 동일한가요?

섹션 제목: “Q4: Query Handler가 Dictionary를 사용하는 것은 실전에서도 동일한가요?”

A: 아닙니다. 예제에서는 학습 목적으로 Dictionary를 인메모리 저장소로 사용했습니다. 실전에서는 Repository 인터페이스를 DI로 주입받아 데이터베이스에서 조회하며, Repository가 반환하는 Fin<T>ToFinResponse()로 변환하여 FinResponse<T>를 반환합니다.

02-Query-Usecase-Example/
├── QueryUsecaseExample/
│ ├── QueryUsecaseExample.csproj
│ ├── GetProductQuery.cs
│ └── Program.cs
├── QueryUsecaseExample.Tests.Unit/
│ ├── QueryUsecaseExample.Tests.Unit.csproj
│ ├── xunit.runner.json
│ └── GetProductQueryTests.cs
└── README.md
Terminal window
# 프로그램 실행
dotnet run --project QueryUsecaseExample
# 테스트 실행
dotnet test --project QueryUsecaseExample.Tests.Unit

7개 기본 Pipeline과 Custom Pipeline 슬롯(총 8개)을 모두 연결하여, Command/Query의 전체 요청 처리 흐름을 시뮬레이션합니다.

5.3장: Pipeline 전체 흐름 통합