앞 장에서 동심원을 그리고 의존성 규칙을 세웠다. 그런데 막상 경계를 어디에 그어야 하느냐고 물으면 답이 손에 잡히지 않는다. 이 장은 그 물음에 뜻밖의 나침반 하나를 쥐여 준다 — 테스트 가능성. 어떤 코드는 단위 테스트로 감싸기가 쉽고, 어떤 코드는 지독하게 어렵다. 마틴의 통찰은 이 난이도의 차이가 우연이 아니라는 것이다. 테스트하기 어려운 부분과 쉬운 부분이 갈리는 그 선이, 사실은 아키텍처 경계가 지나가야 할 자리를 가리킨다. 험블 객체 패턴은 그 선을 따라 코드를 둘로 쪼개는 기법이다.
험블 객체 — 테스트할 수 없는 것을 얇게 남긴다
험블 객체 패턴은 원래 GUI를 테스트하려던 사람들이 발견한 요령이다. 화면에 무언가를 그리는 코드는 단위 테스트하기가 고약하다. 버튼이 제자리에 떴는지, 색이 맞는지, 픽셀이 어긋나지 않았는지를 코드로 검증하는 일은 가능은 해도 비싸고 부서지기 쉽다. 반면 “이 금액을 화면에 어떤 문자열로, 어떤 색으로 보여줄 것인가”를 결정하는 로직은 순수한 계산이라 테스트가 쉽다.
패턴의 핵심은 이 둘을 갈라놓는 것이다. 테스트하기 어려운 행위 — 실제로 그리는 일 — 은 **험블(humble)**하게, 즉 아무 판단도 하지 않고 시키는 대로 출력만 하는 껍데기로 남긴다. 판단이 사라졌으니 테스트할 것도 없다. 그 대신 모든 결정은 테스트하기 쉬운 짝에게로 옮긴다. 험블한 쪽은 너무 단순해서 틀릴 여지가 없고, 복잡한 쪽은 순수해서 마음껏 테스트한다.
판단 기준: 어떤 모듈을 테스트하기 어렵다면, 그 안에서 “판단”과 “그리기(출력·IO)“를 분리할 수 있는지 물어라 — 판단을 걷어내고 남은 그리기가 자명해질 때까지 얇게 만드는 것이 목표다. 함정: 험블한 쪽을 “나중에 어떻게든 테스트하지”라며 로직을 남겨 두면, 가장 검증하기 힘든 자리에 가장 검증이 필요한 결정이 눌러앉는다.
프레젠터와 뷰 — 갈라진 선의 이름
이 패턴을 GUI 경계에 적용한 것이 바로 프레젠터와 뷰의 분리다. 뷰는 험블 객체다. 화면에 데이터를 밀어 넣는 일 말고는 아무것도 하지 않는다. 어떤 값을 보여줄지, 어떻게 포맷할지, 무슨 색으로 칠할지는 뷰가 결정하지 않는다. 뷰가 하는 일은 오직 하나 — 이미 다 만들어진 데이터를 받아 화면 위젯에 옮기는 것이다.
그 “이미 다 만들어진 데이터”를 만드는 것이 프레젠터다. 프레젠터는 유스케이스로부터 결과를 받아, 화면에 나타날 모든 것을 **뷰 모델(View Model)**이라는 단순한 데이터 구조로 빚어낸다. 날짜를 “2026-03-08”이라는 문자열로 포맷하는 일, 금액에 통화 기호와 천 단위 콤마를 붙이는 일, 잔액이 음수면 색상 필드에 “red”를 넣는 일, 버튼을 회색으로 죽여야 하면 활성화 플래그를 false로 놓는 일 — 이 모든 결정이 프레젠터에서 끝난다. 뷰 모델이 완성되면, 뷰가 할 일은 그 필드들을 위젯에 그대로 붓는 것뿐이다.
여기서 뷰 모델의 성격이 중요하다. 뷰 모델에는 로직이 없다. 심지어 포맷할 원본 숫자도 없다 — 이미 포맷된 문자열, 이미 결정된 색 이름, 이미 정해진 불리언만 담긴다. 그래서 뷰는 판단할 것이 남지 않는다. “이 값이 음수면 빨강”이라는 판단은 프레젠터가 이미 내려 색상 문자열로 박아 두었다. 뷰는 그 문자열을 읽어 칠할 뿐, 왜 빨강인지 묻지 않는다.
판단 기준: 뷰 모델을 설계할 때 “뷰가 이 필드를 받고도 무언가를 더 계산해야 하는가”를 물어라 — 계산이 필요하면 그 계산을 프레젠터로 끌어올려야 뷰가 진짜로 험블해진다. 함정: 뷰 모델에 날것의 도메인 객체(Order, Employee)를 그대로 실어 보내는 것 — 그 순간 뷰가 객체의 게터를 뒤지며 포맷과 판단을 다시 시작하고, 분리는 이름만 남는다.
테스트 가능성이 경계를 드러낸다
이 장의 진짜 주장은 프레젠터라는 특정 패턴이 아니라 그 뒤의 원리다. 우리는 “여기에 경계를 그어야 한다”는 것을 어떻게 아는가. 22장은 고수준과 저수준, 정책과 세부라는 말로 답했지만 그 구분은 때로 흐릿하다. 이 장은 더 날카로운 리트머스지를 내민다 — 테스트하기 쉬운 코드와 어려운 코드가 갈리는 지점에 경계가 있다.
왜 그런가. 테스트하기 어려운 코드란 대개 외부 세계에 붙어 있는 코드다. 화면, 데이터베이스, 네트워크, 파일 시스템 — 통제할 수 없고 느리고 변덕스러운 것들. 이것들은 정확히 아키텍처가 세부사항으로 밀어내고 싶어 하는 대상이다. 그러니 “이건 테스트하기 어렵다”는 감각은 곧 “이건 저수준 세부다”라는 신호이고, 반대로 순수한 계산으로 테스트가 쉬운 코드는 우리가 지켜야 할 고수준 정책일 확률이 높다. 험블 객체 패턴으로 둘을 갈라놓으면, 그 갈라진 선이 그대로 아키텍처 경계가 된다.
이 관점은 도구 하나를 준다. 어디에 경계를 그을지 막막할 때, “이 코드를 단위 테스트로 감싸려면 무엇이 방해가 되는가”를 물어라. 방해가 되는 것 — 화면을 붙잡아야 하고, DB를 띄워야 하고, 진짜 API를 불러야 하는 것 — 을 험블한 껍데기로 밀어내고 나면, 남은 순수한 알맹이가 경계 안쪽에 들어갈 정책이다.
판단 기준: 경계 후보가 헷갈리면 “이 선의 한쪽은 테스트가 쉽고 다른 쪽은 어려운가”를 확인하라 — 난이도가 뚜렷이 갈리면 좋은 경계이고, 양쪽 다 어렵다면 아직 험블하게 뽑아낼 세부가 섞여 있는 것이다. 함정: 테스트가 어렵다는 이유로 그 부분을 통째로 테스트에서 제외해 버리는 것 — 어려운 부분을 얇게 만들어 테스트 대상에서 빼는 것과, 두꺼운 채로 덮어 두는 것은 전혀 다르다.
뷰에 뒤섞인 로직을 갈라내기
말로 그린 분리를 코드로 짚어 보자. 주문 요약을 화면에 뿌리는 흔한 코드에서 출발한다. 처음엔 뷰 컴포넌트 하나가 도메인 객체를 받아 포맷하고, 판단하고, 색을 고르고, 그리기까지 전부 한다. 여기서 판단을 한 단계씩 프레젠터로 걷어내면, 뷰는 점점 험블해지고 결정은 순수한 함수로 모여 테스트 가능해진다.
// OrderView가 도메인 객체를 직접 받아 스스로 포맷하고 판단한다.class OrderView { render(order: Order): void { const dateText = `${order.createdAt.getFullYear()}-` + `${String(order.createdAt.getMonth() + 1).padStart(2, "0")}-` + `${String(order.createdAt.getDate()).padStart(2, "0")}` const total = order.items.reduce((s, it) => s + it.price * it.qty, 0) const totalText = "₩" + total.toLocaleString("ko-KR") // 판단이 그리기 코드 한복판에 박혀 있다. const balance = order.paid - total const balanceColor = balance < 0 ? "red" : "black" const canRefund = order.status === "COMPLETED" && balance >= 0 this.dateLabel.text = dateText this.totalLabel.text = totalText this.balanceLabel.text = "₩" + balance.toLocaleString("ko-KR") this.balanceLabel.color = balanceColor this.refundButton.enabled = canRefund }}// 이 render를 테스트하려면 화면 위젯(dateLabel, refundButton…)을 다 띄워야 한다.// 그런데 진짜 검증하고 싶은 것은 "잔액이 음수면 red", "완료+잔액≥0이면 환불 가능"// 같은 판단이다. 가장 검증이 필요한 로직이 가장 테스트하기 힘든 자리에 갇혀 있다.
무엇이 달라졌나. 첫 단계에서 “잔액이 음수면 빨강”, “완료이고 잔액이 양수면 환불 가능”이라는 판단은 화면 위젯을 붙잡아야만 닿을 수 있는 자리에 있었다. 마지막 단계에서 그 판단들은 OrderPresenter.present라는 순수 함수 안에 모여, 주문 하나를 넣고 뷰 모델 하나를 받는 것으로 전부 검증된다. 뷰는 뷰 모델의 필드를 위젯에 붓는 대여섯 줄로 줄었고, 그 대여섯 줄에는 틀릴 만한 판단이 하나도 없다. 결정은 테스트 가능한 쪽으로 모였고, 테스트 불가능한 쪽은 판단이 없어 테스트할 필요가 사라졌다.
판단 기준: 뷰에서 if·삼항·reduce·포맷 호출이 보이면 그것은 프레젠터로 옮길 판단이다 — 뷰에 남아도 되는 것은 “필드를 위젯에 대입한다”뿐이다. 함정: 프레젠터를 만들었는데도 뷰 모델에 원본 숫자를 담아 뷰에서 toLocaleString을 부르는 것 — 포맷이라는 결정이 여전히 험블해야 할 쪽에 새어 있으면 경계는 이름뿐이다.
같은 패턴, 다른 경계 — 게이트웨이·데이터베이스·서비스
험블 객체는 GUI 전용 요령이 아니다. 시스템 곳곳의 경계가 정확히 같은 모양을 하고 있고, 어디에나 이 패턴을 겹쳐 놓을 수 있다.
데이터베이스 게이트웨이. 유스케이스와 데이터베이스 사이에도 이 선이 지난다. 유스케이스는 EmployeeGateway 같은 인터페이스에 대고 findPending(), save(order) 같은 업무의 언어로 말한다. 이 인터페이스를 구현하는 게이트웨이 구현체는 SQL을 짜고 결과 집합을 도메인 객체로 옮기는 험블한 쪽이다 — 여기엔 업무 규칙이 없어야 한다. 그래야 유스케이스는 진짜 DB 없이 게이트웨이를 가짜(fake)로 갈아 끼워 테스트되고, 테스트하기 어려운 SQL 매핑은 얇은 껍데기로 밀려난다. 5장에서 급여 규칙이 EmployeeGateway 뒤로 DB를 밀어낸 것이 바로 이 그림이다.
ORM. 마틴이 못박는 미묘한 점 하나 — ORM은 데이터베이스 계층이 아니라 게이트웨이 구현과 DB 사이에 놓이는 또 하나의 험블한 매핑층이다. 관계형 테이블의 행을 객체로 옮기는 일은 판단이 아니라 기계적 변환이므로, 그 자체가 험블 객체다. ORM이 업무 규칙을 알기 시작하면 세부사항이 정책으로 역류한 것이다.
서비스 경계. 프로세스를 넘나드는 서비스 호출에도 같은 선이 있다. 네트워크 직렬화·역직렬화, 요청을 실어 보내고 응답을 받아 오는 부분은 테스트하기 어려운 험블한 쪽이다. 그 응답을 받아 무엇을 할지 결정하는 인터랙터는 순수한 정책이라 서비스를 가짜로 세워 테스트된다. 어느 경계든 구조는 한결같다 — 바깥에 붙어 테스트하기 힘든 껍데기 하나, 안쪽에 순수하게 결정하는 알맹이 하나.
판단 기준: 새 경계를 마주하면 “이 선의 바깥쪽을 판단 없는 껍데기로 얇게 만들 수 있는가, 그래서 안쪽을 진짜 외부 없이 테스트할 수 있는가”를 물어라 — 그럴 수 있으면 험블 객체 패턴이 그대로 적용된다. 함정: 껍데기라 부르면서 그 안에 조용히 업무 판단을 남기는 것 — 게이트웨이 구현이 “이 주문은 환불 대상인가”를 결정하는 순간, 정책이 테스트 불가능한 세부로 새어 나가 경계가 무너진다.
정리하면 이렇다.
- 험블 객체 패턴은 테스트하기 어려운 행위(그리기·IO)와 쉬운 판단(계산)을 갈라, 어려운 쪽을 판단 없는 껍데기로 얇게 남긴다.
- GUI 경계에서 뷰는 험블 객체이고, 프레젠터가 유스케이스 결과를 뷰 모델로 빚는다. 뷰 모델은 이미 포맷·판단이 끝난 납작한 데이터라, 뷰는 붓기만 하면 된다.
- 게이트웨이·ORM·서비스 경계도 같은 모양이다 — 바깥에 험블한 껍데기, 안쪽에 순수한 정책.
- 핵심 통찰은 방법이 아니라 진단이다. 테스트하기 쉬운 코드와 어려운 코드가 갈리는 선이 곧 아키텍처 경계가 지나야 할 자리다. 테스트 가능성이 경계를 드러낸다.
이 장은 경계를 찾는 실용적 감각 하나를 남긴다 — 막막할 때 테스트의 어려움을 물어라. 다음 장에서는 완전한 경계가 치러야 할 비용을 직시하고, 그 비용을 다 내기 아까울 때 택하는 부분적 경계의 여러 형태를 살핀다.
다음장으로 24장