대출 갈아타기 계산기, 리팩토링 회고
변경 이유가 다르면 층을 나눈다
1편에서 2년차에 한 화면짜리 계산기를 설계한 이야기를 썼다. 이 글은 3년 뒤 리드를 맡아, 내가 주니어 때 만든 그 화면이 10종으로 늘어난 코드베이스를 재설계한 이야기다.
- 계산기를 합치는 게 목적이 아니었다. 검증 자산을 하나로 만드는 게 목적이었다.
- 단위 테스트를 붙일 수 없는 구조에서 테스트 전략은 e2e로 도망간다. 테스트가 어렵다는 건 구조가 보내는 증상이다.
- 폴더 구조는 곧 아키텍처다. FSD가 향하는 곳은 결국 높은 응집도와 낮은 결합도다.
- 정책은 상수가 아니라 시행일자를 가진 데이터다. 법정최고금리는 20%가 아니라 “2021년 7월 7일부터 20%“다.
0. 리팩토링이라는 일에 대해
백로그에서 가장 오래 사는 티켓
어느 팀의 백로그에나 “리팩토링”이라는 티켓이 하나쯤 있다. 대체로 그 티켓은 오래 산다. 스프린트마다 우선순위에서 밀리다가, 분기가 바뀌면 새 티켓으로 다시 태어난다.
나는 이걸 조직의 게으름이라고 생각하지 않는다. 구조적으로 그럴 수밖에 없다. 개발자에게 유명한 책 ’리팩토링’의 저자 마틴 파울러의 정의를 빌리면 리팩토링은 겉으로 보이는 동작을 바꾸지 않으면서 내부 구조를 개선하는 일이다. 동작을 바꾸지 않는다는 조건이 정의 안에 들어 있으니, 잘 끝나도 사용자와 지표에는 아무 일도 일어나지 않는다. 성과 보고서에 쓸 문장이 “아무 일도 일어나지 않았습니다”인 일을 조직이 앞장서서 시키기는 어렵다.
그래서 리팩토링은 시켜서 시작되는 일이 아니라고 생각한다. 이 코드를 오래 들여다본 사람, 여기 이 중복이 언젠가 사고를 낸다는 걸 아는 사람, 새 기능을 붙일 때마다 어디서 반나절씩 새는지 몸으로 아는 사람. 코드에 그만큼의 애정이 있는 사람만 시작할 수 있다.
다만 애정만으로 밀어붙이면 취미가 된다. 조직의 시간을 쓰는 일에는 명분이 필요하다. 워드 커닝햄이 기술 부채라는 은유를 만든 것도 이 지점을 설명하기 위해서였다고 생각한다. 빚 자체는 죄가 아니다. 빨리 내야 할 때 빚을 내는 건 합리적인 결정이다. 문제는 이자를 계속 내면서 원금 갚을 계획이 없는 상태다. 리팩토링은 원금 상환이고, 상환 제안은 모두가 이자를 체감하는 순간에 해야 통과된다.
5년차에 리드를 맡고 가장 먼저 한 일 중 하나가 이 프로젝트의 코드 정리였다. 애정은 전부터 있었다. 처음 만든 게 나였으니까.
1. 계산기가 갑자기 중요해졌다
리드를 맡았을 때 팀이 운영하는 계산기는 10종을 넘어 있었다. 1편의 대출 갈아타기 계산기에서 시작해 대출 이자 계산기, 청년도약계좌 계산기, 여윳돈 계산기 같은 것들이 붙어 있었다. 상당수가 정부 정책과 묶여 있어서 정책이 발표되면 그에 맞는 계산기를 빠르게 내야 했다. 청년도약계좌처럼 언론 보도가 나가는 날 검색량이 튀는 상품은, 바로 그날 계산기가 있어야 그 트래픽을 받을 수 있다.
중요도를 끌어올린 결정적 계기는 데이터였다. 계산기로 자기 숫자를 확인한 사용자가 상품 신청까지 이어지는 비율이 그렇지 않은 사용자보다 뚜렷하게 높다는 게 확인됐다. 계산기가 유입용 콘텐츠에서 전환 퍼널의 핵심 구간으로 승격된 것이다. 그때부터 요구는 한 방향으로 정리됐다. 더 많이, 더 빨리.
여기엔 정면으로 충돌하는 제약이 둘 있다. 빨리 내야 하고, 동시에 고객은 계산 오류에 극도로 민감하다. 돈 문제라 이미 화가 난 상태로 문의가 들어온다. 빨리 낼수록 검증되지 않은 산술이 늘어난다.
실제로 문제가 터지고 있었다. 계산 오류 장애가 월 5건, 관련 고객 문의가 월 20건을 넘었다. CX 팀이 먼저 지쳤다. 기능이 안 되는 것보다 숫자가 틀리는 쪽이 훨씬 세게 항의를 받는다.
원인을 찾아보니 계산식 자체가 틀린 경우는 드물었다. 같은 계산식이 10벌 있었던 게 문제였다.
flowchart TB
subgraph BEFORE["재설계 전"]
direction LR
C1["갈아타기 계산기<br/>· 반올림 규칙<br/>· 이자 공식<br/>· 세율 15.4%<br/>· 최고금리 20%"]
C2["대출이자 계산기<br/>· 반올림 규칙<br/>· 이자 공식<br/>· 세율 15.4%<br/>· 최고금리 24%"]
C3["적금 계산기<br/>· 반올림 규칙<br/>· 복리 공식<br/>· 세율 14%"]
C4["… 7종 더"]
end
POLICY["정책 변경<br/>(세율 · 한도)"] -->|"고쳐야 할 곳 10군데"| C1
POLICY --> C2
POLICY --> C3
POLICY --> C4
style POLICY fill:#fee,stroke:#a55
style C2 fill:#fdd,stroke:#c33
style C3 fill:#fdd,stroke:#c33
세율이 바뀌면 10곳을 고쳐야 하는데 7곳만 고쳐진다. 나머지 3곳은 다음 분기에 고객이 찾아준다. 최고금리처럼 시기에 따라 값이 달라지는 것들은 어디는 옛날 값이, 어디는 새 값이 박혀 있었다.
이건 개인의 실수가 아니라 구조가 요구하는 실수다. 열 군데를 빠짐없이 고치는 일을 사람의 주의력에 맡겨두면 언젠가 반드시 빠진다.
변명을 하자면, 처음 설계할 때는 계산기가 이렇게 늘어날 줄 몰랐다. 계산기 한 종을 잘 만들기 위한 설계였고 그 안의 결정들은 지금도 유효하다고 생각한다. 새 계산기가 필요할 때마다 기존 것을 복제해서 고치는 게 가장 빠른 방법이었고, 열 번째 복제까지는 실제로 빨랐다. 청구서는 그다음에 왔다.
2. 테스트가 먼저 백기를 들었다
장애가 반복될 때의 정석은 안전망부터 치는 것이다. 레거시 코드를 다루는 순서로 못 박은 그대로, 먼저 테스트를 두르고 그다음에 고친다. 그래서 리팩토링에 앞서 테스트 구축부터 시작했다.
시작하자마자 막혔다. 단위 테스트를 붙일 자리가 없었다.
계산식이 컴포넌트와 훅 안에 흩어져 있었다. 월납입금 공식 하나를 검증하려면 슬라이더를 렌더하고 드래그 이벤트를 시뮬레이션한 다음 화면에 뜬 문자열을 파싱해야 했다. 코드를 고치지 않고도 동작을 관찰하거나 대체할 수 있는 지점을 이음새(seam)라고 부르는데, 이 코드베이스의 이음새는 브라우저 하나뿐이었다.
이음새가 브라우저뿐이면 테스트 전략은 e2e로 도망갈 수밖에 없다. 실제로 그렇게 됐다. 계산기 10종에 상환방식과 경계값 조합을 곱하니 e2e 스위트가 CI에서 수십 분을 돌았고, 타이밍 문제로 수시로 깨졌고, 깨져도 계산이 틀린 건지 셀렉터가 바뀐 건지 알 수 없었다. 숫자 하나를 검증하는 데 렌더링과 네트워크와 애니메이션이 전부 딸려 들어오니 당연한 결과다.
테스트 피라미드는 아래가 넓은 그림이다. 단위 테스트가 두텁게 깔리고 e2e는 꼭대기에 얇게 얹힌다. 우리는 정확히 뒤집혀 있었다. 이 모양을 아이스크림 콘 안티패턴이라고 부른다는 건 나중에 알았다. 이름이 붙어 있다는 건 우리만 겪는 일이 아니라는 뜻이다.
여기서 진단이 섰다. 테스트 도구를 바꾸거나 e2e를 더 얹어서 해결될 일이 아니었다. 순수 함수로 존재할 수 있는 계산이 UI에 눌어붙어 있는 것이 문제의 뿌리였고, 테스트는 그 증상을 가장 먼저 보여줬을 뿐이다.
그래서 경영진에게 이 일을 해야할 때 “리팩토링”이라는 단어를 쓰지 않았다. 산출물이 안 보이는 단어라 설득에 불리하다. 대신 다음 계산기부터 출시 기간을 절반으로 줄이고, 계산 오류 장애를 구조적으로 끝내는 투자. 같은 일의 다른 이름이지만 조직이 살 수 있는 이름은 후자다.
어려운 변경을 해야 한다면 먼저 그 변경이 쉬워지도록 만들고(이게 어려울 수 있다), 그다음 쉬운 변경을 하라. 우리에게 필요한 변경은 계산기를 빠르고 정확하게 늘리는 것이었고, 지금 구조에서 그 변경은 어려웠다. 그러니 구조부터.
3. 통합이 답이라고 말하기 전에
이 문제를 “계산 로직을 공통 모듈로 합치자”로 접근하면 반드시 이 질문을 받는다. 실제로 설계 리뷰에서 처음 나온 질문이었다.
합치면 한 번 틀렸을 때 10개가 다 틀리는 거 아닌가요?
맞는 지적이다. 합치기만 하면 장애의 폭발 반경만 키운다. 이 질문에 답할 수 있어야 설계가 성립한다.
답은 통합 그 자체가 아니라 그 뒤에 있다. 검증을 한곳에 모을 수 있기 때문에 합치는 것이다. 흩어져 있으면 열 벌을 각각 검증해야 하고, 현실적으로 그건 아무도 안 한다. 한곳에 모이면 한 번 검증한 것이 열 종을 동시에 지킨다.
그래서 목표를 이렇게 다시 썼다. 계산기를 합치는 게 아니라, 검증 자산을 하나로 만든다. 2절에서 무너졌던 테스트 전략도 이 문장 안에 들어온다. 검증이 모일 자리가 생겨야 단위 테스트가 생기고, 단위 테스트가 계산을 지켜줘야 e2e가 살을 뺀다. 이렇게 두면 그다음 질문이 자연스럽게 따라온다. 무엇을 합쳐야 검증이 모이는가.
4. 변경 이유가 다르면 층을 나눈다
공통 폐쇄 원칙
무엇을 합칠지 고르는 기준으로 처음 떠올린 건 “무엇이 같은가”였다. 이자 계산이니까 이자 계산끼리, 세금 계산이니까 세금 계산끼리.
이 기준으로 나누면 한 파일 안에 변경 주기가 전혀 다른 것들이 함께 앉는다. 세율은 정부 발표마다 바뀌고 복리 공식은 몇 년째 그대로인데, 둘이 같은 파일에 있으면 세율 한 줄을 고칠 때마다 검증된 공식 파일의 diff가 생긴다. 리뷰어는 매번 공식까지 다시 봐야 하고, 몇 번 반복되면 안 보게 된다.
그래서 기준을 바꿨다. 무엇이 같은 이유로 바뀌는가.
나중에 알았지만 이 기준에는 이름이 있다. 로버트 마틴이 공통 폐쇄 원칙(Common Closure Principle)이라고 부르는 것으로, 같은 이유로 같은 시점에 바뀌는 것들을 하나의 컴포넌트로 모으고 다른 이유로 바뀌는 것들을 분리하라는 원칙이다. 단일 책임 원칙의 “책임”이라는 모호한 단어를 “변경 이유”로 바꿔 읽으면 같은 이야기가 된다.
| 층 | 내용 | 변경 주기 | 여기에 붙는 것 |
|---|---|---|---|
| 연산 | 돈 반올림, 이율 양자화, 달력, 복리 원시연산 | 거의 안 바뀜 | 검증. 여기가 10종을 동시에 지킨다 |
| 현금흐름 | 대출 상환 3종, 예적금 적립 방식 | 상품이 늘 때만 | 상품별 불변식 |
| 정책 | 세율, 법정최고금리, 한도, 우대·감면 | 정부 발표마다 | 시행일자를 가진 데이터 |
flowchart TB
subgraph L3["정책 층 · 발표마다 바뀐다"]
P1["세율"]
P2["법정최고금리"]
P3["한도 · 우대"]
end
subgraph L2["현금흐름 층 · 상품이 늘 때만"]
F1["원리금균등"]
F2["만기일시"]
F3["원금균등"]
F4["단리 · 복리 적립"]
end
subgraph L1["연산 층 · 거의 안 바뀐다"]
O1["돈 반올림"]
O2["이율 양자화"]
O3["달력 · 기간"]
O4["복리 원시연산"]
end
L2 --> L1
L3 -.->|"값으로 주입"| L2
VERIFY["검증 자산<br/>불변식 · 골든값 · 전수검증"] ==> L1
style L1 fill:#eef,stroke:#557
style L3 fill:#fee,stroke:#a55
style VERIFY fill:#efe,stroke:#5a5
가장 자주 바뀌는 것과 가장 정확해야 하는 것이 서로 닿지 않게 한다. 이 한 줄이 전부다.
판별 기준도 정했다. 연산 층에 상품 이름이 등장하면 그건 연산 층이 아니다. 복리 계산 함수가 “대출”이나 “적금”을 알면 그건 이미 현금흐름 층으로 내려간 것이다.
이 기준 덕분에 초기에 피한 실수가 하나 있다. 계산기들을 공통 인터페이스로 묶으려는 시도다.
interface Calculator<I, O> {
calculate(input: I): O
}이름만 같은 껍데기라 아무것도 공유되지 않는다. 적금과 대출은 입력도 출력도 다르고, 이 인터페이스를 만족시켜봐야 실제로 재사용되는 코드는 한 줄도 없다. 공유는 인터페이스 단위가 아니라 함수 단위로 해야 했다.
Schedule이라는 타입 하나로 대출 상환과 예적금 적립을 함께 묶으려는 유혹도 있었다. 세전과 세후, 단리와 복리, 월납과 일시가 상환방식 3종과 같은 추상에 들어가면 옵션 플래그가 폭발한다. 샌디 메츠가 이 상황에 정확한 말을 남겼다. 잘못된 추상화보다 중복이 훨씬 싸다. 중복은 나중에 걷어낼 수 있지만, 잘못된 추상화는 그 위에 쌓인 코드 전부가 인질이 된다. 참는 게 맞았다.
5. 층을 폴더로 옮긴다: FSD
폴더 구조가 곧 아키텍처다
기준을 정했으면 코드가 놓일 자리를 정해야 한다. 우리 팀은 폴더 구조로 FSD(Feature-Sliced Design)를 도입하고 있었다. FSD 자체는 이미 따로 정리한 이전 글에서 자세히 다뤘고 사내에서도 여러 번 발표한 주제라, 여기서는 왜 이 프로젝트에 FSD였는지만 쓴다.
짧게 요약하면 FSD는 코드를 수평의 레이어(app → pages → widgets → features → entities → shared)와 수직의 슬라이스(비즈니스 단위)로 가른다. 규칙은 사실상 둘이다. 상위 레이어만 하위 레이어를 import할 수 있다. 그리고 같은 레이어의 슬라이스끼리는 서로를 모르는 게 기본이다. FSD 공식 문서는 @x 표기로 슬라이스 간 교차 참조를 예외로 허용하는데, 이 프로젝트는 그 예외 없이 완전히 격리하기로 했다.
FSD는 디자인 패턴 하나가 아니고, 클린 아키텍처의 프론트엔드 번역판도 아니다. 저 두 규칙이 강제하는 건 결국 높은 응집도(함께 바뀌는 것은 한 슬라이스에 모인다)와 낮은 결합도(슬라이스는 공개 API로만 만난다)다. 그리고 그 규칙이 폴더에 새겨져 있어서 폴더 구조가 곧 아키텍처가 된다. import 문이 어느 폴더에서 어느 폴더로 향하는지만 보면 아키텍처 위반 여부를 알 수 있다.
FSD는 구조가 오히려 복잡해진다는 말을 자주 듣는다. 절반만 동의한다. 공식 가이드도 모든 레이어를 요구하지 않는다. app과 shared, pages 정도로 시작해서 필요해질 때 레이어를 늘리면 되고, 작은 구조에서도 그만큼 가볍게 시작할 수 있다. components/, hooks/ 같은 기술 역할 폴더로 시작하는 것도 토이 프로젝트나 작은 규모에서는 나쁘지 않다. 다만 그건 편의의 선택이다. 이 코드가 어느 레이어, 어느 슬라이스에 속하는가를 매번 묻는 노력이 프로덕트에도 개인에게도 더 많은 것을 남긴다고 생각한다. 그 질문이 사실 4절의 질문, 그러니까 이 코드는 무엇 때문에 바뀌는가와 같은 질문이기 때문이다. 프로덕트마다 갈리는 건 도입 여부라기보다 어느 수준까지 강제할 것인가다.
이 프로젝트에서 FSD가 특히 잘 맞았던 건, 4절에서 나눈 세 층이 FSD 레이어로 그대로 떨어졌기 때문이다. 연산 층은 shared/lib/finance에, 현금흐름 층은 entities/loan에, 정책 층은 entities/policy에 놓았고, 계산기 한 종은 pages/ 슬라이스 하나가 됐다. 실제 폴더 구조로 보면 이렇다.
src/
├─ app/ # 라우팅, 프로바이더. 조립만 하고 로직은 없다
├─ pages/
│ ├─ loan-change/ # 계산기 한 종 = 슬라이스 하나
│ ├─ loan-interest/
│ └─ youth-account/
├─ widgets/
│ ├─ loan-card-list/ # 보유 대출 카드 리스트
│ └─ result-compare/ # 결과 비교 블록
├─ features/
│ ├─ simulate-terms/ # 금리·기간 슬라이더 조작
│ └─ register-loan/ # 대출 등록 폼
├─ entities/
│ ├─ loan/ # 현금흐름 층: 상환 3종, 대출 모델
│ └─ policy/ # 정책 층: 시행일자를 가진 데이터
└─ shared/
├─ lib/finance/ # 연산 층: 반올림·양자화·달력·복리
└─ ui/ # 도메인을 모르는 UI경계가 잘 잡혔는지 확인하는 값싼 사고 실험이 있다. 계산기 한 종을 서비스에서 내린다고 상상하는 것이다. pages/loan-interest 슬라이스를 지우고 라우트 한 줄을 지우면 끝나야 한다. 지우는데 다른 폴더에 손이 가면 경계가 새고 있는 것이다. 잘 지워지는 구조는 잘 늘어나는 구조이기도 해서, 11번째 계산기는 슬라이스 하나를 추가하는 일이 된다.
4절에서 필요하다고 결론 낸 경계 두 개, 그러니까 상품을 모르는 연산 코어와 계산기끼리의 상호 격리가 FSD에서는 관례가 아니라 규칙이다. shared는 정의상 비즈니스를 모르고, 같은 레이어의 슬라이스는 이 프로젝트 규칙상 서로를 import할 수 없다. FSD 공식 문서는 @x 표기로 슬라이스 간 교차 참조를 허용하지만, 이 프로젝트는 그런 교차 참조 없이 완전히 격리하기로 했다. 우리가 설계로 도달한 지점에 FSD는 이미 이름을 붙여두고 있었다.
문제는 폴더 구조가 그림일 뿐이라는 데 있다. 층을 나눠놓아도 시간이 지나면 섞인다. 급한 수정이 들어오고, 한 줄이면 되는데 하며 import를 추가하고, 리뷰에서 그걸 잡을 사람이 그날 휴가다.
그래서 경계를 린트로 막았다. FSD 생태계에는 공식 린터(steiger)도 있는데, 우리는 프로젝트 고유 규칙까지 한곳에 얹으려고 ESLint로 직접 구성했다.
의존은 안쪽으로만 흐른다. app → pages → widgets/features → entities → shared 순으로 흐르고, shared/lib/finance가 entities나 pages를 거꾸로 부르는 경로는 린트로 막았다. FSD의 레이어 규칙이 강제하는 방향이고, 클린 아키텍처의 의존성 규칙(Dependency Rule)과 같은 방향이기도 하다. shared/lib/finance는 바깥에 무엇이 있는지 모르는 가장 안쪽이고, 여기서 바깥을 부르는 경로는 전부 막혀 있다.
여기서 실무적으로 걸린 게 하나 있다. no-restricted-imports의 문자열 패턴만으로는 부족하다. 이 규칙은 import 문에 적힌 글자를 보기 때문에, shared/lib/finance/x.ts에서 '../../../entities/loan/y'라고 쓰면 그냥 통과한다. 경로를 실제로 해석하는 import/no-restricted-paths가 있어야 표기와 무관하게 잡힌다.
'import/no-restricted-paths': ['error', {
zones: [
{ target: './shared/lib/finance', from: './entities/loan',
message: '연산 층은 어떤 상품을 계산하는지 모릅니다.' },
{ target: './shared/lib/finance', from: './entities/policy',
message: '연산 층은 정책을 모릅니다. 정책은 바깥 층이 조회해 값으로 넘기세요.' },
// 같은 레이어의 슬라이스는 서로를 모름 (FSD 슬라이스 격리). 복붙 재사용을 막음
{ target: './pages/loan-change', from: './pages/loan-interest' },
{ target: './pages/loan-interest', from: './pages/loan-change' },
],
}]메시지를 길게 쓴 이유가 있다. 규칙에 걸린 사람이 알아야 하는 건 “금지됨”이 아니라 “그럼 어떻게 하라는 것인가”다. 여기서 안내를 안 하면 다음 수단은 규칙 우회다.
아키텍처 결정을 문서에 적는 것과 실행되는 검사로 두는 것은 다른 일이다. 진화적 아키텍처 쪽 용어로는 이런 검사를 적합성 함수(fitness function)라고 부른다. 문서는 어긋나도 조용하지만, 검사는 CI에서 소리를 낸다.
5.1 규칙을 적는 것과 규칙이 발화하는 것은 다르다
린트 규칙에는 조용히 죽는 실패 모드가 있다. 리졸버 설정이 빠지거나 files 글롭이 어긋나면 ESLint는 에러를 내지 않고 그냥 아무것도 안 잡는다. 그 순간부터 “린트로 막고 있다”는 문장은 조용히 거짓이 된다.
그래서 규칙이 실제로 발화하는지를 테스트로 고정했다.
it('연산 층은 entities/loan을 import할 수 없다 (상대경로로 우회해도)', async () => {
const messages = await lint(
'shared/lib/finance/__probe__.ts',
"import type { ValidatedTerms } from '../../../entities/loan/terms'\n…",
)
expect(ruleIds(messages)).toContain('import/no-restricted-paths')
})위반하는 코드를 만들어 린트에 흘리고 그게 잡히는지 확인한다. 프로브는 디스크에 쓰지 않고 메모리로만 흘린다. 실제 파일을 만들면 병렬로 도는 다른 테스트가 그 파일을 훑어서 오탐한다.
테스트로 두면 매 실행 확인된다.
5.2 이름으로 들어오는 결합은 린트가 못 막는다
의존 방향 린트에는 구멍이 하나 있다. import를 막을 뿐이지, 연산 층 안에서 상품 개념을 이름으로 들여오는 건 막지 못한다. calculateLoanPayment 같은 함수가 shared/lib/finance에 생겨도 린트는 조용하다.
두 번째 계산기가 대출 이자 계산기라 이 구멍이 더 위험했다. 둘 다 대출이라 상품 다양성만으로는 코어의 상품 무지를 증명할 수 없다.
그래서 소스를 AST로 훑어 식별자와 문자열 리터럴을 검사하는 테스트를 뒀다.
const FORBIDDEN =
/(loan|mortgage|repayment|installment|amortiz|savings|deposit|refinanc|maturit|대출|상환|적금|예금|만기|갈아타기)/i주석은 일부러 검사하지 않는다. 정규식으로 파일 텍스트를 통째로 훑으면 한국어 주석에 전부 걸리고, 그러면 이 코드베이스에서 가장 값나가는 자산인 설명 주석이 위축된다. 결합은 이름과 값에 생기지 산문에 생기지 않는다.
실제로 result.ts의 주석에는 “만기일”이라는 단어가 있다. 실패 이유의 어휘를 각 층이 정해야 한다는 설명을 하려면 예시가 필요하기 때문이다. 검사 대상을 식별자와 문자열로 한정한 덕에 이런 설명을 마음 놓고 쓸 수 있다.
6. 연산 층: 반올림이 일어나는 유일한 지점
연산 층은 상품을 모른다. 아는 건 숫자를 어떻게 다루는지뿐이다.
6.1 돈과 금리를 만드는 경로를 하나로 좁힌다
돈은 원 단위로 확정되는 순간이 있고, 그 순간이 코드베이스에 하나만 있어야 한다.
/** 원 단위로 확정된 금액. 실수로 우회하는 경로는 컴파일 타임에 잡힌다 */
export type Won = number & { readonly __brand: 'Won' }
export function toWon(x: number): Won {
if (!Number.isFinite(x)) throw new Error(`toWon: 유한하지 않은 값 ${x}`)
return Math.round(Number(x.toPrecision(15))) as Won
}브랜드 타입을 쓰면 Won을 실수로 우회해서 만드는 경로가 컴파일 타임에 잡힌다. 어딘가에서 Math.round를 따로 불러 반환하는 코드가 생기면 타입이 안 맞아서 컴파일이 안 된다.
toPrecision(15)의 15는 임의로 고른 자릿수가 아니다. double의 유효자릿수가 15~17자리인데, 그중 안전한 하한을 잡은 것이다.
이 접근에는 이름이 있다. Parse, don’t validate라는 글로 정리한 원칙이다. 값이 올바른지 쓰는 곳마다 검사하는 대신, 경계에서 한 번 파싱해 그 사실을 타입에 새긴다. 안쪽 코드는 Won을 받았다는 사실만으로 검증이 끝났음을 안다. 검사를 빠뜨렸는지 리뷰어가 기억할 필요가 없다. 1편 6.3절에서 “리뷰어의 기억이 아니라 타입이 막아야 한다”고 썼는데, 그 약속을 이행하는 도구가 이것이다.
금리도 같은 처리를 했다. 처음엔 돈에만 브랜드를 달고 금리는 그냥 number로 뒀는데, 슬라이더의 step=0.1이 만드는 12.299999999999999 같은 값이 그대로 URL로 새어나갔다. 화면에서는 toFixed(1)로 가려지니 테스트로는 안 잡히고 주소창에서만 드러난다.
/** Rate를 만드는 유일한 경로. 입력은 0.1%p 단위 정수(tenths). */
export function toRate(tenths: number): Rate {
if (!Number.isInteger(tenths)) {
throw new Error(`toRate: tenths는 정수여야 한다 (받은 값: ${tenths})`)
}
return (tenths / 10) as Rate
}슬라이더는 내부적으로 정수만 다룬다. min=10, max=200, step=1로 두고 0.1%p 단위로 세면 누적 오차가 생길 자리가 없다.
6.2 부동소수점이 실제로 1원을 만든다
이 층에서 가장 오래 걸린 문제다. 처음 진단은 틀렸다.
기존 계산기에서 검산이 안 맞는 케이스가 두 개 있었다. 3천만원을 15.5%로 1개월 빌린 경우와, 3천만원을 11.5%로 60개월 빌린 경우다. 화면에서는 둘 다 “1원이 안 맞는다”로 똑같이 보였다. 그래서 반올림 정책이 통일되지 않은 문제라고 결론 내렸다.
유리수 연산으로 정확값을 구해서 대조해보니 원인이 갈렸다.
| 케이스 | 표시된 월납입금 | 표시된 총이자 | 월납입금으로 검산하면 | 차이 |
|---|---|---|---|---|
| A (3천만 / 15.5% / 1개월) | 30,387,499 | 387,500 | 387,499 | 1원 |
| B (3천만 / 11.5% / 60개월) | 659,778 | 9,586,693 | 9,586,680 | 13원 |
A는 표시된 월납입금 자체가 틀렸다. 정확값은 30,387,500이고 총이자 387,500은 맞다. 정확 연산으로 계산하면 차이가 사라진다.
B는 월납입금 659,778이 정확값이다. 그런데 총이자를 반올림 전 원시값으로 계산해서 표시하는 바람에, 화면에 뜬 월납입금으로 검산하면 13원이 안 맞는다. 정확 연산으로 바꿔도 이 차이는 그대로 남는다.
이 표의 수치는 재설계 전, 기존 계산기가 실제로 표시하던 값이다. 같은 케이스의 총이자는 재설계 후 최종회차 흡수 정책이 적용되면서 8.1절에서 9,586,698로 다시 등장한다.
A는 부동소수점 문제고 B는 반올림 정책 문제다. 화면에서 증상이 같아서 하나로 묶어버릴 뻔했다.
A에서 무슨 일이 일어나는지 보면 이렇다. 원리금균등 공식의 교과서 형태는 분모에 (1+i)ⁿ − 1이 있는데, n=1이면 이게 (1+i) − 1이 된다. 1을 더했다가 다시 빼는 동안 i의 유효숫자가 날아간다.
i = 0.0129166666666666665741 (참값)
(1+i) − 1 = 0.0129166666666666873908 ← 상대오차 +1.6e-15
A_raw = 30387499.999999951571 ← 정확값은 30,387,500상대오차 1.6e-15는 아무것도 아닌 것처럼 보인다. 그런데 결과가 x.99999995가 되고 여기에 내림을 적용하면 1원이 작아진다. 파국적 소거(catastrophic cancellation)의 교과서적인 사례다.
규모를 확인해봤다. 잔액을 만원 단위로, 금리를 0.1% 단위로 두고 n=1을 전수 대입하면 정확값이 정수로 떨어지는 지점이 41.5%다. 그중 절반에서 내림 결과가 1원 작아진다. 사용자가 실제로 넣는 값이 정확히 그 지점에 몰려 있다. 처음 발견한 케이스가 곧바로 걸린 게 우연이 아니었다.
해법은 소거가 일어나지 않는 항등식으로 바꾸는 것이다.
/**
* 매기 같은 금액을 n회 낼 때의 1회 금액 (반올림 전).
*
* A = P·i / (1 − (1+i)⁻ⁿ) 로 쓴다. 교과서 형태 P·i(1+i)ⁿ/((1+i)ⁿ−1) 은
* 분모에서 n=1일 때 (1+i)−1 이 i의 유효숫자를 잃는다.
* i=0이면 이 식 대신 예외로 Math.floor를 적용해 내림으로 확정한다.
*/
export function levelPaymentRaw(P: number, i: number, n: number): number {
if (i === 0) return Math.floor(P / n)
if (n === 1) return P * (1 + i)
return P * i / (-Math.expm1(-n * Math.log1p(i)))
}expm1과 log1p는 정확히 이 소거를 피하려고 존재하는 함수다. Math.log1p(i)는 Math.log(1 + i)와 수학적으로 같지만, i가 작을 때 1 + i에서 잃어버리는 자릿수를 잃지 않는다.
여기에 두 가지 분기를 뒀다. n=1은 대수적으로 그냥 P(1+i)라서 따로 계산하면 오차가 0이 된다. i=0은 내림한다. 반올림하면 1회 금액이 실제보다 커져서 마지막 회차 전에 원금을 다 갚아버리고, 그 초과분이 나중에 “0%인데 붙은 이자”로 나타난다.
효과를 전수 검증으로 확인했다. 잔액 1만원부터 100억까지, 금리 1.0%부터 20.0%까지, 기간 1개월부터 120개월까지, 이 범위를 촘촘한 격자로 전수 대입해 검증했다.
| 전략 | 정확값과 다른 횟수 |
|---|---|
교과서 공식 + Math.round |
7,659 |
수치 안정 공식 + Math.round |
상당히 감소 |
수치 안정 공식 + toPrecision(15) |
3 (잔액 84억 초과 구간에서만) |
잔액 20억 이하 전 구간에서 0이다. 실제 서비스에서 다룰 범위는 전부 여기 들어온다.
decimal.js 같은 임의정밀도 라이브러리는 쓰지 않기로 했다. 근거를 처음에는 “정밀도는 원인이 아니다”로 잡았는데 위에서 봤듯 그건 틀렸다. 정정한 근거는 이렇다. 정밀도 문제는 수치적으로 안정한 공식으로 해결되고, 그 편이 번들 크기와 계산 비용 없이 더 정확하다. 게다가 decimal.js의 pow()도 근사라 완전히 정확하지 않다.
6.3 두 경로로 계산하면 어긋날 수 있다
상환 결과를 요약값으로 낼지 회차별 스케줄로 낼지 고민했다. 스케줄 생성기를 근본 연산으로 두고 요약을 파생시키자는 안이 있었고, 성능을 위해 요약값과 스케줄을 각각 별도의 계산 경로로 두자는 안까지 나왔다.
문제는 같은 개념을 두 경로에서 각각 계산하면, 결국 두 결과가 어긋날 가능성이 생긴다는 것이다.
예를 들어 요약 화면에서 “대출 종료 시점의 잔액”을 직접 공식으로 계산하고, 상세 화면에서는 1회차부터 마지막 회차까지 스케줄을 생성한 뒤 마지막 회차의 잔액을 사용한다고 하자.
겉으로는 같은 계산처럼 보이지만 실제 구현은 다음처럼 달라질 수 있다.
요약 계산
입력값
↓
잔액 공식
↓
최종 잔액
스케줄 계산
입력값
↓
1회차 이자 계산
↓
1회차 잔액 계산
↓
2회차 이자 계산
↓
2회차 잔액 계산
↓
...
↓
마지막 회차 잔액특히 금융 계산에서는 회차별 반올림 여부, 계산 순서, 중간값의 정밀도, 마지막 회차 처리 방식이 조금만 달라도 결과가 달라질 수 있다. 한쪽에서는 중간 계산을 반올림하고 다른 쪽에서는 마지막에만 반올림한다면, 각각의 계산은 개별적으로는 그럴듯해도 최종 결과는 달라진다.
실제로 기존 구현을 비교했을 때 최대 201원까지 차이가 났고, 비교한 케이스의 96%에서 두 경로의 결과가 일치하지 않았다. 이는 단순한 부동소수점 오차 하나의 문제가 아니라, 동일한 금융 개념을 서로 다른 계산 경로에서 구현했을 때 구조적으로 결과가 달라질 수 있다는 것을 보여준다.
우리가 기존 구현의 결함이라고 지적했던 것도 정확히 “같은 개념을 두 경로로 계산한다”는 것이었다. 그런데 성능을 이유로 새 설계에서 다시 같은 구조를 만들 뻔했다.
따라서 계산 경로를 둘로 나누는 대신, 상환 계산의 핵심이 되는 수학적 정의를 하나만 두기로 했다. 요약값이든 스케줄이든 동일한 계산 규칙을 사용해야 하며, 어느 화면에서 계산하느냐에 따라 금융 결과가 달라져서는 안 된다.
닫힌형식 하나만 남긴다
상환 잔액은 회차를 하나씩 순회하지 않고 닫힌형식으로 계산한다. 즉, k회차까지의 결과를 구하기 위해 1회차부터 k회차까지 반복 계산하지 않고, 시작 원금 P, 이율 i, 회차 k, 납입액 payment만으로 해당 시점의 잔액을 직접 구한다.
/** P에서 시작해 매기 payment씩 k회 낸 뒤 남은 잔액 (반올림 전). */
export function balanceAfter(P: number, i: number, k: number, payment: number): number {
if (i === 0) return P - payment * k
const qMinus1 = growthMinus1(i, k)
return P * (1 + qMinus1) - payment * qMinus1 / i
}여기서 중요한 것은 단순히 “반복문을 없앴다”는 것이 아니다. 같은 금융 개념을 계산하는 공식 자체를 하나로 고정했다는 데 의미가 있다.
balanceAfter(P, i, k, payment)가 “k회 납입 후 잔액”의 기준이 되면, 요약값을 계산할 때도 이 정의를 사용하고, 필요한 시점의 잔액을 구할 때도 동일한 정의를 사용할 수 있다. 이렇게 하면 요약 계산과 스케줄 계산이 서로 다른 수식을 갖는 문제를 피할 수 있다. 이 함수는 회차마다 원본 입력값 P, i, payment에서 잔액을 독립적으로 다시 계산하기 때문에, 이전 회차의 반올림된 결과를 다음 회차에 그대로 넘기는 구조가 아니다. 그래서 회차를 거듭할수록 반올림 오차가 누적되는 문제 자체가 생기지 않는다.
다만 스케줄 자체가 필요하다면 회차별 데이터를 생성하는 작업은 여전히 필요하다. 여기서 없애려는 것은 “스케줄 생성”이 아니라 “같은 금융 결과를 서로 다른 계산 로직으로 다시 구현하는 것”이다.
성능 때문에 계산 경로를 분리할 필요가 있었는가
처음에는 스케줄 전체를 생성하는 비용이 크다고 생각했다. 요약값만 필요한 상황에서도 수백 개의 회차 객체를 만들게 되면, 모바일 환경이나 사용자가 입력값을 빠르게 변경하는 상황에서 성능 문제가 생길 수 있다고 판단했다.
그래서 요약값은 닫힌형식으로 직접 계산하고, 상세 화면에서만 스케줄을 생성하는 식으로 아키텍처를 분리하는 방안이 나왔다.
하지만 이 판단은 실제 측정 없이 세운 가정이었다.
따라서 먼저 실제 실행 환경에서 스케줄 생성 비용을 측정했다. 측정 대상은 단순히 함수의 실행 시간만이 아니라, 스케줄을 생성하면서 발생하는 객체 생성과 메모리 할당까지 포함했다.
측정 결과 당시 기준으로 스케줄 생성 과정에서 발생하는 단기 메모리 할당은 프레임당 약 77KB 수준이었다. 여기서 77KB는 “스케줄 데이터가 77KB의 메모리를 계속 점유한다”는 의미가 아니다. 계산 과정에서 생성된 객체와 배열 등이 일시적으로 할당하는 메모리이며, 계산 이후 더 이상 참조되지 않는 객체는 GC 대상이 된다.
또한 별도의 반복 벤치마크에서는 스케줄 생성에 필요한 객체 생성 자체가 초당 약 7만 객체 수준으로 처리되는 것을 확인했다.
이 두 숫자는 서로 다른 것을 측정한 값이다.
77KB는 한 번의 계산 과정에서 발생하는 단기적인 메모리 할당 규모를 확인하기 위한 값이다.초당 7만 객체는 반복 실행을 통해 객체 생성 자체가 어느 정도의 처리량을 갖는지 확인하기 위한 값이다.
따라서 “객체가 많이 만들어진다”는 사실만으로 스케줄 생성이 실제 성능 병목이라고 판단할 수 없었다. 중요한 것은 그 객체 생성이 실제 사용자 경험에 영향을 줄 정도의 CPU 시간이나 메모리 압력을 발생시키는지였다.
측정 결과 이 비용은 아키텍처를 두 개의 계산 경로로 분리해야 할 정도로 크지 않았다. 오히려 계산 경로를 분리하면 성능을 조금 아끼는 대신 동일한 금융 개념을 두 군데에서 구현해야 하고, 그 결과가 다시 어긋날 가능성이 생긴다.
따라서 이번에는 측정되지 않은 성능 우려를 근거로 구조를 복잡하게 만들지 않기로 했다.
계산 결과의 일관성을 보장하는 하나의 계산 경로를 우선하고, 실제 병목이 측정되었을 때만 별도의 최적화를 추가한다.
결국 설계의 기준은 다음과 같이 정리했다.
같은 금융 개념
↓
하나의 계산 정의
↓
┌─────────────┐
│ │
요약값 스케줄
│ │
└─────────────┘
성능 문제가 실제로 측정되기 전에는
계산 경로를 별도로 만들지 않는다.이번 결정에서 중요한 것은 “스케줄 생성이 항상 빠르다”는 것이 아니다. 중요한 것은 성능 문제를 추측으로 가정하지 않고 측정한 뒤, 그 결과를 기준으로 아키텍처를 결정했다는 것이다.
성능을 이유로 계산 경로를 분리하려면 먼저 실제 병목을 측정해야 한다. 그렇지 않으면 최적화하려던 비용보다 “같은 개념을 두 번 구현하고 결과를 동기화해야 하는 복잡성”이 더 큰 비용이 될 수 있다.
7. 정책을 코드에서 뺀다
층을 나눈 것만으로는 출시 속도가 빨라지지 않는다. 속도를 만든 건 정책 층이다.
7.1 20%는 값이 아니라 값의 이력이다
법정최고금리를 상수로 두면 이렇게 된다.
export const MAX_RATE = 20 // 법정최고금리이 코드에는 표현할 수 없는 사실이 하나 있다. 2021년 7월 7일 전에는 24%였다는 것이다. 그전에는 27.9%였다. 지금 실행 중인 대출 중에는 24% 시절에 나간 것들이 살아 있고, 그건 불법이 아니라 그때는 합법이었던 금리다.
상수로 두면 값을 바꾸는 순간 과거 계산이 조용히 틀린 답을 낸다. 사용자가 예전에 공유한 링크를 열면 그때와 다른 숫자가 나온다.
export interface Enacted<T> {
readonly effectiveFrom: DateOnly
readonly value: T
readonly source: string
}
/** 레코드가 비어 있을 수 없게 타입으로 막는다. 조회는 실패할 수 없어야 한다. */
export type EnactedHistory<T> = readonly [Enacted<T>, ...Enacted<T>[]]정책은 상수가 아니라 시행일자를 가진 데이터다. 이렇게 두면 값이 바뀌는 게 아니라 레코드가 추가된다. 회계나 인사 시스템 쪽에서 유효일자(effective dating)라고 부르는 오래된 패턴이기도 하다. 새로운 발명이 아니라는 점이 오히려 믿을 구석이다.
const CAPS: EnactedHistory<number> = [
{
effectiveFrom: { y: 2018, m: 2, d: 8 },
value: 240,
source: '이자제한법·대부업법 시행령 개정, 연 24% (2018-02-08 시행)',
},
{
effectiveFrom: { y: 2021, m: 7, d: 7 },
value: 200,
source: '이자제한법·대부업법 시행령 개정, 연 20% (2021-07-07 시행)',
},
]위 배열은 최근 두 번의 개정만 예시로 담았다. 27.9%처럼 그 이전 이력도 실제 정책 데이터에는 더 있지만 여기서는 지면상 생략했다. 이 예시 범위보다 이른 시점을 조회하면 effectiveAsOf는 가장 이른 레코드(2018년 24%)를 돌려주는데, 이건 7.2절에서 다룰 의도된 동작이다.
source를 필수 필드로 뒀다. 근거 없는 숫자는 추정치이고, 추정치가 정책 층에 있으면 안 된다. 이 필드가 비어 있으면 그 값은 다른 곳에 있어야 한다는 신호다.
값을 0.1%p 단위 정수로 저장한 건 계산기가 쓰는 격자와 맞추기 위해서다. 조회 결과를 다시 반올림하는 일이 생기면 거기서 또 오차가 붙는다.
7.2 조회는 실패하지 않는다
export function effectiveAsOf<T>(records: EnactedHistory<T>, asOf: DateOnly): Enacted<T> {
let best = records[0]
for (const record of records) {
// 최초 시행일보다 이른 시점: 가장 이른 레코드를 고른다.
if (compareDate(best.effectiveFrom, asOf) > 0) {
if (compareDate(record.effectiveFrom, best.effectiveFrom) < 0) best = record
continue
}
// 시행 중인 것 중 가장 늦은 것. 시행일 당일은 시행 중이다.
if (compareDate(record.effectiveFrom, asOf) <= 0
&& compareDate(record.effectiveFrom, best.effectiveFrom) > 0) {
best = record
}
}
return best
}이 함수에는 결정이 세 개 들어 있다.
반환 타입이 Enacted<T>지 Enacted<T> | null이 아니다. 정책 조회가 실패해서 계산기가 죽는 일은 없어야 한다. 사용자는 아주 오래된 링크를 열었을 뿐인데 빈 화면을 보는 건 우리 쪽 사정이다. 그래서 최초 시행일보다 이른 시점을 물으면 가장 이른 레코드를 돌려준다. 타입에서 EnactedHistory<T>를 비어 있을 수 없는 배열로 만든 것도 같은 이유다.
레코드가 정렬되어 있다고 가정하지 않는다. 새 정책을 배열 아무 데나 끼워 넣어도 동작한다. 정렬을 가정하면 언젠가 누가 위쪽에 추가하고 그날부터 조용히 틀린다.
시행일 당일은 시행 중으로 친다. 경계값을 어느 쪽으로 둘지는 취향이 아니라 법령 해석이고, 코드에 주석으로 남겨야 나중에 뒤집히지 않는다.
7.3 정책 층도 시계를 읽으면 안 된다
5절의 린트 스코프에 entities/policy를 함께 넣은 게 나중에 보니 중요했다.
files: ['shared/lib/finance/**/*.{ts,tsx}', 'entities/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': ['error',
{ selector: "NewExpression[callee.name='Date']",
message: '도메인 코드는 시각을 읽지 않는다. asOf를 인자로 받아라.' },
// …
],
}정책 조회 함수가 내부에서 new Date()를 부르면 “과거 시점 재현”이 즉시 거짓이 된다. 함수가 오늘을 보고 답하기 시작하면 asOf를 넘기는 의미가 없다. 시각을 읽는 곳은 서버 컴포넌트 한 군데로 몰고, 거기서 확정한 asOf를 아래로 흘려보낸다.
여기서 asOf는 렌더링 시점이 아니라 대출 실행일처럼 사용자 입력값이거나 공유 링크에 실려 전달되는 값이다. 서버 컴포넌트가 “확정한다”는 건 지금 시각을 읽는다는 뜻이 아니라, 이 값을 파싱하고 없으면 기본값을 채우는 절차라는 뜻이다.
확장자를 .ts로 한정하지 않고 .tsx까지 건 것도 같은 맥락이다. entities에 .tsx 파일 하나가 생기는 것만으로 이 규칙들이 통째로 침묵한다.
constants.ts 같은 파일명은 쓰지 않기로 했다. 내용을 알려주지 않는 쓰레기통 이름이고, 실제로 초기 설계에서 같은 숫자가 두 파일에 독립적으로 존재하는 구멍이 생긴 지점이 정확히 거기였다. 파일 이름은 lending-rate-cap.ts처럼 무엇이 들어 있는지 말해야 한다.
8. 검증을 연산 층에 모은다
층을 나눈 목적이 여기 있다. 검증이 한곳에 모여야 한 번 검증한 것이 열 종을 지킨다.
8.1 공통 불변식이 무너진 이야기
처음에 세운 불변식은 이거였다.
총이자 = 월납입금 × n − 원금전 상환방식에 공통으로 적용된다는 가정이었다. 검증해보니 즉시 무너진다.
| 방식 | 월납입금 | 불변식이 주는 값 | 실제 총이자 | 결과 |
|---|---|---|---|---|
| 원리금균등 | 659,778 | 9,586,680 | 9,586,698 | 정의상 성립(최종회차가 18원 흡수) |
| 만기일시 | 287,500 | −12,750,000 | 17,250,000 | 총이자가 음수 |
| 원금균등 | 787,500 | 17,250,000 | 8,768,750 | 1.9672배 과대 |
원리금균등 행의 두 값이 18원 차이 나는 건 오차가 아니라, 마지막 회차가 반올림 잔돈을 흡수하도록 설계했기 때문이다. 이 정책은 뒤에서 다시 설명한다.
만기일시는 매달 이자만 내고 원금은 만기에 한 번에 갚으니, 월납입금에 개월수를 곱해봐야 원금이 안 들어 있다. 거기서 원금을 빼면 음수가 된다.
원금균등은 매달 납입액이 줄어드는데 1회차를 상수처럼 취급해서 곱하니 과대계상된다. 배수가 정확히 2n/(n+1)인 게 재밌다. 60개월이면 120/61 = 1.9672다. 등차수열의 합을 첫항 × 항수로 계산했을 때 나오는 값이다.
그래서 불변식을 방식별로 갈랐다.
공통 (오차 0인 구조 불변식):
Σ 원금ₜ = P
잔액ₙ = 0
Σ 납입ₜ = Σ 이자ₜ + P
모든 출력이 정수, NaN·Infinity 미출력
원리금균등 전용:
총이자 = 1회차 × (n−1) + 최종회차 − 원금
총이자 ≥ 0, 무이자면 정확히 0
만기일시 전용:
총이자 = 월납입금 × n
원리금균등·원금균등 단조성 (원금이 상환되고 금리 > 0일 때):
금리↑ ⇒ 총이자↑
기간↑ ⇒ 월납입금↓ ∧ 총이자↑
(만기일시·무이자 구간은 제외한다: 총이자가 기간과 무관하게 고정되거나 0이다)총이자를 월납입금에서 유도하지 않고 상환방식이 직접 반환하게 바꿨다. 유도하면 반올림 오차가 n배로 증폭돼서 무이자 대출에도 가짜 이자가 몇 원 붙는다.
대신 최종회차가 반올림 잔돈을 흡수한다. 케이스 B로 확인하면 이렇다.
659,778 × 59 = 38,926,902
최종회차 + 659,796
───────────
39,586,698
원금 − 30,000,000
───────────
총이자 9,586,698화면에 뜨는 숫자만으로 검산이 성립한다. 무이자 대출은 이자가 정확히 0이 된다.
8.2 골든값을 두 벌로 나눈 이유
목표는 두 가지였다. 기존 구현의 실측값을 골든값으로 고정하는 것과, 화면 숫자로 검산이 성립하는 것. 이 둘은 동시에 만족할 수 없다.
| 구현 | 케이스 A | 케이스 B |
|---|---|---|
| 교과서 공식 + 반올림 + 유도 | 정확값과 다름 | 검산 안 맞음 |
| 교과서 공식 + 내림 + 유도 | 실측과 같음 | 검산 안 맞음 |
| 수치 안정 + 최종회차 흡수 | 실측과 1원 다름 | 검산 맞음 |
| 기존 구현 | 실측과 같음 | 실측과 같음 |
네 숫자를 다 맞추는 유일한 조합이 우리가 결함이라고 지적한 그 규칙이다. 그래서 골든값 파일을 두 개로 나눴다.
하나는 기존 구현의 관측값이다. 마이클 페더스가 특성화 테스트(characterization test)라고 부르는 물건으로, 목적이 정답 검증이 아니다. 현재 동작을 사진으로 찍어두고, 바뀌면 안 되는 것이 바뀌었을 때 감지하는 용도다. 파일 맨 위에 이 값들이 우리 구현의 목표가 아니라는 걸 적었다.
다른 하나는 우리 정책의 결과다. 케이스 A에서 기존과 1원 다르다는 사실과 그 이유를 파일에 문서화했다.
이걸 한 파일에 뒀다면 몇 달 뒤에 누군가 “테스트가 실패하네” 하며 값을 고쳤을 것이다. 두 벌로 나누고 각각의 존재 이유를 적어두면 그럴 일이 없다.
8.3 속성 테스트의 생성기 범위
속성 기반 테스트(property-based testing) 도구인 fast-check로 URL 왕복 속성을 테스트할 때 임의 float를 생성하면 거짓 실패가 난다. 15.500000000000002 같은 값은 도메인이 애초에 만들 수 없다. 6.1에서 toRate로 경로를 하나로 막았기 때문이다.
fc.integer({ min: 10, max: 200 }) // 0.1%p 단위 정수생성기를 실제 도달 가능한 값으로 제한한다. 이걸 안 하면 속성 테스트가 계속 빨간불을 내고, 몇 번 반복되면 “쓸모없다”는 결론으로 폐기된다. 좋은 도구가 잘못된 범위 때문에 버려지는 건 흔한 일이다.
도메인 테스트는 TZ=UTC와 TZ=Asia/Seoul 두 번 돌린다. 날짜 계산이 들어가는 코드에서 타임존은 한 번 잘못되면 특정 시간대에만 틀리는 종류의 버그를 만든다.
여기까지 오면 2절의 아이스크림 콘이 뒤집힌다. 숫자의 정확성은 연산 층의 단위 테스트가 밀리초 단위로 지키고, e2e에는 e2e만 검증할 수 있는 것들, 공유 링크 복원이나 화면 흐름 같은 것만 남긴다. 브라우저로 전수 조합을 돌리던 시절의 e2e는 계산 검증이라는 짐을 벗고 나서야 안정됐다.
9. 재사용을 증명하는 방법은 하나뿐이다
여기까지가 설계고, 이 설계가 실제로 작동하는지는 다른 문제다.
“재사용 가능하게 만들었다”는 진술은 검증되지 않는다. 두 번째 계산기를 실제로 붙여보기 전까지는 근거 없는 추상화와 구별할 방법이 없다. 그래서 대출 이자 계산기를 얇게 만들었다.
export function calculateInterest(input: InterestInput, asOf: DateOnly): InterestResult | null {
const terms = validateTerms(input, {
rate: interestRateRange(asOf),
term: INTEREST_TERM,
})
if (!terms.ok) return null
const s = summary(input.kind, terms.value)
return {
principal: terms.value.principal,
summary: s,
totalPayment: toWon(terms.value.principal + s.totalInterest),
}
}이게 두 번째 계산기가 도메인에 하는 일의 전부다. 자기만의 산술이 한 줄도 없다. 검증 경계도 상환 공식도 갈아타기 계산기와 같은 함수를 부른다. 다른 것은 그 문에 넘기는 규칙뿐이다.
flowchart TB
CH["갈아타기 계산기<br/>rate: 1~20%<br/>term: 12~120개월"]
IN["대출이자 계산기<br/>rate: 별도 범위<br/>term: 별도 범위"]
VAL["validateTerms(input, rules)<br/>검증 경계"]
SUM["summary(kind, terms)<br/>상환방식 3종"]
CORE["shared/lib/finance<br/>반올림 · 양자화 · 복리"]
CH --> VAL
IN --> VAL
VAL --> SUM
SUM --> CORE
CH -.->|"린트로 금지"| X(("✕"))
X --- IN
style CORE fill:#eef,stroke:#557
style X fill:#c33,color:#fff,stroke:#900
두 계산기가 서로를 import하지 못하게 린트로 막아뒀다. 이게 없으면 “재사용했다”가 사실은 “옆 계산기에서 함수를 하나 가져다 썼다”가 되고, 그러면 두 화면이 서로에게 묶인다. 공유가 필요하면 공통 층으로 올려야 한다.
공통 층에 올릴 때도 기준은 같다.
/**
* 상환방식의 화면 문구. 두 계산기가 공유한다.
*
* 수식과 같은 파일에 두지 않는 이유는 변경 이유가 다르기 때문이다. 상환 수식은
* 상품이 늘 때 바뀌고, 이 문구는 용어 통일·마케팅·로케일 추가 때 바뀐다.
* 한 파일에 두면 문구 하나를 고치는 일이 검증된 연산 파일의 diff를 만든다.
*/
export const REPAYMENT_LABELS: Record<RepaymentKind, string> = {
'equal-payment': '원리금균등상환',
'bullet': '만기일시상환',
'equal-principal': '원금균등상환',
}같은 개념을 다루는 두 조각이라도 바뀌는 이유가 다르면 파일을 나눈다. 마케팅에서 용어를 바꾸자고 할 때 그 PR에 복리 계산 파일이 딸려오면 안 된다.
두 번째 계산기를 붙이면서 공유 층 코드는 한 줄도 고치지 않았다. 이 문장이 “재사용 가능하게 설계했다”보다 훨씬 강하고, 반증도 가능하다. 고쳤으면 커밋에 남아 있을 것이다.
10. 돌아가는 10종을 안 깨고 옮기기
설계가 맞아도 이관에서 무너지면 아무 의미가 없다. 이미 사용자가 쓰고 있는 계산기 10종을 한 번에 갈아엎는 건 선택지가 아니었다.
접근은 마틴 파울러가 스트랭글러 무화과(Strangler Fig)라고 이름 붙인 패턴을 따랐다. 무화과나무가 숙주를 감고 자라다 어느 날 그 자리를 대신하듯, 돌아가는 시스템을 멈추지 않은 채 옆에 새 구조를 세우고 한 조각씩 옮겨 심는다.
순서는 이렇게 잡았다.
먼저 갈아타기 계산기 하나를 새 구조로 옮겼다. 가장 복잡하고 상태 관리가 까다로운 것을 먼저 골랐다. 여기서 안 되는 구조면 나머지도 안 된다.
옮기는 동안 기존 구현의 결과를 골든값으로 박아뒀다. 8.2에서 골든값을 두 벌로 나눈 게 여기서 값을 한다. 새 구현이 기존과 다른 숫자를 내면 그게 개선인지 회귀인지 판단해야 하는데, 두 파일을 나란히 두면 “의도한 차이”와 “의도하지 않은 차이”가 구분된다. 케이스 A의 1원은 의도한 차이고, 나머지가 전부 같은지는 테스트가 지킨다.
그다음 두 번째 계산기를 붙여서 공유 층이 실제로 재사용되는지 확인했다. 9절의 내용이다. 이게 통과한 뒤에야 나머지를 순차 이관했다.
정책 층은 이관과 별개로 먼저 뺐다. 이건 이관 순서와 무관하게 이득이 나오는 작업이라 앞에 두는 게 맞았다. 옛 구조로 돌아가는 계산기도 정책 조회는 새 모듈을 쓰게 하면 그날부터 “어디는 반영되고 어디는 안 됨”이 사라진다.
11. 닫는 글
지금 이 구조로 다시 만든 프로젝트에서 단위 테스트 307개가 돈다. 도메인 스위트는 타임존 두 개에서 각각 돌고, e2e는 공유 링크 복원과 리렌더 경계만 지킨다. 숫자 자체보다 중요한 건 이 테스트들이 붙어 있는 위치다. 대부분이 연산 층에 있어서 계산기가 몇 종이 되든 같은 테스트가 지킨다.
돌이켜보면 이 재설계에서 실제로 한 일은 하나였다. 코드를 나누는 기준을 바꾼 것이다.
1편에서 2년차의 나는 코드를 “무엇이 같은가”로 나눴다. 같은 대출 정보를 다루니까 한곳에, 같은 UI 상태니까 다른 곳에. 이 기준은 분류로서 깔끔하고 설명하기도 쉽다. 문제는 이 기준이 미래를 전혀 반영하지 않는다는 것이다.
“무엇이 같은 이유로 바뀌는가”로 나누면 파일 경계가 곧 변경 경계가 된다. 세율을 고치는 PR에는 세율 파일만 들어 있고, 복리 공식을 고치는 PR은 몇 년에 한 번 열린다. 리뷰어가 무엇을 봐야 하는지 diff가 알려준다. FSD는 그 기준을 폴더에 새겨 넣을 수 있게 해준 그릇이었다.
첫 설계가 틀렸던 건 아니라고 생각한다. 계산기가 하나뿐이던 시점에 연산 코어를 미리 분리했다면 그건 근거 없는 추상화였을 것이다. 요구사항이 변경 축을 늘렸고, 그때 구조도 같이 바뀌었어야 했다. 이상적인 시점은 아마 두 번째나 세 번째 계산기가 복제되던 무렵이었을 것이다. 우리는 3년 늦었다.
0절의 이야기로 돌아가서 끝내려 한다. 이 리팩토링도 성과로 잘 드러나는 종류의 일은 아니었다. 재설계가 끝난 뒤로 계산기는 회사에서 화제가 되지 않았다. 새 계산기가 나가는 날 아무도 긴장하지 않았고, 정책이 바뀌는 날 코드베이스를 전체 검색하는 사람도 없었다. 잘된 리팩토링이 남기는 건 이런 종류의 조용함이라, 보고서에는 여전히 쓸 문장이 마땅치 않다.
그래도 이 일은 주기적으로 해야 한다고 생각한다. 다음 코드베이스의 백로그 어딘가에도 오래 사는 티켓이 하나 있을 것이다. 아마 또 집어들 것이다.