Clean Architecture
분류: Layer 9 - 아키텍처 & 설계 패턴
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”Clean Architecture는 비즈니스 핵심 로직을 프레임워크·DB·UI·외부 인터페이스로부터 독립시키기 위해 안쪽으로만 향하는 의존성 규칙 으로 4계층(Entities → Use Cases → Interface Adapters → Frameworks & Drivers)을 구성하는 아키텍처 패턴이다. Robert C. Martin이 정립했다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”Clean Architecture를 모르면 비즈니스 로직이 TypeORM 데코레이터·Express Request 객체·Redis 클라이언트에 직접 의존하게 된다. 한 인프라가 바뀌면 비즈니스 로직 전체가 흔들리고, 단위 테스트는 DB 없이 실행할 수 없으며, 새 팀원은 “어디부터 봐야 할지” 모른다. Clean Architecture가 정의한 의존성 규칙 하나만 지키면 인프라 교체가 비즈니스 로직에 닿지 않게 격리된다 — 시니어 백엔드의 첫 번째 설계 안전망이다.
선수지식: design-principles.md (SOLID·안티패턴 메타). 자주 결합되는 패턴: ddd-basics.md.
왜 등장했는가: 3-tier와 프레임워크 중심 설계의 한계
섹션 제목: “왜 등장했는가: 3-tier와 프레임워크 중심 설계의 한계”전통 3-tier는 화면 → 서비스 → DB처럼 런타임 호출 흐름을 그대로 코드 의존성으로 옮기기 쉽다. 초기에는 빠르지만, 주문 정책이 Request, ORM row, cache client 이름을 직접 알기 시작하면 “DB 교체”가 저장소 구현 교체가 아니라 비즈니스 규칙 수정이 된다. Robert C. Martin의 Clean Architecture 원문은 Hexagonal, Onion, BCE 같은 선행 패턴이 모두 관심사 분리와 비즈니스 규칙 독립을 목표로 했고, 이를 4개 원으로 통합해 “안쪽 원은 바깥 원의 이름을 몰라야 한다”는 의존성 규칙으로 정리했다.
따라서 이 토픽의 배경은 단순한 레이어 유행이 아니라, 복잡해진 시스템에서 인프라-비즈니스 결합을 끊는 문제다. 해결 메커니즘은 OrderService -> TypeormOrderRepository처럼 호출 방향을 그대로 import하지 않고, OrderService -> OrderRepository <- TypeormOrderRepository처럼 의존 방향을 안쪽 정책으로 되돌리는 것이다. 이 구조는 마이크로서비스의 서비스 경계에도 이어진다. Fowler는 성공한 마이크로서비스 사례가 대개 커진 모놀리스를 나눈 경우였고, 새 시스템을 처음부터 잘게 쪼갠 사례는 “serious trouble”에 빠진 경우가 많다고 정리한다. Clean Architecture는 배포 단위를 무리하게 쪼개기 전, 모듈형 모놀리스 안에서 경계를 학습하게 해 주는 중간 장치로 볼 수 있다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”비유: 양파 껍질
섹션 제목: “비유: 양파 껍질”양파를 생각해보자. 안쪽 층(알맹이)은 가장 본질적인 부분이고, 바깥 껍질은 양파를 감싸는 외부 환경이다. 안쪽 알맹이는 바깥 껍질에 전혀 신경 쓰지 않는다. 마찬가지로 핵심 비즈니스 로직(안쪽)은 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에서의 적용
섹션 제목: “Nest.js에서의 적용”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로 바꾸고 싶다면? TypeormOrderRepository만 PrismaOrderRepository로 교체하면 된다. 도메인/유스케이스 코드는 단 한 줄도 바꾸지 않는다.
NestJS 모듈에서 의존성 주입(DI)으로 의존성 규칙 지키기
섹션 제목: “NestJS 모듈에서 의존성 주입(DI)으로 의존성 규칙 지키기”NestJS의 DI 컨테이너는 Clean Architecture의 의존성 규칙을 자연스럽게 구현할 수 있는 핵심 도구다. 공식 문서는 providers 배열에서 문자열·symbol·enum 같은 비클래스 토큰을 provide 값으로 쓸 수 있고, useClass로 실제 구현체를 선택할 수 있다고 설명한다. 즉 애플리케이션 레이어는 "OrderRepository" 토큰만 알고, 인프라 레이어의 TypeormOrderRepository 또는 PrismaOrderRepository는 모듈 조립부에서만 선택한다.
// 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, // ← 이 한 줄만 수정}조용히 깨지는 실패도 있다. TypeScript interface는 런타임에 사라지므로 constructor(private readonly orderRepository: OrderRepository)만 쓰면 Nest가 어떤 provider를 넣어야 하는지 알 수 없다. 이때 테스트 환경에서 mock이 우연히 들어가면 배포 전까지 놓치기 쉽다. 진단은 import 방향과 DI 토큰을 동시에 확인한다.
rg 'from ".*/infrastructure|from ".*/presentation|@Inject\\("OrderRepository"\\)' src/orders/domain src/orders/application예상 출력은 애플리케이션 생성자에서 @Inject("OrderRepository")만 나오고, domain 또는 application에서 infrastructure·presentation import는 0건이어야 한다. 인프라 import가 나오면 의존성 규칙 위반이고, @Inject("OrderRepository")가 0건이면 런타임 DI 토큰 누락을 의심한다.
📖 더 보기: 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 블로그 — 의존성 규칙의 원문 설명과 각 레이어의 정의 (입문)
Clean Architecture가 역효과를 내는 상황과 결정 기준
섹션 제목: “Clean Architecture가 역효과를 내는 상황과 결정 기준”Clean Architecture가 항상 정답은 아니다. 다음 조건에서는 오히려 복잡도만 늘어난다. Fowler가 마이크로서비스에 대해 “작은 단위”가 운영 부담을 함께 가져온다고 설명하듯, Clean Architecture도 코드 내부의 경계를 늘리는 만큼 매핑·DI·테스트 fixture 비용이 생긴다. 경계가 실제 변경 비용을 줄일 때만 투자할 가치가 있다.
역효과 조건:- 팀 규모 1~3명, 도메인 복잡도 낮음 → 레이어 간 매핑 보일러플레이트가 비즈니스 코드보다 많아짐 → Entity → DTO → Schema 변환 코드만 50줄 넘어감
- Read 비율 > 95%, Write 로직 단순 → UseCase 레이어가 Repository를 그냥 위임하는 패스스루가 됨 → CreateOrderUseCase.execute() = orderRepository.save(order) 한 줄
- 초기 MVP 단계 → "TypeORM → Prisma 교체" 시나리오가 현실화되기 전에 팀이 지쳐버림 → 인터페이스 정의→구현체 교체가 실제로 일어나지 않는 이상 투자 회수 없음Trade-off 선택: 이 경우 완전한 Clean Arch 대신 Service/Repository 2레이어만 분리하는 경량 구조로 시작하고, 도메인 복잡도가 높아지면 점진적으로 레이어를 추가한다. 전환 기준은 “레이어가 예쁘게 보이는가”가 아니라 변경 압력이다. 예를 들어 최근 4주 동안 결제 정책·할인 규칙·재고 예약처럼 같은 유스케이스의 비즈니스 규칙이 3회 이상 바뀌었거나, DB 없이 1초 이내로 돌려야 하는 도메인 단위 테스트가 필요해졌거나, TypeORM/Prisma/R2/외부 API 중 하나를 교체할 가능성이 분기 계획에 들어왔다면 인터페이스 경계를 세운다. 반대로 CRUD 화면 5개, 읽기 요청 95% 이상, 규칙 변경이 월 1회 미만인 백오피스라면 2레이어가 더 낫다.
실패 시나리오는 보통 두 방향이다. 첫째, 과소 설계하면 OrderService가 ORM row와 HTTP DTO를 동시에 알아서 PostgreSQL → MySQL 전환 시 유스케이스 테스트가 대량으로 깨진다. 둘째, 과대 설계하면 CreateOrderUseCase.execute()가 repository.save() 한 줄만 감싸고 Entity → DTO → Schema 변환이 50줄을 넘어서 기능 추가 속도를 늦춘다. 전자는 rg 'typeorm|express|redis' src/orders/domain src/orders/application에서 외부 기술 import가 잡히는 것으로 감지하고, 후자는 유스케이스 메서드가 검증·상태 전이 없이 저장소에 그대로 위임되는지 코드 리뷰에서 확인한다.
경량 구조 (소규모/단순 도메인): Controller → Service → Repository (구현체 직접 주입)
Clean Architecture (중규모/복잡 도메인): Controller → UseCase → Domain → Repository (인터페이스) ← 구현체4. 자주 헷갈리는 개념 비교
섹션 제목: “4. 자주 헷갈리는 개념 비교”| A | B | 차이점 |
|---|---|---|
| Clean Architecture | Hexagonal (Ports & Adapters) | Hexagonal은 외부 통신을 Port·Adapter로 추상화. Clean Arch는 계층 의존 규칙 강조. 본질은 같음(DIP) |
| Clean Architecture | Onion Architecture | Onion이 먼저 제안. Clean Arch는 Onion + Use Case 레이어 추가. 호환됨 |
| Clean Architecture | 전통 3-tier | 3-tier는 데이터 흐름 (UI→Logic→DB)이 의존 방향. Clean Arch는 Stable Abstractions 가 의존 방향(역방향) |
| Use Case | Service | Use Case는 단일 사용자 의도 (CreateOrder). Service는 기술적 묶음 (OrderService = 다수 Use Case 컨테이너) |
| Repository | DAO | Repository는 도메인 객체의 컬렉션 추상. DAO는 테이블 단위 CRUD. Repository가 더 추상적 |
| DTO | Entity | DTO는 레이어 간 데이터 전송 용 (직렬화 가능). Entity는 도메인 규칙 보유 (메서드 가짐) |
5. 출처
섹션 제목: “5. 출처”- Robert C. Martin, The Clean Architecture — 4계층, 의존성 규칙, DB/UI/framework 독립성.
- NestJS, Custom providers — 비클래스 provider token,
useClassprovider 매핑. - Martin Fowler, Monolith First — 마이크로서비스 경계를 처음부터 안정적으로 잡기 어렵다는 사례 기반 판단.
- Martin Fowler, Microservice Prerequisites — 운영 준비 기준, 배포 파이프라인은 “no more than a couple of hours”가 되어야 한다는 기준.
6. 체크리스트
섹션 제목: “6. 체크리스트”- 의존성 규칙(안쪽으로만)이 깨지면 어떤 일이 일어나는지 PostgreSQL → MySQL 마이그레이션 시나리오로 설명할 수 있는가?
- Entity, Use Case, Repository 인터페이스, Repository 구현체를 NestJS 프로젝트 구조에 매핑할 수 있는가?
- DIP(의존성 역전)와 DI(의존성 주입)의 차이를 설명할 수 있는가?
- Clean Architecture가 오히려 역효과인 3가지 조건을 들 수 있는가?
- 경량 구조 vs Clean Arch의 전환 시점(도메인 복잡도·팀 규모)을 판단할 수 있는가?
7. 5줄 요약
섹션 제목: “7. 5줄 요약”- 의존성은 안쪽으로만: Frameworks → Adapters → Use Cases → Entities, 역방향은 절대 불가.
- DIP가 핵심: 고수준(비즈니스 로직)과 저수준(인프라) 둘 다 추상화(인터페이스)에 의존.
- NestJS DI 컨테이너가 의존성 규칙을 자연스럽게 구현. providers에서 인터페이스 토큰 → 구현체 매핑.
- 소규모/단순 도메인은 역효과: 보일러플레이트가 비즈니스 코드를 압도. 경량 구조로 시작 후 점진 도입.
- 인접 패턴: Hexagonal·Onion과 본질 동일(DIP), DDD(ddd-basics.md)와 자주 결합.