애플리케이션을 만드는 진짜 이유는 화면도 데이터베이스도 아니다. 그것들이 없어도 존재할 수 있는 무언가 — 돈을 벌거나 아끼게 해 주는 규칙, 그 규칙을 컴퓨터로 옮기지 않았다면 사람이 손으로라도 지켰을 규칙이 있다. 마틴은 이것을 업무 규칙(business rules) 이라 부르고, 시스템에서 가장 높은 수준에 있는 이 규칙을 나머지 모든 것으로부터 지켜 내는 것이 아키텍처의 목적이라고 못박는다. 이 장이 던지는 질문은 하나다 — 무엇이 진짜 업무 규칙이고, 그것을 어디에 두어야 UI와 DB의 변덕에 휘둘리지 않는가.
컴퓨터가 없어도 존재하는 규칙
은행이 대출에 N%의 이자를 물린다고 하자. 이 규칙은 은행이 종이 장부와 주판으로 일하던 시절에도 존재했다. 대출 잔액에 이율을 곱해 이자를 매기는 계산은 자바로 짜든 엑셀로 하든 손으로 하든 동일하다. 이렇게 컴퓨터가 없어도 성립하는, 사업 그 자체인 규칙을 마틴은 핵심 업무 규칙(Critical Business Rule)이라 부른다. 그리고 이런 규칙은 대개 자신이 다루는 데이터를 필요로 한다 — 대출 잔액, 이율, 지급 일정 같은 것. 이 데이터 역시 컴퓨터가 없어도 존재하므로 핵심 업무 데이터(Critical Business Data) 다.
규칙과 데이터가 이토록 밀접하게 얽혀 있으니, 둘을 한 객체로 묶는 것이 자연스럽다. 마틴은 이 객체를 엔티티(Entity) 라 부른다. 엔티티는 핵심 업무 데이터를 품고, 그 데이터에 대해 동작하는 핵심 업무 규칙을 메서드로 갖는다. 중요한 것은 이 엔티티가 데이터베이스, 사용자 인터페이스, 프레임워크 같은 것을 하나도 몰라야 한다는 점이다. 엔티티는 오직 사업의 언어로만 말한다. 대출은 이자를 계산할 줄 알아야 하지만, 자신이 어느 테이블에 저장되는지, 어느 화면에 그려지는지는 알 이유가 없다.
판단 기준: 어떤 규칙이 핵심 업무 규칙인지 알려면 “이 사업을 컴퓨터 없이 한다면 이 규칙이 여전히 필요한가”를 물어라 — 그렇다면 엔티티에 속하고, 화면이나 저장 방식이 바뀌어도 살아남아야 한다. 함정: “화면에 금액을 두 자리로 반올림한다” 같은 규칙을 핵심 규칙으로 착각하기 쉽다 — 그것은 표현의 규칙일 뿐, 사업이 아니라 UI에 속한다.
유스케이스 — 엔티티를 언제 어떻게 쓰는지
그런데 모든 업무 규칙이 컴퓨터 없이도 성립하는 것은 아니다. “매월 말일에 이번 달 이자를 계산해 청구서를 만들어 고객에게 보낸다” 같은 규칙은 자동화된 시스템이 있어야 의미가 생긴다. 이런 규칙은 애플리케이션이 어떻게 동작하는지를 정의하며, 마틴은 이것을 유스케이스(use case) 라 부른다.
유스케이스와 엔티티의 관계를 정확히 잡는 것이 이 장의 핵심이다. 유스케이스는 엔티티를 언제, 어떤 순서로 사용할지를 지시하는 규칙이다. 이자 계산이라는 핵심 규칙 자체는 대출 엔티티가 안다. 유스케이스가 하는 일은 그 규칙을 호출하는 절차를 짜는 것이다 — 어떤 입력을 받고, 어떤 엔티티의 어떤 규칙을 부르고, 그 결과를 어떻게 되돌려 줄지. 그래서 유스케이스는 애플리케이션에 특화된(application-specific) 규칙이다. 같은 대출 엔티티라도, 콜센터용 시스템과 모바일 앱은 서로 다른 유스케이스로 그것을 사용할 수 있다.
여기서 마틴의 가장 중요한 방향 규정이 나온다 — 엔티티는 유스케이스를 모른다. 유스케이스가 엔티티를 알지, 그 반대가 아니다. 이유는 수준(level) 때문이다. 엔티티는 여러 애플리케이션에서 재사용될 수 있는 더 일반적이고 더 높은 수준의 개념이고, 유스케이스는 특정 애플리케이션에 매인 더 낮은 수준의 개념이다. 소스 의존성은 낮은 수준에서 높은 수준을 향해야 하므로, 유스케이스가 엔티티에 의존하고 엔티티는 유스케이스로부터 독립한다.
판단 기준: 어떤 규칙이 엔티티인지 유스케이스인지 헷갈리면 “이 규칙이 다른 애플리케이션에서도 그대로 통하는가”를 물어라 — 그대로 통하면 엔티티(고수준), 이 애플리케이션에서만 말이 되면 유스케이스(저수준)다. 함정: 흔히들 유스케이스를 더 “중심적”이라 여겨 엔티티가 유스케이스를 참조하게 만든다 — 그 순간 재사용 가능한 핵심 규칙이 특정 애플리케이션에 묶여 함께 죽는다.
요청·응답 모델은 어디에도 매이지 않는다
유스케이스는 입력을 받아 출력을 낸다. 그러나 그 입력이 HTTP 요청이거나 출력이 HTML이어서는 안 된다. 유스케이스가 웹 요청 객체를 직접 받으면, 웹이라는 전달 메커니즘이 애플리케이션 규칙 안으로 새어 들어온다. 나중에 이 시스템을 배치 작업이나 콘솔에서 돌리려 하면 유스케이스를 통째로 고쳐야 한다.
그래서 유스케이스는 자신만의 요청 모델(request model) 과 응답 모델(response model) 이라는 단순한 데이터 구조로 소통한다. 이 구조체들은 어떤 프레임워크도, 어떤 엔티티도, 어떤 UI 형식도 참조하지 않는 순수한 데이터 꾸러미다. 웹 계층이 HTTP 요청을 요청 모델로 번역해 유스케이스에 건네고, 유스케이스가 돌려준 응답 모델을 다시 화면용으로 번역한다. 이 번역 지점이 경계가 된다.
주의할 함정 하나 — 응답 모델을 엔티티로 대신하고 싶은 유혹이다. 대출 엔티티를 그대로 응답으로 돌려주면 코드가 짧아 보인다. 그러나 그렇게 하면 UI나 컨트롤러가 엔티티에 의존하게 되고, 엔티티의 사소한 변경이 화면까지 파급된다. 엔티티와 요청·응답 모델은 우연히 같은 필드를 가질 수 있어도 다른 이유로 변한다 — 하나는 사업이 바뀌어서, 하나는 화면이 바뀌어서. 다른 이유로 변하는 것을 하나로 묶지 않는 것, 그것이 이 분리의 값이다.
판단 기준: 유스케이스의 입출력 타입을 정할 때 “이 타입이 웹·DB·프레임워크의 이름을 하나라도 참조하는가”를 확인하라 — 참조하면 그 세부가 애플리케이션 규칙을 오염시킨 것이다. 함정: 엔티티를 응답 모델로 재활용해 중복을 줄이려는 시도는 서로 다른 변경 축을 하나로 묶어, 나중에 훨씬 비싼 결합으로 되갚는다.
규칙이 UI와 DB에 뒤섞인 코드를 풀어낸다
말로 세운 구분을 코드로 짚어 보자. 아래 스텝은 은행 대출 이자 청구를 다룬다. 처음엔 이자 계산이라는 핵심 규칙이 SQL과 콘솔 출력 사이에 뭉개져 있다. 여기서 대출 엔티티를 세워 규칙을 제자리로 돌리고, 다시 “이자를 청구한다”는 절차를 유스케이스 인터랙터로 뽑아내면서, 각 조각이 어떤 수준에 앉는지를 따라간다.
public class LoanController { public void chargeInterest(HttpServletRequest req) { String loanId = req.getParameter("loanId"); // 저장 세부 — JDBC가 그대로 노출된다 Connection conn = DriverManager.getConnection(DB_URL); ResultSet rs = conn.createStatement() .executeQuery("SELECT balance, rate FROM loans WHERE id=" + loanId); rs.next(); double balance = rs.getDouble("balance"); double rate = rs.getDouble("rate"); // ← 진짜 핵심 업무 규칙이 여기 한 줄로 숨어 있다 double interest = balance * rate / 12.0; double newBalance = balance + interest; conn.createStatement() .executeUpdate("UPDATE loans SET balance=" + newBalance + " WHERE id=" + loanId); // 화면 세부 — 출력 형식이 규칙과 뒤엉킨다 System.out.println("이자 " + String.format("%.2f", interest) + "원이 청구되었습니다."); }}// 컴퓨터 없이도 성립할 규칙(balance * rate / 12)이 HTTP·JDBC·콘솔에 포위됐다.// DB를 바꾸거나 출력을 웹으로 바꾸면, 사업의 심장인 이자 계산이 함께 흔들린다.
무엇이 달라졌나. 첫 단계에서 이자 계산은 SQL 문자열과 System.out.println 사이에 낀 한 줄이었다 — 사업의 심장이 전달 세부에 인질로 잡혀 있었다. 마지막 단계에서 그 규칙은 Loan.monthlyInterest()로 올라가 어떤 세부도 모른 채 홀로 선다. 그 위에 ChargeInterestInteractor가 “불러오고, 계산하고, 저장하고, 응답한다”는 절차를 짜지만, 이 인터랙터조차 HTTP나 JDBC의 이름을 모른다 — 요청·응답 모델과 게이트웨이 인터페이스를 통해서만 바깥과 만난다. 엔티티는 가장 안쪽에서 유스케이스를 모른 채 앉아 있고, 유스케이스는 엔티티에 의존하되 전달 세부로부터는 독립한다.
판단 기준: 엔티티 클래스의 import 목록을 보라 — 표준 타입과 다른 엔티티만 있으면 옳고, 웹·DB·프레임워크 타입이 하나라도 끼면 규칙이 세부로 새어 내려간 것이다. 함정: 인터랙터에서 loan을 그대로 반환해 응답 모델 만드는 수고를 아끼고 싶어진다 — 그러면 컨트롤러가 엔티티에 결합되어, 사업 규칙의 변경이 화면까지 번지는 문을 다시 연다.
엔티티와 유스케이스는 섞이려 한다
이 분리를 실제로 유지하기는 생각보다 어렵다. 코드를 짜다 보면 엔티티에 애플리케이션 편의 메서드를 하나둘 붙이게 되고(“이 화면에서 필요하니까 Loan에 toDisplayString()을 넣자”), 반대로 유스케이스에 핵심 계산을 슬쩍 옮기게 된다(“한 줄인데 굳이 엔티티까지 갈 것 있나”). 두 방향의 오염 모두 수준의 경계를 무너뜨린다. 전자는 UI 관심사를 고수준 엔티티에 끌어들이고, 후자는 재사용돼야 할 핵심 규칙을 특정 유스케이스에 가둔다.
마틴이 이 둘을 굳이 다른 객체로 갈라 두라고 강조하는 이유가 여기 있다. 데이터가 겹치고 이름이 비슷해 하나로 합치고 싶은 순간이 계속 오지만, 둘은 서로 다른 속도와 서로 다른 이유로 변한다. 엔티티는 사업의 본질이 바뀔 때(이자 산정 방식 자체가 바뀔 때) 변하고, 유스케이스는 애플리케이션의 절차가 바뀔 때(청구 시점이나 승인 흐름이 바뀔 때) 변한다. 다른 이유로 변하는 것을 분리해 두는 것이 결국 변경 비용을 낮춘다.
판단 기준: 엔티티에 메서드를 추가하려 할 때 “이 메서드가 특정 화면·특정 흐름 때문에 필요한가”를 물어라 — 그렇다면 그것은 유스케이스의 일이지 엔티티의 일이 아니다. 함정: 둘의 필드가 겹친다는 이유로 합치면 잠깐 단순해 보이지만, 서로 다른 변경 축을 한 클래스에 묶어 두 종류의 변경이 서로를 깨뜨리게 만든다.
정리하면 이렇다.
- 핵심 업무 규칙과 그것이 다루는 핵심 업무 데이터는 컴퓨터가 없어도 존재하는 사업의 본질이며, 이 둘을 묶은 엔티티가 시스템에서 가장 높은 수준에 앉는다.
- 유스케이스는 엔티티를 언제·어떻게 쓸지 지시하는 애플리케이션 특화 규칙이다. 유스케이스가 엔티티에 의존하지, 엔티티는 유스케이스를 모른다.
- 유스케이스는 프레임워크·엔티티·UI를 참조하지 않는 요청·응답 모델로만 바깥과 소통한다. 엔티티를 응답으로 재활용하면 화면이 엔티티에 결합된다.
- 엔티티와 유스케이스는 필드가 겹쳐 자꾸 합쳐지려 하지만, 서로 다른 이유로 변하므로 갈라 두어야 변경 비용이 낮게 유지된다.
이 장은 시스템의 가장 안쪽 두 겹 — 엔티티와 유스케이스 — 을 세우고 그 사이의 방향을 못박았다. 다음 장은 이렇게 세운 업무 규칙이 아키텍처의 표면에서 어떻게 “드러나야” 하는지, 즉 시스템의 구조가 프레임워크가 아니라 유스케이스를 소리쳐야 한다는 이야기로 이어진다.
다음장으로 21장