ArchitectureJan 2, 2026

Clean Architecture 적용 가이드: 주문 흐름을 의존성 규칙으로 고정하기 (Nest.js + MikroORM)

Clean Architecture 적용 가이드: 주문 흐름을 의존성 규칙으로 고정하기 (Nest.js + MikroORM)

Clean Architecture 적용 가이드: 주문 흐름을 의존성 규칙으로 고정하기 (Nest.js + MikroORM)

이 글은 Clean Architecture를 “폴더 구조 템플릿”이 아니라 의존성 규칙(Dependency Rule)으로 다룬다. 주문(Order) 예시 흐름(presentation → application → domain ← infrastructure)을 기준으로, 레이어 책임과 의존성 방향을 코드 경계로 고정하는 방법을 정리한다.

이 글은 DDD 글과 같은 예시/같은 흐름을 공유하지만, 여기서는 의존성 방향, 레이어 책임, 포트/어댑터, DTO 변환, 테스트 전략처럼 “경계를 유지하는 방법”을 중심으로 다룬다. 경계(Bounded Context), 도메인 규칙/불변조건(invariant), 도메인 이벤트는 아래 DDD 글에서 더 자세히 다룬다.

이 글의 초점은 세 가지다.

  • 도메인 규칙을 건드리지 않고 경계를 통해 변경 비용을 줄이는 방법
  • HTTP/Cron/Queue처럼 실행 역할이 달라도 동일 경계를 유지하는 방법
  • Reader/BFF/contracts를 “조회/통합 경계”로 묶어 운영하는 방법


목차



Dependency Rule (의존성 규칙)

Clean Architecture의 핵심은 한 문장으로 요약된다.

의존성은 항상 “안쪽(정책/규칙)”으로만 향한다.

프레임워크/ORM/DB는 바뀌기 쉬운 구현 디테일이고, 도메인/유스케이스는 상대적으로 안정적인 정책이다. 그래서 바깥(구현)이 안쪽(정책)을 알아야 하고, 안쪽은 바깥을 몰라야 한다.


이 규칙이 지켜지면 ORM/프레임워크 변경이 와도 도메인/유스케이스 수정 범위가 급격히 줄어든다.



Boundary Contract (용어/포트/공유 경계)

먼저 용어와 경계를 고정하면, 이후 코드 예시를 읽을 때 레이어 책임이 흔들리지 않는다.

1) 용어와 책임 매핑 (Clean Architecture ↔ 팀 표준 용어)

표준 용어를 팀 코드베이스의 용어로 고정하지 않으면 Clean Architecture는 금방 “구호”가 된다. 그래서 이 글은 표준 용어를 아래처럼 팀 표준 용어로 매핑해서 사용한다.

