SOLID의 나머지 원칙들이 “무엇에 의존하라”를 다뤘다면, 이 장은 그 반대를 말한다 — 무엇에 의존하지 말라. 마틴이 든 그림은 단순하다. 세 사용자 User1, User2, User3이 각각 하나의 연산만 필요로 하는데, 그 세 연산 op1, op2, op3이 전부 OPS라는 클래스 하나에 담겨 있다. User1은 op1만 쓴다. 그런데도 op2가 바뀌면 User1은 흔들린다. 쓰지도 않는 것 때문에.
이 장의 핵심 질문은 여기서 나온다 — 내가 부르지도 않는 메서드가 바뀌었는데, 왜 내가 다시 컴파일되고 다시 배포되어야 하는가.
쓰지 않는 것에 매달리는 죄
User1이 OPS를 안다는 것은, 문법적으로 OPS 전체를 안다는 뜻이다. op1만 부른다는 사실은 런타임의 이야기일 뿐, 컴파일러의 눈에는 User1의 소스가 OPS라는 이름에 걸려 있다. 그래서 op2의 시그니처가 바뀌거나, OPS의 어느 필드가 추가되거나, 심지어 op3이 던지는 예외 목록이 달라지기만 해도 OPS는 다시 컴파일되고 — 그것에 매달린 User1도 함께 다시 컴파일된다. User1의 로직은 한 글자도 안 바뀌었는데.
이것이 살찐 인터페이스(fat interface)의 대가다. 여러 관심사를 한 타입에 몰아넣으면, 그 타입을 아는 모든 코드가 서로의 변경에 묶인다. User2의 요구로 op2를 고쳤을 뿐인데 User1과 User3의 재빌드·재배포가 딸려 온다. 세 사용자는 서로를 모르는데도, 가운데의 살찐 클래스 하나를 통해 은밀히 결합되어 있다.
판단 기준: 어떤 타입에 의존한다면 “나는 이 타입의 모든 멤버를 실제로 쓰는가”를 물어라. 절반도 안 쓴다면, 나는 안 쓰는 절반의 변경까지 떠안는 계약에 서명한 것이다. 함정: “어차피 안 부르니까 상관없다”는 착각 — 정적 타입 언어에서 결합은 호출 여부가 아니라 이름을 아느냐로 결정된다. 부르지 않아도 아는 순간 묶인다.
언어가 결합의 세기를 정한다
마틴이 짚는 미묘한 지점이 있다. ISP가 얼마나 절박한 문제인가는 언어의 타입 시스템에 달려 있다는 것이다.
정적 타입 언어 — 자바, C#, C++ — 에서는 import, include, using으로 상대의 이름을 선언해야 상대를 부를 수 있다. 이 선언이 곧 소스 의존성이고, 재컴파일을 강제하는 사슬이다. 반면 루비나 파이썬 같은 동적 타입 언어에는 이런 선언이 없다. 타입은 런타임에 추론될 뿐, 소스에 박히지 않는다. 그래서 동적 언어에서는 살찐 클래스가 있어도 재컴파일 문제가 훨씬 약하게 나타난다 — 애초에 컴파일 단계의 선언 의존성이 없으니까.
여기서 마틴은 ISP를 언어의 문제로 축소하지 않고 한 단계 끌어올린다. 재컴파일·재배포는 표면적 증상이고, 그 밑에 아키텍처 수준의 교훈이 깔려 있다는 것이다 — 불필요한 것에 대한 의존은, 그것이 언어의 특성이든 아니든, 예상하지 못한 트러블을 부른다. 이 문장이 이 장을 SOLID의 한 항목에서 아키텍처의 원리로 넘긴다.
판단 기준: “이건 컴파일 언어에서만 문제”라고 선을 긋기 전에, 그 의존이 재컴파일 말고 또 무엇을 딸려 오는지 세어 보라 — 배포 단위, 테스트 범위, 팀 간 조율. 함정: 동적 언어를 쓴다는 이유로 ISP를 무시하는 것 — 재컴파일이라는 증상이 사라졌을 뿐, 불필요한 결합이라는 병 자체는 남는다.
인터페이스를 사용자별로 가른다
해법은 이름 그대로다. 하나의 살찐 인터페이스를, 그것을 쓰는 사용자별로 갈라놓는 것. User1은 op1만 담긴 인터페이스를, User2는 op2만 담긴 인터페이스를 향하게 한다. 그러면 op2가 바뀌어도 User1이 아는 인터페이스에는 아무 변화가 없으므로, User1은 재컴파일에서 풀려난다.
아래 스텝은 이 분리를 손으로 짚는다. 살찐 인터페이스 하나에서 시작해, 실제로 쓰는 연산만 담은 인터페이스로 갈라 불필요한 결합을 끊는다.
public interface Ops { void op1(); void op2(); void op3();}// User1은 op1만 쓴다. 그런데 Ops 전체를 안다.public class User1 { private Ops ops; void run() { ops.op1(); }}// User2는 op2만, User3은 op3만 쓴다 — 모두 Ops를 안다.// op2의 시그니처가 바뀌면 Ops가 재컴파일되고,// op2를 부르지도 않는 User1·User3까지 함께 재컴파일·재배포된다.
무엇이 달라졌나. 구현체 OpsImpl은 여전히 세 연산을 한곳에 갖고 있을 수 있다 — 물리적으로 하나로 뭉쳐 있어도 상관없다. 달라진 것은 사용자가 무엇을 향해 의존하는가다. User1이 Op1이라는 좁은 창으로만 구현체를 바라보게 되자, 그 창에 비치지 않는 op2의 변경은 User1에게 도달하지 못한다. 결합의 세기는 구현이 얼마나 크냐가 아니라, 그것을 바라보는 창이 얼마나 좁으냐로 정해진다.
판단 기준: 인터페이스는 그것을 제공하는 쪽이 아니라 쓰는 쪽의 필요로 갈라라 — User1이 원하는 것만 담긴 인터페이스가 옳고, 구현이 가진 것을 다 나열한 인터페이스는 살쪘다. 함정: 인터페이스를 뽑았다는 사실만으로 안심하는 것 — 세 연산을 그대로 담은 인터페이스 하나를 만들면, 이름만 인터페이스일 뿐 여전히 살찐 클래스와 똑같이 셋을 묶는다. 가르지 않은 인터페이스는 분리가 아니다.
왜 이것이 아키텍처의 문제인가
op1, op2 같은 메서드 수준의 이야기는 자칫 사소해 보인다. 마틴이 이 원칙을 SOLID에 넣고, 나아가 컴포넌트 원칙(13장의 CRP — 공통 재사용 원칙)으로까지 끌고 가는 이유는 이 사소함이 규모를 키우면 결코 사소하지 않기 때문이다.
메서드가 컴포넌트가 되고, import가 배포 의존성이 되는 순간을 상상해 보라. 내 시스템 S가 프레임워크 F를 쓰고, F는 내부적으로 데이터베이스 D를 쓴다고 하자. S는 D를 직접 부르지도, 알 필요도 없다. 그런데 F가 D를 소스 수준에서 물고 있으면, D의 어느 기능 하나가 바뀔 때 F가 다시 빌드되고, F에 매달린 S까지 다시 빌드·재배포된다. S가 쓰지도 않는 D의 한 구석 변경이, 전혀 무관해 보이던 S의 배포를 강제하는 것이다.
이것이 “불필요한 것에 대한 의존이 예상 못 한 트러블을 부른다”는 문장의 실체다. 트러블이 예상 밖인 이유는, 사슬의 이쪽 끝(S)에 앉은 사람이 저쪽 끝(D)과 자신이 묶여 있다는 사실조차 모르기 때문이다. 필요 없는 것에 걸어 둔 의존은 평소엔 잠자코 있다가, 남의 변경이 예고 없이 나를 깨우는 방식으로 청구서를 내민다.
판단 기준: 어떤 컴포넌트를 끌어올 때 “그것이 딸고 오는 것들”까지 세어 보라 — 내가 쓰는 기능 하나 때문에 그것의 무거운 전이 의존성 전부를 배포 사슬에 들이는 것은 아닌지. 함정: 필요한 기능 한 조각만 보고 무거운 컴포넌트 전체를 삼키는 것 — 그 순간 그 컴포넌트가 물고 있는 모든 변동성이 내 배포 안정성에 전염된다.
지나친 분리라는 반대편 함정
그렇다고 모든 인터페이스를 메서드 하나 단위로 쪼개라는 뜻은 아니다. ISP는 결합을 끊는 도구이지, 인터페이스 개수를 늘리는 대회가 아니다. op1과 op2가 늘 같은 사용자에게 함께 쓰이고 늘 같은 이유로 함께 바뀐다면, 그 둘을 억지로 가르는 것은 오히려 응집된 것을 흩는 일이다 — 창을 좁히기는커녕 관리할 창의 수만 늘린다.
1장(오브젝트가 아니라 이 책 1장에서도)부터 반복된 렌즈가 여기서도 통한다. 분리는 공짜가 아니다. 인터페이스를 하나 더 만들 때마다 이름 짓고, 관리하고, 구현체가 그것을 이행하도록 붙들어야 하는 비용이 든다. 그러니 물어야 할 것은 “가를 수 있는가”가 아니라 “서로 다른 이유로, 서로 다른 사용자를 위해 바뀌는가”다. 함께 변하는 것을 가르면 대가만 치르고, 따로 변하는 것을 묶으면 불필요한 결합을 남긴다. ISP가 겨누는 것은 후자다.
판단 기준: 두 연산이 항상 같은 사용자에게 같은 이유로 함께 바뀐다면 한 인터페이스에 두고, 서로 다른 사용자가 서로 다른 리듬으로 요구한다면 갈라라 — 기준은 “쓰는 쪽의 변경 축이 같은가”다. 함정: ISP를 “최대한 잘게”로 오해하는 것 — SRP가 액터로 책임을 가르듯, ISP도 사용자로 인터페이스를 가르는 것이지 무조건 쪼개는 원칙이 아니다.
정리하면 이렇다.
- 정적 타입 언어에서 결합은 호출 여부가 아니라 이름을 아느냐로 정해진다 — 쓰지 않는 메서드가 바뀌어도, 그것을 문법적으로 아는 쪽은 재컴파일·재배포를 강제당한다.
- 해법은 인터페이스를 사용자별 필요로 가르는 것. 구현은 한 몸이어도 좋고, 중요한 것은 사용자가 향하는 창을 좁히는 일이다.
- 이 원칙은 메서드에서 컴포넌트로 규모를 키우면 배포 사슬 전체의 문제가 된다 — 불필요한 의존은 남의 변경이 예고 없이 나를 깨우는 트러블로 청구된다.
- 그러나 분리에도 대가가 있다. 함께 바뀌는 것을 억지로 가르지는 마라 — 기준은 개수가 아니라 변경의 축이다.
이 장은 SOLID의 다섯 원칙 중 넷째를 매듭지으면서, 다음 장의 문을 연다. 인터페이스를 사이에 끼우고 사용자가 그 좁은 창만 향하게 한다는 발상은, 결국 “무엇에 의존해야 하는가”라는 물음으로 이어진다. 다음 장의 DIP는 그 답을 내놓는다 — 변덕스러운 구체가 아니라, 안정적인 추상을 향하라.
다음장으로 11장