Design Principles
분류: Layer 9 - 아키텍처 & 설계 패턴
소프트웨어 설계 원칙 (Clean Architecture / DDD / Twelve-Factor App)
섹션 제목: “소프트웨어 설계 원칙 (Clean Architecture / DDD / Twelve-Factor App)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”소프트웨어가 변화에 강하고, 테스트하기 쉽고, 배포 환경에 독립적으로 동작하게 만드는 설계 철학의 집합이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”백엔드 시스템은 비즈니스 요구사항이 바뀌면 코드도 바뀐다. 문제는 “얼마나 쉽게 바꿀 수 있느냐”이다.
- 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. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. Clean Architecture (클린 아키텍처)
섹션 제목: “3-1. Clean Architecture (클린 아키텍처)”분리됨 — 별도 문서에서 깊이 다룹니다: 의존성 규칙(안쪽으로만)·4레이어 구조(Entities → Use Cases → Interface Adapters → Frameworks & Drivers)·NestJS DI 적용·역효과 조건은 clean-architecture.md에서 다룹니다. 이 문서는 SOLID·ADR·안티패턴·트러블슈팅 등 설계 원칙 메타에 집중합니다.
3-2. DDD (Domain Driven Design)
섹션 제목: “3-2. DDD (Domain Driven Design)”분리됨 — 별도 문서에서 깊이 다룹니다: Bounded Context·Ubiquitous Language·Aggregate Root·전략/전술 설계·모듈러 모놀리스 진화 경로는 ddd-basics.md에서 다룹니다.
3-3. Twelve-Factor App (12팩터 앱)
섹션 제목: “3-3. Twelve-Factor App (12팩터 앱)”분리됨 — 별도 문서에서 깊이 다룹니다: 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 Modelexport 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 코드 불변SOLID ↔ NestJS 매핑 요약
섹션 제목: “SOLID ↔ NestJS 매핑 요약”| 원칙 | NestJS 패턴 | 실무 적용 포인트 |
|---|---|---|
| SRP | Controller/Service/Repository 분리 | 이벤트 기반 부수 효과 분리 |
| OCP | 인터페이스 + useClass DI | 결제/알림 등 전략 패턴으로 확장 |
| LSP | 인터페이스 분리, 계약 준수 | Repository 읽기/쓰기 인터페이스 분리 |
| ISP | 역할별 작은 인터페이스 정의 | CQRS의 Command/Query 분리와 연결 |
| DIP | @Inject() + 추상화 토큰 DI | Clean Architecture의 의존성 규칙 구현 |
3-6. ADR (Architecture Decision Records)
섹션 제목: “3-6. ADR (Architecture Decision Records)”코드에 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 필요)ADR을 언제 쓰는가?
섹션 제목: “ADR을 언제 쓰는가?”- 기술 스택 선택 (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.mdADR 파일은 코드와 함께 Git으로 관리한다. PR 리뷰처럼 ADR도 팀 리뷰를 거친다.
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”대기업/중견기업 백엔드 팀
섹션 제목: “대기업/중견기업 백엔드 팀”- 신규 서비스 설계 시: 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를 추출한다.
AWS ECS 환경에서의 체크리스트
섹션 제목: “AWS ECS 환경에서의 체크리스트”배포 전 체크:[ ] DATABASE_URL 등 모든 설정이 환경변수로 분리되어 있는가?[ ] 코드에 하드코딩된 IP, 비밀번호가 없는가?[ ] SIGTERM 핸들러가 구현되어 있는가? (enableShutdownHooks)[ ] 로그가 파일이 아닌 stdout으로 출력되는가?[ ] 컨테이너가 무상태인가? (세션을 로컬 메모리에 저장하지 않는가?)5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”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가지를 묻는다.
- 의존성 규칙은 어디서 깨지는가? — 비즈니스 로직 클래스에서 프레임워크 어노테이션/import를 grep
- Spring Boot:
@Service클래스에JpaRepository/@Repository가 직접 의존하면 Clean Architecture 위반 - Django: View가
Model.objects.filter()를 직접 호출하면 Use Case 레이어 부재 신호 - Go: 비즈니스 패키지가
database/sql을 직접 import하면 인터페이스 추상화 부재
- Spring Boot:
- Bounded Context 경계가 디렉터리/패키지에 반영되는가? —
src/<context>/{domain,application,infrastructure}구조가 보이면 의도된 분리, layered만 있으면 공유 모델 함정 위험 - Twelve-Factor의 Config·Logs·Disposability는 어떻게 구현되는가? — Spring
@Value, Djangoos.environ, Goos.Getenv등 환경변수 진입점이 1곳으로 좁혀지는지, 시작 시 누락 검증이 있는지 확인. 없으면 III번 위반 - Aggregate에 해당하는 트랜잭션 경계는 어떤 단위인가? — Spring
@Transactional메서드, Djangotransaction.atomic()블록, Godb.BeginTx()단위가 비즈니스 불변식 1개를 보호하는지 확인
Spring Boot 코드베이스에 적용해 본 결과: 처음 접한 Spring Boot 프로젝트에서 OrderController → OrderService → OrderRepository 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가 저장소 세부사항을 직접 아는지 본다. 출력이 없으면 해당 관점에서는 위반 증거가 부족하므로 구조 변경보다 기능 구현을 계속한다.
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”Clean Architecture vs Hexagonal Architecture (Port & Adapter)
섹션 제목: “Clean Architecture vs Hexagonal Architecture (Port & Adapter)”| 관점 | Clean Architecture | Hexagonal 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)라는 표현이 자주 쓰인다.
DDD vs CRUD 패턴
섹션 제목: “DDD vs CRUD 패턴”| 관점 | DDD | CRUD |
|---|---|---|
| 중심 개념 | 도메인 모델과 비즈니스 규칙 | 데이터 생성/읽기/수정/삭제 |
| 언제 적합? | 복잡한 비즈니스 규칙이 있을 때 | 단순 데이터 관리 (어드민 패널 등) |
| 복잡도 | 높음 (배움 곡선 있음) | 낮음 (빠른 개발) |
| 코드량 | 많음 | 적음 |
언제 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) |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”문제 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.원인: 도메인 레이어의 Order가 Payment를 직접 import하고, Payment도 Order를 직접 import하는 경우 발생. 의존성 규칙을 위반한 것이다.
해결 방법:
// ❌ 순환 참조 - 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.tsexport type OrderId = string;export enum OrderStatus { PENDING, CONFIRMED, CANCELLED,}// Order와 Payment 모두 shared를 importNestJS에서 순환 의존 해결:@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)# 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.tsexport 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초다. 적용 여부는 배포 후 태스크 정의에서 확인한다.
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가 강제 종료할 수 있으므로, 요청을 더 짧게 만들거나 작업을 큐에 재시도 가능하게 반환해야 한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”Clean Architecture 체크리스트
섹션 제목: “Clean Architecture 체크리스트”- Domain 레이어가
@nestjs/common, TypeORM, Prisma 등을 import하지 않는가? - Repository는 Domain 레이어에 인터페이스로 정의되어 있고, 구현체는 Infrastructure 레이어에 있는가?
- Use Case가 HTTP Request/Response 객체를 직접 다루지 않는가? (Controller가 처리)
- 의존성 주입(DI)으로 인터페이스에 의존하는가? (구현체가 아닌)
- DB를 교체한다고 가정할 때, Repository 구현체만 바꾸면 되는가?
DDD 체크리스트
섹션 제목: “DDD 체크리스트”- 팀(기획자 포함)이 동일한 용어(Ubiquitous Language)를 사용하는가?
- 각 Bounded Context의 경계가 명확하게 정의되어 있는가?
- Aggregate Root를 통해서만 내부 객체를 수정할 수 있는가?
- Value Object는 불변(immutable)인가?
- Domain Event로 서로 다른 Context 간 통신하는가?
Twelve-Factor App 체크리스트
섹션 제목: “Twelve-Factor App 체크리스트”- 모든 설정이 환경변수로 분리되어 있는가? (코드/파일에 하드코딩 없음)
-
process.env.KEY || 'default'대신 필수 환경변수는 명시적으로 검증하는가? - 로그가
console.log(stdout)로 출력되고, 파일에 직접 쓰지 않는가? -
app.enableShutdownHooks()가 설정되어 있는가? - 컨테이너 재시작 후에도 데이터가 유지되는가? (세션을 Redis 등 외부에 저장)
- 로컬 Docker 이미지와 프로덕션 ECR 이미지가 동일한 Dockerfile로 빌드되는가?
7.5 실습 후 자가 점검
섹션 제목: “7.5 실습 후 자가 점검”다음 질문에 코드 없이 언어로 답할 수 있으면 이 문서를 이해한 것이다.
- 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는 각각 어느 레이어에 해당하는가?
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”- 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)
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 The Clean Architecture — Uncle Bob 블로그 — 의존성 규칙과 레이어 구조의 원문 설명. 가장 먼저 읽어야 할 문서 (입문)
- 📖 Twelve-Factor App 공식 문서 (한국어) — 12가지 원칙 전문 한국어 번역. 짧고 명확하다 (입문)
- 📖 Developing Twelve-Factor Apps using Amazon ECS — AWS 공식 블로그 — ECS/Fargate 환경에서 12팩터 원칙 적용 AWS 공식 가이드 (중급)
- 📖 Amazon ECS task definition parameters — AWS 공식 문서 —
stopTimeout기본 30초, Fargate 최대 120초의 현재 기준 출처 (입문) - 📖 Graceful shutdowns with ECS — AWS Containers 공식 블로그 — SIGTERM 처리, in-flight 요청 종료, PID 1 신호 전달 문제를 설명하는 운영 사례 (중급)
- 📖 Effective Aggregate Design Part I — Vaughn Vernon (PDF) — “Design Small Aggregates” 권고와 “한 트랜잭션 = 한 Aggregate 변경” 원칙의 권위 출처. 절대 ms 임계값 대신 lock contention 회피를 강조 (중급)
- 📖 kubectl rollout undo — Kubernetes 공식 레퍼런스 — 직전 revision 즉시 복귀 명령. 12팩터 위반 발견 시 재배포보다 빠른 복구 절차의 표준 (입문)
- 📖 Mastering NestJS: Clean Architecture and DDD in E-commerce — NestJS Ninja — 이커머스 예제로 NestJS에서 Clean Architecture + DDD를 구현하는 6편 시리즈 (중급)
- 📖 Mastering DDD with NestJS: A Final Reflection — Codanyks — 모듈러 모놀리스부터 MSA 전환까지 DDD 실전 적용 총정리 (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: Clean Architecture 구조로 NestJS 프로젝트 생성
섹션 제목: “실습 1: Clean Architecture 구조로 NestJS 프로젝트 생성”# NestJS CLI로 새 프로젝트 생성npx @nestjs/cli new clean-arch-democd clean-arch-demo
# 디렉토리 구조 생성mkdir -p src/orders/domain/entitiesmkdir -p src/orders/domain/repositoriesmkdir -p src/orders/domain/value-objectsmkdir -p src/orders/application/use-casesmkdir -p src/orders/infrastructure/persistencemkdir -p src/orders/presentation/controllers
# 구조 확인find src/orders -type d예상 출력:src/orderssrc/orders/domainsrc/orders/domain/entitiessrc/orders/domain/repositoriessrc/orders/domain/value-objectssrc/orders/applicationsrc/orders/application/use-casessrc/orders/infrastructuresrc/orders/infrastructure/persistencesrc/orders/presentationsrc/orders/presentation/controllers실습 2: Graceful Shutdown 동작 확인
섹션 제목: “실습 2: Graceful Shutdown 동작 확인”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();# 서버 시작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 원칙)”# 필수 환경변수 없이 실행 시 에러 확인# .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)→ 잘못된 환경에서 절대 배포되지 않음을 보장10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”Clean Architecture는 “핵심 비즈니스 로직을 외부 환경으로부터 보호”하고, DDD는 “팀이 공통된 언어로 복잡한 도메인을 모델링”하며, Twelve-Factor App은 “어느 클라우드 환경에서든 안정적으로 동작하는 앱을 만드는 방법”이다.