17장에서 경계를 어디에 그을지 배웠고, 18장에서 그 경계를 넘나드는 비용을 셈했다. 그런데 경계를 긋는다는 것은 결국 무언가를 안쪽에, 무언가를 바깥쪽에 놓는다는 뜻이다. 무엇을 안에 두고 무엇을 밖에 둘 것인가. 19장은 이 질문에 이름표 하나를 붙여 답한다 — 수준(level). 안과 밖을 가르는 자는 취향이 아니라 수준이며, 수준은 놀랍도록 구체적으로 잴 수 있는 값이다.

컴퓨터 프로그램이란 무엇인가. 마틴은 가장 앙상한 정의에서 출발한다. 프로그램은 입력을 출력으로 바꾸는 정책(policy)의 상세한 기술이다. 큰 정책은 작은 정책들로 쪼개지고, 그 작은 정책들이 다시 더 작은 정책으로 나뉜다. 결국 시스템 전체는 크고 작은 정책들이 얽힌 그물이다. 아키텍처의 일은 이 정책들을 어떻게 묶고, 어떻게 배치하느냐로 요약된다.

프로그램은 정책의 집합이다

먼저 정책을 어떻게 묶을 것인가. 여기서 마틴은 앞서 세운 두 원칙을 그대로 소환한다. SRP(7장)와 CCP(13장)다. 같은 이유로, 같은 시점에 바뀌는 정책끼리 한데 모으고, 다른 이유로 다른 시점에 바뀌는 정책은 갈라놓는다. 어떤 정책이 회계 규정이 바뀔 때 함께 바뀐다면 그것들은 한 컴포넌트다. 어떤 정책이 화면 표시 방식이 바뀔 때 흔들린다면 그것은 다른 컴포넌트에 속한다.

이 묶음의 기준이 “기능이 비슷한가”가 아니라 “변경의 이유가 같은가”라는 점이 핵심이다. 겉보기에 비슷해 보이는 두 코드가 서로 다른 이유로 바뀐다면 갈라야 하고, 전혀 달라 보이는 두 코드가 늘 같은 요청에 함께 바뀐다면 붙여야 한다. 1장 오브젝트의 표현을 빌리면, 무엇을 함께 두느냐의 기준은 “지금 같이 생겼는가”가 아니라 “앞으로 같이 흔들리는가”다.

판단 기준: 두 정책을 한 컴포넌트에 둘지 말지는 “이 둘이 항상 같은 이유로 함께 바뀌는가”로 묻는다. 그렇다면 묶고, 아니라면 가른다. 함정: 코드의 표면적 유사성(둘 다 문자열을 다룬다, 둘 다 파일을 연다)을 변경 이유의 동일성으로 착각하면, 서로 다른 속도로 바뀔 것들을 한 상자에 묶어 매 변경마다 무관한 코드를 함께 건드리게 된다.

수준이란 입력과 출력으로부터의 거리다

묶는 법을 정했으면, 이제 묶인 컴포넌트들을 어떤 순서로 쌓을 것인가. 여기서 이 장의 진짜 개념인 수준이 등장한다. 마틴의 정의는 도발적일 만큼 간결하다 — 수준이란 입력과 출력으로부터의 거리다. 입출력에 가까울수록 낮은 수준이고, 멀수록 높은 수준이다.

왜 하필 이 거리가 수준의 척도인가. 입력과 출력은 시스템에서 가장 변덕스러운 지점이기 때문이다. 사용자가 콘솔로 입력하다가 파일로 바꾸고, 파일이 다시 네트워크 소켓으로 바뀐다. 화면에 찍던 출력이 프린터로, 다시 데이터베이스로 옮겨간다. 이 입출력의 세부는 업무의 본질과 무관하게 자주 흔들린다. 반대로 입출력에서 멀리 떨어진 정책 — 그 프로그램이 “무엇을 하는 존재인가”를 규정하는 핵심 규칙 — 은 좀처럼 바뀌지 않는다. 거리가 곧 안정성이고, 거리가 곧 수준이다.

여기서 “고수준”이라는 말이 일상어의 어감과 어긋난다는 점을 짚어 둘 필요가 있다. 고수준 컴포넌트는 더 복잡하거나 더 대단한 것이 아니다. 오히려 시스템의 심장에 가까워, 자잘한 입출력 세부를 모른 채로도 자기 일을 다 할 수 있는 정책이다. 저수준 컴포넌트가 “어떤 장치에서 어떻게 읽고 쓰는가”의 세부를 안다면, 고수준 컴포넌트는 “읽은 것을 무엇으로 바꾸는가”의 본질만 안다.

