콘텐츠로 이동

Design Principles

분류: Layer 9 - 아키텍처 & 설계 패턴

소프트웨어 설계 원칙 (Clean Architecture / DDD / Twelve-Factor App)

섹션 제목: “소프트웨어 설계 원칙 (Clean Architecture / DDD / Twelve-Factor App)”

소프트웨어가 변화에 강하고, 테스트하기 쉽고, 배포 환경에 독립적으로 동작하게 만드는 설계 철학의 집합이다.


백엔드 시스템은 비즈니스 요구사항이 바뀌면 코드도 바뀐다. 문제는 “얼마나 쉽게 바꿀 수 있느냐”이다.

  • Clean Architecture가 없으면: DB를 MySQL에서 PostgreSQL로 바꿀 때 비즈니스 로직 코드까지 수정해야 한다.
  • DDD가 없으면: 팀마다 “주문”의 정의가 달라 커뮤니케이션 오류가 발생한다.
  • Twelve-Factor App이 없으면: 로컬에서는 되는데 ECS/K8s에 올리면 환경설정 오류가 터진다.

이 세 가지는 서로 보완적이다. Clean Architecture는 코드 구조를, DDD는 도메인 모델링을, Twelve-Factor App은 배포·운영 철학을 다룬다.


3-1. Clean Architecture (클린 아키텍처)

섹션 제목: “3-1. Clean Architecture (클린 아키텍처)”

양파를 생각해보자. 안쪽 층(알맹이)은 가장 본질적인 부분이고, 바깥 껍질은 양파를 감싸는 외부 환경이다. 안쪽 알맹이는 바깥 껍질에 전혀 신경 쓰지 않는다. 마찬가지로 핵심 비즈니스 로직(안쪽)은 DB나 HTTP 프레임워크(바깥쪽)를 전혀 몰라야 한다.

원리: 의존성 규칙 (Dependency Rule)

섹션 제목: “원리: 의존성 규칙 (Dependency Rule)”

Robert C. Martin이 제시한 핵심 규칙은 단 하나다.

“소스 코드의 의존성은 반드시 안쪽(고수준 정책)을 향해야 한다.”

바깥 레이어는 안쪽 레이어를 알 수 있지만, 안쪽 레이어는 바깥 레이어를 절대 알 수 없다.

[가장 바깥] Frameworks & Drivers (Express, TypeORM, AWS SDK)
↓ 의존
[두 번째] Interface Adapters (Controller, Repository 구현체, DTO)
↓ 의존
[세 번째] Application / Use Cases (비즈니스 흐름 조율)
↓ 의존
[가장 안쪽] Entities / Domain (순수 비즈니스 규칙)

화살표 방향이 중요하다. 항상 안쪽으로 향한다. 역방향 의존은 절대 불가.

레이어역할예시
Entities (Domain)순수 비즈니스 규칙. 프레임워크 0%Order, User 클래스 (어떤 DB인지 모름)
Use Cases (Application)비즈니스 흐름 조율. 도메인 객체를 사용해 작업 수행CreateOrderUseCase, CancelOrderUseCase
Interface Adapters바깥 세계와 Use Case 사이 번역Controller, Repository 인터페이스, DTO 변환
Frameworks & Drivers실제 구현체. 언제든 교체 가능TypeORM, Express, Redis 클라이언트

Nest.js의 구조는 Clean Architecture와 자연스럽게 매핑된다.

src/
orders/ ← Bounded Context (모듈 경계)
domain/
entities/
order.entity.ts ← Entity (Domain Layer)
order-item.entity.ts
value-objects/
money.vo.ts ← Value Object
repositories/
order.repository.ts ← Repository 인터페이스 (Domain Layer)
application/
use-cases/
create-order.use-case.ts ← Use Case (Application Layer)
cancel-order.use-case.ts
dtos/
create-order.dto.ts
infrastructure/
persistence/
typeorm-order.repository.ts ← Repository 구현체 (Infrastructure Layer)
order.schema.ts
presentation/
controllers/
orders.controller.ts ← Controller (Interface Adapter)
orders.module.ts ← 모든 레이어를 묶는 NestJS Module
// domain/entities/order.entity.ts (Domain Layer - 순수 비즈니스 규칙)
// TypeORM, NestJS를 import하지 않는다!
export class Order {
private readonly id: string;
private status: OrderStatus;
private items: OrderItem[];
constructor(id: string) {
this.id = id;
this.status = OrderStatus.PENDING;
this.items = [];
}
addItem(item: OrderItem): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error("확정된 주문에는 상품을 추가할 수 없습니다");
}
this.items.push(item);
}
confirm(): void {
if (this.items.length === 0) {
throw new Error("주문 상품이 없습니다");
}
this.status = OrderStatus.CONFIRMED;
}
}
// domain/repositories/order.repository.ts (Domain Layer - 인터페이스만 정의)
// 실제 DB가 MySQL인지 PostgreSQL인지 모른다
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// application/use-cases/create-order.use-case.ts (Application Layer)
import { Injectable } from "@nestjs/common";
import { OrderRepository } from "../../domain/repositories/order.repository";
import { Order } from "../../domain/entities/order.entity";
@Injectable()
export class CreateOrderUseCase {
constructor(
// 인터페이스에만 의존 (구현체가 TypeORM인지 Prisma인지 모름)
private readonly orderRepository: OrderRepository,
) {}
async execute(userId: string): Promise<string> {
const orderId = `order-${Date.now()}`;
const order = new Order(orderId);
await this.orderRepository.save(order);
return orderId;
}
}
// infrastructure/persistence/typeorm-order.repository.ts (Infrastructure Layer)
// 여기서만 TypeORM을 사용한다
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { OrderRepository } from "../../domain/repositories/order.repository";
import { Order } from "../../domain/entities/order.entity";
import { OrderSchema } from "./order.schema";
export class TypeormOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderSchema)
private readonly ormRepo: Repository<OrderSchema>,
) {}
async findById(id: string): Promise<Order | null> {
const schema = await this.ormRepo.findOne({ where: { id } });
if (!schema) return null;
// DB 스키마 → 도메인 엔티티 변환
return new Order(schema.id);
}
async save(order: Order): Promise<void> {
// 도메인 엔티티 → DB 스키마 변환 후 저장
await this.ormRepo.save({ id: order.getId() });
}
}

핵심 장점: TypeORM 대신 Prisma로 바꾸고 싶다면? TypeormOrderRepositoryPrismaOrderRepository로 교체하면 된다. 도메인/유스케이스 코드는 단 한 줄도 바꾸지 않는다.

NestJS 모듈에서 의존성 주입(DI)으로 의존성 규칙 지키기

섹션 제목: “NestJS 모듈에서 의존성 주입(DI)으로 의존성 규칙 지키기”

NestJS의 DI 컨테이너는 Clean Architecture의 의존성 규칙을 자연스럽게 구현할 수 있는 핵심 도구다. 모듈의 providers 설정에서 인터페이스와 구현체를 연결하면 된다.

// orders.module.ts - DI로 의존성 규칙 준수
@Module({
imports: [TypeOrmModule.forFeature([OrderSchema])],
controllers: [OrdersController],
providers: [
CreateOrderUseCase,
// 핵심: 인터페이스 토큰 → 구현체 매핑
{
provide: "OrderRepository", // 도메인 레이어 인터페이스
useClass: TypeormOrderRepository, // 인프라 레이어 구현체
},
],
})
export class OrdersModule {}
// 실행 결과: DI가 올바르게 작동하는지 확인
[Nest] LOG [InstanceLoader] OrdersModule dependencies initialized +12ms
[Nest] LOG CreateOrderUseCase → OrderRepository(TypeormOrderRepository) 주입 완료
// Prisma로 교체 시: useClass만 변경
{
provide: 'OrderRepository',
useClass: PrismaOrderRepository, // ← 이 한 줄만 수정
}

