Command Handler와 Application Service, 뭐가 다른가
Command Handler와 Application Service, 뭐가 다른가
DDD나 클린 아키텍처를 구현할 때, 최근에는 CQRS(명령-조회 책임 분리) 패턴을 결합하여 Command/Query Handler를 두는 구조를 자주 사용합니다.
하지만 이때 반드시 마주치는 혼란이 있습니다. **"이 로직을 CQRS의 Handler에 그냥 둘지, 전통적인 Application Service로 뽑아야 할지"**에 대한 경계선입니다.
둘 다 유스케이스를 처리하는 '애플리케이션 계층'의 컴포넌트이다 보니 역할이 겹쳐 보이고, 감으로 나누다 보니 코드베이스마다 기준이 흔들렸습니다. 그래서 CQRS의 Handler 패턴을 유지하면서도 Application Service와 어떻게 조화를 이루어야 하는지 그 기준을 정리해두려고 합니다.
네 가지 역할
| 구성요소 | 책임 |
|---|---|
| Command / Query Handler | 외부 또는 Application 진입 요청을 받는 얇은 UseCase 경계 |
| Application Service | 실제 Application Layer의 업무 흐름/단계 로직을 담는 서비스 |
| Domain Entity | 상태 전이와 불변 조건을 보장하는 핵심 모델 |
| Repository / Port | 저장소나 외부 능력에 접근하는 추상화 |
헷갈리는 지점은 대부분 Handler와 Application Service 사이입니다. 둘 다 "요청을 받아서 처리한다"는 점에서 비슷해 보이기 때문입니다. 차이는 진입점이냐 아니냐에 있었습니다.
CQRS Handler만 쓸 때의 함정
CQRS 패턴에 심취해 모든 비즈니스 흐름을 CommandHandler 하나에 밀어 넣다 보면 뜻밖의 복병을 만납니다.
- 중복 코드의 발생: 만약 "주문 취소"라는 흐름이 사용자 요청뿐만 아니라 '스케줄러 배치 작업'이나 '관리자 API' 등 다른 경로에서도 필요하다면 어떻게 할까요? Handler 안의 코드를 복사하거나, 하나의 Handler가 다른 Handler를 호출하는 어색한 의존성이 생깁니다.
- 단일 진입점의 부재: Handler는 외부 요청(DTO)을 받아들이는 '문'의 역할에 최적화되어 있어, 순수한 비즈니스 흐름 단위로 재사용하기가 까다롭습니다.
이 문제를 해결하기 위해 **"진입점(Handler)"**과 **"실제 일하는 흐름(Application Service)"**의 역할을 철저히 분리해야 합니다.
판단 기준
- 외부 진입점이 있으면 → Command Handler
- 내부 재사용 단계면 → Application Service
- 둘 다 필요하면 → Command Handler가 Application Service를 감싼다
즉 Command Handler는 "밖에서 들어오는 문"이고, Application Service는 "그 문 뒤에서 실제로 일하는 로직"입니다. 문이 없는 로직을 굳이 문처럼 만들 필요는 없습니다.
예시: 주문 취소
// Command Handler — 외부 진입점
class CancelOrderHandler {
constructor(private readonly cancelOrder: CancelOrderService) {}
async handle(command: CancelOrderCommand) {
await this.cancelOrder.execute(command.orderId, command.reason);
}
}
// Application Service — 업무 흐름 (여러 Handler/배치에서 재사용 가능)
class CancelOrderService {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentPort,
) {}
async execute(orderId: string, reason: string) {
const order = await this.orders.findById(orderId);
order.cancel(reason); // 불변 조건 검사는 Entity가 담당
await this.payments.refund(order.paymentId);
await this.orders.save(order);
}
}CancelOrderHandler는 외부 요청을 받는 것 외에는 아무 일도 하지 않습니다. 실제 취소 흐름(주문 조회 → 상태 전이 → 환불 → 저장)은 CancelOrderService에 있고, "취소 가능한 상태인가" 같은 불변 조건은 Order 엔티티의 cancel() 안에서 보장됩니다.
만약 관리자 배치 작업에서도 같은 취소 흐름이 필요하다면, CancelOrderService를 그대로 재사용하고 배치 쪽에는 별도 Handler를 두거나 아예 Handler 없이 직접 호출하면 됩니다.
Application Service로 추출하는 기준
- 여러 UseCase에서 재사용되면 Application Service로 추출
- 상위 UseCase의 내부 단계인데, 책임 이름이 명확하면 Application Service로 추출
- 한 Handler 안에서만 쓰이고 작으면 private method로 충분
세 번째 기준이 특히 중요했습니다. "재사용되지 않는다고 무조건 private method"가 아니라, 이름 붙일 만큼 책임이 명확한지를 먼저 봅니다. 재사용 여부보다 응집도가 더 우선순위가 높은 기준이라는 뜻입니다.
Command Handler를 유지/생략하는 기준
- 외부 진입점이 있거나 독립적으로 호출될 의미가 있으면 Command Handler 유지
- 외부 진입점이 없고 내부 단계일 뿐이면 Command Handler까지 만들 필요 없음
Application Service를 만들 때마다 짝으로 Command Handler를 만들 필요는 없습니다. Handler는 "그 자체로 하나의 유스케이스 진입점"일 때만 의미가 있고, 다른 서비스의 내부 단계라면 굳이 껍데기를 씌우지 않아도 됩니다.
정리
(외부 요청)
│
▼
Command / Query Handler ── 얇은 진입점, 조립만
│
▼
Application Service ── 업무 흐름, 여러 진입점에서 재사용 가능
│
├──▶ Domain Entity ── 상태 전이 + 불변 조건
└──▶ Repository / Port ── 저장소/외부 시스템 접근
결국 기준은 하나로 요약됩니다. "이 코드가 밖에서 독립적으로 불릴 이유가 있는가". 있으면 Handler, 없으면 그 아래 계층에서 끝내면 됩니다.