thenullpage.com

배송비를 계산하는 코드를 예로 든다. 배송 방법은 일반, 빠른 배송, 편의점 수령 세 가지고 방법마다 요금이 다르다. 처음 짜는 사람이라면 대부분 이렇게 쓴다.


type Method = "standard" | "express" | "pickup";

function fee(method: Method, weight: number): number {
switch (method) {
case "standard": return 3000;
case "express": return 3000 + weight * 500;
case "pickup": return 0;
}
}

console.log(fee("express", 4)); // 5000


이 함수만 놓고 보면 흠잡을 데가 없다. 문제는 이 switch가 이 파일에만 있지 않다는 데서 시작한다.

같은 분기가 여러 벌 복사된다

주문서 화면에는 도착 예정일이 필요하고, 결제 내역에는 사람이 읽을 이름이 필요하다. 각각 다른 파일에 이런 함수가 생긴다.


function eta(method: Method): string {
switch (method) {
case "standard": return "3일";
case "express": return "1일";
case "pickup": return "2일";
}
}

function label(method: Method): string {
switch (method) {
case "standard": return "일반 배송";
case "express": return "빠른 배송";
case "pickup": return "편의점 수령";
}
}


여기서 새벽 배송을 추가한다고 하자. Method에 한 줄을 더한 순간 고쳐야 할 자리가 셋으로 늘어난다. 반환 타입을 number나 string으로 못 박아 두면 타입스크립트가 빠진 곳을 잡아 주지만, 파이썬이나 자바스크립트처럼 검사가 없는 곳이라면 label만 고치고 eta를 잊었을 때 화면에 undefined가 찍힌다. 그것도 새벽 배송을 고른 사용자에게만 찍힌다.


한 가지를 바꾸려고 여러 파일을 돌아다니며 같은 모양의 수정을 반복하는 상황을 산탄총 수술(shotgun surgery)이라 부른다. 원인은 분명하다. 배송 방법 하나에 관한 지식이 요금, 기간, 이름 세 조각으로 쪼개져 서로 다른 곳에 흩어져 있다.

분기를 객체 안으로 옮기기

갈라지는 기준이 종류라면 그 종류를 클래스로 만든다. 흩어져 있던 세 조각이 한 클래스 안에 모인다.


interface Shipping {
fee(weight: number): number;
eta(): string;
label(): string;
}

class Standard implements Shipping {
fee() { return 3000; }
eta() { return "3일"; }
label() { return "일반 배송"; }
}

class Express implements Shipping {
fee(weight: number) { return 3000 + weight * 500; }
eta() { return "1일"; }
label() { return "빠른 배송"; }
}

class Pickup implements Shipping {
fee() { return 0; }
eta() { return "2일"; }
label() { return "편의점 수령"; }
}


쓰는 쪽에는 switch가 남지 않는다.


function summary(s: Shipping, weight: number) {
return s.label() + " " + s.fee(weight) + "원, " + s.eta();
}

console.log(summary(new Express(), 4));
// 빠른 배송 5000원, 1일


새벽 배송을 추가하는 작업은 이제 파일 하나를 새로 만드는 일이다. summary도, 그 함수를 부르는 화면들도 열 필요가 없다.

조건문은 사라지지 않고 한 곳으로 모인다

정직하게 말하면 분기가 없어진 것은 아니다. 사용자가 고른 "express"라는 문자열을 객체로 바꾸는 자리는 여전히 필요하다.


const REGISTRY: Record<string, () => Shipping> = {
standard: () => new Standard(),
express: () => new Express(),
pickup: () => new Pickup(),
};

function create(code: string): Shipping {
const make = REGISTRY[code];
if (!make) throw new Error("알 수 없는 배송 방법: " + code);
return make();
}

console.log(summary(create("pickup"), 4));
// 편의점 수령 0원, 2일


달라진 것은 개수다. 세 벌이든 서른 벌이든 흩어져 있던 분기가 문자열이 객체로 바뀌는 입구 한 곳으로 모였다. 목표는 조건문을 지우는 것이 아니라 같은 기준으로 갈라지는 분기가 두 벌 이상 존재하지 않게 만드는 것이다.

널 검사도 같은 모양이다

쿠폰이 있을 수도 없을 수도 있는 계산을 보자.


function total(fee: number, coupon: Coupon | null) {
if (coupon === null) return fee;
return fee - coupon.discount(fee);
}


이 null 검사는 결제, 주문서 미리보기, 영수증에 똑같이 복사된다. 한 곳을 빠뜨리면 그 화면만 널 참조 오류로 죽는다. 갈라지는 기준이 쿠폰의 종류라는 점에서 앞의 switch와 다르지 않다. 없음도 종류 하나로 취급하면 된다.


class NoCoupon implements Coupon {
discount() { return 0; }
}

const coupon = user.coupon ?? new NoCoupon();
console.log(total(5000, coupon)); // 5000


total 안의 if가 사라지고 없음을 다루는 규칙이 클래스 한 곳에 적힌다. 이 방식을 널 객체(Null Object)라 한다. 다만 없음이 정상적인 상태일 때만 쓴다. 쿠폰이 없는 것은 흔한 일이지만 결제 수단이 없는 것은 사고다. 사고까지 0으로 덮으면 오류가 조용히 묻힌다.

조건문을 그대로 두어야 하는 자리

모든 if를 클래스로 바꾸려 들면 코드는 오히려 나빠진다. 판단은 세 가지를 확인하면 끝난다.


첫째, 갈라지는 기준이 종류인가 값인가. weight가 5를 넘으면 추가 요금 같은 검사는 종류가 아니라 값에 대한 판단이다. 무게 구간마다 클래스를 만들 이유는 없다. 둘째, 같은 기준의 분기가 두 곳 이상인가. 한 곳뿐이라면 switch 하나가 클래스 셋보다 읽기 쉽다. 셋째, 종류가 앞으로 늘어나는가. 요일 일곱 개처럼 더 늘 일이 없는 목록이면 굳이 옮기지 않는다.


반대 방향의 함정도 있다. 클래스로 나눠 두면 종류를 추가하기는 쉽지만 동작을 추가하기는 어려워진다. 배송 방법에 정산용 계정 코드를 붙여 달라는 요청이 오면 클래스 세 개를 전부 열어야 한다. 다형성은 종류가 늘어나는 쪽에 강하고 동작이 늘어나는 쪽에는 불리하다. 어느 축이 더 자주 흔들리는지를 보고 고르는 것이지, 조건문이 보인다고 무조건 옮기는 것이 아니다.


그래서 switch를 만나면 지울 궁리부터 하지 말고 같은 분기가 몇 벌 있는지부터 센다. 한 벌이면 그대로 둔다. 여러 벌이면 그 갈라짐이 곧 클래스 하나가 될 자리다.