Clean Architecture 표준 용어팀 표준 용어핵심 책임
Controller (Inbound Adapter)presentation/*HTTP 입력 처리 + DTO → Criteria 변환
Use Case / Interactorapplication/commands, application/queries트랜잭션/오케스트레이션 + 도메인 호출
Entity / Enterprise Ruledomains/entities/*불변조건/상태 전이/규칙
Input Portapplication/* public API유스케이스 진입점(명세)
Output Portdomains/repositories/*외부 의존 인터페이스(repository/gateway/publisher)
Presenter / ViewModelpresentation/*response.tsResult → API 응답 변환
Adapter(Repository/Gateway)infrastructure/*, lib/*Output Port 구현(ORM/외부 API)
Frameworks & DriversNestJS/MikroORM/DB/SQS런타임/인프라 디테일

2) Ports & Adapters(입출력 포트/어댑터)

구현 레벨에서 Clean Architecture는 결국 “포트와 어댑터”로 정리된다. 핵심은 “인터페이스를 어디에 두느냐”다.

  • Output Port(인터페이스)는 안쪽(도메인/유스케이스)에 둔다. (domains/repositories/*)
  • Adapter(구현)는 바깥(인프라/게이트웨이)에 둔다. (infrastructure/*, lib/*)

같은 맥락으로 Input Port도 “진입 계약”으로 고정한다. 즉, Controller가 어떤 프레임워크를 쓰든 application 진입 시그니처는 유지되도록 둔다.


3) Shared/Common/Lib + Contracts 경계

재사용 위치는 “편의”가 아니라 “의존성 방향”으로 구분한다.

위치두는 것(Do)두지 말아야 할 것(Don't)
shared/*Money/Email/Nullable/Pagination 같은 순수 공통 타입/VO/유틸Command/Query/Event/Reader 계약, framework 의존 코드
common/*Guard/Filter/Interceptor/Decorator, 에러 매핑 등 기술 공통특정 도메인 정책/용어가 강한 규칙
lib/*outbox/queue/lambda 등 런타임 어댑터aggregate 상태 전이 같은 도메인 규칙
contractsOpenAPI + Context 간 메시지/이벤트 계약(외부 통합 언어)Domain Entity/VO, Repository/Handler 같은 내부 구현 공유

핵심은 “외부 통합 모델”과 “내부 도메인 모델”을 분리해, API 계약 변경과 도메인 변경의 파급을 끊는 것이다.


위 규칙을 실제로 운영하려면 팀이 자주 헷갈리는 네이밍을 함께 고정해 두는 편이 좋다.

  • 경계 용어: presentation(입력) / application(유스케이스 실행) / domain(규칙) / infra, gateway(구현) / eventlistener(후처리)
  • 의존 방향: 바깥(구현) → 안쪽(정책/규칙)만 허용

프로젝트에서 자주 쓰는 네이밍을 함께 고정하면, 포트/어댑터는 “구조 템플릿”이 아니라 “일관된 규칙”이 된다.

  • domains/*: entity/domain service/repository port 같은 핵심 도메인 구성요소
  • application/commands, application/queries: Command/Query 유스케이스 실행 경계
  • infrastructure/*: repository/gateway 등 Output Adapter 구현
  • contracts/*: Context 간 통신 계약, shared/*: 순수 공통, common/*: 프레임워크 공통


같은 흐름으로 보는 코드 경계 (presentation → application → domain ← infrastructure)

이 섹션은 같은 주문 예시 흐름을 “Clean 관점”으로 다시 읽는다. 핵심은 레이어별 책임과 변환 지점을 고정하는 것이다.

0) presentation: Controller (Inbound Adapter)

컨트롤러는 얇게 유지한다. 핵심은 변환이다.

  • Request DTO → Criteria
  • Result → Response DTO
ts
// modules/ordering/presentation/orders.controller.ts
@Controller("orders")
export class OrdersController {
	constructor(
		private readonly commandBus: CommandBus,
		private readonly queryBus: QueryBus,
	) {}

	@Post()
	@ApiDataResponse({ model: CreateOrderResponse }, { status: 201 })
	@ApiErrorEnvelopeResponse({ status: 400 })
	async create(
		@Body() body: CreateOrderRequest,
	): Promise<DataEnvelope<CreateOrderResponse>> {
		const command = new CreateOrderCommand(body);
		const result = await this.commandBus.execute(command);
		const response = CreateOrderResponse.fromResult(result);

		return ResponseHelper.data(response);
	}
}

위 예시는 Command 유스케이스를 보여준다. 실무에서는 application/commandsapplication/queries를 분리해, 상태 변경(Command)과 조회(Query)의 책임을 명확히 나누는 편이 운영과 테스트에 유리하다.


1) application: Use Case / Interactor

application은 “규칙을 담는 곳”이 아니라 “규칙을 실행하는 곳”이다. 트랜잭션/오케스트레이션/외부 포트 호출 순서를 여기에서 고정한다.

ts
// modules/ordering/application/commands/create-order.command.ts
import { Command } from "@nestjs/cqrs";

export class CreateOrderCommand extends Command<{ orderId: string }> {
	public readonly userId: string;
	public readonly amount: number;
	public readonly currency: string;
	public readonly items?: Array<{ sku: string; quantity: number }>;

	constructor(input: {
		userId: string;
		amount: number;
		currency: string;
		items?: Array<{ sku: string; quantity: number }>;
	}) {
		super();
		this.userId = input.userId;
		this.amount = input.amount;
		this.currency = input.currency;
		this.items = input.items;
	}
}
ts
// modules/orders/application/commands/handlers/create-order.handler.ts
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
	constructor(
		@Inject(IOrderRepositorySymbol)
		private readonly orderRepository: IOrderRepository,
		private readonly uow: UnitOfWork,
	) {}

	async execute(command: CreateOrderCommand): Promise<{ orderId: string }> {
		return this.uow.transaction(async () => {
			const { userId, amount, currency } = command;
			const total = Money.of(amount, currency);
			const items =
				command.items?.map((item) =>
					OrderItem.of(item.sku, item.quantity),
				) ?? [];
			const order = Order.create({
				userId,
				total,
				items,
			});

			await this.orderRepository.persist(order);

			return { orderId: order.id };
		});
	}
}

2) domain: Entities / Rules

domain은 가능한 한 프레임워크/ORM에 의존하지 않고 불변조건과 상태 전이를 담는다. 예를 들어 주문 금액 검증, 상태 전이, 도메인 이벤트 발행 규칙은 domain 안에서 닫히고, application은 이를 “호출하고 조합”하는 역할만 맡는다.


3) infrastructure: Repository Adapter (Output Adapter)

인프라는 Output Port를 구현한다. 즉, application/domain은 ORM의 타입을 몰라도 된다.

ts
// modules/ordering/infrastructure/repositories/order.repository.ts
@Injectable()
export class OrderRepository implements IOrderRepository {
	constructor(
		private readonly em: EntityManager,
		private readonly mapper: OrderMapper,
	) {}

	async getById(
		id: string,
		options?: RepositoryGetByIdOptions,
	): Promise<Order> {
		const em = this.emForContext();
		const failHandler =
			options?.failHandler ??
			(() =>
				ApplicationErrorFactory.create(
					ORDERING_APPLICATION_ERRORS.ORDER_NOT_FOUND,
					{ details: { id } },
				));
		const found = await em.findOneOrFail(
			OrderSchema,
			{ uuid: id },
			{ failHandler },
		);

		return this.mapper.toDomain(found);
	}
}

Reader/BFF/Saga를 같이 쓰는 경우에도 경계는 동일하다.

  • Reader: 타 도메인 조회는 Entity 직접 참조 대신 Reader DTO 계약으로 연결
  • BFF: Query 조합 중심 + 제한적 Command 위임만 허용(신규 도메인 정책 소유 금지, 직접 repository 호출 금지)
  • Saga: 다중 도메인 순서/보상/재시도 정책을 오케스트레이션하고, 실제 상태 변경은 각 도메인 Command로 위임


런타임 실행 모델(HTTP/Cron/Queue)

같은 코드베이스를 역할(role)별로 실행하더라도, 권장 원칙은 한 가지다: 진입점만 분기하고 Application/Domain 경계는 고정한다.

  • HTTP API: 요청/응답 진입점
  • Cron/Scheduler: 주기 실행 진입점
  • Queue poller/consumer: 비동기 소비 진입점

실무 규칙:

  • 런타임 분기는 entrypoint 레이어(main/runtime-role)에서만 결정
  • cron/worker도 내부 구현 우회 없이 Command/Query 경계를 통해 유스케이스 실행
  • Actor(AuthContext)는 HTTP/비동기 모두 동일 규칙으로 전파


유스케이스 경계와 일관성 (Outbox)

Clean 관점에서 트랜잭션은 DB 호출 묶음이 아니라 유스케이스 경계다.

  • 트랜잭션은 application에서만 시작한다.
  • domain은 트랜잭션을 모른다.
  • commit과 이벤트 발행의 원자성 문제는 Outbox로 푼다.

즉, 유스케이스는 “바로 publish”보다 “같은 경계에서 outbox append”를 기본 경로로 두고, 별도 퍼블리셔가 전달을 담당하도록 분리하는 편이 안정적이다.

여기서 Outbox는 단순 테이블이 아니라 운영 상태 모델로 다룬다.

  • 상태: PENDING → PUBLISHED → CONSUMED (실패 시 FAILED, 이후 재시도)
  • 소비 안정성: lock + idempotency claim(consumerName + eventId) + retry metadata
  • 실패 메타데이터(attempt, nextAttemptAt, lastError)는 같은 Unit of Work에서 갱신

또한 RequestContext를 실행 단위로 고정한다.

  • HTTP 요청 1건, Queue 메시지 1건, Cron chunk 1회마다 컨텍스트 1개
  • 재시도 경계와 컨텍스트 경계를 일치시켜 부분 성공/중복 처리 리스크를 낮춤


레이어별 테스트 전략

의존성을 안쪽으로만 두면 테스트는 자연스럽게 쉬워진다.

  • Domain 테스트: 프레임워크/DB 없이 유닛 테스트로 불변조건/상태 전이 검증
  • Application 테스트: Output Port를 fake/mock으로 두고 유스케이스 테스트
  • Infra 테스트: repository adapter는 통합 테스트로 검증

실무에서는 Application 테스트에 “outbox append 호출 보장”까지 함께 검증하면, 트랜잭션 경계 누수를 빨리 잡을 수 있다.



흔한 함정과 체크리스트

  1. domain이 프레임워크 타입(예: @nestjs/common)을 import 엄격한 Clean을 원하면 domain은 순수 에러를 던지고, Controller/Presenter에서 HTTP 예외로 변환하는 편이 낫다.
  2. Criteria/Result가 웹 DTO로 오염 Request/Response 변경이 유스케이스까지 전파되기 시작하면 경계가 무너진 신호다.
  3. 트랜잭션이 여기저기 퍼짐 트랜잭션을 application에만 두는 규칙이 깨지면 일관성 이슈 추적 비용이 폭증한다.
  4. contracts에 도메인 모델을 공유 contracts는 통합 경계(OpenAPI/통합 타입)만 두고, Domain Entity/VO 공유는 피하는 편이 파급을 줄인다.
  5. cron/worker가 Bus 경계를 우회 운영 코드에서 내부 구현 직접 호출이 늘면 HTTP 경로와 규칙이 갈라져 장애 분석이 어려워진다.
  6. Outbox 없이 즉시 publish를 기본 경로로 채택 실패/재시도/중복 처리 규약이 분산되기 쉬워 운영 분류와 복구 절차가 급격히 어려워진다.
  7. BFF가 도메인 엔티티 변경/Repository 접근으로 정책을 직접 소유 BFF는 조합과 위임 경계로 두고, 상태 전이 규칙은 원 도메인 Command Handler가 소유해야 한다.
  8. shared에 Command/Query/Event 계약 또는 프레임워크 의존 코드 배치 shared는 순수 공통만 두고, 통신 계약은 contracts, 프레임워크 공통은 common으로 분리한다.
  9. Query 경로에서 상태 변경을 수행하거나 Repository를 범용 조회 API로 비대화 조회는 조회 전용 접근으로 최적화하고, 상태 변경은 Command 경계로 고정하는 편이 장기 유지보수에 유리하다.


마무리하며

이 글은 Clean Architecture를 “정답 아키텍처”로 소개하려는 글이 아니다. 대신 의존성 규칙을 팀의 컨벤션으로 고정해 변경 비용을 구조적으로 통제하는 방법을 정리했다. 반대로 “무엇을 도메인 규칙으로 둘지”, “Bounded Context를 어떻게 나눌지”는 아래 DDD 글에서 더 자세히 다룬다.

Share this post

N