콘텐츠로 이동

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은 배포·운영 철학을 다룬다.


2.5. 선행 방식의 한계 — 왜 세 원칙을 함께 보는가

섹션 제목: “2.5. 선행 방식의 한계 — 왜 세 원칙을 함께 보는가”

전통적인 controller/service/repository 레이어링, CRUD 중심 모델, 서버별 설정 파일 배포는 작은 시스템에서는 빠르다. 하지만 변경이 누적되면 세 가지 문제가 동시에 나타난다. 첫째, 프레임워크·DB·HTTP 형식이 비즈니스 규칙 안쪽까지 스며들어 DB 교체나 메시지큐 도입 때 업무 로직까지 흔들린다. Robert C. Martin은 Hexagonal, Onion, Screaming Architecture 등 선행 아키텍처가 모두 관심사 분리를 목표로 하며, Clean Architecture의 핵심을 “소스 코드 의존성은 안쪽으로만 향한다”는 의존성 규칙으로 정리했다. 출처: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

둘째, 객체 그래프를 업무 경계로 착각하면 DDD도 실패한다. Vaughn Vernon의 Aggregate 사례에서 Product 하나에 BacklogItem, Release, Sprint를 모두 넣은 큰 Aggregate는 두 사용자가 동시에 작업하는 것만으로도 한 사용자의 커밋이 버전 충돌로 실패한다(version 1을 보고 시작한 두 요청 중 하나가 version 2로 먼저 저장되면 다른 요청은 거절됨). 해결 메커니즘은 “포함 관계”가 아니라 “한 트랜잭션 안에서 반드시 지켜야 하는 불변식”을 기준으로 Aggregate를 작게 나누는 것이다. 출처: https://www.dddcommunity.org/wp-content/uploads/files/pdf_articles/Vernon_2011_1.pdf

셋째, 배포 환경이 늘어나면 코드 안 상수와 서버별 설정 파일은 쉽게 갈라진다. Twelve-Factor App은 설정을 코드에서 분리해 환경변수로 두고, 프로세스는 빠르게 시작·종료 가능해야 한다고 본다. ECS에서는 태스크 중지 시 기본 30초 뒤 강제 종료(SIGKILL)가 발생하며 Fargate stopTimeout의 최대값은 120초이므로, Graceful Shutdown은 “있으면 좋은 운영 팁”이 아니라 배포 중 요청 유실을 막는 설계 조건이다. 출처: https://12factor.net/config, https://12factor.net/disposability, https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html

따라서 이 문서의 한 줄 정의처럼 세 원칙은 “변화에 강하고 테스트·배포가 쉬운 소프트웨어”라는 같은 문제를 서로 다른 층에서 푼다. Clean Architecture는 외부 기술 변화가 안쪽 규칙을 오염시키지 못하게 하고, DDD는 업무 언어와 트랜잭션 경계를 맞추며, Twelve-Factor App은 같은 코드가 여러 배포 환경에서 예측 가능하게 실행되도록 만든다.


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

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

분리됨 — 별도 문서에서 깊이 다룹니다: 의존성 규칙(안쪽으로만)·4레이어 구조(Entities → Use Cases → Interface Adapters → Frameworks & Drivers)·NestJS DI 적용·역효과 조건은 clean-architecture.md에서 다룹니다. 이 문서는 SOLID·ADR·안티패턴·트러블슈팅 등 설계 원칙 메타에 집중합니다.

분리됨 — 별도 문서에서 깊이 다룹니다: Bounded Context·Ubiquitous Language·Aggregate Root·전략/전술 설계·모듈러 모놀리스 진화 경로는 ddd-basics.md에서 다룹니다.

분리됨 — 별도 문서에서 깊이 다룹니다: 12원칙·III/VI/IX 위반 패턴·SIGTERM 30초 grace period·롤백 런북은 twelve-factor-app.md에서 다룹니다.


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의 레이어 분리는 “한 업무 규칙 변경이 Controller, Service, Repository 3곳 이상에 번진다”, “같은 결제/알림/권한 규칙이 2개 이상 진입점(HTTP, 배치, 메시지 컨슈머)에 중복된다”처럼 변경 비용이 보일 때 도입한다. 반대로 관리자용 단순 CRUD처럼 상태 전이·금액 한도·동시성 규칙이 거의 없고 한 화면 변경이 한 파일에서 끝난다면 레이어와 포트를 먼저 만들지 않는다.
  • DDD는 주문, 정산, 재고처럼 단어 정의가 팀마다 달라 장애로 이어지는 영역부터 적용한다. 예를 들어 “주문 취소”가 결제 취소, 쿠폰 복구, 재고 복구, 배송 중단 중 어디까지 포함하는지 회의마다 달라진다면 Ubiquitous Language와 Bounded Context가 필요하다. 단순 공지사항이나 내부 배너처럼 title, body, visible만 저장하는 기능은 CRUD로 시작하고, 규칙이 생긴 시점에 Value Object나 Aggregate를 추출한다.
배포 전 체크:
[ ] 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를 이해하면 “이 기능은 어떤 서비스에 넣어야 하나?” 논의에 기여할 수 있다.

미지의 프레임워크/시스템에 같은 원리를 적용하는 4단계 분석

섹션 제목: “미지의 프레임워크/시스템에 같은 원리를 적용하는 4단계 분석”

