앞의 서른세 장은 경계와 의존성 방향, 동심원과 험블 객체를 이야기했다. 그러나 그 모든 원칙은 다이어그램 위의 화살표로 남아 있었다. 이 마지막 장(게오르그 옐름의 기고)이 던지는 질문은 실무자에게 가장 절실한 것이다 — 그 화살표를 실제 소스 트리의 어디에, 어떤 패키지와 디렉터리로 옮겨 적을 것인가. 유스케이스를 인터랙터로, 세부사항을 어댑터로 나누라는 말은 아름답지만, 파일을 손으로 만들어야 하는 사람에게는 “그래서 OrdersController.java를 어느 폴더에 두느냐”가 진짜 문제다. 이 장은 그 조직화의 네 가지 전형을 나란히 세우고, 각각이 무엇을 지불하고 무엇을 사는지를 따진다.

주제를 관통하는 예시 하나를 고정해 두자. 주문(Order)을 받아 저장하는 아주 작은 시스템이다. 웹으로 주문 요청이 들어오고, 업무 규칙이 그것을 검증하고, 데이터베이스에 기록한다. 컨트롤러 하나, 서비스(유스케이스) 하나, 리포지토리 하나, 엔티티 하나. 이 네 조각을 어떻게 묶느냐에 따라 소스 트리의 모양이, 그리고 시스템의 운명이 달라진다.

계층 기준 패키지 — 가장 먼저 떠오르고, 가장 오래 남는 습관

처음 배우는 조직화는 거의 언제나 계층 기준이다. 기술적 역할이 같은 것끼리 모은다. 컨트롤러는 컨트롤러끼리, 서비스는 서비스끼리, 리포지토리는 리포지토리끼리.

com.example.orders
├── web          OrdersController, ProductsController, ...
├── service      OrderService, ProductService, ...
├── repository   OrderRepository, ProductRepository, ...
└── domain       Order, Product, ...

이 구조는 수평의 얇은 지층으로 시스템을 자른다. 프레임워크 튜토리얼이 이렇게 가르치고, MVC라는 익숙한 삼단 구성과 그림이 곧바로 겹쳐 보이니 손이 먼저 간다. 장점은 분명하다. “모든 컨트롤러가 어디 있지?”라는 질문에 단번에 답한다. 기술 계층 하나를 통째로 교체할 때(예: 영속성 기술 전환) 리포지토리 패키지만 열면 된다.

문제는 우리가 지불하는 대가다. 하나의 기능 — 주문 처리 — 을 이해하거나 바꾸려면 네 개의 패키지를 동시에 열어 오르내려야 한다. 기능은 수직으로 흐르는데 패키지는 수평으로 잘려 있으니, 어떤 변경이든 모든 지층을 관통한다. 더 깊은 병은 눈에 안 보인다. service 패키지는 화면에 필요한 모든 서비스를 한데 담고 있어, 자연히 public으로 열려 있다. 그래야 다른 패키지의 컨트롤러가 호출할 수 있기 때문이다. 즉 계층 구조는 거의 모든 것을 public으로 강요한다. 캡슐화가 설 자리가 없다. 옆 기능의 서비스를 몰래 호출해도 컴파일러가 막아 주지 않는다.

판단 기준: 시스템을 “기술 종류”로 나눌 때 이득을 보는가, “기능 단위”로 나눌 때 이득을 보는가를 먼저 물어라. 배포와 변경이 기능 단위로 온다면 계층은 결의 방향이 어긋난 절단이다. 함정: 계층 구조가 깔끔해 보이는 것은 다이어그램에서일 뿐이다 — 실제로는 모든 것이 public으로 새어 나가, 그림 속 경계가 코드에는 존재하지 않는다.

기능 기준 패키지 — 결의 방향을 세로로 돌리다

계층의 반대 극단은 기능 기준이다. 기술 역할이 아니라 관련된 기능끼리 모은다. 절단면을 수평에서 수직으로 90도 돌리는 것이다.

com.example
├── orders    OrdersController, OrderService, OrderRepository, Order
└── products  ProductsController, ProductService, ProductRepository, Product

이제 “주문 기능을 바꿔라”는 요청에 orders 패키지 하나만 열면 된다. 변경이 오는 방향(기능)과 코드가 놓인 방향이 일치한다. 이것이 22장에서 말한 “소리치는 아키텍처”에 한 발 다가선 모습이다 — 소스 트리를 열면 이 시스템이 주문과 상품을 다룬다는 사실이 먼저 보인다.

그런데 캡슐화는 얼마나 나아졌나. 마음만 먹으면 OrderService를 패키지-프라이빗으로 감추고, 오직 OrdersController만이 그것을 알게 할 수 있다. 계층 구조에서는 불가능했던 은닉이 여기서는 가능해진다. 그러나 “가능하다”와 “그렇게 한다”는 다르다. 대부분의 팀은 습관대로 여전히 모든 클래스를 public으로 선언한다. 도구가 캡슐화를 허락할 뿐, 강제하지는 않기 때문이다.

