24장이 완전한 경계가 얼마나 비싼지, 그래서 부분적 경계로 그 값을 어떻게 깎는지를 다뤘다면, 25장은 한 걸음 물러서서 다른 질문을 던진다 — 경계는 대체 몇 개나 있는가. 시스템을 “업무 규칙”과 “UI”와 “DB” 셋으로 나누는 그림에 익숙해지면, 경계가 딱 그 세 자리에만 있다고 착각하기 쉽다. 마틴은 이 착각을 깨기 위해 일부러 우스울 만큼 사소한 프로그램을 하나 꺼낸다. 1970년대의 텍스트 게임, Hunt the Wumpus다. 화면에 글자만 찍히는 이 장난감 안에도 경계가 셋 이상 숨어 있다는 것을 보이고 나면, “경계는 몇 개인가”라는 질문의 답이 달라진다 — 경계는 우리가 긋기로 선택하는 만큼 있고, 긋지 않기로 선택하는 만큼 없다.
장난감 게임 속에 숨은 첫 번째 경계
Hunt the Wumpus의 규칙은 단순하다. 플레이어는 동굴들이 이어진 미로를 돌아다니며 괴물 웜퍼스를 사냥한다. 화면에는 “당신은 12번 동굴에 있다. 통로는 1, 5, 9번으로 나 있다. 근처에서 웜퍼스 냄새가 난다”는 식의 문장이 뜨고, 플레이어는 “이동 5”나 “화살 9” 같은 명령을 친다. 텍스트가 들어오고 텍스트가 나가는, 이보다 더 단순할 수 없는 프로그램이다.
여기서 아무 생각 없이 짜면, 게임 규칙을 다루는 코드와 화면에 문장을 찍는 코드가 한 덩어리로 엉킨다. 웜퍼스가 인접한 동굴에 있는지 판정하는 로직 바로 옆에서 println("근처에서 웜퍼스 냄새가 난다")가 실행된다. 동작한다. 그러나 이 순간 우리는 첫 번째 경계를 지워 버린 것이다 — 게임 규칙과 사용자 인터페이스 사이의 경계다.
이 경계가 실재한다는 증거는 변경축을 세어 보면 드러난다. 게임 규칙(동굴 연결, 화살 개수, 웜퍼스의 이동)은 게임 디자이너가 바꾼다. 반면 화면 표현(문장을 어떻게 찍을지, 색을 입힐지, 나중에 그래픽으로 바꿀지)은 UI 담당이 바꾼다. 둘은 서로 다른 이유로, 서로 다른 시점에, 서로 다른 사람에 의해 바뀐다. 서로 다른 축 위에서 움직이는 두 관심사를 한 함수에 묶어 두면, 한쪽의 변경이 늘 다른 쪽을 인질로 잡는다.
// 규칙과 표현이 엉킨 형태 — 경계가 지워졌다
void reportCave(int cave) {
if (wumpusIsAdjacent(cave))
System.out.println("근처에서 웜퍼스 냄새가 난다");
}
// 규칙은 "무슨 일이 일어났나"만 정하고, 표현은 그 뒤로 밀어낸다
GameEvent look(int cave) {
return wumpusIsAdjacent(cave) ? WUMPUS_NEARBY : NOTHING;
}아래쪽 형태에서 게임 규칙은 WUMPUS_NEARBY라는 추상적 사건만 반환할 뿐, 그것이 화면에 어떤 문장으로 찍히는지는 모른다. 규칙과 표현 사이에 경계가 그어졌다.
판단 기준: 두 코드 조각이 “서로 다른 사람이 서로 다른 이유로 고칠” 후보라면, 그 사이에 경계가 실재하는 것이다 — 지금 한 함수에 붙어 있더라도 그렇다. 함정: 프로그램이 작고 화면 출력이 한 줄뿐이면 경계가 눈에 안 보인다. “이렇게 사소한 데 무슨 계층이냐”며 규칙과 표현을 붙여 두면, 나중에 UI를 바꾸려는 순간 게임 규칙까지 헤집게 된다.
두 번째 경계 — 언어는 규칙이 아니다
규칙과 UI를 갈라 두고 나면, 곧바로 두 번째 경계가 자기 존재를 드러낸다. 이 게임을 스페인어로도, 프랑스어로도 팔고 싶다고 해 보자. “근처에서 웜퍼스 냄새가 난다”라는 문장 자체는 게임 규칙일까? 아니다. 웜퍼스가 인접해 있다는 것은 규칙이지만, 그 사실을 어느 나라 말로 어떻게 표현하는가는 전혀 다른 관심사다.
그래서 게임 규칙과 화면 사이에 하나의 경계를 더 끼워 넣게 된다 — 언어(자연어) 경계다. 규칙은 WUMPUS_NEARBY라는 언어 중립적 사건을 내놓고, 그 뒤에 놓인 번역 계층이 그 사건을 사용자의 언어로 된 문장으로 바꾸며, 다시 그 뒤의 텍스트 전달 계층이 그 문장을 실제 화면·터미널·소켓으로 내보낸다. 냄새 하나를 보고하는 데에도 규칙 → 언어 → 전달이라는 세 층이 순서대로 관여한다. 각 층은 독립적으로 갈아 끼울 수 있다. 스페인어를 추가해도 규칙은 그대로고, 화면을 웹소켓으로 바꿔도 번역은 그대로다.
판단 기준: “이것을 다른 형태로도 내보내야 하나”(다른 언어, 다른 매체)를 물어서 답이 그렇다면, 그 변주의 축마다 경계가 하나씩 생긴다. 함정: 문자열을 규칙 코드 안에 하드코딩하면 언어 경계가 규칙 경계와 뭉개진다 — 번역을 추가하려다 게임 로직을 건드리게 되고, 그 반대도 마찬가지다.
세 번째 경계 — 어디에 저장하는가
경계는 출력 쪽에만 있지 않다. 게임은 상태를 저장해야 한다. 플레이어의 위치, 남은 화살, 지도의 모양을 어딘가에 기록했다가 다시 불러온다. 그런데 “어딘가”가 어디인지는 게임 규칙이 알 바가 아니다. 로컬 파일일 수도, 플래시 메모리일 수도, 네트워크 너머의 클라우드 저장소일 수도 있다. 규칙은 “이 상태를 저장하라”, “저장된 상태를 다오”라고 요청할 뿐, 그 요청이 디스크로 가는지 클라우드로 가는지는 몰라야 한다.
여기서 세 번째 경계 — 저장소 경계가 그어진다. 규칙과 저장소 사이에 인터페이스를 하나 두면, 저장 매체를 통째로 갈아 끼워도 규칙은 한 줄도 바뀌지 않는다. 이 경계를 건널 때 오가는 것은 저장 매체의 세부(파일 핸들, SQL, HTTP)가 아니라, 규칙이 정의한 단순한 데이터 구조여야 한다. 22장의 의존성 규칙이 여기서 그대로 작동한다 — 저장소는 세부사항이고, 세부사항은 규칙 안쪽을 향해 의존해야지 그 반대가 아니다.
이렇게 세어 보면, 화면에 글자만 찍는 이 장난감 게임 안에 이미 경계가 셋이다. 규칙과 UI 사이, 언어와 전달 사이, 규칙과 저장소 사이. 여기에 입력 파싱(플레이어의 명령을 규칙이 이해하는 사건으로 바꾸는 계층)까지 세면 넷이다. 마틴이 이 우스운 예시를 고른 이유가 여기 있다 — 경계는 시스템의 크기가 아니라 관심사의 가짓수를 따라 생긴다. 작은 프로그램이라고 경계가 적은 게 아니다.
판단 기준: 데이터가 시스템을 드나드는 지점(입력 파싱, 화면 출력, 상태 저장)마다 “이 바깥세상의 세부가 규칙 안으로 새어 들어오는가”를 물어라. 새어 든다면 거기에 경계가 필요하다. 함정: 경계를 “업무 규칙 / UI / DB” 세 칸으로만 상상하면, 언어·매체·입력 파싱처럼 그 셋에 안 들어가는 경계를 통째로 놓친다. 경계는 세 칸짜리 표가 아니라, 관심사가 갈리는 모든 이음매에 잠재한다.
모든 경계는 공짜가 아니다
그렇다면 결론은 “경계를 잔뜩 그으면 좋다”인가. 정반대다. 이 장의 진짜 무게는 여기서부터 실린다. 경계를 하나 실체화한다는 것은 인터페이스를 정의하고, 경계를 건너는 데이터 구조를 따로 만들고, 양쪽을 잇는 어댑터를 쓰고, 그것들을 조립·관리하는 일이다. 24장이 보였듯 완전한 경계는 비싸다. 경계마다 코드가 늘고, 간접 계층이 하나 더 끼며, 흐름을 눈으로 좇기 어려워진다.
그래서 마틴은 여기서 YAGNI(You Aren’t Going to Need It)를 불러온다. 필요하지도 않을 유연성을 미리 지어 두는 것은 미덕이 아니라 낭비다. 오지 않을 언어 확장을 위해 번역 계층을 미리 깔고, 절대 안 바뀔 저장 매체를 위해 저장소 추상을 미리 세우면, 우리는 쓰이지 않을 유연성의 값을 매일의 복잡성으로 갚게 된다. 이것이 과잉 엔지니어링이다.
하지만 반대편에도 낭떠러지가 있다. 정말로 바뀔 축인데 경계를 안 그어 두면, 나중에 변경이 닥쳤을 때 규칙과 세부가 엉킨 코드를 통째로 헤집어야 한다. 이것이 과소 엔지니어링이다. 1장의 그래프처럼, 이 비용은 조용히 누적되다가 어느 릴리스에서 청구서로 도착한다.
판단 기준: 경계를 그을지 말지는 “이 축의 변경이 실제로 올 확률”과 “지금 경계를 그어 두는 비용”을 저울에 함께 올려 판단한다 — 확률이 높고 비용이 낮으면 긋고, 확률이 낮고 비용이 높으면 미룬다. 함정: “언젠가 필요할지 모르니 일단 추상화”는 YAGNI를 정면으로 어긴다. 반대로 “지금 안 쓰니 영영 필요 없다”고 단정하는 것도 같은 크기의 오판이다. 두 오류는 대칭이며, 어느 쪽도 기본값으로 삼아선 안 된다.
과잉과 과소 사이에서 균형 잡기
그러면 어떻게 판단하는가. 마틴의 답은 겸손하다 — 완벽하게 맞힐 수는 없으니, 지켜보라. 프로젝트 초기에 모든 경계를 다 그으려 하면 과잉이고, 하나도 안 그으면 과소다. 실력 있는 아키텍트는 어떤 축이 실제로 흔들리는지 지켜보다가, 그 축의 변경 압력이 실제로 감지되는 순간 경계를 실체화한다.
Hunt the Wumpus로 돌아가 보자. 이 게임을 나 혼자 쓰는 콘솔 장난감으로만 만들 거라면, 언어 경계도 저장소 경계도 과잉이다. 그냥 println하고 파일에 쓰면 된다. 그러나 이 게임을 상용 제품으로 여러 나라에 팔 계획이 잡히는 순간, 언어 경계는 갑자기 과소 엔지니어링의 위험 지대로 바뀐다. 같은 코드, 같은 경계인데 맥락에 따라 과잉이 되기도 하고 필수가 되기도 한다. 경계의 옳고 그름은 코드 안에 있지 않고 그것이 놓인 상황 안에 있다.
판단 기준: 경계 결정은 한 번 내리고 끝내는 것이 아니라, 변경 압력을 계속 관찰하며 갱신하는 것이다. 초기엔 과소 쪽으로 살짝 기울여 시작하되, 특정 축이 두세 번 흔들리는 게 보이면 그때 경계를 세운다. 함정: 아키텍처를 한 번에 다 결정지으려는 조급함이 과잉의 근원이다. 경계 결정을 뒤로 미룰 수 있다는 것 자체가 15장이 말한 “선택지를 열어 두는” 아키텍처의 힘이다 — 미룸은 게으름이 아니라 전략이다.
숨은 경계를 지켜보는 것도 설계다
여기서 이 장의 가장 미묘한 통찰이 나온다. 경계를 아직 실체화하지 않았다고 해서 그것을 인식조차 안 해도 되는 것은 아니다. 규칙과 표현을 지금은 한 함수에 붙여 두더라도, 아키텍트는 그 사이에 경계가 잠재해 있음을 안다. 이 앎이 있으면, 나중에 그 경계를 실체화해야 할 때 코드를 최소한으로만 갈라도 된다. 앎이 없으면, 두 관심사가 이미 걷잡을 수 없이 뒤엉킨 뒤라 갈라내는 데 훨씬 큰 비용이 든다.
그래서 좋은 설계는 두 가지를 동시에 한다 — 지금 그을 경계를 고르는 일과, 지금은 안 긋지만 어디에 경계가 잠재하는지 목록에 올려 두고 지켜보는 일. 후자는 눈에 보이는 산출물이 없어서 설계 작업으로 안 쳐지기 쉽지만, 실은 이쪽이 더 어려운 판단이다. 숨은 경계를 지켜본다는 것은, 코드를 지금 가르지 않으면서도 그것을 가르는 선이 어디쯤 지나갈지를 머릿속에 계속 그려 두는 일이다. 그 선을 알고 있으면, 변경이 닥쳤을 때 이미 절반은 설계돼 있는 셈이다.
판단 기준: 지금 경계를 안 긋기로 했다면, “그 경계가 어디쯤 지나가는지”를 명시적으로 기록·인지하고 그 축의 변경 신호를 지켜본다 — 안 긋는 것과 잊는 것은 다르다. 함정: 경계를 미룬 것을 경계가 없는 것으로 착각하면, 두 관심사가 부지불식간에 뒤엉켜 나중에 실체화 비용이 폭증한다. 미룬 경계는 관리해야 할 부채지, 존재하지 않는 무엇이 아니다.
정리 — 경계는 세는 것이 아니라 판단하는 것
- 화면에 글자만 찍는 Hunt the Wumpus에도 경계가 셋 이상 있다 — 규칙과 UI, 언어와 전달, 규칙과 저장소. 경계는 시스템 크기가 아니라 관심사의 가짓수를 따라 생긴다.
- 모든 경계는 인터페이스·데이터 구조·어댑터라는 비용을 부른다. YAGNI: 오지 않을 유연성을 미리 짓는 것은 과잉 엔지니어링이고, 올 변경에 경계를 안 두는 것은 과소 엔지니어링이다.
- 같은 경계가 맥락에 따라 과잉이 되기도, 필수가 되기도 한다. 옳고 그름은 코드가 아니라 변경 압력이라는 상황 안에 있다.
- 그래서 경계 결정은 한 번에 끝내는 것이 아니라, 어느 축이 흔들리는지 지켜보며 실체화 시점을 고르는 지속적 판단이다.
- 지금 안 긋는 경계도 어디에 잠재하는지 인지하고 관찰해야 한다 — 숨은 경계를 지켜보는 것 자체가 설계다.
경계는 많아도 병이고 적어도 병이다. 25장은 그 사이에서 균형점을 고르는 일이 곧 아키텍트의 일임을 장난감 하나로 증명했다. 다음 장은 이 모든 경계와 계층을 실제로 조립하고 시동을 거는 자리, 시스템의 가장 낮고 가장 더러운 진입점 — Main 컴포넌트로 내려간다.
다음장으로 26장