의존성 역전 원칙은 한 문장으로 요약된다 — 유연성이 극대화된 시스템에서는 소스 코드 의존성이 오직 추상을 향하고, 결코 구체를 향하지 않는다. 5장에서 우리는 다형성이 소스 의존성을 제어 흐름과 반대로 꺾을 수 있음을 손으로 짚어 봤다. DIP는 그 능력을 하나의 규율로 세운다. 그런데 5장의 예제에는 아직 정직하지 못한 구석이 남아 있었다. 인터페이스를 사이에 끼우고도, 그 인터페이스를 구현한 객체를 누군가는 반드시 new로 만들어야 한다. 만드는 순간 만든 쪽은 구체의 이름을 알게 된다. 5장은 그 생성을 시스템 시작점인 Main으로 몰아넣어 해결했다. 이 장은 그 손쉬운 해결이 통하지 않는 자리 — 생성이 업무 규칙 흐름 한복판에서, 런타임에, 반복적으로 일어나야 하는 자리 — 로 들어가 추상 팩토리라는 도구를 꺼낸다.

향하지 말아야 할 것은 변동성 큰 구체다

DIP를 “구체에 의존하지 마라”로 외우면 절반만 이해한 것이다. 우리가 짜는 모든 코드는 결국 구체 위에서 돈다. String을 쓰지 않는 자바 프로그램은 없고, 아무도 String을 인터페이스 뒤에 숨기라고 요구하지 않는다. String은 구체 클래스지만 극도로 안정적이다 — 수십 년간 거의 바뀌지 않았고, 앞으로도 바뀔 일이 없다. 안정적인 구체에 의존하는 것은 위험이 아니다. 그 의존이 결코 우리를 흔들지 않기 때문이다.

그래서 DIP가 진짜로 겨누는 표적은 구체 일반이 아니라 변동성이 큰 구체다. 지금 개발 중이라 자주 바뀌는 클래스, 벤더가 릴리스마다 시그니처를 갈아치우는 라이브러리, 요구에 따라 교체될 후보인 데이터베이스 드라이버와 결제 게이트웨이. 이런 것들의 이름을 고수준 정책이 직접 알고 있으면, 그것들이 요동칠 때마다 정책이 함께 재컴파일되고 함께 흔들린다. DIP는 이 변동성이 정책으로 전파되는 경로를 끊는 원칙이다.

판단 기준: 어떤 구체에 의존해도 되는지 물으려면 “이 클래스가 앞으로 바뀔 확률이 얼마인가”를 먼저 재라. 안정적이고 성숙한 구체(String, 표준 컬렉션, 검증된 값 객체)라면 인터페이스로 감싸는 것은 낭비다. 함정: DIP를 교조로 받아들여 모든 구체 앞에 인터페이스를 세우면, 결코 갈아 끼울 일 없는 대상까지 추상으로 감싸 코드베이스가 얇은 껍데기 인터페이스로 뒤덮인다 — 유연성은 오지 않을 변경에만 걸린 채 읽는 비용만 치솟는다.

추상은 안정적이고, 그래서 의지할 만하다

왜 하필 추상을 향하라고 하는가. 잘 만든 인터페이스는 구현보다 훨씬 덜 바뀌기 때문이다. findAll(), present(line) 같은 선언은 그 뒤의 구현이 MySQL에서 Postgres로, 콘솔에서 웹으로 갈아치워지는 동안에도 그대로 서 있다. 추상은 시스템이 무엇을 하는지를 붙들고, 구체는 그것을 어떻게 하는지를 담는다. 어떻게는 변덕스럽고 무엇은 진득하다. 그러니 안정을 원하는 고수준 정책이 의지할 상대는 진득한 쪽, 곧 추상이어야 한다.

이 대비를 마틴은 코딩 관례로 압축한다 — 변동성 큰 구체 클래스를 참조하지 마라, 그런 클래스를 상속하지 마라, 구체 함수를 오버라이드하지 마라. 세 관례가 가리키는 방향은 하나다. 소스 코드에서 변동성 큰 이름을 지우고 그 자리에 추상의 이름을 놓아라. 그러면 그 파일은 구현이 아무리 요동쳐도 다시 컴파일되지 않는다.

