Clean Architecture의 의존성 규칙
Clean Architecture는 비즈니스 로직이 프레임워크와 인프라에 끌려가지 않게 의존 방향을 안쪽으로 고정하는 설계 방식이다. NestJS DI, DIP, 경계 도입 기준, 과소 설계와 과대 설계의 실패 모드를 함께 짚는다.
Script Companion
오디오와 함께 스크립트 보기
- 01
Clean Architecture의 출발점은 비즈니스 로직을 보호하는 것이다. 이 구조를 모르면 주문 정책 같은 핵심 규칙이 TypeORM 데코레이터, Express Request 객체, Redis 클라이언트에 직접 의존하기 쉽다. 그러면 인프라 하나를 바꾸는 일이 저장소 구현 교체가 아니라 비즈니스 규칙 수정으로 번진다. 단위 테스트도 DB 없이 실행하기 어려워지고, 새 팀원은 코드에서 어디가 정책이고 어디가 기술 세부사항인지 잡기 어려워진다.
- 02
이 문제가 등장한 배경에는 전통적인 3-tier와 프레임워크 중심 설계의 한계가 있다. 화면, 서비스, DB로 이어지는 런타임 호출 흐름을 그대로 코드 의존성으로 옮기면 초기 개발은 빠르지만, 시간이 지날수록 주문 정책이 Request, ORM row, cache client의 이름을 알게 된다. Robert C. Martin은 Hexagonal, Onion, BCE 같은 선행 패턴이 모두 관심사 분리와 비즈니스 규칙 독립을 목표로 했다고 보고, 이를 안쪽 원과 바깥쪽 원의 의존성 규칙으로 정리했다.
- 03
핵심 이미지는 양파 껍질이다. 안쪽 알맹이는 가장 본질적인 부분이고, 바깥 껍질은 외부 환경에 가깝다. Clean Architecture에서 안쪽은 핵심 비즈니스 로직이고, 바깥쪽은 DB나 HTTP 프레임워크 같은 세부사항이다. 규칙은 단순하다. 소스 코드의 의존성은 반드시 안쪽, 즉 고수준 정책을 향해야 한다. 바깥 레이어는 안쪽 레이어를 알 수 있지만, 안쪽 레이어는 바깥 레이어의 이름을 몰라야 한다.
- 04
레이어를 나누면 역할도 분명해진다. Entities는 Order나 User처럼 DB 종류를 모르는 순수 비즈니스 규칙을 담는다. Use Cases는 CreateOrderUseCase나 CancelOrderUseCase처럼 도메인 객체를 사용해 흐름을 조율한다. Interface Adapters는 Controller, Repository 인터페이스, DTO 변환처럼 바깥 세계와 유스케이스 사이를 번역한다. Frameworks and Drivers는 TypeORM, Express, Redis 클라이언트처럼 실제 구현체이며, 언제든 교체 가능한 위치에 둔다.
- 05
이 의존 방향을 코드에서 실현하는 핵심 원리는 의존성 역전 원칙, DIP다. 고수준 모듈인 비즈니스 로직이 저수준 모듈인 인프라에 직접 의존하지 않고, 둘 다 추상화에 의존하게 만든다. 호출은 OrderService에서 TypeormOrderRepository로 흘러갈 수 있지만, 코드 의존은 OrderService가 OrderRepository를 알고 TypeormOrderRepository가 그 계약을 구현하는 방향으로 잡는다. 이렇게 하면 TypeORM을 Prisma로 바꿔도 도메인과 유스케이스 코드는 그대로 둘 수 있다.
- 06
NestJS에서는 DI 컨테이너가 이 규칙을 구현하는 도구가 된다. providers 배열에서 문자열, symbol, enum 같은 비클래스 토큰을 provide 값으로 쓰고, useClass로 실제 구현체를 선택할 수 있다. 그래서 애플리케이션 레이어는 OrderRepository 토큰만 알고, TypeormOrderRepository나 PrismaOrderRepository 선택은 모듈 조립부에서 처리한다. 중요한 함정도 있다. TypeScript interface는 런타임에 사라지므로 생성자에서 타입만 적으면 Nest가 어떤 provider를 넣어야 하는지 알 수 없다.
- 07
이 구조는 조용히 깨질 수 있어서 진단 기준이 필요하다. 애플리케이션 생성자에는 @Inject("OrderRepository") 같은 런타임 DI 토큰이 보여야 하고, domain 또는 application에서 infrastructure나 presentation import는 없어야 한다. 인프라 import가 잡히면 의존성 규칙 위반이고, 토큰 주입이 보이지 않으면 런타임 DI 매핑 누락을 의심한다. 테스트 환경에서 mock이 우연히 들어가면 배포 전까지 놓칠 수 있으므로, import 방향과 DI 토큰을 함께 확인해야 한다.
- 08
Clean Architecture가 항상 정답은 아니다. Fowler가 마이크로서비스의 작은 단위가 운영 부담을 함께 가져온다고 설명하듯, Clean Architecture도 코드 내부 경계를 늘리는 만큼 매핑, DI, 테스트 fixture 비용이 생긴다. 경계가 실제 변경 비용을 줄일 때만 투자할 가치가 있다. 최근 4주 동안 결제 정책, 할인 규칙, 재고 예약처럼 같은 유스케이스의 규칙이 3회 이상 바뀌었거나, DB 없이 1초 이내로 도메인 단위 테스트가 필요하거나, TypeORM, Prisma, R2, 외부 API 중 하나의 교체가 분기 계획에 있다면 인터페이스 경계를 세울 만하다.
- 09
반대로 CRUD 화면 5개, 읽기 요청 95% 이상, 규칙 변경이 월 1회 미만인 백오피스라면 Service와 Repository 정도의 2레이어가 더 나을 수 있다. 실패는 두 방향으로 나타난다. 과소 설계하면 OrderService가 ORM row와 HTTP DTO를 동시에 알아서 PostgreSQL에서 MySQL로 바꿀 때 유스케이스 테스트가 대량으로 깨진다. 과대 설계하면 CreateOrderUseCase.execute()가 repository.save() 한 줄만 감싸고, Entity에서 DTO와 Schema로 이어지는 변환이 50줄을 넘어서 기능 추가 속도를 늦춘다.
- 10
정리하면 Clean Architecture의 중심은 레이어 이름이 아니라 의존성 규칙이다. Frameworks and Drivers, Interface Adapters, Use Cases, Entities로 갈수록 의존은 안쪽을 향하고, 역방향은 허용하지 않는다. DIP는 이 규칙을 가능하게 하는 원리이고, NestJS DI 컨테이너는 토큰과 구현체 매핑으로 이를 실무 코드에 옮기는 장치다. 다만 단순 도메인에서는 보일러플레이트가 비즈니스 코드를 압도할 수 있으므로, 변경 압력과 테스트 필요성을 기준으로 점진 도입하는 편이 맞다.
같은 레이어
L9에서 이어 듣기
- 설계 원칙을 운영 가능한 코드로 잇기 길이 미정
- DDD 기본기: 도메인 언어와 경계 설계 길이 미정
- Twelve-Factor App 운영 원칙 길이 미정
- CAP과 일관성으로 보는 분산 시스템 선택 길이 미정
- MSA 패턴, 분리의 이득과 운영 비용 길이 미정
- Saga Pattern: 로컬 커밋과 역순 보상 길이 미정
- CQRS와 이벤트 소싱의 운영 경계 길이 미정
- TDD와 테스트 피라미드로 설계하는 테스트 전략 길이 미정
- 대규모 웹 크롤러의 큐, 정중함, 중복 제거 길이 미정
- API 계약으로 안전하게 서비스 경계를 진화시키기 길이 미정
- URL Shortener와 Rate Limiter로 보는 시스템 디자인 길이 미정