앞선 서른두 장은 원칙을 하나씩 벼려 왔다. SRP는 액터를, 컴포넌트 원칙은 배포 단위를, 경계와 의존성 규칙은 화살표의 방향을 다뤘다. 그러나 원칙은 낱개로 들고 있을 때가 아니라 하나의 실제 문제 앞에서 동시에 작동할 때 비로소 제 모습을 드러낸다. 그래서 마틴은 마지막 앞에서 소프트웨어 하나를 처음부터 설계한다 — 온라인으로 비디오를 파는 시스템.
던지는 질문은 이것이다. 요구사항 한 뭉치를 손에 쥐었을 때, 무엇을 먼저 세어 아키텍처의 뼈대를 잡을 것인가. 답은 기능 목록이 아니라 누가 변경을 요청하는가 — 액터에서 출발한다. 이 장은 그 출발점에서 컴포넌트 구조까지 걸어 내려가는 한 번의 완주다.
제품을 먼저 그린다 — 무엇을 파는 소프트웨어인가
설계 이전에 팔 물건을 분명히 해 둔다. 이 시스템은 비디오를 웹으로 판다. 비디오는 두 방식으로 소비된다 — 스트리밍으로 보거나, 다운로드로 구매한다. 비디오는 낱개로도 팔리고 여러 편을 묶은 배치(강의 과정 같은 것)로도 팔린다. 그리고 이 비디오들은 누군가 만들어 올려야 하고, 누군가 가격을 매기고 공개·삭제해야 한다.
이 한 문단에서 이미 서로 다른 관심이 넷 섞여 있다. 보는 사람, 사는 사람, 올리는 사람, 관리하는 사람. 성급한 손은 여기서 곧장 VideoService 하나를 떠올려 모든 걸 밀어 넣는다. 그러나 이 넷은 서로 다른 이유로, 서로 다른 시점에, 서로 다른 이해관계자의 요청으로 바뀐다. SRP(7장)가 정확히 이 지점을 경고했다 — 한 모듈은 한 액터에게만 답해야 한다.
판단 기준: 요구사항 문단을 읽을 때 동사가 아니라 그 동사를 요청하는 주체에 밑줄을 그어라. 주체가 갈리면 코드도 갈릴 후보다. 함정: “비디오를 다룬다”는 명사의 동일성에 홀려 하나의 서비스로 묶는 것. 같은 데이터를 만지더라도 만지는 이유가 다르면 다른 책임이다.
액터를 세면 유스케이스가 딸려 나온다
그래서 첫 작업은 액터를 명시적으로 세는 것이다. 이 시스템에는 넷이 있다.
- 저자(Author) — 비디오를 추가하고 설명·본문을 수정한다.
- 관리자(Administrator) — 비디오를 삭제하고, 가격을 정하고, 배치로 묶어 공개한다.
- 시청자(Viewer) — 카탈로그를 보고, 산 비디오를 스트리밍으로 본다.
- 구매자(Purchaser) — 카탈로그를 보고, 비디오나 배치를 구매하고, 다운로드로 내려받는다.
액터가 정해지면 유스케이스는 그 액터가 시스템에 요구하는 행위로 자연히 떨어진다. 저자에게는 “비디오 추가”, 관리자에게는 “비디오 삭제”와 “가격 결정”, 시청자에게는 “카탈로그 열람”과 “구매한 비디오 시청”, 구매자에게는 “카탈로그 열람”과 “구매”. 각 유스케이스는 20장에서 본 애플리케이션 특화 규칙의 한 조각이다.
여기서 눈여겨볼 한 쌍이 있다. 시청자의 “카탈로그 열람”과 구매자의 “카탈로그 열람”은 화면도 로직도 거의 판박이다. DRY의 반사신경은 즉시 둘을 하나로 합치라고 속삭인다. 그러나 이 둘은 다른 액터에 속한다. 시청자용 카탈로그는 “이미 본 것/안 본 것”을 강조하도록 바뀔 수 있고, 구매자용 카탈로그는 “할인가/장바구니”를 강조하도록 바뀔 수 있다 — 서로 다른 이유로 서로 다른 방향으로 갈라질 코드다. 16장에서 못 박은 우발적 중복이 바로 이것이다. 지금 똑같아 보인다고 합치면, 한쪽 액터의 요구가 다른 쪽 화면을 흔드는 결합을 스스로 심는 것이다.
판단 기준: 두 유스케이스가 지금 똑같아도 소속 액터가 다르면 일단 갈라 두고, 진짜 공통인 알맹이(엔티티 수준)만 공유한다. 함정: 코드 라인의 현재 동일성만 보고 합치는 것. 중복인지 아닌지는 “지금 같은가”가 아니라 “같은 이유로 함께 바뀔 운명인가”로 가른다.
유스케이스 하나가 다섯 자리를 만든다
이제 유스케이스 하나를 골라 그것을 실현할 클래스들을 세운다. “구매”를 예로 들자. 22장의 동심원을 이 한 흐름에 그대로 적용하면 다섯 개의 자리가 생긴다.
웹 요청이 들어오면 **컨트롤러(Controller)**가 프레임워크의 요청 객체를 받아 안쪽이 편한 요청 구조체로 번역한다. 그것을 **인터랙터(Interactor)**가 받아 업무 규칙을 돌린다 — 재고를 확인하고, 결제를 검증하고, 구매 이력을 남긴다. 이때 인터랙터가 부리는 핵심 업무 규칙과 데이터는 **엔티티(Entity)**에 산다(Video, Sale, Customer). 인터랙터는 결과를 화면에 직접 그리지 않는다. 대신 자기가 소유한 출력 경계로 응답을 던지고, 바깥의 **프레젠터(Presenter)**가 그것을 뷰 모델로 빚어 **뷰(View)**가 출력만 하게 한다.
다섯 자리의 성격이 각기 다르다는 데 주목한다. 컨트롤러·프레젠터·뷰는 전달 메커니즘(웹)에 묶인 바깥 원이고, 인터랙터·엔티티는 웹이 사라져도 사는 안쪽 원이다. 그리고 이 다섯이 한 유스케이스마다 반복된다 — “비디오 추가”에도, “카탈로그 열람”에도 저마다의 컨트롤러·인터랙터·프레젠터·뷰·엔티티 참여가 있다.
판단 기준: 유스케이스 하나를 구현할 때 “입력을 번역하는 자리(컨트롤러) / 규칙을 도는 자리(인터랙터) / 규칙이 사는 자리(엔티티) / 출력을 빚는 자리(프레젠터) / 출력하는 자리(뷰)“가 각각 있는지 확인하라. 함정: 컨트롤러에 업무 규칙을 얹거나 인터랙터에 화면 서식을 얹어 자리를 겸직시키는 것 — 23장의 험블 객체 분리가 무너지고 테스트가 불가능해진다.
유스케이스에서 컴포넌트 구조로 — 손으로 따라가기
말로 세운 다섯 자리를, 코드가 진화하는 모습으로 따라가 본다. 순진한 한 덩어리에서 시작해 자리를 가르고, 마지막에 그 자리들을 컴포넌트로 묶는다.
// 하나의 서비스가 요청 파싱·업무 규칙·화면 서식·출력을 전부 떠안는다public class VideoPurchaseService { private final JpaSaleRepository saleRepo; // ← DB 프레임워크를 직접 안다 public String purchase(HttpServletRequest http) { // ← 웹 타입을 직접 안다 String customerId = http.getParameter("customerId"); String videoId = http.getParameter("videoId"); VideoRow row = saleRepo.findVideo(videoId); // DB 로우가 그대로 올라온다 long price = row.getPriceCents(); // 업무 규칙과 화면 서식이 뒤엉킨다 saleRepo.insertSale(customerId, videoId, price); return "<div>구매 완료: ₩" + (price * 13 / 1000) + "</div>"; // HTML까지 여기서 }}// 네 액터(저자·관리자·시청자·구매자)의 관심이 이런 서비스들에 흩어져 뒤섞이면,// 한 액터의 요구가 다른 화면을 깨고, 웹·DB 없이는 한 줄도 테스트할 수 없다.
무엇이 달라졌나. 마지막 상태에서 Entities와 Interactors 컴포넌트는 Web도 Database도 이름으로 모른다. 웹을 걷어내고 모바일 앱을 붙이려면 Controllers·Presenters·Views만 새로 짜면 되고, DB를 갈아도 게이트웨이 구현만 바꾼다. 다섯 자리로 가른 것은 SRP·험블 객체였고, 그 자리들을 액터의 변경축으로 묶은 것은 CCP였으며, 컴포넌트 사이 화살표를 안쪽으로 정렬한 것은 의존성 규칙이었다. 세 원칙이 한 다이어그램에서 동시에 작동한다.
판단 기준: 클래스를 컴포넌트로 묶을 때 “이것들이 같은 이유로 함께 바뀌는가”(CCP)와 “화살표가 안쪽을 향하는가”(의존성 규칙)를 함께 물어라. 둘 다 예여야 한 컴포넌트다. 함정: 계층 이름(controller/service/repo)만 보고 폴더로 나눈 뒤 컴포넌트 아키텍처를 세웠다고 믿는 것 — 22장에서 봤듯 나눔이 아니라 화살표의 방향이 기준이다.
컴포넌트를 가르는 축은 기술이 아니라 액터다
방금 묶은 컴포넌트들을 다시 보면 두 갈래의 분할이 겹쳐 있다. 하나는 기술 계층에 따른 가로 분할(뷰 / 프레젠터 / 인터랙터 / 컨트롤러 / 엔티티)이고, 다른 하나는 액터에 따른 세로 분할(저자용 / 관리자용 / 시청자용 / 구매자용)이다. 실제 시스템에서는 이 둘이 교차한다 — 인터랙터 컴포넌트 안에도 저자 인터랙터·구매자 인터랙터가 나뉘어 있다.
어느 축을 1차로 삼을지는 무엇이 더 자주, 더 독립적으로 바뀌느냐로 정한다. 만약 시청자 기능과 구매자 기능이 서로 다른 팀에 의해 서로 다른 주기로 배포된다면, 액터 축이 1차가 되어 “구매자 전체 스택(컨트롤러+인터랙터+프레젠터+뷰)“이 하나의 배포 단위로 묶이는 편이 낫다. 반대로 화면 기술이 통째로 자주 갈리면 계층 축이 1차가 된다. 14장의 SDP가 여기서 다시 나온다 — 자주 바뀌는 컴포넌트가 덜 바뀌는 컴포넌트에 의존하도록, 안정성의 방향을 따라 화살표를 놓는다.
판단 기준: 컴포넌트 경계의 1차 축은 “독립적으로 배포·개발되어야 하는 단위가 무엇인가”로 정하라 — 팀 경계와 배포 주기가 그 답을 준다. 함정: 무조건 기술 계층으로만 가르는 습관. 그러면 구매자 화면 하나를 바꾸는 데 뷰·프레젠터·인터랙터 컴포넌트를 모두 건드려 배포가 커진다.
결합 분리는 나중에 실체화할 선택지다
이 모든 경계를 처음부터 물리적 컴포넌트(별도 .jar, 별도 서비스)로 쪼갤 필요는 없다. 18장의 결합 분리 스펙트럼이 여기서 실전 판단이 된다. 초기에는 이 다섯 자리와 네 액터를 하나의 배포 단위 안에서 소스 수준으로만 갈라 둘 수 있다 — 패키지와 인터페이스로 화살표만 바르게 정렬해 두는 것이다. 그러면 나중에 구매자 스택만 따로 배포해야 할 필요가 실제로 생겼을 때, 경계는 이미 그어져 있으므로 물리적 분리는 접착 코드를 바꾸는 수고로 줄어든다.
이것이 24·25장이 말한 부분적 경계와 “언제 실체화할지 지켜보기”의 실천이다. 경계를 인터페이스로 미리 그어 두는 비용은 싸고(인터페이스 하나, 구조체 하나), 그것을 물리적 배포 경계로 승격하는 결정은 필요가 증명될 때까지 미룬다. 15장의 약속 — 좋은 아키텍처는 선택지를 열어 둔다 — 이 여기서 실현된다.
판단 기준: 경계는 소스 수준(패키지+인터페이스)으로 먼저 긋고, 배포·서비스 수준으로의 승격은 독립 배포의 필요가 실제로 관찰될 때 하라. 함정: 처음부터 모든 액터를 마이크로서비스로 쪼개는 것(27장) — 결합 분리는 저절로 오지 않고, 증명되지 않은 경계는 통신 오버헤드와 분산 복잡성만 사서 치른다.
하나로 모이는 그림
이 장이 새 원칙을 가르치지 않는다는 점이 핵심이다. 비디오 판매 시스템은 앞서 배운 것들이 서로를 부르며 하나로 잠기는 장면일 뿐이다. 액터를 세는 순간 SRP가 유스케이스의 경계를 그었고, 유스케이스를 다섯 자리로 가르는 순간 험블 객체와 DIP가 제어 흐름과 소스 의존성을 갈라놓았고, 자리들을 액터 축으로 묶는 순간 CCP와 의존성 규칙이 컴포넌트의 모양과 화살표를 정했다. 마지막으로 그 경계를 물리적으로 언제 실체화할지는 결합 분리 스펙트럼의 판단으로 미뤄 두었다.
그래서 이 사례가 주는 교훈은 “이렇게 폴더를 나눠라”가 아니라, 요구사항에서 아키텍처로 내려가는 순서가 액터 → 유스케이스 → 자리 → 컴포넌트라는 것, 그리고 그 매 단계에서 지불하는 것은 인터페이스 몇 개와 번역 구조체 몇 개의 수고이며 사는 것은 액터별·기술별 변경을 서로 무관한 사건으로 만드는 자유라는 것이다.
- 아키텍처는 기능 목록이 아니라 액터를 세는 데서 출발한다 — 액터가 유스케이스를, 유스케이스가 컴포넌트 경계를 부른다.
- 유스케이스 하나는 컨트롤러·인터랙터·엔티티·프레젠터·뷰의 다섯 자리를 만들고, 그중 인터랙터·엔티티만 안쪽 원(웹·DB와 무관)이다.
- 지금 똑같은 두 유스케이스라도 소속 액터가 다르면 갈라 둔다 — 우발적 중복을 합치면 한 액터의 변경이 다른 화면을 흔든다.
- 클래스를 컴포넌트로 묶는 기준은 CCP(같은 이유로 바뀜)와 의존성 규칙(화살표는 안쪽)이며, 물리적 분리는 필요가 증명될 때 미뤄서 실체화한다.
다음장으로 34장