판단 기준: 어떤 컴포넌트의 수준을 재려면 “이것이 입력·출력 장치의 교체에 얼마나 영향을 받는가”를 물어라. 장치가 바뀌어도 태연한 코드가 고수준이다. 함정: 코드 줄 수나 호출 깊이, “메인에 가까운가”를 수준으로 착각하지 말 것 — 수준의 자는 오직 입출력으로부터의 거리 하나다.

소스 의존성은 수준 높은 쪽을 향해야 한다

수준을 정의한 이유는 단 하나, 의존성의 방향을 정하기 위해서다. 이 장의 결론은 이 한 문장이다 — 소스 코드 의존성은 그 컴포넌트가 연결된 데이터 흐름이 아니라, 컴포넌트의 수준에 따라 결정되어야 하며, 낮은 수준이 높은 수준을 향하도록 흘러야 한다. 변덕스러운 저수준이 안정된 고수준에 의존해야지, 그 반대가 되어선 안 된다.

이것이 2장에서 본 “변경 비용은 형태가 아니라 범위에 비례해야 한다”는 약속을 지키는 방법이다. 저수준이 고수준에 의존하면, 입출력 장치를 바꿀 때 변경이 저수준 안에 갇힌다. 고수준의 핵심 정책은 자기 밑에서 무슨 장치가 바뀌든 알지 못하고, 알 필요도 없다. 반대로 고수준이 저수준에 의존하도록 놔두면, 콘솔을 소켓으로 바꾸는 사소한 변경이 시스템의 심장까지 거슬러 올라가 핵심 정책을 오염시킨다. 형태에 비례하는 비용이 바로 이렇게 태어난다.

판단 기준: 의존성 화살표를 그렸을 때, 화살표가 늘 입출력에서 먼 쪽(고수준)을 가리키면 건강하다. 함정: 데이터가 흐르는 방향과 소스 의존성의 방향을 같게 두는 것이 가장 흔한 실수다 — 데이터는 입력에서 출력으로 흐르지만, 의존성까지 그 흐름을 따라가면 고수준이 저수준에 붙들린다. 둘은 별개이며, 필요하면 DIP(11장)로 흐름과 반대로 의존성을 꺾어야 한다.

문자를 암호화하는 작은 프로그램

이 모든 원칙이 손바닥만 한 예제 하나에 통째로 담긴다. 마틴이 드는 것은 단순한 암호화 프로그램이다. 입력 문자를 하나씩 읽어(read), 어떤 규칙으로 변환하고(translate), 결과를 하나씩 내보낸다(write). 정책이 셋으로 나뉜다. 읽기, 변환, 쓰기.

이 셋의 수준을 재 보자. 읽기와 쓰기는 입출력 장치에 딱 붙어 있다 — 콘솔에서 읽을지 파일에서 읽을지, 화면에 쓸지 프린터에 쓸지가 여기서 결정된다. 입출력으로부터의 거리가 0이니, 가장 낮은 수준이다. 변환은 다르다. 어떤 문자를 어떤 문자로 바꾸는가는 이 프로그램이 “암호화기”라는 존재임을 규정하는 본질이며, 입력이 콘솔이든 파일이든, 출력이 화면이든 프린터든 전혀 상관없다. 입출력에서 가장 멀리 떨어진, 가장 높은 수준의 정책이다.

// 고수준 정책: 흐름을 지휘하되 장치를 모른다.
public class Encrypt {
    private Reader reader;   // 추상에 의존
    private Writer writer;   // 추상에 의존
 
    public void run() {
        int c;
        while ((c = reader.read()) != EOF) {
            writer.write(translate(c)); // 변환은 자기 안에 있다
        }
    }
}

여기서 결정적인 대목은, 고수준인 EncryptConsoleReaderFileWriter 같은 구체적 저수준 장치를 직접 알지 않는다는 점이다. EncryptReaderWriter라는 추상에만 의존하고, 콘솔용·파일용 구현이 그 추상을 거꾸로 향해 꽂힌다. 데이터는 리더에서 라이터로 흐르지만, 소스 의존성은 그 흐름을 거슬러 저수준 구현에서 고수준 정책 쪽으로 모인다.

// 저수준 세부: 추상을 향해 위로 의존한다.
class ConsoleReader implements Reader { public int read() { /* 콘솔에서 */ } }
class FileWriter   implements Writer { public void write(int c) { /* 파일로 */ } }

이렇게 배치하면 입력을 콘솔에서 원격 소켓으로 바꿔도, 출력을 파일에서 데이터베이스로 바꿔도, Encrypt의 소스는 한 줄도 손대지 않는다. 새 Reader·Writer 구현을 추가해 꽂기만 하면 된다. 암호화라는 핵심 정책은 자기 밑에서 장치가 몇 번을 바뀌든 태연하다. 이것이 수준에 따라 의존성을 배치한다는 말의 전부다.

