앞의 여러 장이 경계를 그으라고 설득했다면, 이 장은 그 설득이 부딪히는 현실의 벽을 마주한다. 완전한 아키텍처 경계를 하나 세우는 일은 생각보다 비싸다. 반대 방향을 가리키는 인터페이스 한 쌍, 경계를 넘나드는 입력·출력 데이터 구조, 그리고 그 경계를 사이에 두고 독립적으로 컴파일·배포되는 양쪽 컴포넌트까지 — 이 모든 것을 갖춰야 비로소 “완전한” 경계다. 문제는 이만큼 공들여 세운 경계가 정작 쓰이지 않을 때다. 예상했던 변경이 끝내 오지 않으면, 그 인터페이스와 데이터 구조와 컴포넌트 분리는 전부 헛돈 지출로 남는다.

그래서 마틴은 이 장에서 한 발 물러선다. 경계는 필요할지도 모르지만 지금 완전한 값을 치르기는 아까운, 그 애매한 중간 지대를 위한 타협안을 세 가지 내놓는다. 부분적 경계란 완전한 경계가 주는 미래의 선택지를 일부라도 사 두되, 지금 당장의 비용은 깎는 흥정이다.

완전한 경계는 왜 그렇게 비싼가

값을 논하려면 먼저 무엇에 값을 치르는지 세어 봐야 한다. 22장의 동심원에서 경계 하나를 제대로 세운다고 해 보자. 경계를 사이에 둔 두 컴포넌트가 서로 독립적으로 개발·배포되려면, 둘 사이의 모든 통신은 추상 인터페이스를 거쳐야 한다. 제어 흐름은 한쪽에서 다른 쪽으로 흐르지만, 소스 의존성은 DIP로 뒤집혀 양쪽 다 안쪽의 인터페이스를 향한다. 그래서 경계 하나에 인터페이스가 한 방향이 아니라 양방향으로 필요하다.

여기에 경계를 넘나드는 데이터도 독립적인 구조체로 따로 정의해야 한다. 한쪽의 내부 모델을 그대로 넘기면 그 순간 두 컴포넌트가 다시 엉겨 붙기 때문이다. 마지막으로, 이 모든 것을 담을 별도의 컴포넌트 — 독립적으로 컴파일되고 배포되는 .jar.dll — 를 두 개 만들고 그 경계를 유지 보수해야 한다. 인터페이스, 데이터 구조, 양쪽 컴포넌트. 이 셋을 다 갖추는 것이 완전한 경계의 청구서다.

판단 기준: 완전한 경계는 “이 축의 변경이 실제로 올 것이고, 왔을 때 두 쪽을 따로 배포해야 할 만큼 크다”는 확신이 설 때 값을 치른다. 함정: 경계의 진짜 비용을 인터페이스 하나 추가하는 정도로 과소평가하기 쉽다 — 양방향 인터페이스, 별도 데이터 구조, 두 개의 배포 단위와 그 유지 보수까지가 한 묶음이며, 쓰이지 않으면 그 전부가 순손실이다.

첫째, 마지막 분리 단계를 건너뛴다

첫 번째 절충은 완전한 경계를 거의 다 만들되, 가장 값비싼 마지막 한 걸음만 생략하는 것이다. 즉 양방향 인터페이스도 만들고, 입출력 데이터 구조도 갖추고, 의존성도 제대로 역전시켜 놓는다. 두 컴포넌트로 나뉠 준비를 전부 마친다. 그런데 정작 그것들을 별개의 컴포넌트로 컴파일·배포하지는 않는다. 둘을 여전히 같은 컴포넌트, 같은 배포 단위 안에 함께 둔다.

무엇을 아꼈나. 컴포넌트를 둘로 쪼개 각각 버전을 매기고 따로 배포하고 그 경계를 추적 관리하는, 지속적으로 발생하는 운영 비용을 통째로 아꼈다. 무엇을 샀나. 코드는 이미 두 쪽으로 온전히 갈라져 있으므로, 훗날 진짜로 독립 배포가 필요해지는 날 마지막 분리 단계만 밟으면 된다. 미래의 완전한 경계로 가는 길을 거의 다 닦아 둔 셈이다.

그런데 이 방식에는 조용한 위험이 있다. 두 코드가 같은 컴포넌트 안에 함께 살면, 컴파일러도 배포 시스템도 그 경계를 강제하지 않는다. 오직 개발자의 규율만이 경계를 지킨다. 누군가 급할 때 인터페이스를 우회해 반대쪽 내부를 직접 호출해 버리면, 애써 갈라 둔 두 쪽이 슬그머니 다시 붙어 버린다. 물리적으로 분리되지 않은 경계는 부지불식간에 침식된다.