NestJS 외 처음 보는 스택(Spring Boot, Django, Go의 Wire/Echo, Rails 등)을 마주쳐도 본 문서의 원리는 그대로 작동한다. 새 코드베이스를 열었을 때 순서대로 다음 4가지를 묻는다.

  1. 의존성 규칙은 어디서 깨지는가? — 비즈니스 로직 클래스에서 프레임워크 어노테이션/import를 grep
    • Spring Boot: @Service 클래스에 JpaRepository/@Repository가 직접 의존하면 Clean Architecture 위반
    • Django: View가 Model.objects.filter()를 직접 호출하면 Use Case 레이어 부재 신호
    • Go: 비즈니스 패키지가 database/sql을 직접 import하면 인터페이스 추상화 부재
  2. Bounded Context 경계가 디렉터리/패키지에 반영되는가?src/<context>/{domain,application,infrastructure} 구조가 보이면 의도된 분리, layered만 있으면 공유 모델 함정 위험
  3. Twelve-Factor의 Config·Logs·Disposability는 어떻게 구현되는가? — Spring @Value, Django os.environ, Go os.Getenv 등 환경변수 진입점이 1곳으로 좁혀지는지, 시작 시 누락 검증이 있는지 확인. 없으면 III번 위반
  4. Aggregate에 해당하는 트랜잭션 경계는 어떤 단위인가? — Spring @Transactional 메서드, Django transaction.atomic() 블록, Go db.BeginTx() 단위가 비즈니스 불변식 1개를 보호하는지 확인

Spring Boot 코드베이스에 적용해 본 결과: 처음 접한 Spring Boot 프로젝트에서 OrderControllerOrderServiceOrderRepository extends JpaRepository<OrderEntity, Long> 구조를 발견했다고 하자. 1번 결과 OrderService가 JPA에 직접 의존(의존성 규칙 위반), 2번 결과 디렉터리는 controller/service/repository만 있고 Bounded Context 분리 없음, 4번 결과 @Transactional이 Service 메서드 전체에 걸려 Aggregate 경계가 모호함. 이때는 먼저 rg -n "JpaRepository|EntityManager|@Transactional" src/main/java로 저수준 의존과 트랜잭션 위치를 확인한다. 예상 출력에 OrderService.java가 잡히면 “OrderService를 도메인 인터페이스 OrderRepositoryPort에 의존시키고 JPA 구현체 JpaOrderRepositoryAdapter를 별도 패키지로 분리”한다. 같은 방식으로 Django에서는 rg -n "objects\\.|transaction.atomic" ., Go에서는 rg -n "database/sql|BeginTx" .를 실행해 View/handler가 저장소 세부사항을 직접 아는지 본다. 출력이 없으면 해당 관점에서는 위반 증거가 부족하므로 구조 변경보다 기능 구현을 계속한다.


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를 억지로 적용하면 오버엔지니어링이 된다.

결정 기준은 “규칙의 수”보다 “불변식을 지키는 실패 비용”이다. 게시글 CRUD처럼 잘못 저장해도 수정 폼에서 고칠 수 있는 데이터는 CRUD가 낫다. 반면 주문 합계가 결제 승인 금액을 넘으면 안 된다, 재고 예약과 결제 만료가 동시에 일어나도 음수 재고가 되면 안 된다, 정산 상태가 PAID 이후 되돌아가면 감사 로그가 깨진다처럼 한 트랜잭션에서 반드시 보호해야 하는 규칙이 있으면 DDD의 Aggregate 경계가 필요하다. 실패 시나리오는 조용하게 나타난다. 예를 들어 OrderService.cancel()AdminOrderService.forceCancel()이 각각 상태를 바꾸면 테스트는 통과해도 한쪽만 쿠폰 복구 이벤트를 누락할 수 있다. 이때 rg -n "status\\s*=\\s*['\\\"]cancelled|OrderStatus\\.CANCELLED" src로 상태 변경 지점을 찾고, 예상 출력이 2개 이상 서비스에 흩어져 있으면 Order.cancel() 하나로 규칙과 이벤트 발행을 모은다.

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 커넥션 풀 종료 완료");
}
}
{
"containerDefinitions": [
{
"name": "my-app",
"stopTimeout": 60
}
]
}

ECS 공식 문서 기준 stopTimeout을 생략하면 기본 30초, Fargate Linux에서는 최대 120초다. 적용 여부는 배포 후 태스크 정의에서 확인한다.

Terminal window
aws ecs describe-task-definition --task-definition my-app \
--query 'taskDefinition.containerDefinitions[?name==`my-app`].[name,stopTimeout]'
예상 출력:
[
["my-app", 60]
]

출력이 null이면 기본 30초로 동작한다. 이 상태에서 요청 처리 시간이 30초를 넘거나 onModuleDestroy()가 DB 커넥션 종료 전에 오래 걸리면 ECS가 강제 종료할 수 있으므로, 요청을 더 짧게 만들거나 작업을 큐에 재시도 가능하게 반환해야 한다.


  • 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에서 DB를 MySQL → PostgreSQL로 바꿀 때 수정되는 파일이 어느 레이어인가? 왜 그 레이어만 수정하면 되는가?
  • DDD에서 Order.confirm()이 Domain Exception을 던지는 것과 OrderService.confirm()이 던지는 것의 차이는 무엇인가?
  • ECS 배포 시 502 오류가 30초간 발생한다면 Twelve-Factor App의 어느 원칙을 점검해야 하는가?
  • “DDD를 쓰면 무조건 좋다”라는 주장이 틀린 이유를 팀 규모와 도메인 복잡도 관점에서 설명하라.
  • Clean Architecture와 Hexagonal Architecture의 철학은 같지만 용어가 다르다. Port와 UseCase는 각각 어느 레이어에 해당하는가?

  • 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은 “어느 클라우드 환경에서든 안정적으로 동작하는 앱을 만드는 방법”이다.