버트런드 마이어가 1988년에 이름 붙인 이 원칙은 이제 거의 표어가 되어 버렸다 — 소프트웨어 개체는 확장에는 열려 있고 수정에는 닫혀 있어야 한다. 뜻은 간명하다. 새 기능이 필요할 때 기존 코드를 뜯어고치는 대신 새 코드를 덧붙이는 것만으로 시스템의 행위를 늘릴 수 있어야 한다는 것이다. 그런데 이 문장은 오랫동안 클래스 하나를 잘 짜는 요령쯤으로 소비되어 왔다. 마틴이 이 장에서 하는 일은 그 초점을 옮기는 것이다. OCP의 진짜 무대는 한 클래스의 상속 계층이 아니라 아키텍처 — 컴포넌트를 어떻게 나누고 그 사이의 의존성을 어느 방향으로 놓는가다. 잘 설계된 시스템이란, 있을 법한 변경이 닥쳤을 때 손대야 할 코드의 양이 변경의 성격에 비례해 최소로 유지되는 시스템이고, 이상적으로는 0에 수렴한다.
확장은 덧붙임이고, 수정은 뜯어냄이다
먼저 두 낱말의 무게를 갈라 두자. 확장(extension) 은 새 코드를 더하는 일이고, 수정(modification) 은 이미 있는 코드를 고쳐 쓰는 일이다. 둘은 비용이 다르다. 코드를 더하는 일은 기존에 동작하던 것을 건드리지 않으므로, 이미 통과한 테스트와 이미 검증된 행위를 위험에 빠뜨리지 않는다. 반면 코드를 고치는 일은 그 코드에 의존하던 모든 것을 다시 의심하게 만든다 — 재컴파일, 재테스트, 재배포, 그리고 회귀의 위험까지 딸려 온다.
그래서 “수정에 닫혀 있다”는 말은 코드를 영원히 얼려 두자는 뜻이 아니다. 있을 법한 변경의 상당수를, 기존 코드를 건드리지 않고 새 코드를 더하는 것만으로 흡수하도록 구조를 짜자는 뜻이다. 여기서 결정적인 단서가 하나 나온다 — 모든 변경으로부터 닫을 수는 없다는 것. 어떤 축의 변경에는 열고 어떤 축에는 닫을지를 골라야 한다. 1장에서 조영호가 “예상되는 변경의 축을 고르는 것이 설계”라고 못박은 그 태도가, 마틴에게 오면 컴포넌트 경계를 어디에 긋느냐의 문제로 번역된다.
판단 기준: 새 요구가 왔을 때 “기존 파일을 몇 줄 고쳐야 하는가”를 세어 보라 — 0에 가까울수록 그 축에 대해 OCP가 지켜진 것이다. 함정: OCP를 “모든 미래 변경에 대비”로 오해하면, 오지 않을 확장점까지 추상화해 시스템을 알아볼 수 없게 만든다. OCP는 전방위 방탄복이 아니라, 실제로 올 축을 골라 그 방향으로만 여는 선택이다.
재무 보고서 — 같은 데이터, 다른 얼굴
마틴이 드는 장면은 이렇다. 재무 데이터를 웹 페이지에 표로 뿌리는 시스템이 있다. 화면은 스크롤되고, 음수는 빨간색으로 강조된다. 여기까지는 평범하다. 그런데 요구가 하나 도착한다 — 같은 보고서를 종이에 인쇄할 수 있도록 PDF로도 뽑아 달라. 페이지는 나뉘어야 하고, 페이지마다 머리글과 꼬리글과 쪽번호가 붙어야 하며, 음수는 색 대신 괄호로 감싼다.
핵심 질문은 이것이다. 이 새 요구를 만족시키기 위해 기존 코드를 얼마나 고쳐야 하는가. 잘못 설계된 시스템에서는, 데이터를 계산하는 로직과 그것을 화면에 그리는 로직이 한 덩어리로 엉켜 있어서, 새 출력 형식 하나를 위해 계산 코드까지 파헤쳐야 한다. 이상적으로 설계된 시스템에서는, PDF 출력이라는 새 코드 뭉치를 더하기만 하고 나머지는 손대지 않는다. 두 경우의 차이를 만드는 것은 프로그래머의 성실함이 아니라 구조다.
이 요구를 뜯어보면 변하는 것과 변하지 않는 것이 갈린다. 보고서에 어떤 숫자가 담기는가 — 매출, 비용, 잔액을 어떻게 계산하는가 — 는 출력이 웹이든 PDF든 똑같다. 변하는 것은 오직 그 숫자를 어떤 모양으로 보여 주는가뿐이다. 그렇다면 이 둘은 서로 다른 이유로, 서로 다른 시점에 바뀐다. 계산 규칙은 회계 정책이 바뀔 때 바뀌고, 표현 형식은 전달 매체가 바뀔 때 바뀐다. 7장의 SRP가 가르친 대로, 다른 이유로 바뀌는 것은 애초에 다른 곳에 있어야 한다.
판단 기준: 새 요구를 받으면 “이 요구로 인해 실제로 변하는 것”과 “그대로인 것”을 먼저 갈라라 — 그 경계선이 곧 컴포넌트를 나눌 자리다. 함정: “데이터”와 “그 데이터의 표현”을 한 클래스에 같이 두는 것은 워낙 자연스러워서 죄로 보이지 않는다 — 두 번째 출력 형식이 요구되는 순간에야 엉킴의 대가가 청구된다.
책임을 가르고, 의존성을 배치한다
그래서 OCP를 아키텍처 수준에서 지키는 절차는 두 걸음이다. 첫째, SRP로 서로 다른 이유로 바뀌는 책임들을 서로 다른 클래스로 가른다. 둘째, DIP와 ISP로 그 클래스들 사이의 의존성이 한 방향으로만 흐르도록 배치한다. 첫 걸음이 변경을 격리할 조각을 만들고, 둘째 걸음이 그 조각들 사이에 보호막을 세운다.
마틴은 재무 보고서를 네 종류의 책임으로 가른다. 데이터베이스에서 원장을 읽어 오는 일, 그 데이터로 보고서에 담길 숫자를 계산하는 업무 규칙(Interactor), 계산된 숫자를 화면에 맞는 형태로 다듬는 프레젠터(Presenter), 그리고 그 형태를 실제 픽셀이나 PDF 바이트로 그려 내는 뷰(View). 여기서 가장 높은 수준의 정책은 Interactor다 — 무엇이 보고서에 담기는가를 아는 곳이고, 웹이든 PDF든 상관없이 살아남아야 할 코드다. 가장 낮고 가장 변덕스러운 것은 뷰다.
이제 결정적인 선택이 온다. 이 네 조각 사이의 소스 코드 의존성을 어느 방향으로 놓을 것인가. 5장에서 본 대로, 자연스럽게 짜면 의존성은 제어 흐름을 따라 흐른다 — Interactor가 Presenter를 부르고 Presenter가 View를 부르니, Interactor가 Presenter를 알고 Presenter가 View를 알게 된다. 그러면 View가 바뀔 때마다 파급이 위로 거슬러 올라가 Interactor까지 흔든다. OCP는 이 방향을 뒤집으라고 요구한다 — 모든 화살표가 안쪽, 즉 더 높은 수준의 정책을 향하도록. Presenter는 Interactor가 소유한 인터페이스를 향해 의존하고, View는 Presenter가 소유한 인터페이스를 향해 의존한다. 그러면 PDF 뷰를 새로 더해도 그 의존성은 안쪽을 가리킬 뿐, 안쪽의 어느 것도 바깥의 새 뷰를 알지 못한다. 새 코드는 순수한 덧붙임이 된다.
public class FinancialReport { public String generateWebPage() { Ledger ledger = new Database().loadLedger(); long balance = 0; StringBuilder html = new StringBuilder("<table>"); for (Entry e : ledger.entries()) { balance += e.amount(); // 계산(balance)과 표현(빨간 글씨, <td>)이 같은 루프에 뒤엉켜 있다. String color = balance < 0 ? "red" : "black"; html.append("<tr><td style='color:").append(color).append("'>") .append(balance).append("</td></tr>"); } return html.append("</table>").toString(); }}// PDF 인쇄를 더하려면? 이 클래스 안에 generatePdf()를 또 파고,// 계산 로직(balance += ...)을 복사하거나 이 코드를 헤집어야 한다 — 수정에 열려 있다.
무엇이 달라졌나. 첫 단계에서 계산과 표현은 한 루프 안에 뒤엉켜 있었고, PDF라는 새 요구는 그 덩어리를 헤집을 수밖에 없었다 — 수정에 활짝 열린 구조였다. 마지막 단계에서 계산 정책(ReportInteractor)은 어떤 뷰의 이름도 모르는 채로 얼어 있고, 새 출력 형식은 ReportView를 구현하는 파일 하나를 더하는 것으로 끝난다. 의존성 화살표가 모두 안쪽(고수준 정책)을 향하기 때문에, 바깥에 무엇을 덧붙여도 안쪽은 그것을 알 필요가 없다. 확장에는 열리고, 수정에는 닫혔다 — 그 여닫음을 만든 것은 의존성의 방향이다.
판단 기준: OCP가 지켜졌는지 보려면 “새 확장을 더할 때 화살표가 어느 쪽을 가리키는가”를 확인하라 — 새 코드가 기존의 고수준을 향해 의존하면(고수준은 새 코드를 모르면) 열린 것이다. 함정: 인터페이스를 뽑아 놓고도 그 인터페이스를 저수준 쪽에 두면(뷰가 정의한 인터페이스를 Interactor가 구현하면) 화살표가 다시 바깥을 향해, 겉모습만 OCP인 채 보호는 거꾸로 걸린다.
보호의 방향 — A를 B의 변경으로부터 지킨다
여기서 마틴이 이 장에 심어 둔 가장 실용적인 한 문장이 나온다. 컴포넌트 A를 컴포넌트 B의 변경으로부터 보호하고 싶다면, B가 A에 의존하게 만들어라. 의존성 화살표는 곧 보호의 방향이다 — 화살을 쏘는 쪽이 흔들리고, 화살을 맞는 쪽이 보호받는다. B가 A를 향해 의존하면, A가 바뀔 때 B가 흔들리지만 B가 바뀔 때 A는 무사하다.
그렇다면 무엇을 무엇으로부터 보호해야 하는가. 답은 수준(level)에 있다. 더 높은 수준의 정책일수록, 더 낮은 수준의 세부로부터 보호받아야 한다. 재무 보고서의 계산 규칙(Interactor)은 시스템에서 가장 중요하고 가장 오래 살아남아야 할 코드이고, 뷰의 픽셀 배치는 가장 변덕스럽고 언제든 갈아치울 세부다. 그러니 화살표는 뷰에서 Interactor를 향해야 한다 — 변덕스러운 것이 안정된 것에 의존하고, 중요한 것이 사소한 것으로부터 격리된다. 이것이 뒤의 장들에서 동심원으로 그려질 의존성 규칙의 씨앗이다.
판단 기준: 두 컴포넌트 사이에 화살표를 그을 때 “둘 중 무엇이 더 중요하고 더 오래 사는가”를 물어라 — 화살은 언제나 덜 중요하고 더 변덕스러운 쪽에서 더 중요하고 더 안정된 쪽으로 향해야 한다. 함정: 제어 흐름의 방향(Interactor가 뷰를 부른다)을 그대로 의존성 방향으로 삼으면 보호가 정확히 거꾸로 걸린다 — 가장 지켜야 할 것이 가장 흔들리는 것에 매달리게 된다.
정보 은닉 — 알 필요 없는 것은 알지 못하게
방향만으로는 부족하다. 마틴은 여기에 ISP의 결을 얹는다. 컨트롤러가 Interactor를 거쳐 데이터에 닿을 때, 컨트롤러가 Interactor 너머의 것들(데이터베이스 구조, 원장 스키마)까지 훤히 알게 두면 안 된다. 만약 컨트롤러가 자신이 직접 쓰지도 않는 저수준 세부를 알게 되면, 그 세부가 바뀔 때 컨트롤러까지 재컴파일 대상이 된다. 쓰지 않는 것에 대한 의존은, 5장의 표현을 빌리면 “이름을 아는 것만으로 걸리는 결합”이다.
그래서 각 경계는 필요한 것만 드러내고 나머지는 인터페이스 뒤로 숨긴다. 컨트롤러는 Interactor가 노출한 좁은 인터페이스만 보고, 그 뒤에 데이터베이스가 있는지 파일이 있는지 메모리 캐시가 있는지 알지 못한다. 이 전이적 의존(transitive dependency)의 차단이야말로 OCP를 실제로 지탱하는 뼈대다 — 확장을 더할 때 파급이 경계에서 멈추는 것은, 경계 너머의 세부가 애초에 바깥으로 새어 나오지 않았기 때문이다.
판단 기준: 한 컴포넌트가 아는 이름의 목록을 적어 보라 — 그중 실제로 호출하지 않는 것, 두세 다리 건너 딸려 온 것이 있으면 그것이 OCP를 새는 구멍이다. 함정: “그냥 참조만 해 두면 편하니까” 하고 넓은 인터페이스나 공용 DTO를 통째로 열어 두면, 지금은 안 쓰더라도 그 이름이 바뀌는 날 함께 끌려 나간다.
무엇을 열고 무엇을 닫을지는 예측이다
마지막으로 이 원칙의 절제된 한계를 못박아 두자. 앞서 말했듯 모든 변경으로부터 닫을 수는 없다. 컴포넌트를 잘게 나누고 화살표를 안쪽으로 정렬하는 데는 대가가 따른다 — 인터페이스가 늘고, 조각이 흩어지고, 흐름을 한눈에 좇기 어려워진다. 1장의 트레이드오프가 여기서 되풀이된다. 그러니 아키텍트는 어떤 변경이 실제로 올 것인가를 예측하고, 그 축으로만 시스템을 연다. 재무 보고서에서 “새 출력 형식”이 올 것을 내다봤기에 데이터와 뷰의 경계를 그은 것이지, 상상할 수 있는 모든 축을 다 열어 둔 것이 아니다.
이 예측이 빗나가면 두 방향으로 실패한다. 올 변경을 못 내다보면 정작 필요할 때 코드를 뜯어야 하고(과소 설계), 오지 않을 변경까지 대비하면 쓰이지 않을 유연성의 유지비만 문다(과잉 설계). OCP의 실력은 얼마나 많은 확장점을 만들었는가가 아니라, 꼭 필요한 곳에만 경계를 그었는가에서 나온다.
판단 기준: 확장점을 하나 만들기 전에 “이 축의 변경이 이 프로젝트의 수명 안에 실제로 올 근거가 있는가”를 물어라 — 근거를 대지 못하면 그 경계는 아직 긋지 말고 미뤄 두는 편이 낫다. 함정: “언젠가 쓸지 모르니까”는 근거가 아니라 불안이다 — YAGNI를 무시하고 미리 판 확장점은, 정작 진짜 변경이 예상 밖의 축에서 올 때 아무 도움도 못 되면서 코드만 무겁게 한다.
정리하면 이렇다.
- OCP의 무대는 클래스가 아니라 아키텍처다. 있을 법한 변경이 왔을 때 고쳐야 할 코드가 최소가 되도록 컴포넌트를 나누고 의존성을 배치하는 것이 이 원칙의 실체다.
- 절차는 두 걸음이다 — SRP로 다른 이유로 바뀌는 책임을 가르고, DIP·ISP로 그 사이의 의존성이 안쪽(고수준 정책)을 향하도록 놓는다.
- 의존성 화살표는 보호의 방향이다. 더 중요하고 더 오래 사는 정책이, 더 변덕스러운 세부로부터 화살을 맞으며 보호받는다.
- 모든 변경으로부터 닫을 수는 없다. 어느 축을 열지는 예측이며, OCP의 실력은 확장점의 개수가 아니라 경계를 그은 자리의 정확함에서 나온다.
이 장은 5장에서 손에 쥔 “의존성 방향을 정하는 힘”을, 변경을 격리하는 설계 원칙으로 벼려 낸다. 다음 장에서는 그 경계를 넘나드는 타입들이 지켜야 할 계약 — 하위 타입이 상위 타입을 배신하지 않아야 한다는 리스코프 치환 원칙을, 정사각형과 직사각형이라는 오래된 함정으로 본다.
다음장으로 9장