팀원 중 누군가가 src/domain/invoice.ts에 pg를 임포트했다. PR은 컴파일된다. 테스트는 통과한다. 코드 리뷰는 삼백 줄짜리이고, 아묘도 눈치채지 못한다.
세 달 뒤, 도메인 로직을 공유 패키지로 추출하려 한다. 불가능하다. Postgres 타입, 커넥션 풀링 로직, 모노리스에만 존재하는 커스텀 드라이버 래퍼에 의존하고 있기 때문이다. 동심원으로 그려진 화이트보드 다이어그램은 이제 웃음거리가 되었다.
이것이 자동화된 강제가 없는 클린 아키텍처의 표준적인 부패 패턴이다. 아키텍트는 안쪽을 향하는 화살표를 그린다. 개발자는 바깥을 향하는 임포트를 작성한다. 컴파일러는 당신의 계층 따위 신경 쓰지 않는다.
”도메인이 인프라를 임포트하지 않는다”는 것의 실제 의미
계층형 아키텍처에서 의존성은 안쪽으로 흐른다. 중심에 있는 도메인 계층은 엔티티, 비즈니스 규칙, 유스케이스를 정의한다. HTTP, SQL, 큐, 파일 시스템에 대해 전혀 알지 못한다.
인프라는 바깥 고리에 위치한다. 도메인이 정의한 인터페이스를 구현한다. 리포지토리 인터페이스는 도메인에 있다. Postgres 구현체는 인프라에 있다. 도메인은 save(invoice)를 호출한다. 절대 pool.query()를 호출하지 않는다.
도메인 코드가 인프라를 직접 임포트하면 두 가지 일이 일어난다. 첫째, 데이터베이스가 실행되지 않으면 도메인을 테스트할 수 없게 된다. 둘째, 비즈니스 로직을 다시 작성하지 않고는 Postgres를 DynamoDB로 바꿀 수 없다. 계층화의 핵심인 테스트 용이성과 교체 가능성이 증발한다.
코드 리뷰만으로는 부족한 이유
모든 팀에는 리뷰에서 잘못된 임포트를 잡아내는 시니어 엔지니어가 있다. 그 엔지니어도 휴가를 가고, 아프고, 금요일 오후 다섯 시에 쉰 개 파일짜리 PR을 리뷰하고 있다.
인간은 유한한 RAM을 가진 패턴 매처다. 자동화된 의존성 규칙은 무한한 인내심을 가진 결정론적 검증기다. 아키텍처 경계를 강제해야 할 적절한 장소는 구문 오류를 강제하는 것과 같은 곳이다: 빌드 파이프라인. 컴파일은 되지만 의존성 그래프를 위반한다면 실패해야 한다.
dependency-cruiser가 계층 경계를 강제하는 방법
TypeScript와 JavaScript 코드베이스에서 dependency-cruiser는 아키텍처 다이어그램을 테스트 가능한 규칙 집합으로 전환한다. 임포트 그래프를 파싱하고 금지된 엣지가 나타나면 빌드를 실패시킨다.
설치:
npm install --save-dev dependency-cruiser
npx depcruise --init
init 스크립트가 .dependency-cruiser.js 설정을 생성한다. 도메인이 인프라를 접근하는 것을 금지하는 규칙을 추가한다:
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: "domain-cannot-import-infrastructure",
comment:
"Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
severity: "error",
from: {
path: "^src/domain",
},
to: {
path: "^src/infrastructure",
},
},
{
name: "domain-cannot-import-node-modules",
comment:
"Domain code must not depend on external I/O libraries.",
severity: "error",
from: {
path: "^src/domain",
},
to: {
dependencyTypes: ["npm"],
// Allow only pure logic libraries like date-fns or lodash
pathNot: "^(date-fns|lodash|ramda)",
},
},
],
options: {
doNotFollow: {
path: "node_modules",
},
tsPreCompilationDeps: true,
tsConfig: {
fileName: "tsconfig.json",
},
},
};
이 규칙은 다음을 말한다: src/domain 아래의 파일이 src/infrastructure 아래의 무엇이든 임포트하면 빌드가 깨진다. 두 번째 규칙은 node_modules를 통해 들어오는 pg나 axios 임포트를 잡아낸다.
CI에 연결하라:
# .github/workflows/ci.yml
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx depcruise src
이제 src/domain/invoice.ts의 pg 임포트는 사람이 PR을 열어보기도 전에 실패한다.
실패는 이렇게 보인다
개발자가 규칙을 어기면 다음과 같은 출력을 받는다:
error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
Domain code must not depend on infrastructure. Move the import to an adapter or repository.
error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
Domain code must not depend on external I/O libraries.
✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.
오류 메시지는 무엇을 해야 하는지 알려준다. 데이터베이스 호출을 인프라 계층의 리포지토리로 옮겨라. 리포지토리를 유스케이스에 인터페이스로 전달하라. 도메인 코드는 순수하게 유지된다.
다른 생태계를 위한 대안
모두가 TypeScript를 사용하는 것은 아니다. 원칙은 어디서나 같다. 바뀌는 것은 도구뿐이다.
Java: ArchUnit은 골드 스탠다드다. 설정 파일이 아닌 테스트를 작성한다:
@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAPackage("..infrastructure..");
이는 JUnit 테스트로 실행되므로 새로운 CI 배관 없이도 Maven과 Gradle과 통합된다.
C#: NetArchTest는 .NET에서 동일한 API 스타일을 제공한다. 단위 테스트를 작성하고 CI에서 실행하라.
Go: 성숙한 동등물은 없다. 대부분의 팀은 간단한 셸 스크립트로 이를 강제한다:
#!/bin/bash
# scripts/check-domain-imports.sh
if grep -r "infrastructure/" internal/domain/ ; then
echo "Domain code imports infrastructure. Fix the dependency direction."
exit 1
fi
난해하지만 작동한다. 많은 Go 팀은 커스텀 go vet 분석기나 go/ast 패키지를 사용하여 적절한 검사를 구축하기도 한다.
빌드 시스템: Bazel, Gradle, Nx는 모두 모듈 의존성 그래프를 기본적으로 지원한다. 도메인과 인프라를 명시적인 의존성 선언과 함께 별도의 모듈로 정의하면 빌드 도구가 무료로 경계를 강제한다. 전제 조건은 먼저 해당 빌드 시스템을 도입해야 한다는 것이다.
트레이드오프: 거짓 양성, 마이그레이션 고통, 팀 마찰
자동화된 규칙은 공짜가 아니다. 켜기 전에 비용을 알아야 한다.
거짓 양성은 계층 사이에 위치한 합법적인 공유 유틸리티가 있을 때 발생한다. 문자열 슬러그화 도구나 커스텀 에러 베이스 클래스가 src/shared에 있을 수 있다. 도메인과 인프라 모두가 이를 임포트하면 규칙은 괜찮다. src/infrastructure/utils에 넣고 도메인이 임포트하면 dependency-cruiser가 불평할 것이다. 해결책은 보통 공유 코드를 옮기는 것인데, 이는 어차피 종종 올바른 조치다.
마이그레이션 고통은 현실이다. 코드베이스가 이미 서른 군데에서 규칙을 위반하고 있다면 영웅적인 리팩토링 없이는 스위치를 끌 수 없다. 실용적인 접근법은 경고로 시작하여 스프린트 동안 기존 위반 사항을 수정한 뒤 에러로 격상하는 것이다. 또는 알려진 레거시 파일에 대해 pathNot 예외를 사용하고 첫날부터 새로운 위반을 금지하라.
팀 마찰은 숨겨진 비용이다. 엄격한 계층 강제와 함께 일해본 적 없는 개발자들은 반발할 것이다. 작은 임포트 하나는 해롭지 않다고, 규칙은 관료적이라고, 속도를 늦춘다고 주장할 것이다. 이는 기술적 신호가 아닌 문화적 신호다. 같은 개발자들이 밤 열한 시에 기능을 출시하기 위해 도메인 모델에 express를 임포트할 것이다. 규칙이 존재하는 이유는 압박 속의 인간이 지름길을 선택하기 때문이다.
기존 코드베이스에서 이를 구현하는 방법
대규모 재작성이 필요한 것은 아니다. 경계와 이를 측정할 방법이 필요하다.
-
계층을 그려라. 어떤 디렉토리가 도메인, 애플리케이션, 인프라인지 결정하라.
ARCHITECTURE.md에 기록하라. -
dependency-cruiser(또는 해당 생태계의 동등물)를 하나의 규칙과 함께 추가하라: 도메인은 인프라를 임포트할 수 없다.
-
실행하라. 위반 사항을 세라. 숫자가 적다면 설정을 머지하기 전에 수정하라. 크다면 레거시 경로를
pathNot예외에 추가하라. -
CI에서 실패하게 만들어라. 선택 사항이 아니다. 경고가 아니다. 머지를 차단하는 에러다.
-
개발자가 에러에 부딪힐 때마다 임포트를 옮기도록 도와라. 빌드가 빨갛다고만 말하지 마라. 코드가 어디에 있어야 하고 왜 그런지 설명하라.
이 주 내에 팀은 도메인 파일에 pg를 임포트하려는 시도를 멈출 것이다. 화이트보드의 아키텍처 다이어그램이 마침내 저장소의 코드와 일치하게 될 것이다.
FAQ
애플리케이션 계층이 인프라를 임포트해도 되는가?
보통 그렇다. 애플리케이션 계층은 유스케이스를 오케스트레이션한다. 리포지토리와 외부 서비스를 알 수 있지만 구체적인 구현체가 아닌 인터페이스에 의존해야 한다. 엄격한 강제를 원한다면 두 번째 규칙을 추가하라: application은 infrastructure 구현체를 임포트할 수 없고, 인터페이스만 가능하다.
도메인 이벤트는 어떤가?
도메인 이벤트는 흔한 허점이다. 도메인 이벤트는 순수한 데이터이므로 도메인 계층에 속한다. 이를 발행하는 인프라 이벤트 버스는 인프라에 속한다. 애플리케이션 계층이 도메인 이벤트 핸들러를 버스에 구독시킨다. 도메인 자체는 버스의 존재를 절대 알지 못한다.
Nx나 Turborepo와 함께 사용할 수 있는가?
그렇다. Nx에는 프로젝트 레벨에서 작동하는 내장 모듈 경계 규칙이 있다. 도메인과 인프라가 별도의 Nx 라이브러리라면 별도의 도구 없이 규칙을 강제할 수 있다. Bazel과 Gradle에도 동일하게 적용된다.
인프라의 유틸리티가 필요하면 어떻게 하나?
필요 없다. 유틸리티를 인프라 의존성이 없는 공유 common이나 kernel 패키지로 옮겨라. 정말로 인프라가 필요하다면 그것은 유틸리티가 아니다. 인프라다.
핵심
자동화된 강제가 없는 클린 아키텍처는 신사협정이다. 신사협정은 프로덕션 데드라인을 버티지 못한다.
Dependency-cruiser, ArchUnit, 또는 grep이 들어간 셸 스크립트조차도 당신의 아키텍처를 제안에서 보장으로 전환한다. 도메인은 순수하게 유지된다. 인프라는 플러그 가능하게 유지된다. 그리고 다음에 누군가 비즈니스 규칙에 pg를 임포트하려 할 때, 빌드는 PR이 인간 리뷰어에게 도달하기 전에 실패할 것이다.
하나의 규칙부터 시작하라. CI에서 빨갛게 만들어라. 첫 번째 위반을 고쳐라. 나머지는 따라올 것이다.