📖 더 보기: DDD vs Reality: Common Pitfalls in NestJS (Medium) — NestJS에서 DI를 잘못 사용해 의존성 규칙을 위반하는 실제 사례와 해결법 (중급)

“왜 의존성 규칙이 없으면 시스템이 썩는가” (동작 원리 심화)

섹션 제목: ““왜 의존성 규칙이 없으면 시스템이 썩는가” (동작 원리 심화)”

소프트웨어는 시간이 지날수록 변경된다. 의존성 규칙이 없으면 변경의 파급 범위를 예측할 수 없다.

의존성 규칙 위반 시나리오:
OrderService (비즈니스 로직)
└── import TypeORM (인프라)
└── import Express Request (웹 프레임워크)
└── import Redis (캐시)
"PostgreSQL → MySQL 마이그레이션"을 결정했을 때:
→ OrderService 코드를 수정해야 함
→ OrderService 테스트도 모두 수정
→ OrderService를 의존하는 모든 코드 연쇄 수정
→ 인프라 변경이 비즈니스 로직까지 오염
의존성 규칙 준수 시나리오:
OrderService (비즈니스 로직)
└── import OrderRepository (인터페이스만 알고 있음)
"PostgreSQL → MySQL 마이그레이션" 결정:
→ TypeormOrderRepository → MysqlOrderRepository 교체 (Infrastructure Layer만)
→ OrderService는 변경 없음
→ OrderService 테스트도 변경 없음
→ 비즈니스 로직은 인프라 변경을 전혀 느끼지 못함

이것이 **의존성 역전 원칙(DIP: Dependency Inversion Principle)**이다. 고수준 모듈(비즈니스 로직)이 저수준 모듈(인프라)에 의존하는 대신, 둘 다 **추상화(인터페이스)**에 의존한다.

기존 (잘못된 방향):
OrderService ──의존→ TypeormOrderRepository
DIP 적용 후:
OrderService ──의존→ IOrderRepository (인터페이스)
↑ 구현
TypeormOrderRepository ───┘

📖 더 보기: Clean Architecture - Robert C. Martin 블로그 — 의존성 규칙의 원문 설명과 각 레이어의 정의 (입문)


같은 회사에서도 팀마다 같은 단어가 다른 의미를 가진다.

  • 회계팀의 “고객”: 세금계산서를 발행해야 하는 법인/개인 → 사업자번호, 세금 정보가 중요
  • 마케팅팀의 “고객”: 캠페인 타겟 → 구매 이력, 선호도가 중요
  • 배송팀의 “고객”: 배송지가 있는 수령인 → 주소, 연락처가 중요

이 세 팀이 하나의 “고객” 테이블을 공유하면 서로의 변경이 충돌한다. DDD는 이 문제를 Bounded Context로 해결한다.

원리: 전략적 설계 (Strategic Design)

섹션 제목: “원리: 전략적 설계 (Strategic Design)”

큰 그림을 어떻게 나눌지 결정한다.

① Bounded Context (경계 컨텍스트)

각 팀/도메인이 자신만의 모델을 가지는 경계. 경계 안에서는 모델이 일관성을 유지한다.

[ 주문 Context ] [ 배송 Context ] [ 정산 Context ]
Order Shipment Invoice
OrderItem DeliveryAddress Payment
Customer (구매자) Customer (수령인) Customer (세금정보)

같은 “Order”라도 각 Context에서 다른 속성을 가진다.

② Ubiquitous Language (유비쿼터스 언어)

개발자와 도메인 전문가(기획자, 비즈니스 담당자)가 동일한 용어를 사용해야 한다. “주문을 확정한다”가 비즈니스 용어라면 코드에도 order.confirm()이어야 한다. order.setStatus("CONFIRMED")가 아니라.

③ Context Map

여러 Bounded Context 사이의 관계를 정의한다.

주문 Context → (Anti-Corruption Layer) → 외부 결제 시스템 PG사
주문 Context ← (Publisher/Subscriber) → 배송 Context

”왜 Bounded Context 분리가 필수인가” — 공유 모델의 함정

섹션 제목: “”왜 Bounded Context 분리가 필수인가” — 공유 모델의 함정”

Bounded Context 없이 하나의 “Customer” 모델을 공유하면, 각 팀이 필요한 필드를 계속 추가하면서 “신(God) 객체”가 탄생한다. 필드가 50개 넘는 Customer 테이블이 만들어지고, 한 팀의 스키마 변경이 다른 팀 전체를 깨뜨린다.

공유 모델의 시간 경과:
[Month 1] Customer { id, name, email } → 깔끔
[Month 3] Customer { id, name, email, address, phone,
taxId, preferredBrand, lastCampaign } → 비대해짐
[Month 6] Customer { ... 30개 필드 ...
+ 팀 A가 추가한 필드가 팀 B의 마이그레이션을 깨뜨림 }
→ Bounded Context 분리 후:
주문 Context: Customer { id, name, email }
배송 Context: Recipient { name, address, phone }
정산 Context: Payer { name, taxId, bankAccount }
각 팀이 독립적으로 모델을 변경 가능

📖 더 보기: Martin Fowler: Bounded Context — Bounded Context의 정의와 Context Map 관계 유형 (중급)

원리: 전술적 설계 (Tactical Design)

섹션 제목: “원리: 전술적 설계 (Tactical Design)”

경계 안의 코드를 어떻게 구조화할지 결정한다.

개념설명예시
Entity고유한 ID로 식별되는 객체. 속성이 바뀌어도 동일 객체Order(id="123") - 상태가 바뀌어도 같은 주문
Value ObjectID 없이 속성 값으로 동일성 판단. 불변Money(amount=1000, currency="KRW")
Aggregate연관 객체의 묶음. 외부에서는 루트(Root)를 통해서만 접근Order(루트) + OrderItem
RepositoryAggregate의 영속성 관리 인터페이스OrderRepository.findById()
Domain Event도메인에서 발생한 중요 사건OrderConfirmed, PaymentFailed
Domain Service특정 엔티티에 속하기 어려운 비즈니스 로직PricingService.calculateDiscount()

”왜 Aggregate Root가 비즈니스 규칙을 지켜야 하는가” — 불변식(Invariant) 보호

섹션 제목: “”왜 Aggregate Root가 비즈니스 규칙을 지켜야 하는가” — 불변식(Invariant) 보호”

Aggregate Root의 핵심 역할은 **불변식(Invariant)**을 보호하는 것이다. 불변식이란 “어떤 상황에서도 반드시 참이어야 하는 비즈니스 규칙”이다.

예를 들어 “주문 총 금액은 절대 음수일 수 없다”, “확정된 주문에는 상품을 추가할 수 없다”가 불변식이다. 외부에서 OrderItem을 직접 수정하면 이 규칙이 깨질 수 있으므로, 반드시 Aggregate Root(Order)를 통해서만 상태를 변경해야 한다.

Aggregate Root 없이 직접 접근하는 경우:
// 외부 코드가 OrderItem을 직접 수정
order.items[0].quantity = -5; // 불변식 깨짐! (음수 수량)
order.items.push(new OrderItem(...)); // 확정 상태인데 추가됨!
→ 데이터 무결성 붕괴, 결제 오류, 정산 오류로 이어짐
Aggregate Root를 통한 접근:
order.addItem('prod-1', price, 2); // Root가 상태 검증 후 추가
// → PENDING 상태가 아니면 DomainException 발생
// → 수량이 0 이하면 DomainException 발생
// → 총 금액 자동 재계산