판단 기준: 지금은 한 덩이로 배포해도 충분하지만 언젠가 갈라질 가능성이 뚜렷할 때, 코드만 미리 갈라 두고 배포는 합쳐 두는 이 방식이 값을 낸다 — 미래의 분리를 싸게 예약해 둔다. 함정: 경계를 지키는 것이 규율뿐이라는 사실을 잊는 것이다. 컴파일러가 막아 주지 않으므로, 한 번의 지름길 호출이 갈라 둔 두 쪽을 도로 엉기게 만들고 예약해 둔 미래를 무효로 돌린다.

둘째, 일차원 경계 — 전략 패턴으로 한 방향만 연다

완전한 경계는 반대 방향을 가리키는 인터페이스 두 개를 요구했다. 두 번째 절충은 그 절반, 한 방향만 여는 것이다. 마틴은 이를 전략(Strategy) 패턴으로 설명한다. 클라이언트가 의존할 서비스 인터페이스를 하나 두고, 실제 구현은 그 인터페이스를 구현해 뒤편에서 갈아 끼운다.

// 클라이언트가 의존하는 유일한 추상 — 경계는 여기 한 겹뿐이다
interface DataAccess {
    Record read(long id);
    void  write(Record record);
}
 
class BusinessRules {
    private final DataAccess data;   // 구체 구현은 모른다
    BusinessRules(DataAccess data) { this.data = data; }
    // ...업무 규칙은 DataAccess라는 추상만 바라본다
}
 
// 구현은 뒤편에서 갈아 끼운다 — 클라이언트 코드는 이 이름을 모른다
class MysqlDataAccess implements DataAccess {
    public Record read(long id) { /* ... */ }
    public void  write(Record record) { /* ... */ }
}

이것은 완전한 경계보다 확실히 싸다. 인터페이스가 한 겹이고, 데이터 구조를 양방향으로 넘길 일도 없으며, 컴포넌트도 나누지 않는다. 클라이언트는 추상 인터페이스만 알므로 구현은 언제든 교체된다.

대신 무엇을 못 사는지가 이 방식의 정체다. 경계가 일차원이라는 말은, 반대 방향 — 서비스 구현 쪽에서 클라이언트를 가리키는 소스 의존성 — 이 여전히 살아 있다는 뜻이다. 위 코드에서 MysqlDataAccess가 사는 컴포넌트는 여전히 DataAccess가 정의된 컴포넌트를 알아야 하고, 그 사슬을 따라 클라이언트 쪽으로 향하는 의존성이 완전히는 끊기지 않는다. 한쪽 방향의 변경은 잘 막아 주지만, 다른 방향에서 밀고 들어오는 변경은 막지 못한다. 반쪽짜리 방패다.

판단 기준: 막고 싶은 변경이 한 방향으로만 온다면 — 이를테면 업무 규칙을 데이터 접근 기술의 교체로부터 지키는 것이 목적이고 그 반대는 걱정거리가 아니라면 — 일차원 경계로 충분하고, 그것이 정확히 필요한 만큼만 사는 길이다. 함정: 일차원 경계를 완전한 경계로 착각하는 것이다. 반대 방향 의존성이 남아 있으므로, 예상 못 한 축에서 변경이 밀려오면 이 경계는 아무것도 막지 못한다 — 무엇을 막고 무엇은 못 막는지를 알고 써야 한다.

셋째, 파사드 — 가장 얕은 경계

세 번째는 가장 값싸고 가장 얕다. 인터페이스마저 없앤다. 파사드(Facade) 클래스 하나를 세워, 그 뒤에 숨길 서비스들을 향한 호출을 전부 이 파사드의 메서드로 받는다. 클라이언트는 개별 서비스들을 직접 알지 못하고 오직 파사드만 알게 된다.

// 클라이언트는 이 창구 하나만 안다
class OrderFacade {
    private final Inventory inventory = new Inventory();
    private final Payment   payment   = new Payment();
    private final Shipping  shipping  = new Shipping();
 
    void placeOrder(Order order) {
        inventory.reserve(order);
        payment.charge(order);
        shipping.schedule(order);
    }
}

파사드는 서비스들을 한 창구 뒤로 모아 정돈해 준다. 클라이언트가 여러 서비스에 제각기 뻗던 의존을 파사드 하나로 모으니, 표면적으로는 경계가 생긴 것처럼 보인다.

그러나 이것이 사는 것은 지극히 적다. 파사드에는 의존성 역전이 없다. 파사드 클래스 자신이 숨기려던 서비스들을 직접 참조하고 직접 호출한다. 그래서 클라이언트는 파사드를 통해 그 서비스들에 전이적으로(transitively) 의존한다. 정적 타입 언어라면 서비스 클래스 하나의 소스가 바뀔 때 파사드가 재컴파일되고, 파사드에 의존하는 클라이언트까지 재컴파일이 번진다. 경계라기보다 정돈된 창구에 가깝다 — 이름을 감춰 주지만 변경은 막지 못한다.

