객체 지향이 무엇이냐고 물으면 대개 세 단어가 돌아온다 — 캡슐화, 상속, 다형성. 마틴은 이 통념을 그대로 받아들이지 않는다. 캡슐화는 C도 헤더와 구현 파일로 이미 훌륭히 해냈고 오히려 객체 지향 언어들이 그것을 약화시켰으며, 상속은 편법이 먼저 있고 문법이 뒤늦게 정리해 준 것에 불과하다는 것이다. 그가 남기는 것은 하나뿐이다. 다형성 — 그리고 그것이 아키텍트에게 주는 절대적 권력, 즉 소스 코드의 의존성을 제어 흐름과 반대로 뒤집는 능력. 이 장은 “OO란 무엇인가”라는 물음을 “OO는 무엇을 할 수 있게 해주는가”로 바꾸는 데 전부를 건다.
다형성은 함수 포인터를 길들인 것이다
다형성이 대단히 새로운 발명은 아니다. C 프로그래머는 이미 함수 포인터로 같은 일을 했다. 유닉스가 모든 장치를 open, read, write, close 다섯 함수의 포인터 테이블 뒤에 세워 두고, 콘솔이든 디스크든 프린터든 동일한 인터페이스로 다뤘던 것이 그 증거다. 어떤 장치를 꽂아도 상위 코드는 바뀌지 않는다 — 이것이 다형성의 본질이다.
문제는 함수 포인터가 위험하다는 데 있다. 포인터를 직접 다루는 코드는 규약을 조금만 어겨도 통째로 무너지고, 사람이 손으로 관례를 지켜야 한다. 객체 지향 언어가 한 일은 이 위험한 도구를 언어 차원의 규율로 감싼 것이다. 함수 포인터 테이블을 vtable로 숨기고, 그 조작을 컴파일러에게 맡겨 실수의 여지를 없앴다. 그러니까 다형성이란 “안전하고 편리해진 함수 포인터” 다. 새로운 능력이 아니라, 위험해서 아무도 함부로 쓰지 못하던 능력을 누구나 쓸 수 있게 길들인 것이다.
판단 기준: 어떤 기능이 “새로운 힘”으로 보일 때, 그 힘이 이전에도 다른 형태로 존재했는지 물어라 — 대개는 위험하거나 번거로워 봉인돼 있던 것을 규율로 안전하게 푼 경우다. 함정: 문법이 주는 편리함에 취해, 그 밑에서 실제로 벌어지는 일(간접 호출, 런타임 디스패치)을 모른 채 쓰면 성능·구조의 함의를 놓친다.
의존성은 제어 흐름을 따라 흐르려 한다
여기서 이 장의 심장으로 들어간다. 어떤 함수 main이 함수 f를 부르고, f가 다시 g를 부른다고 하자. 제어 흐름은 main → f → g로 흐른다. 그리고 자연스럽게 짜면 소스 코드의 의존성도 같은 방향으로 흐른다 — main을 담은 파일은 f를 알아야 하고, f를 담은 파일은 g를 알아야 한다. 호출하려면 상대의 이름을 알아야 하니, 부르는 쪽이 불리는 쪽에 의존한다.
이 기본값이 왜 문제인가. 업무 규칙이 데이터베이스를 호출하고, 데이터베이스가 화면을 갱신하는 시스템을 떠올려 보라. 제어는 업무 규칙에서 시작해 저수준 IO로 흘러간다. 소스 의존성이 이 흐름을 그대로 따르면, 고수준의 업무 규칙이 저수준의 DB와 UI에 의존하게 된다. DB 종류를 바꾸면 업무 규칙을 담은 파일이 다시 컴파일되고, 화면 프레임워크를 갈아치우면 핵심 정책이 흔들린다. 가장 중요하고 가장 오래 살아야 할 코드가, 가장 변덕스럽고 가장 세부적인 코드의 인질이 되는 것이다.
판단 기준: “이 파일이 컴파일되려면 어떤 파일들의 이름을 알아야 하는가”를 세어 보면 소스 의존성의 방향이 드러난다. 고수준 정책이 저수준 세부의 이름을 알고 있다면 방향이 뒤집혀야 할 후보다. 함정: 제어 흐름과 소스 의존성을 같은 것으로 여기는 순간, 의존성은 “당연히 흐름을 따라야 하는 것”이 되어 역전의 가능성 자체가 시야에서 사라진다.
다형성은 의존성을 흐름과 반대로 꺾는다
다형성이 주는 것이 바로 이 지점이다. f가 g를 직접 부르는 대신, f가 인터페이스 I를 부르고 g가 그 I를 구현하게 만들면 어떻게 되는가. 제어 흐름은 여전히 f → g다. 그런데 소스 의존성은 뒤집힌다 — 이제 g(구현)가 I(인터페이스)를 알아야 하므로, 저수준 g가 고수준의 인터페이스를 향해 의존한다. 흐름은 아래로 가는데 의존성은 위를 가리킨다.
이 한 번의 꺾기가 아키텍트에게 주는 것은 마틴의 표현대로 “절대적 권력”이다. 소스 의존성의 방향을 마음대로 정할 수 있다면, 어떤 컴포넌트가 무엇에 의존할지를 설계자가 결정하게 된다. 업무 규칙이 DB를 모르게, DB가 업무 규칙을 향해 의존하게 배치할 수 있다. 이렇게 뒤집힌 경계선 하나하나가 플러그인 지점이 된다 — 인터페이스를 사이에 두면, 그 뒤의 구현은 언제든 뽑아내고 다른 것을 꽂을 수 있는 플러그인이 되기 때문이다. 데이터베이스도, 화면도, 심지어 프레임워크도 핵심 정책에 꽂았다 뺐다 하는 부품으로 격하된다.
아래 스텝은 이 역전을 손으로 짚어 보인다. 급여를 계산해 보고서로 뽑는 업무 규칙이, 처음엔 MySQL과 콘솔에 직접 매달려 있다가, 인터페이스를 사이에 끼우고 생성 책임을 바깥으로 밀어내면서 의존성의 방향이 어떻게 꺾이는지를 단계별로 따라간다.
public class SalaryReporter { public void report() { // 고수준 업무 규칙이 저수준 세부(MySQL, 콘솔)의 이름을 직접 안다. MySqlEmployeeDatabase db = new MySqlEmployeeDatabase(); ConsolePrinter printer = new ConsolePrinter(); for (Employee e : db.findAll()) { long pay = e.getHours() * e.getHourlyRate(); // ← 진짜 지켜야 할 정책 printer.print(e.getName() + ": " + pay); } }}// 제어 흐름: SalaryReporter → DB/Printer.// 소스 의존성도 같은 방향 — SalaryReporter.java가 컴파일되려면// MySqlEmployeeDatabase와 ConsolePrinter를 알아야 한다.// DB를 Postgres로 바꾸거나 출력을 파일로 바꾸면 이 업무 규칙이 다시 컴파일된다.
무엇이 달라졌나. 첫 단계에서 SalaryReporter는 MySQL과 콘솔의 이름을 직접 알았고, 그래서 그것들이 바뀌면 함께 다시 컴파일됐다. 마지막 단계에서 SalaryReporter가 아는 것은 자신이 소유한 인터페이스와 Employee뿐이다. 진짜 지켜야 할 정책 — 시급 곱하기 시간 — 은 그대로인데, 그 정책을 담은 파일이 이제 어떤 세부사항의 이름도 모른다. 의존성의 방향을 뒤집자, 고수준 정책이 저수준 세부로부터 독립했다.
판단 기준: 인터페이스는 그것을 쓰는 고수준 쪽이 소유해야 한다 — 인터페이스가 업무 규칙의 언어(findAll, present)로 선언되면 방향이 옳고, 구현의 언어(executeQuery, flushBuffer)로 새어 나오면 역전이 실패한 것이다. 함정: 인터페이스를 뽑았다는 사실만으로 안심하기 쉽다 — 두 번째 단계처럼 생성자 안에서 new로 구현을 붙들고 있으면 소스 의존성은 그대로 남는다. 생성 책임을 바깥으로 밀어내기 전까지 역전은 절반이다.
배포 독립성 — 경계는 컴파일과 배포를 가른다
이 역전이 가져다주는 실질적 이득은 추상적인 우아함이 아니라 배포와 개발의 독립성이다. 소스 의존성이 한 방향으로만, 그것도 인터페이스 경계에서 끊기도록 배치되면, 경계 양쪽의 컴포넌트를 따로 컴파일하고 따로 배포할 수 있다. SalaryReporter가 들어 있는 업무 규칙 모듈은 자신만의 리듬으로 개발되고, MySqlEmployeeDatabase가 들어 있는 데이터베이스 모듈은 또 다른 리듬으로 개발된다. 한쪽을 고쳐도 다른 쪽을 다시 빌드할 필요가 없다.
이것이 팀을 나누는 선이 되기도 한다. 업무 규칙 팀과 데이터베이스 팀이 인터페이스라는 계약만 합의해 두면, 그 뒤로는 서로의 내부를 몰라도 병렬로 일할 수 있다. 다형성으로 뒤집은 의존성 하나가 컴파일 단위, 배포 단위, 나아가 조직의 경계까지 정하는 것이다.
판단 기준: 두 컴포넌트를 독립적으로 배포하고 싶다면, 둘 사이의 소스 의존성이 단 한 방향으로만 흐르고 그 방향이 인터페이스에서 끊기는지 확인하라. 양방향으로 이름을 알고 있으면 그 둘은 사실상 한 덩어리다. 함정: 물리적으로 파일이나 패키지를 나눈 것과 의존성이 실제로 끊긴 것을 혼동하기 쉽다 — 폴더는 갈라도 한쪽이 다른 쪽의 구체 클래스를 import하고 있으면, 배포는 여전히 한 몸으로 묶여 있다.
캡슐화·상속을 버리는 게 아니라 순위를 매긴다
오해를 막아 두자. 마틴이 캡슐화와 상속을 다형성 아래에 두는 것은 그것들이 쓸모없다는 뜻이 아니다. 캡슐화로 데이터를 숨기는 일, 상속으로 타입을 확장하는 일은 여전히 매일 쓰는 도구다. 다만 아키텍처의 관점에서 — 즉 시스템의 경계를 어디에 긋고 무엇을 무엇으로부터 독립시킬 것인가의 관점에서 — 판을 바꾸는 힘은 다형성 하나라는 것이다. 캡슐화는 한 객체 안의 질서이고 상속은 타입 사이의 질서이지만, 다형성은 컴포넌트 사이의 의존성 방향을 바꾼다. 앞선 장에서 각 패러다임이 무언가를 빼앗아 규율을 준다고 했듯이, OO가 빼앗는 것은 함수 포인터를 향한 직접 접근이고 그 대가로 주는 것이 이 방향 통제권이다.
판단 기준: 어떤 OO 기능이 아키텍처에 기여하는지 판단하려면 “이것이 컴포넌트 사이 의존성의 방향을 바꾸는가”를 물어라 — 그렇다면 아키텍처의 도구이고, 아니라면 한 모듈 안의 편의 도구다. 함정: 상속 계층을 깊이 쌓는 것을 “좋은 객체 지향”으로 착각하기 쉽다 — 깊은 상속은 오히려 타입들을 강하게 묶어, 의존성을 끊기는커녕 옭아매는 방향으로 작동하기도 한다.
정리하면 이렇다.
- 객체 지향의 세 통념 중 아키텍처를 바꾸는 것은 다형성 하나다. 다형성은 위험한 함수 포인터를 언어의 규율로 안전하게 길들인 것이다.
- 자연스럽게 짜면 소스 의존성은 제어 흐름을 따라 흐르고, 그 결과 고수준 정책이 저수준 세부에 매달린다.
- 인터페이스를 사이에 끼우고 생성을 바깥으로 밀어내면, 제어는 아래로 흐르되 의존성은 위를 가리키도록 꺾인다 — 이것이 의존성 역전이다.
- 뒤집힌 경계는 곧 플러그인 지점이자 독립 배포·독립 개발의 단위가 된다. DB도 UI도 프레임워크도 핵심 정책에 꽂는 부품으로 격하된다.
이 장은 도구 하나를 손에 쥐여 준다 — 의존성의 방향을 마음대로 정하는 힘. 다음 장에서는 세 번째 패러다임인 함수형 프로그래밍이 무엇을 빼앗아 어떤 규율을 주는지, 그리고 그것이 동시성 문제와 어떻게 맞닿는지를 본다.
다음장으로 6장