판단 기준: 어떤 이름을 소스에 직접 쓸지 말지는 “그 이름이 가리키는 것이 무엇을 약속하는가(추상) 아니면 어떻게 하는가(구체)“로 가른다. 약속에 기대는 의존은 안전하고, 방법에 기대는 의존은 취약하다. 함정: 인터페이스라는 문법을 썼다고 추상인 것은 아니다 — 인터페이스가 executeQuery(sql)처럼 구현의 방법을 그대로 노출하면, 이름만 추상이지 실제로는 구체의 변동성을 고스란히 물려받는다.

경계에서 제어 흐름과 소스 의존성이 갈라선다

DIP가 그리는 가장 인상적인 그림은 하나의 곡선이다. 고수준 정책이 저수준 세부를 사용한다 — 제어 흐름은 위에서 아래로, 정책에서 세부로 흐른다. 그런데 그 둘 사이에 추상을 세우고 세부가 추상을 구현하게 하면, 소스 의존성은 아래에서 위로, 세부에서 정책 쪽으로 거슬러 올라간다. 실행 시점에 제어는 경계를 아래로 넘고, 컴파일 시점에 의존성은 같은 경계를 위로 넘는다. 한 경계 위에서 두 화살표가 정확히 반대를 가리키는 이 지점이 아키텍처의 경계선이다.

이 갈라섬이 왜 그토록 중요한가. 경계의 위쪽 — 고수준 정책 — 은 아래쪽에서 무슨 일이 벌어지는지 소스 수준에서 전혀 모르게 되기 때문이다. 제어는 분명 아래로 넘어가 저수준 코드를 실행시키지만, 정책의 소스는 그 저수준의 이름을 한 글자도 담고 있지 않다. 그래서 저수준을 통째로 들어내고 다른 것으로 갈아 끼워도 정책은 재컴파일조차 필요 없다. 의존성의 방향을 제어 흐름과 어긋나게 꺾는 것 — 이것이 저수준 세부를 정책에 꽂았다 뺐다 하는 플러그인으로 격하시키는 유일한 방법이다.

판단 기준: 두 컴포넌트 사이에 아키텍처 경계가 실제로 있는지 확인하려면, 그 경계에서 제어 흐름과 소스 의존성이 반대 방향인지 보라. 둘이 같은 방향이면 경계가 아니라 그냥 호출이다. 함정: 제어가 아래로 흐르니 의존성도 당연히 아래로 흘러야 한다고 여기는 순간, 역전의 가능성 자체가 시야에서 사라진다 — 이 둘은 원래 한 몸이 아니라, 추상을 끼워야 비로소 갈라놓을 수 있는 별개의 것이다.

생성이라는 마지막 구멍 — 누군가는 new를 해야 한다

여기까지는 5장의 연장선이다. 그러나 인터페이스를 아무리 정성껏 세워도 메울 수 없는 구멍이 하나 남는다. 객체를 만드는 행위 자체다. 인터페이스 EmployeeGateway를 향해 의존하도록 정책을 짜 놓아도, 그 정책이 돌아가려면 어디선가 new MySqlEmployeeDatabase()가 실행돼야 한다. 그리고 new를 쓴 파일은 그 구체의 이름을 소스에 담을 수밖에 없다. 생성은 DIP를 뚫고 들어오는 가장 끈질긴 위반이다.

5장은 이 구멍을 시스템 시작점으로 몰아 막았다. 모든 구체를 Main에서 한 번 만들어 생성자로 주입하고, 그 뒤로 정책은 인터페이스만 붙들고 돈다. 시작할 때 한 번 조립하고 끝나는 객체라면 이것으로 충분하다. 문제는 정책이 실행 도중에, 반복적으로 새 객체를 만들어야 할 때다. 주문 한 건이 들어올 때마다 그 주문에 맞는 배송 라벨을 새로 찍어야 한다면, 라벨을 만드는 순간은 Main이 조립하던 먼 과거가 아니라 업무 규칙이 돌아가는 지금 이 순간이다. 시작점 주입으로는 이 구멍을 막을 수 없다. 생성 시점이 정책 흐름 한복판에 있기 때문이다.

