네 개의 그림, 하나의 규칙
아키텍처 논의에서 육각형 그림, 동심원 그림, 사각형을 쌓은 그림이 번갈아 등장한다. 세 그림이 각기 다른 선택지처럼 놓이고, "우리는 Clean으로 간다" 같은 결정이 내려진다.
먼저 결론부터 놓는다.
네 그림은 대체로 같은 한 가지 규칙의 다른 그림이다. 그 규칙은 의존 방향이고, 나머지 차이는 어휘와 강조점이다.
이 주장의 근거는 간단한 테스트로 확인된다. 각 스타일에서 "도메인 코드가 DB 드라이버를 import 해도 되는가"를 물으면 Layered만 예라고 답하고 나머지 셋은 아니오라고 답한다. 그리고 셋의 아니오는 같은 아니오다. 셋 다 구현이 인터페이스에 의존하게 만들고, 그 인터페이스를 도메인 쪽이 소유하게 한다.
차이가 없다는 말은 아니다. 차이가 어디에 있는지를 정확히 짚고, 그다음 네 스타일 전부에 공통으로 붙는 가격표를 계산하는 것이 이 편의 순서다.
각 그림이 실제로 말한 것
Layered. 특정 저자가 없다. 프레젠테이션 → 도메인 → 데이터 소스로 쌓고 위에서 아래로만 의존하는 배치이며, Fowler가 PoEAA(2002)에서 정리한 형태가 가장 널리 인용된다. 핵심 특징은 인프라가 맨 아래에 있다는 것이고, 위에서 아래로 의존한다는 규칙에 따라 그 결과는 하나다. 도메인이 인프라에 의존한다. 나머지 셋이 전부 뒤집으려 한 지점이 정확히 여기다.
Hexagonal Architecture (Ports and Adapters). Alistair Cockburn이 2005년에 정리했다. 위아래가 아니라 안과 밖으로 나눈다. 안쪽은 애플리케이션, 바깥쪽은 어댑터다. 포트는 애플리케이션이 선언한 인터페이스이고 어댑터는 그것을 구현하거나 호출한다. Cockburn의 원래 강조점은 좌우 대칭이다. 왼쪽은 애플리케이션을 구동하는 어댑터(HTTP 핸들러, CLI, 테스트 러너), 오른쪽은 애플리케이션에 의해 구동되는 어댑터(DB, 메일 게이트웨이). 육각형인 이유는 변이 여섯 개여야 해서가 아니라 포트가 여러 개일 수 있음을 그리기 위해서다.
Onion Architecture. Jeffrey Palermo가 2008년에 제시했다. 동심원이고 의존은 안쪽으로만 향한다. 중심에 도메인 모델, 그 바깥에 도메인 서비스, 애플리케이션 서비스, 가장 바깥에 인프라와 UI를 둔다. Hexagonal과 규칙은 사실상 같고, 안쪽을 여러 겹으로 더 세분한 것이 차이다.
Clean Architecture. Robert C. Martin이 2012년 블로그 글에서 정리하고 2017년 책으로 확장했다. 위 둘을 통합한 그림이라고 본인이 밝히고 있다. 고유한 기여는 둘이다. 첫째, Entities(기업 전체에 걸친 규칙)와 Use Cases(애플리케이션 고유 규칙)라는 어휘로 안쪽 두 겹을 분리한 것. 둘째, 경계를 넘을 때 데이터를 그 경계에 맞는 형태로 번역하라는 규칙을 명시한 것이다. 안쪽 객체가 바깥 형태로 새어 나가면 안 되므로 경계마다 DTO와 변환이 생긴다.
엔진은 의존성 역전 하나다
세 스타일이 Layered를 뒤집는 방법은 전부 같다. 의존성 역전, 즉 인터페이스를 누가 소유하는가를 바꾸는 것이다. 코드로 보면 차이는 import 한 줄의 방향이다.
// domain/order-service.ts — Before: 도메인이 인프라를 안다
import { drizzleOrderRepo } from '../infra/drizzle-order-repo' // 아래로 향하는 의존
export async function cancelOrder(id: string) {
const order = await drizzleOrderRepo.findById(id)
// ...
}이 코드에서 도메인은 Drizzle을 갈아끼울 수 없고, 테스트하려면 DB가 떠 있어야 한다. 인터페이스를 도메인 쪽으로 옮기면 화살표가 뒤집힌다.
// domain/ports.ts — After: 포트는 도메인이 소유한다. 도메인은 아무것도 import 하지 않는다
export interface OrderPort {
findById(id: string): Promise<Order | null>
save(order: Order): Promise<void>
}
// domain/cancel-order.ts — After: 유스케이스는 포트만 안다
export function makeCancelOrder(port: OrderPort) {
return async (id: string) => {
const order = await port.findById(id)
if (!order) throw new DomainError('not found')
const { refund, next } = order.cancel()
await port.save(next)
return refund
}
}
// infra/drizzle-order-adapter.ts — After: 어댑터가 도메인을 import 한다. 방향이 뒤집혔다
import type { OrderPort } from '../domain/ports'
export const drizzleOrderAdapter: OrderPort = { /* ... */ }바뀐 것은 import가 어느 쪽에 적혀 있느냐 하나다. Before에서는 domain이 infra를 참조했고, After에서는 infra가 domain을 참조한다. 도메인 폴더 안의 어떤 파일도 바깥을 참조하지 않는다는 상태, 그것이 네 그림 중 셋이 공통으로 요구하는 전부다.
DI 컨테이너는 필요 없다. 조립은 진입점에서 함수 호출 한 번으로 끝난다.
// app/api/orders/[id]/cancel/route.ts — After: 구동 어댑터가 조립한다
const cancelOrder = makeCancelOrder(drizzleOrderAdapter)
export async function POST(_: Request, { params }: { params: { id: string } }) {
const refund = await cancelOrder(params.id) // 도메인 호출
return Response.json({ refund }) // HTTP 번역
}클래스도 데코레이터도 컨테이너도 없이 포트와 어댑터가 성립한다. TypeScript에서 이 패턴의 최소 형태는 클로저 하나다.
왜 통하는가 — 변경 빈도로 가른다
의존 방향을 고정하는 이유는 미학이 아니다. 변경 빈도가 다른 것을 갈라놓기 위해서다.
도메인 규칙은 제품 정책이 바뀔 때만 바뀐다. 취소 정책을 분기당 두 번 손댄다면 연 8회다. 인프라는 다르다. 드라이버 업그레이드, 커넥션 풀 설정, 스키마 인덱스 추가, 캐시 도입, 로깅 포맷 변경이 월 3~4건 나온다면 연 42회다. 5배 이상 차이 난다.
의존 방향이 도메인 → 인프라면 이 42회가 도메인 코드의 재컴파일·재테스트·재리뷰를 유발할 수 있다. 방향을 뒤집으면 42회는 어댑터 안에서 끝나고, 도메인 테스트는 DB 없이 그대로 돈다. 자주 바뀌는 쪽이 드물게 바뀌는 쪽을 흔들지 못하게 하는 것, 그게 이 네 그림의 전부다.
유스케이스 하나에 파일 일곱 개
파일 수를 실제로 세어 보면. Route Handler에 다 쓰면 유스케이스 하나에 파일 1개다. Layered면 핸들러·서비스·리포지토리 3개다. Hexagonal/Clean을 규범대로 적용하면 구동 어댑터 1, 유스케이스 1, 아웃바운드 포트 1, 어댑터 구현 1, 요청·응답 DTO 2, 조립 1로 7개다. 유스케이스 20개짜리 서비스면 20개 대 140개의 차이다.
필드 하나를 추가할 때. 주문에 giftMessage 하나를 넣는다고 하자. DB 스키마, 행 타입, 도메인 엔티티, 포트 반환 타입, 유스케이스 출력 타입, 응답 DTO, 도메인↔행 매퍼, 도메인↔DTO 매퍼. 여덟 군데다. 경계마다 번역하라는 Clean의 규칙이 정확히 이 비용을 만든다. 그 대가로 얻는 것은 DB 컬럼명 변경이 API 응답에 새지 않는다는 것이고, 컬럼명이 바뀔 일이 없는 서비스에서는 회수되지 않는다.
"정의로 이동"이 인터페이스에서 멈춘다. port.save()에서 F12를 누르면 OrderPort의 선언으로 간다. 실제 구현을 찾으려면 구현체를 따로 찾아야 하고, 포트 하나에 어댑터가 둘(운영용, 테스트용) 이상이면 어느 쪽이 도는지는 조립 파일을 읽어야 안다. 1편에서 간접성이 파는 것이 추적 가능성이라고 한 것이 이 자리다.
잘못 적용하면 아무 일도 하지 않는 계층이 쌓인다. 서비스가 리포지토리를 그대로 호출하고, DTO가 엔티티와 필드가 같고, 매퍼가 필드를 1:1로 옮기기만 하는 상태다. 이때 각 계층은 비용만 발생시키고 아무것도 막지 않는다. 이 상태의 증상과 해체 방법, 그리고 계층을 추가할 자격 기준("그 계층이 무엇을 막는지 한 문장으로 말할 수 없으면 넣지 않는다")은 안티패턴 4편에서 세웠다.
DI 컨테이너를 들이면 비용이 한 겹 더 붙는다. 위 예제처럼 함수 조립으로 충분한데 컨테이너를 도입하면, 조립 오류가 컴파일 타임이 아니라 런타임에 나타나고 스택 트레이스에 프레임워크 프레임이 끼며, 신규 인원이 배워야 할 대상이 하나 늘어난다. 팀이 20명이고 모듈 경계가 여럿이면 값을 하지만, 유스케이스가 20개 이하라면 조립 파일 하나로 끝난다.
쓰지 말아야 할 때
앞 절의 숫자는 전부 선불이다. 파일 7개도 필드당 8군데도 첫날부터 청구되는데, 반대편의 이득은 나중에 도착한다. 그래서 이 거래가 손해가 되는 조건은 하나로 모인다. 뒤집은 화살표가 실제로는 아무것도 흡수하지 않는 경우다. 네 가지 형태가 있다.
구현이 영원히 하나뿐인 경계. 포트를 하나 만들 때마다 물을 것은 "두 번째 구현이 존재하는가"다. 운영용 어댑터 하나뿐이면 DIP는 추적 가능성만 지불하고 사는 것이 없다. 다만 테스트 더블은 두 번째 구현으로 친다. 도메인 테스트를 DB 없이 돌리는 이득은 CI가 도는 횟수만큼, 즉 매일 회수된다. 반대로 "나중에 DB를 바꿀 수도 있으니까"는 두 번째 구현이 아니다. PostgreSQL을 쓸 것이고 앞으로도 쓸 것이 확실한데 교체 가능성만을 근거로 포트를 두면, 그 포트가 흡수할 변경은 연 42회 중 0회다. 같은 포트라도 근거를 어느 쪽으로 대느냐에 따라 결론이 뒤집힌다.
안쪽에 넣을 것이 없는 서비스. 헥사곤의 중앙은 도메인 규칙이 차지해야 하는 자리다. 유스케이스가 포트 호출 한 줄이고 그 위아래에 판단이 없다면, 중앙이 비어 있는 채로 포트와 어댑터만 두른 셈이다. 이 상태는 2편의 K로 판정된다. K=1이고 유스케이스당 분기가 두 개 이하라면 분리할 규칙 자체가 존재하지 않는다. 이때 의존 방향을 뒤집어도 보호할 대상이 없으므로, 뒤집기 비용만 남는다. 연 8회 대 42회라는 비대칭은 도메인 쪽에 실제로 규칙이 있을 때만 의미를 갖는 숫자다.
어휘가 합의보다 먼저 늘어나는 팀. 네 그림이 대체로 같다는 것은 장점이 아니라 위험이기도 하다. 같은 파일을 누구는 service, 누구는 usecase, 누구는 interactor라고 부르면 리뷰마다 이름 논쟁이 붙어 논의 비용이 오히려 늘어난다. 이 비용은 인원수에 선형으로 붙는다. 그래서 팀이 실제로 정해야 하는 것은 네 이름 중 무엇을 택할지가 아니라 폴더 경계를 어디에 긋고 그 경계를 무엇으로 강제할 것인가다. 후자는 import 방향을 린트 규칙으로 막는 설정에 가깝고, 그것 하나면 네 그림이 요구하는 전부가 충족된다.
프레임워크가 이미 경계를 제공하는 경우. Next.js의 Route Handler와 서버 액션은 그 자체가 구동 어댑터다. HTTP 파싱과 직렬화를 프레임워크가 맡고 있으므로 그 앞에 컨트롤러 계층을 또 두면 같은 경계가 두 겹이 된다. 이때 추가된 겹이 막는 것은 프레임워크 교체뿐이고, 그 확률이 낮으면 파일 7개 중 2개는 순손실이다. 프레임워크가 준 경계는 사용하고, 직접 만드는 경계는 프레임워크가 주지 않는 쪽 — 즉 피구동 어댑터 방향에만 두는 비대칭 구조가 값이 싸다.
네 조건은 한 문장으로 닫힌다. 의존성 역전은 흡수할 변경이 있을 때만 값을 한다. 인프라 변경이 연 42회 발생하고 도메인에 지켜야 할 규칙이 실재할 때 파일 7개와 필드당 8군데는 회수되지만, 둘 중 하나라도 0에 가까우면 그 비용은 그냥 비용이다.
요약
| 스타일 | 핵심 규칙 | 고유 어휘 | 실질적 차이 |
|---|---|---|---|
| Layered | 위에서 아래로만 의존 | 프레젠테이션·도메인·데이터 소스 | 인프라가 맨 아래 → 도메인이 인프라에 의존 |
| Hexagonal (2005) | 안쪽은 바깥을 모른다 | 포트, 구동/피구동 어댑터 | 좌우 대칭(입력과 출력을 같은 장치로 취급) |
| Onion (2008) | 의존은 안쪽으로만 | 동심원, 도메인 서비스 | Hexagonal과 사실상 동일. 안쪽을 더 세분 |
| Clean (2012/2017) | 의존은 안쪽으로만 | Entities, Use Cases | 경계마다 DTO 번역을 규칙으로 명시 |
| 항목 | 내용 |
|---|---|
| 이 편의 논지 | 네 그림은 대체로 같은 규칙의 다른 그림. 규칙은 의존 방향 |
| 엔진 | 의존성 역전 — 인터페이스를 도메인이 소유하면 import 방향이 뒤집힌다 |
| 통하는 이유 | 변경 빈도가 다른 것을 분리. 도메인 연 8회 대 인프라 연 42회 |
| 파일 수 | 유스케이스 1개당 핸들러 전부 1개 / Layered 3개 / Hexagonal·Clean 7개 |
| 필드 추가 | 필드 1개 추가에 8군데 수정(스키마·행·엔티티·포트·출력·DTO·매퍼 2) |
| 추적 비용 | "정의로 이동"이 인터페이스에서 멈춘다. 구현은 조립 파일을 읽어야 안다 |
| 손해가 되는 조건 | 구현이 하나뿐인 포트, 안쪽이 빈 서비스, 어휘만 늘어난 팀, 프레임워크 경계 위의 중복 |
| TS 최소 형태 | 클로저 하나. DI 컨테이너도 클래스도 필요 없다 |
다음 편 — 5편. 도메인을 말하는 어휘 — 값 객체, 엔티티, 애그리게이트, 도메인 이벤트
의존 방향을 뒤집어 안쪽을 비워 놓았다면, 다음 질문은 그 안쪽을 무엇으로 채우느냐다. Evans의 DDD(2003)가 제시한 전술 패턴들을 놓고, 특히 애그리게이트 경계를 어디에 긋느냐가 트랜잭션 크기와 동시성 충돌률에 어떻게 직결되는지를 계산한다.