이 패턴의 핵심 장점은 트랜잭션 일관성이다. Aggregate 내부의 모든 변경은 하나의 트랜잭션으로 저장된다. 부분 성공/부분 실패가 불가능하다.

📖 더 보기: DDD Aggregates in Practice (Medium) — Aggregate 설계 시 경계 설정과 불변식 보호 실전 예시 (중급)

// Value Object 예시 - 불변, ID 없음
export class Money {
constructor(
private readonly amount: number,
private readonly currency: string,
) {
if (amount < 0) throw new Error("금액은 0 이상이어야 합니다");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("다른 통화끼리는 더할 수 없습니다");
}
return new Money(this.amount + other.amount, this.currency); // 새 객체 반환 (불변)
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
getAmount(): number {
return this.amount;
}
getCurrency(): string {
return this.currency;
}
}
// 사용 예
const price = new Money(1000, "KRW");
const discount = new Money(100, "KRW");
const finalPrice = price.add(discount); // 새 Money 객체
// price는 여전히 1000원 (불변성 보장)
// Aggregate Root 예시
export class Order {
// Aggregate Root
private readonly id: string;
private status: OrderStatus;
private items: OrderItem[] = []; // OrderItem은 외부에서 직접 접근 불가
private totalPrice: Money;
// 외부에서 OrderItem에 직접 접근하는 것을 막음
// Order를 통해서만 아이템 추가/삭제 가능
addItem(productId: string, price: Money, quantity: number): void {
if (this.status !== OrderStatus.PENDING) {
throw new DomainException("PENDING 상태에서만 상품을 추가할 수 있습니다");
}
const item = new OrderItem(productId, price, quantity);
this.items.push(item);
this.recalculateTotal();
}
// Domain Event 발행
confirm(): OrderConfirmedEvent {
if (this.items.length === 0) {
throw new DomainException("주문 상품이 없습니다");
}
this.status = OrderStatus.CONFIRMED;
return new OrderConfirmedEvent(this.id, this.totalPrice); // 이벤트 반환
}
private recalculateTotal(): void {
this.totalPrice = this.items.reduce(
(sum, item) => sum.add(item.getSubtotal()),
new Money(0, "KRW"),
);
}
}
예상 출력 구조:
새 주문 생성 후:
- order.status = PENDING
- order.items = []
addItem() 호출 후:
- order.items = [OrderItem { productId: 'prod-1', price: Money(1000, KRW), quantity: 2 }]
- order.totalPrice = Money(2000, KRW)
confirm() 호출 후:
- order.status = CONFIRMED
- 반환값: OrderConfirmedEvent { orderId: '...', totalPrice: Money(2000, KRW) }
확정된 주문에 addItem() 시도:
- throws DomainException: 'PENDING 상태에서만 상품을 추가할 수 있습니다'

”모듈러 모놀리스 → MSA 전환” — DDD가 설계하는 진화 경로

섹션 제목: “”모듈러 모놀리스 → MSA 전환” — DDD가 설계하는 진화 경로”

실무에서 처음부터 MSA로 시작하면 아직 불명확한 도메인 경계 때문에 나중에 서비스를 다시 합쳐야 하는 역설이 발생한다. DDD의 Bounded Context를 활용한 **모듈러 모놀리스(Modular Monolith)**는 이 문제를 해결하는 현실적 전략이다.

진화 단계:
[1단계] 일반 모놀리스 [2단계] 모듈러 모놀리스
src/ src/
users/ orders/ ← 명확한 경계
orders/ domain/
payments/ application/
shared/ ← 경계 없이 뒤섞임 infrastructure/
payments/ ← 독립 모듈
users/
shared/kernel/ ← 최소 공유만
[3단계] MSA (경계가 검증된 후)
order-service/ → 독립 배포
payment-service/ → 독립 배포
user-service/ → 독립 배포

왜 모듈러 모놀리스가 좋은 중간 단계인가?

  • 각 모듈이 자체 DB 테이블을 소유하고, 다른 모듈의 DB를 직접 조회하지 않는다
  • 모듈 간 통신은 인터페이스를 통해서만 한다 → 나중에 HTTP/메시지큐로 교체 가능
  • 하나의 프로세스로 배포되므로 MSA의 분산 시스템 복잡도가 없다
  • 경계가 잘 잡혔다고 판단되면, 모듈을 독립 서비스로 추출하는 것이 자연스러워진다
// NestJS 모듈러 모놀리스 예시 — 모듈 간 통신은 반드시 인터페이스로
// orders/application/ports/payment.port.ts (Domain/Application 레이어)
export interface PaymentPort {
charge(
orderId: string,
amount: number,
): Promise<{ success: boolean; txId: string }>;
}
// orders/orders.module.ts — 다른 모듈의 구현체를 DI로 주입
@Module({
providers: [
CreateOrderUseCase,
{
provide: "PaymentPort",
useClass: PaymentAdapter, // payments 모듈의 어댑터 (나중에 HTTP 클라이언트로 교체)
},
],
})
export class OrdersModule {}
핵심 원칙:
orders 모듈 → TypeORM으로 orders 테이블 직접 쿼리 ✅
orders 모듈 → TypeORM으로 payments 테이블 직접 쿼리 ❌ (모듈 경계 위반)
orders 모듈 → PaymentPort 인터페이스 호출 ✅

📖 더 보기: Mastering DDD with NestJS — A Final Reflection (Codanyks) — NestJS에서 DDD 전 과정을 적용한 6편 시리즈의 최종 정리. 모듈러 모놀리스부터 MSA 전환 관점까지 포함 (중급)


12팩터 앱은 마치 레고처럼 설계된 앱이다. 블록 하나가 고장 나도 쉽게 교체할 수 있고, 어디서든 같은 방식으로 조립된다. ECS든 K8s든 어디에 올려도 동일하게 동작해야 한다.

#원칙요약ECS/K8s 적용
ICodebase하나의 코드베이스, 여러 환경 배포하나의 Git 리포 → ECR 이미지 → dev/staging/prod
IIDependencies의존성을 명시적으로 선언package.json + package-lock.json 고정
IIIConfig설정은 환경변수로 분리ECS Task Definition env vars / K8s ConfigMap + Secret
IVBacking servicesDB, 캐시 등을 교체 가능한 리소스로 취급RDS URL을 환경변수로 → 다른 DB로 교체 가능
VBuild, Release, Run빌드/릴리즈/실행 단계를 엄격히 분리CodePipeline: 빌드(Docker build) → 릴리즈(ECR push) → 실행(ECS deploy)
VIProcesses앱은 무상태(stateless) 프로세스로 실행ECS 컨테이너는 state 없음. 세션은 Redis로
VIIPort binding포트 바인딩으로 서비스 노출Nest.js app.listen(3000) → ALB → ECS Task
VIIIConcurrency프로세스 모델로 수평 확장ECS desired count 증가, K8s replicas 증가
IXDisposability빠른 시작, 우아한 종료SIGTERM 핸들러 구현, graceful shutdown
XDev/prod parity개발/스테이징/운영 환경을 최대한 동일하게Docker Compose 로컬 = ECS 프로덕션과 동일 이미지
XILogs로그를 이벤트 스트림으로 취급stdout 출력 → CloudWatch Logs / Datadog
XIIAdmin processes관리 작업은 일회성 프로세스로 실행ECS Run Task로 마이그레이션 실행