판단 기준: 고수준 정책의 소스 안에 저수준 장치의 이름(콘솔·파일·소켓·특정 DB)이 등장하면, 수준을 거스르는 의존성이 이미 생긴 것이다. 그 이름을 추상 뒤로 밀어내라. 함정: translate(변환) 로직을 읽기·쓰기와 한 함수에 뒤섞어 놓으면, 겉보기엔 “한 흐름”이라 단순해 보이지만 실은 가장 안정된 정책과 가장 변덕스러운 정책을 한 상자에 가둔 것이다 — 장치가 바뀔 때마다 핵심 규칙이 함께 위험에 놓인다.

방향이 잘못되면 시스템이 거꾸로 매달린다

수준을 무시하고 데이터 흐름대로 의존성을 놓으면 어떤 일이 벌어지는가. EncryptConsoleReader를 직접 new로 만들어 쓰고, FileWriter를 직접 지목한다고 하자. 이제 이 프로그램은 콘솔과 파일에 못박힌다. 입력을 소켓으로 바꾸려면 고수준인 Encrypt의 소스를 열어 저수준의 이름을 갈아 끼워야 한다. 변환 규칙 한 글자 바뀌지 않았는데도, 순전히 장치를 바꾼다는 이유로 시스템의 심장을 건드리는 것이다.

더 나쁜 것은 재사용이다. 잘 배치된 Encrypt는 어떤 입출력 조합에도 그대로 재사용된다. 반대로 저수준에 못박힌 Encrypt는 오직 “콘솔에서 읽어 파일에 쓰는” 그 한 조합에서만 산다. 고수준 정책이 저수준 세부에 오염되는 순간, 가장 값진 코드가 가장 하찮은 이유로 재사용 불가능해진다. 마틴이 이 작은 예제로 보여주려는 것이 바로 이 역전의 위험이다 — 의존성의 방향 하나가 시스템 전체의 안정성과 재사용성을 가른다.

판단 기준: “장치를 바꿀 때 어느 컴포넌트의 소스를 열어야 하는가”를 물어 보라. 답이 저수준 컴포넌트 하나로 국한되면 방향이 옳고, 고수준까지 거슬러 올라가면 방향이 뒤집힌 것이다. 함정: 처음 짤 때는 흐름대로 의존성을 놓는 편이 자연스럽고 빠르다 — 그래서 대부분의 코드가 저수준에 못박힌 채로 태어난다. 이 편안함이 나중에 형태에 비례하는 변경 비용으로 청구된다.

수준은 컴포넌트 배치의 나침반이다

정리하면, 이 장은 두 원칙(SRP·CCP)으로 정책을 묶는 법과, 하나의 새 개념(수준)으로 묶인 것들을 쌓는 법을 나란히 놓는다. 묶기는 “같은 이유로 바뀌는가”가, 쌓기는 “입출력에서 얼마나 먼가”가 판단한다. 그리고 이 둘이 만나 하나의 실천 규칙을 이룬다 — 변덕스러운 저수준이 안정된 고수준에 의존하게 하라.

  • 프로그램은 입력을 출력으로 바꾸는 정책의 집합이며, 정책은 같은 이유·같은 시점에 바뀌는 것끼리 묶는다(SRP·CCP).
  • 수준은 입력과 출력으로부터의 거리다. 입출력에 붙은 읽기·쓰기가 저수준, 그로부터 먼 핵심 변환 규칙이 고수준이다.
  • 소스 의존성은 데이터 흐름이 아니라 수준을 따라야 하며, 늘 저수준에서 고수준으로(입출력에서 먼 쪽으로) 흘러야 한다.
  • 암호화 예제: Encrypt(고수준)는 Reader·Writer 추상에만 의존하고, 콘솔·파일 구현(저수준)이 그 추상을 향해 꽂힌다. 장치를 바꿔도 핵심 정책은 태연하다.
  • 방향이 뒤집혀 고수준이 저수준 장치에 못박히면, 가장 값진 정책이 가장 하찮은 이유로 재사용 불가능해진다.

이렇게 수준이라는 자를 손에 쥐면, 다음 질문은 자연스럽게 따라온다. 그 “가장 높은 수준의 정책”, 시스템의 심장에 놓이는 코드란 구체적으로 무엇인가. 20장은 그 심장에 이름을 붙인다 — 업무 규칙과 엔티티, 그리고 유스케이스다.

다음장으로 20장