판단 기준: 주입만으로 충분한지, 팩토리가 필요한지는 “그 객체를 언제 만드는가”로 가른다 — 시작할 때 한 번이면 생성자 주입으로 족하고, 흐름 도중 요청마다 새로 만들어야 하면 생성 자체를 추상 뒤로 밀어야 한다. 함정: 런타임 생성을 두고 “결국 new 한 줄인데 뭐” 하며 정책 안에 그냥 두면, 그 한 줄이 고수준 정책 파일에 변동성 큰 구체의 이름을 도로 새겨 넣는다 — 공들여 세운 인터페이스 경계가 생성 구멍 하나로 무너진다.

추상 팩토리 — 생성마저 경계 밖으로 밀어낸다

이 구멍을 메우는 도구가 추상 팩토리다. 발상은 단순하다. 만드는 행위 자체를 하나의 추상 뒤에 숨긴다. 고수준 정책은 “배송 라벨 하나 만들어 달라”는 요청을 팩토리 인터페이스에 던질 뿐, 그 인터페이스 뒤에서 어떤 구체가 생성되는지 모른다. 팩토리 인터페이스는 정책 쪽이 소유하고, 그것을 구현한 구체 팩토리는 저수준에 산다. 이제 생성이라는 행위에도 앞서 본 곡선이 그대로 그어진다 — 제어는 정책에서 구체 팩토리로 아래로 넘어가 객체를 찍어 오지만, 소스 의존성은 구체 팩토리에서 팩토리 인터페이스로 위를 향한다.

아래 스텝은 주문을 처리하며 배송 라벨을 만들어 내는 업무 규칙이, 처음엔 벤더 구현을 흐름 한복판에서 직접 new 하다가, 추상 팩토리를 도입해 생성마저 역전시키는 과정을 따라간다.