① Config (III번): 환경변수로 설정 분리

// ❌ 잘못된 방법 - 코드에 직접 설정
const dbUrl = "mysql://user:pass@prod-db.example.com:3306/mydb";
// ✅ 올바른 방법 - 환경변수 사용
const dbUrl = process.env.DATABASE_URL;
// Nest.js에서 ConfigModule 활용
// app.module.ts
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ".env", // 로컬 개발용
// 프로덕션에서는 ECS Task Definition이나 K8s Secret이 주입
}),
],
})
export class AppModule {}
ECS Task Definition 환경변수 설정:
{
"environment": [
{ "name": "DATABASE_URL", "value": "mysql://..." },
{ "name": "REDIS_URL", "value": "redis://..." },
{ "name": "NODE_ENV", "value": "production" }
],
"secrets": [
{ "name": "JWT_SECRET", "valueFrom": "arn:aws:secretsmanager:..." }
]
}

② Logs (XI번): stdout으로 출력

// ❌ 잘못된 방법 - 파일에 로그 저장
import * as fs from "fs";
fs.appendFileSync("/var/log/app.log", `[ERROR] ${message}\n`);
// ✅ 올바른 방법 - stdout/stderr로 출력 (CloudWatch, Datadog이 수집)
console.log(
JSON.stringify({
level: "info",
timestamp: new Date().toISOString(),
message: "주문 생성 완료",
orderId: "123",
}),
);
// Nest.js에서 커스텀 Logger
import { Logger } from "@nestjs/common";
const logger = new Logger("OrdersService");
logger.log(`주문 생성: ${orderId}`); // stdout
logger.error(`결제 실패: ${error}`); // stderr

③ Disposability (IX번): Graceful Shutdown

ECS는 배포 시 SIGTERM → 30초 대기 → SIGKILL을 보낸다. 이 30초 안에 진행 중인 요청을 마무리해야 한다.

main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Graceful Shutdown 활성화
app.enableShutdownHooks(); // SIGTERM 시 NestJS lifecycle hooks 호출
await app.listen(3000);
console.log(`서버 시작: port 3000`);
}
// orders.service.ts - OnModuleDestroy 구현
import { OnModuleDestroy } from "@nestjs/common";
@Injectable()
export class OrdersService implements OnModuleDestroy {
async onModuleDestroy() {
// SIGTERM 수신 시 실행
console.log("주문 서비스 종료 중... 진행 중인 작업 마무리");
// DB 커넥션 풀 종료, 메시지큐 연결 해제 등
}
}
ECS 배포 시 동작:
1. ECS → 구 컨테이너에 SIGTERM 전송
2. Nest.js의 enableShutdownHooks()가 감지
3. onModuleDestroy() 실행 (진행 중인 요청 처리)
4. 최대 30초 대기 (stopTimeout 설정)
5. 30초 후 SIGKILL로 강제 종료
→ 새 컨테이너는 이미 RUNNING 상태로 트래픽 받고 있음

📖 더 보기: Twelve-Factor App 공식 문서 (한국어) — 12가지 원칙의 원문 설명. 한국어 번역이 제공되어 접근성이 좋다 (입문)

📖 더 보기: Developing Twelve-Factor Apps using Amazon ECS (AWS 공식 블로그) — ECS/Fargate 환경에서 12팩터 원칙을 적용하는 AWS 공식 가이드 (중급)


3-4. 설계 원칙 위반 안티패턴과 리팩토링 사례

섹션 제목: “3-4. 설계 원칙 위반 안티패턴과 리팩토링 사례”

실무에서 자주 보이는 안티패턴 세 가지와 NestJS 기준 리팩토링 방향을 정리한다.

안티패턴 1: Fat Controller (비즈니스 로직이 Controller에 집중)

섹션 제목: “안티패턴 1: Fat Controller (비즈니스 로직이 Controller에 집중)”

신규 프로젝트나 빠른 프로토타이핑 때 흔히 발생한다. Controller가 DB 조회, 비즈니스 규칙 판단, 이메일 발송까지 모두 담당한다.

// ❌ 안티패턴: Fat Controller
@Post('/orders')
async createOrder(@Body() dto: CreateOrderDto, @Req() req) {
// DB 직접 접근 (Infrastructure 로직이 Controller에)
const user = await this.userRepository.findOne(req.user.id);
if (!user) throw new NotFoundException('유저 없음');
// 비즈니스 규칙이 Controller에 (Application/Domain 로직)
if (user.creditLimit < dto.totalAmount) {
throw new BadRequestException('한도 초과');
}
const order = await this.orderRepository.save({
userId: user.id,
items: dto.items,
status: 'pending',
});
// 부수 효과(이메일 발송)도 Controller에
await this.mailService.sendOrderConfirmation(user.email, order.id);
return order;
}
문제점:
- Controller 단위 테스트 시 DB, 메일 서비스 모두 Mock 필요 → 복잡도 폭증
- 비즈니스 규칙(한도 초과 판단)이 HTTP 계층에 묶여 재사용 불가
- 다른 채널(gRPC, 메시지큐)에서 같은 주문 생성 로직 호출 시 중복 구현
// ✅ 리팩토링: Clean Architecture 적용
// Controller → Use Case → Domain 순서로 역할 분리
// 1. Controller: HTTP 요청/응답만 담당
@Post('/orders')
async createOrder(@Body() dto: CreateOrderDto, @Req() req) {
const orderId = await this.createOrderUseCase.execute({
userId: req.user.id,
items: dto.items,
totalAmount: dto.totalAmount,
});
return { orderId };
}
// 2. Use Case: 비즈니스 흐름 조율 (HTTP 모름)
@Injectable()
export class CreateOrderUseCase {
constructor(
private readonly userRepo: UserRepository, // 인터페이스 의존
private readonly orderRepo: OrderRepository, // 인터페이스 의존
private readonly eventBus: EventBus,
) {}
async execute(cmd: CreateOrderCommand): Promise<string> {
const user = await this.userRepo.findById(cmd.userId);
if (!user) throw new UserNotFoundException(cmd.userId);
// 3. Domain: 비즈니스 규칙은 Domain 객체에
const order = Order.create(user, cmd.items, cmd.totalAmount);
// Order.create() 내부에서 한도 초과 시 DomainException throw
await this.orderRepo.save(order);
this.eventBus.publish(new OrderCreatedEvent(order.id, user.email));
return order.id;
}
}
리팩토링 후 효과:
- CreateOrderUseCase 단위 테스트 시 userRepo, orderRepo만 Mock하면 됨
- Order.create() 도메인 규칙은 NestJS 없이 순수 TypeScript로 테스트 가능
- gRPC Controller, SQS Consumer도 동일한 CreateOrderUseCase 재사용

안티패턴 2: Anemic Domain Model (빈약한 도메인 모델)

섹션 제목: “안티패턴 2: Anemic Domain Model (빈약한 도메인 모델)”

DDD를 흉내냈지만 Entity에 비즈니스 로직이 없고 getter/setter만 있는 경우다. 비즈니스 규칙이 Service 레이어 곳곳에 흩어진다.

