DRY 와 YAGNI 가 부딪히는 곳

쇼핑몰 코드에 배송비와 반품비를 계산하는 함수가 하나씩 있다고 해보자. 둘 다 주문 금액이 5만 원 이상이면 0원이고 그 아래면 3,000원이다. 코드도 거의 똑같다. 이걸 본 개발자는 자연스럽게 손이 간다. DRY 원칙대로 중복을 없애고 calculateFee 라는 함수 하나로 합친다. 코드 리뷰에서는 깔끔해졌다는 칭찬도 받는다.
반년이 지나면 그 함수는 이렇게 생겨 있다.
function calculateFee(order: Order, isReturn = false, isJeju = false, reason?: ReturnReason) {
let fee = order.total >= 50_000 ? 0 : 3_000;
if (isJeju && !isReturn) fee += 3_000;
if (isReturn && reason === "change-of-mind") fee *= 2;
return fee;
}제주 배송비가 붙었고 단순 변심 반품은 두 배를 받기로 했다. 요구사항이 생길 때마다 플래그가 하나씩 늘었다. 그리고 이 코드에는 아무도 모르는 버그가 하나 숨어 있다. 5만 원 이상 주문은 단순 변심으로 반품해도 0원의 두 배, 그러니까 0원이다. 무료 배송 기준을 같이 쓰다 보니 반품비까지 공짜가 됐다.
이번 포스팅에서는 이런 일이 왜 생기는지 DRY 와 YAGNI 라는 두 원칙을 따라가며 이야기해보려고 한다.
DRY 의 원래 문장
DRY(Don't Repeat Yourself)는 1999년 앤디 헌트와 데이브 토머스의 책 『실용주의 프로그래머』에서 나온 말이다. 책에 적힌 정의는 생각보다 길다.
모든 지식은 시스템 안에서 단 하나의, 모호하지 않고 권위 있는 표현을 가져야 한다.
여기서 눈여겨볼 낱말은 코드가 아니라 지식이다. 데이브 토머스는 2003년 인터뷰에서 대부분의 사람이 DRY 를 코드를 중복하지 말라는 뜻으로 받아들이지만 원래 의도는 그게 아니라고 말했다. 같은 지식이 두 군데 흩어져 있으면 한쪽을 고칠 때 다른 쪽을 잊는다. DRY 가 막으려는 건 그 상황이다.
이 기준으로 앞의 예제를 다시 보자. 배송비 규칙과 반품비 규칙은 다른 지식이다. 배송비는 물건을 보내는 비용에 대한 정책이다. 반품비는 고객이 돌려보낸 물건을 받는 비용이라 기준이 따로 움직인다. 처음에 금액이 같았던 건 우연이다. 우연히 같은 모양의 코드 두 벌을 합친 순간 두 정책은 한 함수 안에 묶여서 같이 움직이게 됐다.
잘못된 추상화가 자라는 순서
루비 개발자 샌디 메츠는 2016년 잘못된 추상화라는 글에서 이 과정을 순서대로 적었다.
- 개발자 A 가 중복을 보고 하나로 뽑아 이름을 붙인다.
- 시간이 지나 새 요구사항이 생긴다. 기존 추상화로 거의 되는데 조금 안 맞는다.
- 개발자 B 가 매개변수 하나와 조건문 하나를 추가한다.
- 요구사항이 또 생기고 매개변수와 조건문이 또 늘어난다.
- 코드가 아무도 이해할 수 없는 상태가 된다.
메츠는 마지막 단계에 대해 대략 이렇게 썼다. 당신이 이 이야기에 등장하는 건 대략 이쯤이다. 그리고 그때부터 인생이 꽤 고달파진다. 그리고 글 전체를 한 문장으로 요약했다.
중복은 잘못된 추상화보다 훨씬 싸다.
메츠가 권하는 해결책은 의외로 뒤로 가는 것이다. 추상화된 코드를 호출하는 곳마다 다시 풀어 넣고 각자에게 필요 없는 부분을 지운다. 중복을 일부러 되살리는 셈이다. 앞의 예제라면 calculateFee 를 calculateShippingFee 와 calculateReturnFee 로 되돌리고 각자 자기 규칙만 남기면 된다. 반품비가 무료 배송 기준을 따라 공짜가 되는 버그도 그 과정에서 저절로 드러난다.
YAGNI 와 해적 보험
YAGNI(You Aren't Gonna Need It)는 익스트림 프로그래밍에서 나온 말로, 지금 필요하지 않은 기능은 만들지 말라는 원칙이다. 마틴 파울러는 2015년 YAGNI 글에서 재미있는 예를 든다. 반지의 제왕에 나오는 도시 미나스 티리스에서 해상 운송 보험을 파는 스타트업이 폭풍 위험 요금을 만들고 있다. 팀은 반년 뒤에 필요할 해적 위험 요금도 이참에 같이 만들어 둘지 고민한다.
파울러는 이렇게 미리 만드는 기능에 드는 비용을 네 가지로 나눈다.
| 비용 | 내용 |
|---|---|
| 만드는 비용 | 결국 필요 없어질 기능을 분석하고 만들고 테스트하는 데 쓴 노력 |
| 늦어지는 비용 | 그 시간에 먼저 내보낼 수 있었던 기능을 늦게 내서 잃은 가치 |
| 들고 다니는 비용 | 코드가 복잡해져서 다른 기능을 고치고 디버깅하는 일이 느려지는 비용 |
| 고치는 비용 | 막상 필요해졌을 때 예상과 달라서 다시 고치거나 우회하는 비용 |
반년 뒤의 해적 요금이 지금 예상한 모양 그대로일 가능성은 생각보다 낮다. 그 사이에 해적이 어떻게 움직이는지, 보험사가 무엇을 기준으로 값을 매길지 더 알게 되기 때문이다. 그래서 미리 만든 해적 요금은 대부분 고치는 비용까지 치르게 된다.
파울러는 같은 글에서 YAGNI 가 리팩터링에는 적용되지 않는다고 분명히 적었다. 코드를 고치기 쉽게 다듬는 일은 미래의 기능을 미리 만드는 일과 다르다는 것이다.
두 원칙이 만나는 곳
여기까지 오면 앞의 calculateFee 가 다시 보인다. 똑같이 생긴 함수 두 개를 하나로 합치는 건 겉으로는 DRY 를 지키는 일이다. 그런데 속을 들여다보면 배송비와 반품비가 앞으로도 같이 바뀔 거라는 예측 위에 공통 요금 계산기라는 기능을 미리 하나 만든 일이다. YAGNI 가 경고한 해적 요금과 같은 종류의 코드다.
그래서 합칠지 말지를 정할 때는 코드 모양보다 이 질문이 더 쓸모 있다. 이 두 코드는 같은 이유로 바뀌는가. 배송비 정책이 바뀌면 반품비도 반드시 같이 바뀌어야 한다면 둘은 같은 지식이고 합치는 게 맞다. 한쪽만 바뀌는 날이 올 수 있다면 지금 모양이 같아도 두 벌로 두는 편이 싸다.
확신이 서지 않을 때 쓸 만한 경험칙도 있다. 마틴 파울러가 『리팩터링』에서 돈 로버츠에게 들었다며 소개한 3의 법칙이다. 처음에는 그냥 만든다. 두 번째로 비슷한 걸 만들 때는 중복이 거슬려도 일단 그대로 둔다. 세 번째가 되면 그때 리팩터링한다. 세 번쯤 보고 나면 무엇이 진짜 같고 무엇이 우연히 닮았는지 판단할 재료가 생긴다.
다음에 닮은 함수 두 개를 합치고 싶어지면 둘 중 하나의 요구사항이 바뀌는 장면을 먼저 떠올려 보자. 다른 쪽도 같이 바뀌어야 한다면 합치면 된다. 그렇지 않다면 반년 뒤 플래그 네 개짜리 함수를 만날 동료를 위해 두 벌로 남겨 두자.