Refactoring Step 정책이 흐름 도중에 구체를 직접 찍어낸다
1/3
public class OrderProcessor {    public void process(Order order) {        // 진짜 지켜야 할 정책: 무게로 배송 등급을 정한다.        String grade = order.getWeight() > 10 ? "FREIGHT" : "PARCEL";        // 그런데 라벨 생성이 흐름 한복판에 있다 — 요청마다 새로 만들어야 한다.        // 고수준 정책 파일이 벤더 구체(FedExLabel)의 이름을 직접 안다.        FedExLabel label = new FedExLabel(order.getAddress(), grade);        label.print();    }}// 제어 흐름: OrderProcessor → FedExLabel.// 소스 의존성도 같은 방향 — OrderProcessor.java가 컴파일되려면 FedExLabel을 알아야 한다.// 배송사를 UPS로 바꾸면 이 업무 규칙이 다시 컴파일된다. 생성이 구멍을 뚫었다.

무엇이 달라졌나. 첫 단계에서 OrderProcessor는 라벨을 만드는 순간 FedExLabel의 이름을 소스에 담았고, 그래서 배송사를 바꾸면 함께 다시 컴파일됐다. 마지막 단계에서 OrderProcessor가 아는 것은 자신이 소유한 두 추상 ShippingLabelLabelFactory뿐이다. 무게로 등급을 정하는 진짜 정책은 그대로인데, 그 정책을 담은 파일이 이제 어떤 벤더 구체의 이름도, 심지어 그 구체를 만드는 방법조차 모른다. new FedExLabel이라는 유일한 위반은 FedExLabelFactory라는 저수준 부품 안으로 격리됐다. 사용의 의존성만이 아니라 생성의 의존성까지 역전시킨 것 — 그것이 추상 팩토리가 하는 일이다.

판단 기준: 정책 안에 new 구체()가 흐름 도중 반복적으로 나타나면, 그 생성을 팩토리 인터페이스 뒤로 밀어 구체의 이름이 저수준 팩토리 한 곳에만 남게 하라 — 시스템 전체에서 그 구체를 new 하는 자리가 딱 하나로 수렴하면 성공이다. 함정: 팩토리 인터페이스가 makeFedExLabel()처럼 구체의 이름을 메서드에 새기면, 껍데기만 팩토리일 뿐 정책이 여전히 어떤 벤더인지 알게 된다 — 팩토리는 만들 결과의 추상(ShippingLabel)만 약속하고 어느 구현인지는 침묵해야 한다.

DIP 위반은 없앨 수 없다 — 격리할 뿐이다

정직하게 인정하자. 방금 마지막 단계에서도 new FedExLabel은 사라지지 않았다. FedExLabelFactory 안으로 옮겨졌을 뿐이다. 어떤 시스템도 구체 생성을 완전히 없앨 수는 없다. 누군가는, 어딘가에서는 반드시 구체를 new 해야 하고 그 순간 DIP는 국소적으로 위반된다. DIP의 목표는 이 위반을 박멸하는 것이 아니라 격리하는 것이다. 위반을 시스템의 가장 낮고 가장 구체적인 층 — 5장의 Main, 26장에서 다룰 메인 컴포넌트, 그리고 이 장의 구체 팩토리 — 로 몰아넣어, 고수준 정책이 순수하게 추상만 바라보도록 지키는 것이다.

그래서 DIP를 잘 지킨 시스템의 단면도는 이렇게 생겼다. 위쪽에는 추상만 바라보는 정책들이 깨끗하게 모여 있고, 아래쪽 구석에 new와 벤더 이름으로 얼룩진 구체 생성 코드가 격리돼 있다. 더러움을 없앤 게 아니라 한곳에 가둔 것이다. 좋은 아키텍처는 이 더러운 구석의 크기와 위치를 통제한다 — 정책 곳곳에 흩뿌려져 있던 new를, 갈아 끼우기 좋은 한 귀퉁이로 쓸어 모으는 것이 DIP가 사는 실전의 모습이다.

판단 기준: DIP 준수를 평가할 때 “위반이 있는가”가 아니라 “위반이 어디에 몰려 있는가”를 물어라 — 구체 생성이 저수준 팩토리·Main에 갇혀 있으면 건강하고, 고수준 정책 파일 여기저기에 new가 흩어져 있으면 병들었다. 함정: “어차피 완전히 없앨 수 없으니 신경 쓸 것 없다”는 냉소는 격리와 방치를 혼동한 것이다 — 없앨 수 없다는 사실이야말로, 남은 위반을 어디에 둘지를 의도적으로 설계해야 하는 이유다.

정리하면 이렇다.

  • DIP가 겨누는 표적은 구체 일반이 아니라 변동성 큰 구체다. String처럼 안정적인 구체에 대한 의존은 위험이 아니므로 감쌀 필요가 없다.
  • 추상은 구현보다 덜 바뀐다. 안정을 원하는 고수준 정책이 의지할 상대는 변덕스러운 구현이 아니라 진득한 추상이어야 한다.
  • 추상을 경계에 세우면 제어 흐름은 아래로, 소스 의존성은 위로 갈라선다. 이 곡선이 저수준을 플러그인으로 격하시킨다.
  • 인터페이스로 사용을 역전시켜도 생성이라는 구멍이 남는다. 흐름 도중 반복 생성되는 객체는 추상 팩토리로 생성마저 경계 밖으로 밀어낸다.
  • DIP 위반은 박멸이 아니라 격리의 대상이다. 남은 new를 Main과 구체 팩토리 한곳에 몰아 정책을 깨끗이 지킨다.

이 장은 5장이 쥐여 준 도구 — 의존성 방향을 정하는 힘 — 에 마지막 한 조각을 더한다. 사용뿐 아니라 생성까지 역전시키면, 고수준 정책은 저수준 세부의 이름도 그 세부를 만드는 방법도 모른 채 온전히 추상 위에서만 돌 수 있다. 다음 장부터는 이 원칙들을 개별 클래스가 아니라 컴포넌트 단위로 끌어올려, 배포 가능한 덩어리들을 어떻게 묶고 어떤 방향으로 의존시킬지를 본다.

다음장으로 12장