// ❌ 안티패턴: 빈약한 도메인 모델
export class Order {
id: string;
status: string; // 'pending' | 'confirmed' | 'cancelled'
items: OrderItem[];
totalAmount: number;
// 메서드 없음 — 순수 데이터 컨테이너
}
// 비즈니스 규칙이 Service에 흩어짐
@Injectable()
export class OrderService {
cancel(order: Order, reason: string) {
if (order.status === "cancelled") throw new Error("이미 취소됨"); // 규칙 A
if (order.status === "delivered") throw new Error("배송 완료 취소 불가"); // 규칙 B
order.status = "cancelled";
}
confirm(order: Order) {
if (order.status !== "pending") throw new Error("대기 상태만 확정 가능"); // 규칙 C
order.status = "confirmed";
}
}
// 같은 규칙이 다른 Service(AdminOrderService 등)에 중복될 위험
// ✅ 리팩토링: Rich Domain Model
export class Order {
private status: OrderStatus;
cancel(reason: string): OrderCancelledEvent {
if (this.status === OrderStatus.CANCELLED)
throw new DomainException("이미 취소된 주문입니다");
if (this.status === OrderStatus.DELIVERED)
throw new DomainException("배송 완료된 주문은 취소할 수 없습니다");
this.status = OrderStatus.CANCELLED;
return new OrderCancelledEvent(this.id, reason); // 이벤트 반환
}
confirm(): OrderConfirmedEvent {
if (this.status !== OrderStatus.PENDING)
throw new DomainException("대기 상태의 주문만 확정할 수 있습니다");
this.status = OrderStatus.CONFIRMED;
return new OrderConfirmedEvent(this.id);
}
}
// Service는 흐름만 조율
@Injectable()
export class OrderService {
async cancelOrder(orderId: string, reason: string) {
const order = await this.orderRepo.findById(orderId);
const event = order.cancel(reason); // 규칙은 Order가 책임
await this.orderRepo.save(order);
this.eventBus.publish(event);
}
}
리팩토링 후 효과:
- 취소 규칙이 Order 클래스 한 곳에만 존재 → 변경 시 한 파일만 수정
- Order.cancel() 테스트는 NestJS/DB 없이 순수 단위 테스트로 빠르게 검증
- AdminOrderService가 생겨도 order.cancel()을 그대로 재사용

📖 더 보기: Refactoring to Clean Architecture — DEV Community — NestJS 프로젝트를 단계적으로 Clean Architecture로 리팩토링하는 실전 가이드 (중급)


3-5. SOLID 원칙 — NestJS 실전 예시

섹션 제목: “3-5. SOLID 원칙 — NestJS 실전 예시”

SOLID는 Robert C. Martin이 정리한 5가지 객체지향 설계 원칙이다. Clean Architecture와 DDD를 뒷받침하는 기초 원칙이며, NestJS에서 매일 적용하는 패턴들이다.

S — 단일 책임 원칙 (Single Responsibility Principle)

섹션 제목: “S — 단일 책임 원칙 (Single Responsibility Principle)”

“한 클래스는 변경되어야 하는 이유가 하나뿐이어야 한다.”

비유: 스위스 아미 나이프는 다용도지만, 전문 요리사는 목적별 칼을 따로 쓴다.
Controller가 DB 조회, 비즈니스 로직, 이메일 발송을 모두 하면 → 변경 이유가 3가지
// ❌ SRP 위반: UserService가 너무 많은 일을 한다
@Injectable()
export class UserService {
async register(dto: CreateUserDto) {
// 1. 유효성 검사
if (!dto.email.includes("@")) throw new Error("잘못된 이메일");
// 2. DB 저장
const user = await this.userRepository.save(dto);
// 3. 이메일 발송
await this.mailService.sendWelcomeEmail(user.email);
// 4. 분석 이벤트 전송
await this.analyticsService.track("user_registered", user.id);
return user;
}
}
// ✅ SRP 준수: 각 책임을 별도 클래스로 분리
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly eventBus: EventBus,
) {}
async register(dto: CreateUserDto): Promise<User> {
const user = User.create(dto.email, dto.name); // Domain이 유효성 검사
await this.userRepository.save(user);
this.eventBus.publish(new UserRegisteredEvent(user.id, user.email));
return user;
// 이메일/분석은 이벤트 핸들러가 담당 → UserService는 변경 이유가 1가지
}
}
// 예상 효과
UserService 수정이 필요한 경우:
✅ "사용자 등록 로직이 바뀔 때" (1가지 이유)
❌ "웰컴 이메일 템플릿이 바뀔 때" → EmailService만 수정
❌ "분석 이벤트 포맷이 바뀔 때" → AnalyticsHandler만 수정

O — 개방-폐쇄 원칙 (Open/Closed Principle)

섹션 제목: “O — 개방-폐쇄 원칙 (Open/Closed Principle)”

“소프트웨어 개체는 확장에 열려있고, 수정에 닫혀있어야 한다.”

비유: 플러그인 아키텍처. 새 기능을 추가할 때 기존 코드를 건드리지 않고
새 플러그인(구현체)만 추가하면 된다.
// ❌ OCP 위반: 새 결제 수단 추가마다 PaymentService 수정 필요
@Injectable()
export class PaymentService {
async processPayment(method: string, amount: number) {
if (method === "credit_card") {
return this.processCreditCard(amount);
} else if (method === "kakao_pay") {
return this.processKakaoPay(amount);
} else if (method === "naver_pay") {
// 새 수단 추가할 때마다 수정!
return this.processNaverPay(amount);
}
}
}
// ✅ OCP 준수: 인터페이스로 확장 가능하게 설계
export interface PaymentGateway {
process(amount: number): Promise<PaymentResult>;
supports(method: string): boolean;
}
@Injectable()
export class CreditCardGateway implements PaymentGateway {
supports(method: string) {
return method === "credit_card";
}
async process(amount: number) {
/* 신용카드 처리 */ return { txId: "cc-001", success: true };
}
}
@Injectable()
export class KakaoPayGateway implements PaymentGateway {
supports(method: string) {
return method === "kakao_pay";
}
async process(amount: number) {
/* 카카오페이 처리 */ return { txId: "kp-001", success: true };
}
}
@Injectable()
export class PaymentService {
constructor(
@Inject("PAYMENT_GATEWAYS") private readonly gateways: PaymentGateway[],
) {}
async processPayment(method: string, amount: number) {
const gateway = this.gateways.find((g) => g.supports(method));
if (!gateway) throw new Error(`지원하지 않는 결제 수단: ${method}`);
return gateway.process(amount);
// 새 결제 수단 추가 = 새 Gateway 클래스 추가 + 모듈에 등록만 하면 됨
// PaymentService 코드 수정 없음 → OCP 준수
}
}
// 예상 동작
processPayment('credit_card', 10000) → { txId: 'cc-001', success: true }
processPayment('kakao_pay', 5000) → { txId: 'kp-001', success: true }
// 네이버페이 추가 시: NaverPayGateway 클래스만 작성 후 모듈에 주입

📖 더 보기: Applying SOLID Principles in NestJS — Leapcell — NestJS 백엔드에서 SOLID 5원칙을 각각 코드 예시와 함께 적용하는 실전 가이드 (중급)

L — 리스코프 치환 원칙 (Liskov Substitution Principle)

섹션 제목: “L — 리스코프 치환 원칙 (Liskov Substitution Principle)”

“자식 클래스는 부모 클래스의 계약을 깨지 않고 대체할 수 있어야 한다.”