판단 기준: 여러 서비스로 흩어진 호출을 한곳으로 모아 정돈하는 것이 목적이고 변경 격리는 아직 필요 없을 때, 파사드가 가장 적은 비용으로 그 정돈을 준다. 함정: 파사드를 변경을 막아 주는 경계로 기대하는 것이다 — 의존성 역전이 없어 클라이언트가 뒤편 서비스에 전이적으로 매여 있으므로, 서비스가 바뀌면 그 파장이 파사드를 지나 클라이언트까지 그대로 전해진다.

세 절충을 하나의 저울 위에 놓기

세 방식은 우열이 아니라 값의 층위로 나열된다. 마지막 분리 단계 건너뛰기는 완전한 경계에 가장 가깝다 — 코드는 온전히 갈라져 있고 배포 분리라는 마지막 걸음만 남겨 둔다. 값이 비싼 대신 미래의 완전한 경계를 거의 예약해 둔다. 일차원 경계는 그 중간이다 — 한 방향의 변경만 막는 절반의 방패를 절반의 값에 산다. 파사드는 가장 얕다 — 변경을 막지는 못하고 이름과 구조만 정돈하는, 가장 싸고 가장 적게 사는 흥정이다.

어느 것도 그 자체로 옳거나 그르지 않다. 세 방식은 전부 같은 질문에 대한 서로 다른 답이다 — 지금 이 경계에 완전한 값을 치를 확신이 서지 않을 때, 무엇을 깎아 값을 낮추고 그 대가로 무엇을 포기할 것인가. 마지막 걸음을 남길지, 방패를 한 방향만 세울지, 창구만 정돈할지는 예상되는 변경의 방향과 확률에 달려 있다.

그리고 여기에는 앞선 장들이 거듭 경고한 그림자가 드리워 있다. 부분적 경계는 반쪽만 지은 경계이므로, 결국 완전한 경계로 자라거나 아니면 아예 걷어 내야 할 잠정적 상태다. 완전한 경계로 자랄 변경이 끝내 오지 않으면, 우리가 앞당겨 치른 그 부분적 비용조차 헛돈이 된다. 반대로 미리 아무 경계도 두지 않았다가 뒤늦게 완전한 경계가 필요해지면, 얽힌 코드를 그제야 갈라내는 값이 처음부터 부분적 경계를 둔 값보다 훨씬 비쌀 수 있다. 부분적 경계란 이 두 후회 사이에서 손실을 줄이려는 내기다.

판단 기준: 경계가 필요할 “가능성”은 보이지만 “확신”은 없을 때, 완전한 경계와 무경계 사이에서 부분적 경계를 고른다 — 예상 변경의 확률이 높고 임박했으면 마지막 분리만 남기는 쪽으로, 낮고 멀면 파사드 쪽으로 무게를 옮긴다. 함정: 부분적 경계를 영구적 해답으로 착각하는 것이다. 이것은 완전한 경계로 자라거나 걷어 내야 할 잠정 상태이며, 방치하면 반쪽 경계가 주는 안심만 얻고 정작 변경이 왔을 때는 완전한 경계도 아니어서 다시 값을 치른다.

남는 이야기 — 경계를 언제 실체화할 것인가

이 장은 결국 25장이 이어받을 질문의 문을 연다. 경계는 공짜가 아니고, 부분적 경계조차 값을 요구한다. 그렇다면 아키텍트의 진짜 기술은 완전한 경계를 얼마나 많이 세우느냐가 아니라, 어디에 어느 깊이의 경계를 두고 어디는 아직 비워 둘지를 고르는 판단에 있다. 세 절충은 그 판단에 쓸 도구다 — 완전한 경계가 과할 때 그보다 싸게, 그러나 아무것도 안 하는 것보다는 미래를 사 두는 중간 선택지들.

  • 완전한 경계는 양방향 인터페이스·입출력 데이터 구조·독립 배포되는 양쪽 컴포넌트를 모두 요구하는 값비싼 물건이다 — 쓰이지 않으면 그 전부가 순손실이다.
  • 마지막 분리 단계 건너뛰기: 코드는 온전히 갈라 두되 별도 배포만 생략한다. 미래의 완전한 경계를 싸게 예약하지만, 경계를 지키는 것은 규율뿐이라 침식되기 쉽다.
  • 일차원 경계(전략 패턴): 한 방향 인터페이스만 두어 절반 값에 절반의 방패를 산다 — 반대 방향에서 오는 변경은 막지 못한다.
  • 파사드: 인터페이스도 없이 창구 하나로 서비스를 모은다. 가장 싸지만 의존성 역전이 없어 변경을 막지 못하고 이름과 구조만 정돈한다.
  • 부분적 경계는 완전한 경계와 무경계 사이의 내기다 — 예상 변경의 확률과 임박도에 따라 깊이를 고르되, 잠정 상태임을 잊지 마라.

다음장으로 25장