판단 기준: 기능 기준으로 옮겼다면, 실제로 클래스 접근 제어자를 좁혀 패키지 밖으로 새는 문을 닫았는지 확인하라 — 열 수 있는 문을 닫지 않으면 계층 구조와 캡슐화 수준이 같다. 함정: 폴더 이름만 기능으로 바꾸고 모든 클래스를 public으로 두면, 세로로 돌린 계층 구조에 지나지 않는다. 이름이 아니라 접근 제어가 조직화의 실체다.

포트와 어댑터 — 안과 밖을 규율로 가르다

세 번째 전략은 이 책이 내내 그린 그림을 그대로 코드로 옮긴다. 포트와 어댑터, 다른 이름으로 육각형 아키텍처다. 핵심은 하나다. 업무 규칙(안쪽, 도메인)은 인프라(바깥쪽, 웹·DB·프레임워크)를 몰라야 한다. 안쪽은 자신이 필요로 하는 것을 포트라는 인터페이스로 선언하고, 바깥쪽의 어댑터가 그 인터페이스를 구현한다. 의존성은 언제나 바깥에서 안으로만 흐른다.

com.example
├── domain                      ← 안쪽. 바깥을 모른다.
│   ├── Order
│   ├── OrderService
│   └── OrderRepository (port)   인터페이스. 도메인이 소유한다.
└── infrastructure              ← 바깥쪽. 안쪽에 의존한다.
    ├── web   OrdersController
    └── persistence  JdbcOrderRepository (adapter, OrderRepository 구현)

여기서 결정적인 것은 OrderRepository라는 인터페이스가 어느 쪽에 사느냐다. 그것은 domain 안에 있다. 데이터베이스가 도메인에 맞춘 것이지, 도메인이 데이터베이스에 맞춘 것이 아니다. 이것이 11장 DIP의 물리적 실현이다 — 제어 흐름은 컨트롤러에서 서비스를 거쳐 리포지토리로 흐르지만, 소스 의존성은 그 경계에서 역전되어 바깥의 JdbcOrderRepository가 안쪽의 인터페이스를 향한다.

포트와 어댑터의 이점은 순수하다. 도메인은 import문 어디에도 프레임워크나 SQL이 없다. 데이터베이스 없이, 웹 서버를 띄우지 않고, 도메인을 단위 테스트할 수 있다. 세부사항의 교체가 어댑터 하나를 갈아 끼우는 일로 국소화된다.

대가는 규율의 상시 비용이다. 경계를 넘나드는 데이터를 위해 인터페이스와 종종 별도의 데이터 구조가 필요하고, 초심자에게는 “왜 인터페이스가 도메인에 있고 구현이 바깥에 있는지”가 직관에 어긋나 보인다. 작은 시스템에서는 이 구조가 과해 보일 수 있다.

판단 기준: 세부사항(DB·웹·프레임워크)이 실제로 교체되거나, 도메인을 인프라 없이 테스트하는 것이 가치가 있을 때 포트와 어댑터의 값을 치를 만하다. 함정: 포트를 도메인이 아니라 인프라 쪽에 두면 의존성 역전이 무너져, 겉모양만 육각형인 계층 구조가 된다 — 인터페이스의 소유권이 이 전략의 전부다.

컴포넌트 기준 패키지 — 기능과 육각형의 절충

네 번째는 앞의 것들을 절충한 실무형이다. 컴포넌트 기준. 기능 하나를 굵은 단위 — 컴포넌트 — 로 묶되, 그 컴포넌트를 오직 하나의 공개된 문(인터페이스)으로만 접근하게 하고 내부는 모조리 감춘다.

com.example
└── orders                    ← 컴포넌트. 바깥은 이 문만 안다.
    ├── OrdersComponent (public interface)   유일한 진입점
    └── internal (package-private)
        ├── OrderServiceImpl
        ├── OrderRepositoryImpl
        └── Order

바깥세상은 OrdersComponent라는 인터페이스 하나만 볼 수 있다. OrderServiceImplOrderRepositoryImpl도 모두 패키지-프라이빗이라, 컴파일러가 외부 접근을 물리적으로 거부한다. 이것이 기능 기준의 “감출 수 있다”를 “감춰진다”로 못박은 형태다. 나아가 이 컴포넌트는 나중에 별도의 배포 단위(.jar)나 서비스로 떼어내기 쉽다 — 이미 하나의 공개 계약 뒤에 응집돼 있기 때문이다(24장의 부분적 경계에서 27장의 서비스로 나아가는 길목이다).

포트와 어댑터가 안과 밖을 가르는 데 집중한다면, 컴포넌트 기준은 시스템을 여러 개의 굵은 응집 덩어리로 나누고 각 덩어리의 표면적을 최소화하는 데 집중한다. 둘은 배타적이지 않다. 큰 컴포넌트 내부를 다시 포트와 어댑터로 짤 수도 있다.