비유: 전동 자동차는 일반 자동차의 '자동차' 역할을 완전히 수행한다.
그러나 연료 탱크가 없으니 'refuel()' 계약을 어기면 LSP 위반이다.
// ❌ LSP 위반: 읽기 전용 Repository가 save()를 throw한다
class ReadOnlyOrderRepository extends OrderRepository {
async save(order: Order): Promise<void> {
throw new Error("읽기 전용 저장소에는 저장할 수 없습니다"); // 계약 위반!
}
}
// OrderRepository를 기대하는 코드에 ReadOnlyOrderRepository를 주입하면 런타임 에러
// ✅ LSP 준수: 별도 인터페이스로 분리
export interface ReadableOrderRepository {
findById(id: string): Promise<Order | null>;
findAll(): Promise<Order[]>;
}
export interface WritableOrderRepository extends ReadableOrderRepository {
save(order: Order): Promise<void>;
delete(id: string): Promise<void>;
}
// QueryHandler는 ReadableOrderRepository만 필요 (LSP 자연스럽게 준수)
@QueryHandler(GetOrderQuery)
export class GetOrderHandler {
constructor(private readonly repo: ReadableOrderRepository) {}
}

I — 인터페이스 분리 원칙 (Interface Segregation Principle)

섹션 제목: “I — 인터페이스 분리 원칙 (Interface Segregation Principle)”

“클라이언트가 사용하지 않는 메서드에 의존하도록 강제해서는 안 된다.”

// ❌ ISP 위반: 하나의 거대한 인터페이스
export interface UserRepository {
findById(id: string): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
findByEmailForAnalytics(email: string): Promise<UserAnalytics>; // 분석용만 필요
exportToCsv(): Promise<Buffer>; // 내보내기용만 필요
}
// ✅ ISP 준수: 역할별로 인터페이스 분리
export interface UserReader {
findById(id: string): Promise<User | null>;
findAll(): Promise<User[]>;
}
export interface UserWriter {
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
export interface UserAnalyticsReader {
findByEmailForAnalytics(email: string): Promise<UserAnalytics>;
exportToCsv(): Promise<Buffer>;
}
// 각 서비스는 필요한 인터페이스만 의존
@Injectable()
export class UserService {
constructor(
private readonly userReader: UserReader,
private readonly userWriter: UserWriter,
) {}
// UserAnalyticsReader는 모른다 → 불필요한 의존성 없음
}
@Injectable()
export class AnalyticsService {
constructor(private readonly analyticsReader: UserAnalyticsReader) {}
}

D — 의존성 역전 원칙 (Dependency Inversion Principle)

섹션 제목: “D — 의존성 역전 원칙 (Dependency Inversion Principle)”

“고수준 모듈은 저수준 모듈에 의존하지 않아야 한다. 둘 다 추상화에 의존해야 한다.”

이미 3-1 Clean Architecture에서 상세히 다뤘다. NestJS의 DI 컨테이너가 이 원칙을 구현하는 핵심 도구다.

// NestJS DI로 DIP 구현 — 핵심 패턴 요약
@Module({
providers: [
OrderService, // 고수준 모듈
{
provide: "ORDER_REPOSITORY", // 추상화 토큰
useClass: TypeormOrderRepository, // 저수준 구현체 (교체 가능)
},
],
})
export class OrdersModule {}
// OrderService는 'ORDER_REPOSITORY' 토큰(추상화)에만 의존
// TypeORM → Prisma 교체 시 useClass만 변경, OrderService 코드 불변
원칙NestJS 패턴실무 적용 포인트
SRPController/Service/Repository 분리이벤트 기반 부수 효과 분리
OCP인터페이스 + useClass DI결제/알림 등 전략 패턴으로 확장
LSP인터페이스 분리, 계약 준수Repository 읽기/쓰기 인터페이스 분리
ISP역할별 작은 인터페이스 정의CQRS의 Command/Query 분리와 연결
DIP@Inject() + 추상화 토큰 DIClean Architecture의 의존성 규칙 구현

코드에 PR 리뷰 히스토리가 남듯, 아키텍처 결정에도 “왜 이 선택을 했는가”의 히스토리가 필요하다. ADR은 그 히스토리 파일이다.

ADR은 팀이 내린 중요한 아키텍처 결정을 문서화하는 경량 실천법이다. Michael Nygard가 제안한 템플릿이 사실상 표준이며, 각 결정은 별도의 Markdown 파일로 관리한다.

ADR 파일 구조 (Nygard 템플릿):

  • Title: 결정 사항 제목 (예: ADR-001: API 통신에 gRPC 대신 REST를 사용한다)
  • Status: Proposed / Accepted / Deprecated / Superseded by ADR-XXX
  • Context: 왜 이 결정이 필요했는가? (배경 상황)
  • Decision: 무엇을 결정했는가?
  • Consequences: 이 결정으로 무엇이 달라지는가? (장단점)

실제 예시:

# ADR-003: 서비스 간 통신에 SQS 비동기 메시지를 사용한다
## Status
Accepted
## Context
주문 서비스와 결제 서비스 간 동기 HTTP 호출 시 결제 서비스 장애가 주문 서비스로 전파된다.
Circuit Breaker를 추가해도 결제 완료 이벤트 유실 문제가 남는다.
## Decision
서비스 간 통신을 동기 REST에서 SQS 비동기 메시지로 전환한다.
결제 완료/실패 이벤트를 SQS에 발행하고 각 서비스가 구독한다.
## Consequences
- (+) 결제 서비스 장애가 주문 서비스로 전파되지 않음
- (+) 메시지 유실 없이 최소 1회 보장
- (-) 디버깅 시 메시지 추적 도구 필요 (AWS X-Ray)
- (-) 테스트 복잡도 증가 (LocalStack 필요)
  • 기술 스택 선택 (PostgreSQL vs DynamoDB)
  • 아키텍처 패턴 도입 (모놀리스 → MSA 전환 결정)
  • 인프라 결정 (ECS vs EKS 선택)
  • 보안 설계 (JWT stateless vs 세션 방식)

ADR을 쓰지 않아도 되는 경우: 코드 수준의 결정 (함수명, 변수 타입), 되돌리기 쉬운 결정

docs/
adr/
0001-use-postgresql-for-main-db.md
0002-use-sqs-for-async-events.md
0003-use-nestjs-for-api-server.md

ADR 파일은 코드와 함께 Git으로 관리한다. PR 리뷰처럼 ADR도 팀 리뷰를 거친다.


  • 신규 서비스 설계 시: DDD로 Bounded Context를 먼저 정의하고, 각 Context를 NestJS 모듈로 매핑한다.
  • 레거시 리팩토링 시: 컨트롤러에 뭉쳐있는 로직을 Use Case와 Domain으로 분리한다.
  • 마이크로서비스 전환 시: 각 Bounded Context가 독립 서비스가 될 후보다.
  • 초기에는 Twelve-Factor App만 철저히 지키는 것도 충분하다. 특히 Config(환경변수)와 Logs(stdout)는 규모와 관계없이 기본이다.
  • Clean Architecture의 레이어 분리는 팀이 커지고 코드가 복잡해질 때 도입한다.
배포 전 체크:
[ ] DATABASE_URL 등 모든 설정이 환경변수로 분리되어 있는가?
[ ] 코드에 하드코딩된 IP, 비밀번호가 없는가?
[ ] SIGTERM 핸들러가 구현되어 있는가? (enableShutdownHooks)
[ ] 로그가 파일이 아닌 stdout으로 출력되는가?
[ ] 컨테이너가 무상태인가? (세션을 로컬 메모리에 저장하지 않는가?)

BackOps 엔지니어 관점에서:

