thenullpage.com

주문 금액에서 할인을 빼는 코드를 예로 든다. 회원 등급이 VIP면 5퍼센트를 깎고, 쿠폰을 쓰면 2000원을 더 깎는다.


type Order = { total: number; grade: "normal" | "vip"; coupon: boolean };

function discount(o: Order): number {
let d = 0;
if (o.grade === "vip") d += o.total * 0.05;
if (o.coupon) d += 2000;
return Math.round(d);
}

const order: Order = { total: 50000, grade: "vip", coupon: true };
console.log(discount(order)); // 4500


5만 원의 5퍼센트인 2500원에 쿠폰 2000원이 붙어 4500이 찍힌다. 규칙이 둘뿐이라 읽는 데 걸리는 시간도 몇 초다. 이 코드가 나빠지는 과정은 규칙이 늘어나는 속도가 아니라 늘어날 때 무슨 일이 벌어지는지를 봐야 보인다.

고치는 일과 늘리는 일

다음 달에 첫 구매 3000원 할인이 생기고, 그다음 주에 주말 2퍼센트가 붙는다. 그때마다 discount 함수를 열어 if를 한 줄씩 더한다. 동작은 한다. 문제는 이 함수가 매번 열린다는 사실 자체에 있다.


이미 돌아가던 코드를 다시 여는 일에는 값이 붙는다. 지난달에 통과한 등급 할인 테스트가 다시 의심 대상이 된다. 리뷰하는 사람도 새로 붙은 세 줄만 보는 것이 아니라 함수 전체가 여전히 맞는지 다시 읽어야 한다. 두 사람이 각자 다른 프로모션을 같은 주에 작업하면 같은 함수의 같은 자리에서 충돌한다. 새 기능은 하나인데 위험은 파일 전체에 퍼진다.


확장에는 열려 있고 수정에는 닫혀 있어야 한다는 원칙을 개방-폐쇄 원칙(Open-Closed Principle, OCP)이라 한다. 기능을 늘릴 때 기존 코드를 고치지 않고 새 코드를 더하는 것만으로 끝나는 구조를 가리킨다. 닫혀 있다는 말은 아무것도 못 고친다는 뜻이 아니다. 남이 규칙을 하나 추가할 때 내가 쓴 파일이 열리지 않는다는 뜻이다.


판단 기준은 간단하다. 새 기능 하나를 넣는 커밋에서 바뀐 파일 목록을 본다. 새로 만든 파일 하나와 그것을 등록하는 한 줄뿐이면 닫힌 구조다. 이미 있던 함수 본문이 계속 등장하면 열린 구조다.

규칙을 목록으로 받는다

규칙 하나를 객체 하나로 만들고, 계산하는 쪽은 그 객체들의 목록만 받는다.


interface Rule {
apply(o: Order): number;
}

class GradeRule implements Rule {
apply(o: Order) { return o.grade === "vip" ? o.total * 0.05 : 0; }
}

class CouponRule implements Rule {
apply(o: Order) { return o.coupon ? 2000 : 0; }
}

class Discount {
constructor(private rules: Rule[]) {}
of(o: Order) {
let sum = 0;
for (const r of this.rules) sum += r.apply(o);
return Math.round(sum);
}
}

const basic = new Discount([new GradeRule(), new CouponRule()]);
console.log(basic.of(order)); // 4500


결과는 앞과 같은 4500이다. 달라진 것은 주말 할인을 붙일 때 벌어지는 일이다.


class WeekendRule implements Rule {
constructor(private weekend: boolean) {}
apply(o: Order) { return this.weekend ? o.total * 0.02 : 0; }
}

const weekend = new Discount([new GradeRule(), new CouponRule(), new WeekendRule(true)]);
console.log(weekend.of(order)); // 5500


Discount 클래스도, GradeRule도, CouponRule도 한 글자도 건드리지 않았다. 새 파일 하나를 쓰고 목록에 이름을 얹었을 뿐이다. 주말 할인에 버그가 있어도 등급 할인은 지난달과 똑같은 코드로 남아 있으니 의심할 필요가 없다. 프로모션이 끝날 때도 목록에서 한 줄을 빼면 끝난다.


계산하는 쪽이 규칙의 개수도 종류도 모른다는 점이 핵심이다. of는 목록을 돌며 더할 뿐이라 규칙이 세 개든 서른 개든 코드가 같다. 모르기 때문에 고칠 이유가 없는 것이다.

확장점은 미리 만들지 않는다

이 구조를 배운 다음에 흔히 하는 실수가 처음부터 이렇게 짜는 것이다. 규칙이 등급 하나뿐인데 인터페이스를 만들고 클래스를 만들고 목록을 만든다. 파일 세 개를 열어야 5퍼센트라는 숫자에 닿는다. 규칙이 늘어난 적도 없는데 늘어날 것에 대비한 비용만 지불한 상태다. 오지 않은 요구에 미리 뼈대를 세워 두는 것을 투기적 일반화(Speculative Generality)라 한다.


기준은 두 번째다. 규칙이 하나면 if 한 줄로 둔다. 두 번째 규칙이 같은 모양으로 들어오는 순간, 세 번째도 온다고 보고 그때 구조를 만든다. 처음 코드에서 등급과 쿠폰 두 줄이 나란히 붙은 시점이 바로 그 순간이었다.


어디를 열어 둘지는 짐작이 아니라 기록으로 정한다. 지난 반년 동안 이 파일이 어떤 이유로 열렸는지 세어 본다. 프로모션 때문에 열두 번 열렸다면 프로모션 축은 열어 둘 값어치가 있다. 세금 계산 방식 때문에 열린 적이 한 번도 없다면 세금 쪽에 확장점을 파는 것은 낭비다. 모든 축을 다 열어 둔 코드는 어느 축으로도 읽히지 않는다.

닫히는 축은 하나뿐이다

정직하게 말하면 이 구조가 모든 변경을 막아 주지는 않는다. 이번 달 누적 구매액이 10만 원을 넘으면 추가 할인을 준다는 규칙이 들어온다고 하자. 주문 하나만 봐서는 알 수 없는 값이라 인터페이스가 바뀐다.


interface Rule {
apply(o: Order, monthlySpent: number): number;
}


이 한 줄을 고치는 순간 GradeRule도 CouponRule도 WeekendRule도 전부 열린다. 쓰지도 않는 인자를 받도록 시그니처를 맞춰야 하기 때문이다. 닫아 둔 축은 규칙의 개수였지 규칙이 필요로 하는 정보의 종류가 아니었다. 예측한 방향으로 오는 변경만 새 파일로 흡수되고, 다른 방향으로 오는 변경은 그대로 관통한다.


목록을 만드는 자리도 마찬가지다. 어떤 규칙을 넣을지 결정하는 코드는 규칙이 늘 때마다 반드시 바뀐다. 완전히 닫힌 코드는 없고, 바뀌는 자리를 한 군데로 몰아 두는 것이 가능한 최선이다. 그 한 군데가 어디인지 팀이 아는 것 자체가 이 구조의 값이다.


덧붙여 이 예제는 할인들이 서로 독립적으로 더해진다는 전제 위에 서 있다. 할인 총액에 상한이 걸리거나 등급 할인 뒤에 쿠폰이 곱해지는 식으로 규칙끼리 순서를 따지기 시작하면 단순한 합산 구조는 맞지 않는다. 그때는 목록을 도는 방식 자체를 다시 설계해야 한다. 원칙을 지켰다고 해서 도메인이 요구하는 계산 순서까지 저절로 풀리지는 않는다.