판단 기준: 기능 경계가 뚜렷하고 언젠가 서비스로 분리할 가능성이 보인다면, 처음부터 컴포넌트의 표면을 인터페이스 하나로 좁혀 두는 것이 나중의 분리 비용을 낮춘다. 함정: 컴포넌트 인터페이스가 내부 구현의 세부(엔티티·DB 행 구조)를 그대로 노출하면, 문은 하나여도 그 문이 유리문이라 안이 다 비친다 — 계약은 내부 형태가 아니라 바깥이 필요로 하는 것만 담아야 한다.

순환 의존이라는 조용한 부패

네 전략을 관통하는 위험이 하나 있다. 순환 의존. ordersproducts를 알고, products가 다시 orders를 알면, 두 패키지는 사실상 하나로 엉겨 붙는다(14장 ADP가 금지한 바로 그것이다). 어느 한쪽만 떼어 테스트하거나 배포하거나 이해하는 일이 불가능해진다. 그리고 이 부패는 소리 없이 진행된다 — 어느 날 편의를 위해 import 한 줄을 추가하는 순간 사이클이 닫히고, 컴파일은 여전히 통과한다.

방어의 원리는 5장과 11장에서 이미 나왔다. 사이클을 끊고 싶은 지점에 인터페이스를 놓고 의존성을 역전시킨다. products가 주문 완료를 알아야 한다면, products에 인터페이스를 두고 orders가 그것을 구현하게 하여 화살표의 방향을 한쪽으로 정렬한다.

판단 기준: 두 컴포넌트가 서로를 필요로 한다고 느껴지면, 그중 진짜 저수준(더 자주 바뀌는 쪽)이 고수준을 향하도록 인터페이스로 방향을 세워라 — 상호 참조는 거의 언제나 잘못 놓인 소유권의 신호다. 함정: 순환은 큰 결정이 아니라 사소한 편의의 누적으로 생긴다. 빌드 도구로 패키지 간 의존 규칙을 자동 검증하지 않으면, 다이어그램은 비순환인데 코드만 조용히 엉킨다.

모든 다이어그램은 컴파일러 앞에서만 진실이다

이 장을, 그리고 이 책 전체를 닫는 마지막 통찰은 겸손한 것이다. 지금까지의 네 전략은 모두 하나의 전제 위에 서 있다 — 그림 속의 경계선이 코드 속에서도 실제로 존재해야 한다. 그리고 코드 속 경계의 유일한 물증은 접근 제어자다. 화이트보드에 아무리 멋진 동심원을 그려도, 모든 클래스가 public이라면 그 경계는 코드에 없는 것이다. 누구든 아무 데서나 아무거나 호출할 수 있다면, 아키텍처는 문서에만 살아 있고 컴파일러는 그것을 모른다.

그래서 조직화는 순수한 원칙의 문제로 끝나지 않고, 언제나 도구와 언어의 제약과 타협한다. 자바의 패키지-프라이빗은 컴포넌트 기준 은닉을 값싸게 강제해 주지만, 그런 접근 수준이 없는 언어에서는 같은 규율을 네이밍 관례나 빌드 규칙, 별도 모듈로 대신 세워야 한다. 팀이 크면 컴포넌트 경계가 조직의 경계(콘웨이의 법칙)와 부딪히고, 작으면 포트와 어댑터의 인터페이스들이 사치처럼 느껴진다. 정답인 단 하나의 구조는 없다. 있는 것은 오직 이 팀, 이 언어, 이 시스템의 예상되는 변경에 맞춰 고른 균형점뿐이다.

이것이 1장에서 2장으로 이어졌던 시선 그대로다. 좋은 아키텍처란 “무엇이 옳은가”의 정답이 아니라 “무엇을 지불하고 무엇을 사는가”의 판단이며, 그 판단은 마지막에 소스 트리의 폴더 이름과 클래스 앞의 public 한 단어로 착지한다. 화살표를 그리는 것은 쉽다. 어려운 것은, 그 화살표를 컴파일러가 강제하도록 코드에 새겨 넣는 일이다. 이 책의 모든 원칙은 결국 그 마지막 한 걸음에서 진실이 되거나 거짓이 된다.

  • 계층 기준은 기술 역할로 수평 절단해 익숙하지만, 거의 모든 것을 public으로 강요해 캡슐화가 없다.
  • 기능 기준은 결의 방향을 세로로 돌려 변경 방향과 맞추지만, 접근 제어를 좁히지 않으면 이름만 바뀐 계층 구조다.
  • 포트와 어댑터는 인터페이스(포트)를 도메인이 소유하게 해 의존성을 안쪽으로 역전시킨다 — 인터페이스의 소유권이 전부다.
  • 컴포넌트 기준은 하나의 공개 인터페이스 뒤로 내부를 패키지-프라이빗으로 감춰, “감출 수 있다”를 “감춰진다”로 못박고 훗날의 서비스 분리를 예비한다.
  • 순환 의존은 사소한 편의의 누적으로 조용히 생긴다. 인터페이스로 방향을 정렬하고, 빌드 규칙으로 자동 검증하라.
  • 어떤 다이어그램도 접근 제어자로 코드에 새겨지기 전까지는 문서일 뿐이다. 조직화는 결국 팀·언어의 제약과 타협해 고르는 균형점이다.