  • ECS 배포 안정성: Twelve-Factor App의 Disposability(IX)를 지키면 블루/그린 배포나 롤링 업데이트 시 요청 유실이 없다.
  • 환경별 설정 관리: Config(III)를 지키면 dev/staging/prod 환경별 설정 오류가 사라진다.
  • 서비스 확장: Processes(VI, VIII)를 지키면 ECS의 desired count만 늘려도 안전하게 수평 확장된다.
  • 신규 기능 개발: Clean Architecture로 비즈니스 로직을 분리하면 DB 쿼리를 건드리지 않고도 비즈니스 규칙 테스트가 가능하다.
  • 마이크로서비스 논의: DDD의 Bounded Context를 이해하면 “이 기능은 어떤 서비스에 넣어야 하나?” 논의에 기여할 수 있다.

Clean Architecture vs Hexagonal Architecture (Port & Adapter)

섹션 제목: “Clean Architecture vs Hexagonal Architecture (Port & Adapter)”
관점Clean ArchitectureHexagonal Architecture
제안자Robert C. Martin (Uncle Bob)Alistair Cockburn
레이어 수4개 (Entity, Use Case, Adapter, Framework)Port(인터페이스) + Adapter(구현체)
개념 이름레이어(Layer)포트(Port)와 어댑터(Adapter)
공통점의존성이 안쪽을 향함, 비즈니스 로직 보호← 같은 철학
실용적 차이더 구체적인 4레이어 명시Port = 인터페이스, Adapter = 구현체로 명확

실무에서는 두 개념을 혼용해서 사용하는 경우가 많다. 특히 NestJS에서는 Repository 인터페이스(Port)와 TypeORM 구현체(Adapter)라는 표현이 자주 쓰인다.

관점DDDCRUD
중심 개념도메인 모델과 비즈니스 규칙데이터 생성/읽기/수정/삭제
언제 적합?복잡한 비즈니스 규칙이 있을 때단순 데이터 관리 (어드민 패널 등)
복잡도높음 (배움 곡선 있음)낮음 (빠른 개발)
코드량많음적음

언제 DDD를 쓸까?: 도메인 전문가와 긴밀히 협업해야 하고, 비즈니스 규칙이 자주 변경되며, 팀 규모가 클 때. 단순 CRUD만 있는 시스템에 DDD를 억지로 적용하면 오버엔지니어링이 된다.

Twelve-Factor App vs 기존 모놀리식 배포 방식

섹션 제목: “Twelve-Factor App vs 기존 모놀리식 배포 방식”
관점Twelve-Factor App전통적 배포
설정환경변수설정 파일을 서버에 직접 수정
로그stdout → 외부 수집파일로 저장, 직접 서버 접근
확장인스턴스 추가 (수평)서버 사양 증가 (수직)
배포이미지 교체 (Blue/Green)서버 파일 교체 (sftp, rsync)

문제 1: DDD 과도 적용 — 오버엔지니어링

섹션 제목: “문제 1: DDD 과도 적용 — 오버엔지니어링”

증상:

단순 게시판 CRUD를 만들었는데 파일이 50개가 넘었다.
Entity, Value Object, Aggregate, Repository 인터페이스, 구현체, Use Case, DTO...
팀원들이 "이게 다 뭔가요?"라고 물어본다.
간단한 버그 하나 수정에 5개 파일을 수정해야 한다.

원인: DDD는 복잡한 도메인에 적합한 도구다. 단순 CRUD 애플리케이션에 DDD의 모든 전술적 패턴을 적용하면 오히려 복잡도가 증가한다.

해결 방법:

  • 시작은 CRUD, 복잡해지면 DDD: 처음부터 DDD 구조를 갖출 필요가 없다. 비즈니스 규칙이 복잡해지면 그때 리팩토링한다.
  • Bounded Context 단위로 선택: 주문 Context는 DDD, 단순 공지사항 Context는 CRUD로 섞어도 된다.
  • Entity vs Value Object 구분만이라도: 전체 DDD 적용이 어려우면, Entity와 Value Object 개념만 도입해도 코드 품질이 올라간다.

문제 2: Clean Architecture에서 레이어 간 순환 참조

섹션 제목: “문제 2: Clean Architecture에서 레이어 간 순환 참조”

증상:

Error: Circular dependency detected
at OrdersModule → PaymentsModule → OrdersModule
또는 TypeScript 컴파일 에러:
Type 'PaymentEntity' circularly references itself.

원인: 도메인 레이어의 OrderPayment를 직접 import하고, PaymentOrder를 직접 import하는 경우 발생. 의존성 규칙을 위반한 것이다.

해결 방법:

order.entity.ts
// ❌ 순환 참조 - Order가 Payment를 알고 Payment가 Order를 앎
import { Payment } from "../payment/payment.entity"; // 문제!
// ✅ 해결책 1: Domain Event로 결합도 낮추기
// order.entity.ts - Payment를 모른다
export class Order {
confirm(): OrderConfirmedEvent {
this.status = OrderStatus.CONFIRMED;
return new OrderConfirmedEvent(this.id, this.totalAmount);
// 이 이벤트를 Payment 서비스가 구독해서 처리
}
}
// ✅ 해결책 2: 공유 커널(Shared Kernel) 사용
// shared/types/order-status.type.ts
export type OrderId = string;
export enum OrderStatus {
PENDING,
CONFIRMED,
CANCELLED,
}
// Order와 Payment 모두 shared를 import
orders.module.ts
NestJS에서 순환 의존 해결:
@Module({
imports: [forwardRef(() => PaymentsModule)], // forwardRef로 지연 해결
})
export class OrdersModule {}
// payments.module.ts
@Module({
imports: [forwardRef(() => OrdersModule)],
})
export class PaymentsModule {}

문제 3: Twelve-Factor App 위반 — 환경별 동작 불일치

섹션 제목: “문제 3: Twelve-Factor App 위반 — 환경별 동작 불일치”

증상:

로컬(개발)에서는 정상 동작하는데 ECS(프로덕션)에서 에러 발생:
Error: Cannot read property 'url' of undefined
또는:
Error: ECONNREFUSED 127.0.0.1:5432

원인: Config(III번) 위반. 코드에 로컬 환경에서만 유효한 설정이 하드코딩되어 있거나, .env 파일이 프로덕션에 복사되지 않은 경우.

해결 방법:

// ❌ 잘못된 방법
const dbHost = process.env.DB_HOST || "localhost"; // 프로덕션에서 localhost로 연결 시도!
// ✅ 올바른 방법 - 환경변수가 없으면 명시적으로 에러 발생
function getRequiredEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`필수 환경변수 ${key}가 설정되지 않았습니다`);
}
return value;
}
const dbUrl = getRequiredEnv("DATABASE_URL");
// 프로덕션 ECS에서 DATABASE_URL이 없으면 시작 시 에러 → 빠른 실패(Fail Fast)
Terminal window
# ECS Task Definition 환경변수 확인
aws ecs describe-task-definition --task-definition my-app \
--query 'taskDefinition.containerDefinitions[0].environment'
# 예상 출력:
[
{ "name": "DATABASE_URL", "value": "mysql://..." },
{ "name": "REDIS_URL", "value": "redis://..." },
{ "name": "NODE_ENV", "value": "production" }
]

문제 4: NestJS에서 도메인 엔티티와 ORM 엔티티 혼동

섹션 제목: “문제 4: NestJS에서 도메인 엔티티와 ORM 엔티티 혼동”

증상:

도메인 레이어의 Order 클래스에 @Entity(), @Column() 같은 TypeORM 데코레이터가 붙어있다.
도메인 로직을 테스트하려면 TypeORM 연결이 필요하다.
"Clean Architecture인데 왜 도메인에 ORM이 있나요?"라는 리뷰 코멘트가 올라온다.

