이 책이 여기까지 쌓아온 원칙들 — SOLID, 컴포넌트 응집도와 결합, 경계, 정책과 수준 — 은 결국 하나의 그림으로 수렴한다. 마틴은 그 그림을 여러 이름으로 부른다. 헥사고날 아키텍처, DCI, BCE, 어니언 아키텍처. 세부는 다르지만 겨냥하는 곳은 같다. 그래서 그는 그 공통분모를 한 장의 동심원 다이어그램으로 압축하고, 거기에 지키기만 하면 나머지가 따라오는 규칙 하나를 새긴다. 이 장은 그 그림과 그 규칙에 관한 이야기다.
던지는 질문은 21장에서 이어진다. 아키텍처가 유스케이스를 외치게 하려면, 프레임워크와 데이터베이스와 웹을 어디에 두고 무엇으로 갈라놓아야 하는가. 답은 놀랄 만큼 단순하다 — 선을 몇 겹 긋고, 그 선을 넘는 의존성이 오직 한 방향으로만 흐르게 하라.
네 겹의 동심원, 안으로 갈수록 높은 정책
가장 안쪽부터 바깥으로 네 개의 원을 그린다.
엔티티가 중심이다. 핵심 업무 규칙과 핵심 업무 데이터 — 20장에서 본, 애플리케이션이 없어도 성립하는 가장 일반적이고 고수준인 규칙이다. 대출 이자를 계산하는 공식은 그것이 웹앱으로 팔리든 배치 프로그램으로 돌든 변하지 않는다. 그 불변의 알맹이가 여기 산다.
그 바깥이 유스케이스다. 애플리케이션에 특화된 업무 규칙, 즉 엔티티를 어떤 순서로 어떻게 부려 하나의 시나리오를 완성하는지에 관한 규칙이다. “신규 고객이 대출을 신청하면 신용을 조회하고 한도를 산정해 승인 여부를 낸다” 같은 흐름이 여기 있다. 엔티티는 이 유스케이스를 모른다 — 더 높고 더 일반적이기 때문이다.
세 번째 원은 인터페이스 어댑터다. 안쪽(유스케이스·엔티티)이 쓰기 편한 형식과 바깥쪽(DB·웹)이 쓰는 형식 사이에서 데이터를 번역하는 층이다. 컨트롤러·프레젠터·게이트웨이 구현이 여기 산다. SQL은 이 원 안쪽으로 새어 들어가면 안 되고, 화면의 HTML 구조도 여기서 멈춰야 한다.
가장 바깥이 프레임워크와 드라이버다. 웹 프레임워크, 데이터베이스, 디바이스. 마틴의 표현으로는 “모든 세부사항이 모이는 곳”. 이 원의 코드는 대개 우리가 짜지 않고 남이 짠 것을 끼워 넣는 접착제(glue)에 가깝다.
원의 개수는 신성한 4가 아니다. 필요하면 다섯 겹, 여섯 겹이 될 수도 있다. 절대 협상 불가능한 것은 개수가 아니라 방향이다.
판단 기준: 어떤 코드가 몇 번째 원에 속하는지는 “이것이 없어도 더 안쪽이 성립하는가”로 가른다 — 웹이 없어도 유스케이스가 성립하면 웹은 더 바깥이다. 함정: 원을 계층 폴더 이름(controller/service/repository)으로 착각하는 것. 동심원은 물리적 폴더가 아니라 정책의 수준(일반성) 축이다. 폴더를 그렇게 나눴어도 의존성이 안팎으로 뒤섞이면 동심원이 아니다.
의존성 규칙 — 이 책 전체가 이 한 문장이다
동심원을 아키텍처로 만드는 것은 단 하나의 규칙이다. 소스 코드의 의존성은 오직 안쪽을, 즉 더 높은 수준의 정책을 향해야 한다.
바깥 원의 코드는 안쪽 원의 이름(클래스·함수·변수)을 알 수 있다. 그러나 안쪽 원의 코드는 바깥 원에 있는 그 무엇도 이름으로 알아서는 안 된다. 유스케이스 코드 안에 import문으로든 타입 참조로든 데이터베이스 드라이버나 웹 프레임워크의 이름이 등장하는 순간, 규칙은 깨진 것이다.
이 규칙이 강력한 이유는 그것이 변동성의 방향과 반대로 놓이기 때문이다. 바깥으로 갈수록 자주 바뀌는 것(웹 프레임워크 버전, DB 종류, 화면 디자인)이고, 안으로 갈수록 좀처럼 안 바뀌는 것(업무 규칙)이다. 의존성이 항상 안쪽을 향하면, 자주 바뀌는 것이 좀처럼 안 바뀌는 것에 의존하게 된다 — 즉 변경이 안쪽으로 전파되지 못하고 바깥에 갇힌다. 데이터베이스를 오라클에서 포스트그레스로 바꿔도, 웹을 REST에서 gRPC로 바꿔도 유스케이스와 엔티티는 컴파일조차 다시 할 필요가 없다. 이것이 좋은 아키텍처가 “결정을 미룰 수 있게 해준다”는 이 책의 약속이 실제로 지켜지는 메커니즘이다.
여기에 하나를 덧붙인다. 안쪽 원은 바깥 원의 데이터 형식조차 알아서는 안 된다. 특히 데이터베이스가 편의로 만들어 주는 형식(예: ORM이 뱉는 로우 객체, 프레임워크가 만든 요청 객체)이 안쪽으로 넘어가면, 그 형식에 대한 의존이 생겨 규칙을 우회로 위반한다. 경계를 넘는 데이터는 안쪽에 가장 편리한 형식이어야 한다.
판단 기준: 안쪽 모듈의 import 목록만 훑어라 — 거기 프레임워크·DB·웹 이름이 하나도 없으면 규칙을 지킨 것이다. 함정: “우리는 레이어를 나눴으니 괜찮다”는 방심. 레이어를 나눠도 service가 repository 구현 클래스를 직접 import하면 화살표는 여전히 바깥을 향한다. 나눔이 아니라 화살표의 방향이 기준이다.
제어 흐름과 소스 의존성은 갈라선다
여기서 초심자를 가장 헷갈리게 하는 지점이 나온다. 규칙은 “의존성은 안쪽만 향한다”인데, 실제 프로그램의 제어 흐름은 그 규칙을 따르지 않는다.
웹 요청 하나의 일생을 따라가 보자. 요청은 바깥(웹 프레임워크)에서 들어와 컨트롤러(어댑터)를 거쳐 유스케이스 인터랙터(안쪽)로 들어간다 — 여기까지는 바깥에서 안으로, 규칙과 같은 방향이다. 그런데 인터랙터가 일을 마치고 결과를 화면에 내보내려면, 제어는 다시 안에서 바깥으로 — 프레젠터를 거쳐 뷰로 — 나가야 한다. 제어 흐름이 안쪽 원에서 바깥쪽 원으로 넘어가는 순간이 반드시 생긴다.
만약 인터랙터가 결과를 내보내려고 프레젠터를 직접 호출하면, 제어 흐름이 바깥을 향하는 그 지점에서 소스 의존성도 함께 바깥을 향하게 된다. 유스케이스가 프레젠터의 이름을 알아 버리는 것이다. 의존성 규칙이 깨진다.
이 모순을 푸는 도구가 11장에서 본 **의존성 역전(DIP)**이다. 인터랙터가 프레젠터를 직접 부르는 대신, 인터랙터 쪽(안쪽 원)에 “출력 경계(output boundary)“라는 인터페이스를 두고 인터랙터는 그 인터페이스만 안다. 프레젠터는 바깥 원에서 그 인터페이스를 구현한다. 그러면 제어 흐름은 여전히 인터랙터 → 프레젠터로 바깥을 향해 흐르지만, 소스 의존성은 프레젠터 → 인터페이스로 안쪽을 향한다. 두 화살표가 갈라선 것이다. 경계를 넘는 모든 지점에서 이 기법을 쓰면, 제어가 어느 방향으로 흐르든 소스 의존성은 언제나 안쪽을 향하도록 강제할 수 있다.
아래 스텝은 이 역전을 손으로 따라가 본다. 결합된 원본에서 시작해, 출력 경계를 세우고, 데이터를 단순 구조체로 갈아 끼운다.
// interactor 패키지 (안쪽 원: 유스케이스)public class LoanApplicationInteractor { private final LoanGateway gateway; private final LoanScreenView view; // ← 바깥 원(뷰)을 직접 참조 private final CurrencyFormatter formatter; public void apply(String customerId, long amount) { Customer customer = gateway.findCustomer(customerId); long limit = customer.creditLimit(); boolean approved = amount <= limit; // 업무 규칙 처리 도중에 화면 서식까지 인터랙터가 떠안는다 String limitText = formatter.toWon(limit); // "₩12,000,000" String amountText = formatter.toWon(amount); view.render(approved, amountText, limitText); // 뷰를 직접 호출 }}// 소스 의존성이 인터랙터 → 뷰(바깥)로 향한다. 의존성 규칙 위반.// 게다가 통화 서식·문자열 포매팅이라는 화면 관심사가 업무 규칙에 스며들었다.// 뷰 없이 인터랙터를 단위 테스트하기도 불가능하다.
무엇이 달라졌나. 마지막 상태에서 안쪽 원(인터랙터·경계 인터페이스·요청/응답 구조체)은 바깥의 이름을 하나도 모른다. HttpServletRequest도, CurrencyFormatter도, LoanScreenView도 인터랙터의 시야 밖이다. 웹을 걷어내고 CLI를 붙이려면 컨트롤러와 프레젠터만 새로 구현하면 되고, 인터랙터와 엔티티는 손대지 않는다. 경계를 인터페이스로 세우고 DIP로 화살표를 뒤집자, 제어 흐름과 소스 의존성이 갈라서면서 바깥의 변동성이 안쪽에 닿지 못하게 됐다.
판단 기준: 제어가 안쪽에서 바깥으로 나가는 지점을 찾아라 — 거기엔 반드시 안쪽이 소유한 인터페이스(출력 경계)가 있어야 하고, 바깥이 그것을 구현해야 한다. 함정: 인터페이스를 바깥 원(프레젠터 패키지)에 두는 것. 그러면 인터랙터가 바깥 패키지를 import해야 하므로 화살표가 다시 바깥을 향한다. 경계 인터페이스는 그것을 사용하는 안쪽이 소유해야 역전이 성립한다.
경계를 넘는 데이터는 단순해야 한다
경계선을 넘나드는 데이터는 단순하고 격리된 데이터 구조여야 한다. 마틴은 이를 못 박는다 — 엔티티 객체를 그대로 넘기지도, 데이터베이스가 만든 로우 구조를 넘기지도 마라. 이런 구조들은 저마다 자신을 만든 원(엔티티는 업무 규칙, 로우는 DB 스키마)의 사정에 묶여 있어서, 그것을 경계 너머로 넘기는 순간 받는 쪽이 넘긴 쪽의 사정에 의존하게 된다.
그래서 경계를 넘는 것은 오직 요청/응답 모델 같은, 아무것에도 속하지 않는 순수한 구조체여야 한다. 위 스텝의 LoanRequest·LoanResponse가 그것이다. 프레임워크 애노테이션도, DB 매핑도, 화면 서식도 붙어 있지 않다. 이 단순함이 사소해 보여도, 이것이 무너지면 앞서 세운 인터페이스 역전이 헛수고가 된다 — 화살표는 안쪽을 향하는데 데이터 형식에 대한 의존이 몰래 바깥을 향해 뚫려 있기 때문이다.
판단 기준: 경계를 넘는 객체가 어느 원의 사정(스키마·프레임워크·화면)이라도 담고 있으면, 그것을 벗겨낸 순수 구조체로 번역해 넘겨라. 함정: DRY를 이유로 엔티티를 그대로 응답에 재사용하는 것 — 코드는 줄지만 화면과 업무 규칙이 한 형식에 묶여, 화면 요구가 바뀌면 엔티티가 흔들린다. 중복처럼 보여도 이건 진짜 중복이 아니다(16장).
이 다이어그램은 완결이 아니라 최소한이다
마틴 스스로 경고한다. 이 네 겹이 규칙의 전부가 아니라 원리의 최소 집합이라고. 어떤 시스템은 원이 더 필요하고, 어떤 경계는 완전한 양방향 인터페이스까지 갈 것 없이 부분적으로만 그어도 된다(24장). 중요한 건 다이어그램을 신조처럼 베끼는 게 아니라, 그 밑에 깔린 단 하나 — 의존성은 안쪽을 향한다 — 를 시스템의 실제 변경축에 맞춰 적용하는 판단이다.
그래서 이 장을 한 문장으로 줄이면 이렇다. 동심원은 그림일 뿐이고, 아키텍처의 실체는 그 원들을 가로지르는 화살표를 전부 안쪽으로 정렬시키겠다는 규율이다. 그 규율을 지키는 데 드는 유일한 비용은 경계마다 인터페이스 하나와 구조체 하나를 더 만드는 수고이고, 그 대가로 사는 것은 데이터베이스·웹·프레임워크의 교체를 업무 규칙과 무관한 사건으로 만드는 자유다.
- 동심원은 네 겹(엔티티 → 유스케이스 → 인터페이스 어댑터 → 프레임워크와 드라이버)이며, 안으로 갈수록 더 일반적이고 덜 바뀌는 고수준 정책이다.
- 의존성 규칙: 소스 의존성은 오직 안쪽을 향한다. 안쪽은 바깥의 이름도, 데이터 형식도 몰라야 한다.
- 제어 흐름이 안에서 바깥으로 나가는 경계는, 안쪽이 소유한 출력 경계 인터페이스에 DIP를 걸어 소스 의존성을 되돌린다 — 제어 흐름과 소스 의존성이 갈라선다.
- 경계를 넘는 데이터는 어느 원에도 묶이지 않은 단순 구조체여야 한다. 엔티티·DB 로우·프레임워크 요청 객체를 그대로 넘기지 마라.
다음장으로 23장