원인: 도메인 엔티티(비즈니스 규칙)와 영속성 엔티티(DB 스키마)를 하나의 클래스로 합쳐서 사용하고 있다. NestJS 공식 예제가 이 방식을 사용하기 때문에 많은 프로젝트가 무의식적으로 따라한다.

해결 방법:

// ❌ 혼합된 엔티티 (도메인 + ORM이 섞임)
@Entity()
export class Order {
@PrimaryColumn()
id: string;
@Column()
status: string;
confirm() {
this.status = "CONFIRMED";
} // 비즈니스 로직
}
// ✅ 분리: 도메인 엔티티 (순수 비즈니스 로직, 데코레이터 없음)
// domain/entities/order.entity.ts
export class Order {
constructor(
private id: string,
private status: OrderStatus,
) {}
confirm() {
/* 비즈니스 규칙 */
}
}
// ✅ 분리: ORM 스키마 (DB 매핑 전용)
// infrastructure/persistence/order.schema.ts
@Entity("orders")
export class OrderSchema {
@PrimaryColumn() id: string;
@Column() status: string;
}
// ✅ Repository에서 변환
// OrderSchema → Order (DB → 도메인)
// Order → OrderSchema (도메인 → DB)

📖 더 보기: Applying DDD Principles to Nest.js Project (DEV) — 도메인 엔티티와 영속성 엔티티 분리를 NestJS에서 구현하는 실전 가이드 (중급)


문제 5: Graceful Shutdown 미구현으로 인한 배포 시 요청 유실

섹션 제목: “문제 5: Graceful Shutdown 미구현으로 인한 배포 시 요청 유실”

증상:

ECS 배포(롤링 업데이트) 중 일부 API 요청이 실패:
502 Bad Gateway
또는 클라이언트 측에서:
Error: socket hang up

원인: Disposability(IX번) 위반. SIGTERM을 받아도 즉시 프로세스가 종료되어 처리 중이던 요청이 중단됨.

해결 방법:

// main.ts - 올바른 Graceful Shutdown 구현
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 1. NestJS lifecycle hooks 활성화
app.enableShutdownHooks();
await app.listen(3000);
}
// 특정 서비스에서 정리 작업 구현
@Injectable()
export class DatabaseService implements OnModuleDestroy {
async onModuleDestroy() {
console.log("[Shutdown] DB 커넥션 풀 종료 중...");
await this.dataSource.destroy(); // TypeORM 연결 종료
console.log("[Shutdown] DB 커넥션 풀 종료 완료");
}
}
# ECS Task Definition - stopTimeout 설정 (기본 30초, 최대 120초)
{
"containerDefinitions":
[{ "stopTimeout": 60, ? // SIGTERM 후 SIGKILL까지 60초 대기
"name"
: "my-app" }],
}

  • Domain 레이어가 @nestjs/common, TypeORM, Prisma 등을 import하지 않는가?
  • Repository는 Domain 레이어에 인터페이스로 정의되어 있고, 구현체는 Infrastructure 레이어에 있는가?
  • Use Case가 HTTP Request/Response 객체를 직접 다루지 않는가? (Controller가 처리)
  • 의존성 주입(DI)으로 인터페이스에 의존하는가? (구현체가 아닌)
  • DB를 교체한다고 가정할 때, Repository 구현체만 바꾸면 되는가?
  • 팀(기획자 포함)이 동일한 용어(Ubiquitous Language)를 사용하는가?
  • 각 Bounded Context의 경계가 명확하게 정의되어 있는가?
  • Aggregate Root를 통해서만 내부 객체를 수정할 수 있는가?
  • Value Object는 불변(immutable)인가?
  • Domain Event로 서로 다른 Context 간 통신하는가?
  • 모든 설정이 환경변수로 분리되어 있는가? (코드/파일에 하드코딩 없음)
  • process.env.KEY || 'default' 대신 필수 환경변수는 명시적으로 검증하는가?
  • 로그가 console.log (stdout)로 출력되고, 파일에 직접 쓰지 않는가?
  • app.enableShutdownHooks()가 설정되어 있는가?
  • 컨테이너 재시작 후에도 데이터가 유지되는가? (세션을 Redis 등 외부에 저장)
  • 로컬 Docker 이미지와 프로덕션 ECR 이미지가 동일한 Dockerfile로 빌드되는가?

  • Clean Architecture: 의존성 규칙(Dependency Rule), Entity, Use Case, Interface Adapter, Framework & Driver
  • DDD: Bounded Context, Ubiquitous Language, Context Map, Entity, Value Object, Aggregate, Aggregate Root, Repository, Domain Event, Domain Service
  • Twelve-Factor App: Config, Logs as event stream, Disposability, Graceful Shutdown, SIGTERM, Stateless Process
  • 공통: 관심사 분리(Separation of Concerns), 의존성 역전 원칙(Dependency Inversion Principle, DIP), 단일 책임 원칙(Single Responsibility Principle, SRP)


실습 1: Clean Architecture 구조로 NestJS 프로젝트 생성

섹션 제목: “실습 1: Clean Architecture 구조로 NestJS 프로젝트 생성”
Terminal window
# NestJS CLI로 새 프로젝트 생성
npx @nestjs/cli new clean-arch-demo
cd clean-arch-demo
# 디렉토리 구조 생성
mkdir -p src/orders/domain/entities
mkdir -p src/orders/domain/repositories
mkdir -p src/orders/domain/value-objects
mkdir -p src/orders/application/use-cases
mkdir -p src/orders/infrastructure/persistence
mkdir -p src/orders/presentation/controllers
# 구조 확인
find src/orders -type d
예상 출력:
src/orders
src/orders/domain
src/orders/domain/entities
src/orders/domain/repositories
src/orders/domain/value-objects
src/orders/application
src/orders/application/use-cases
src/orders/infrastructure
src/orders/infrastructure/persistence
src/orders/presentation
src/orders/presentation/controllers
main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
// SIGTERM 직접 테스트용
process.on("SIGTERM", () => {
console.log("[TEST] SIGTERM 수신됨 - 종료 시작");
});
await app.listen(3000);
console.log("서버 시작: http://localhost:3000");
}
bootstrap();
Terminal window
# 서버 시작
npm run start:dev
# 다른 터미널에서 SIGTERM 전송
kill -SIGTERM $(lsof -ti:3000)
예상 출력:
서버 시작: http://localhost:3000
[TEST] SIGTERM 수신됨 - 종료 시작
[Nest] 종료 중...
[Nest] 모든 모듈 정리 완료

실습 3: 환경변수 검증 (Twelve-Factor App Config 원칙)

섹션 제목: “실습 3: 환경변수 검증 (Twelve-Factor App Config 원칙)”
Terminal window
# 필수 환경변수 없이 실행 시 에러 확인
# .env 파일에서 DATABASE_URL 제거 후
npm run start:prod
예상 출력 (올바른 구현이라면):
Error: 필수 환경변수 DATABASE_URL가 설정되지 않았습니다
at getRequiredEnv (/app/src/config/env.ts:5:11)
at Object.<anonymous> (/app/src/app.module.ts:15:14)
→ 앱이 시작되지 않고 즉시 에러를 발생시킨다 (Fail Fast)
→ 잘못된 환경에서 절대 배포되지 않음을 보장

Clean Architecture는 “핵심 비즈니스 로직을 외부 환경으로부터 보호”하고, DDD는 “팀이 공통된 언어로 복잡한 도메인을 모델링”하며, Twelve-Factor App은 “어느 클라우드 환경에서든 안정적으로 동작하는 앱을 만드는 방법”이다.