thenullpage.com
주문에 배송비를 매기는 함수를 예로 든다. 우편번호가 63으로 시작하면 5000원, 아니면 3000원이다.
class Address {
constructor(readonly zipCode: string) {}
}
class Customer {
constructor(readonly address: Address) {}
}
class Order {
constructor(readonly customer: Customer) {}
}
function shippingFee(order: Order) {
const zip = order.customer.address.zipCode;
return zip.startsWith("63") ? 5000 : 3000;
}
const order = new Order(new Customer(new Address("63000")));
console.log(shippingFee(order)); // 5000
의도대로 5000이 찍힌다. 얼마 뒤 로그인하지 않은 손님도 주문할 수 있게 바꾸면서 회원 정보가 비는 주문이 생겼다.
const guest = new Order(null as any);
console.log(shippingFee(guest));
// TypeError: Cannot read properties of null (reading 'address')
고장 난 자리는 배송비 함수인데, 바꾼 자리는 주문이다. 배송비 계산은 회원 정보를 다루겠다고 나선 적이 없다. 그런데도 주문에서 회원을 꺼내고 회원에서 주소를 꺼내는 도중에 멈춰 섰다.
점이 늘어날수록 아는 것도 늘어난다
문제는 가운데 줄 하나다. 이 함수가 인자로 받은 것은 주문 하나지만, 실제로 알고 있는 것은 네 가지다. 주문에 회원이 들어 있다는 것, 회원에 주소가 들어 있다는 것, 주소에 우편번호가 문자열로 들어 있다는 것, 그 문자열의 앞 두 글자가 지역을 뜻한다는 것이다.
이렇게 점을 여러 번 찍어 남의 객체 속을 파고드는 호출을 기차 충돌(Train Wreck)이라 부른다. 줄줄이 이어 붙인 모양이 그렇게 보여서 붙은 이름이다.
이 한 줄 때문에 배송비 함수는 세 클래스의 내부 구조에 묶인다. 주소가 우편번호 대신 시도 코드를 갖도록 바뀌면 여기도 고쳐야 한다. 회원과 주문 사이에 배송지 정보가 하나 끼면 여기도 고쳐야 한다. 세 클래스 중 무엇이 흔들려도 이 함수가 같이 흔들린다.
필드를 readonly로 막아 둔 것은 값을 못 바꾸게 했을 뿐이다. 어떤 객체를 품고 있는지는 그대로 드러나 있고, 바깥에서 그 구조에 기대어 코드를 쓰기 시작한 순간 구조는 함부로 못 바꾸는 약속이 된다. 배송비 함수 하나가 아니라 목록 화면, 정산 코드, 알림 문구까지 같은 줄을 복사해 쓰고 있다면 주소 클래스를 손보는 일은 그 자체로 사고다.
묻지 말고 시킨다
고치는 방향은 값을 꺼내 오지 않는 것이다. 판단이 필요한 곳으로 값을 옮기지 말고, 값이 있는 곳에 판단을 맡긴다.
class Address {
constructor(private readonly zipCode: string) {}
isRemote() {
return this.zipCode.startsWith("63");
}
}
class Customer {
constructor(private readonly address: Address) {}
isRemote() {
return this.address.isRemote();
}
}
class Order {
constructor(private readonly customer: Customer | null) {}
shippingFee() {
if (this.customer === null) return 3000;
return this.customer.isRemote() ? 5000 : 3000;
}
}
console.log(new Order(new Customer(new Address("63000"))).shippingFee()); // 5000
console.log(new Order(new Customer(new Address("06000"))).shippingFee()); // 3000
console.log(new Order(null).shippingFee()); // 3000
비회원 주문도 예외 없이 3000을 돌려준다. 회원이 없을 때 어떻게 할지 정하는 코드가 주문 안으로 들어왔기 때문이다. 그 판단은 원래부터 주문의 몫이었다.
각 클래스가 아는 범위도 한 칸씩으로 줄었다. 우편번호 규칙은 주소만 안다. 주소가 있다는 사실은 회원만 안다. 회원이 있는지 없는지는 주문만 안다. 우편번호가 시도 코드로 바뀌면 고칠 곳은 isRemote 하나이고, 회원과 주소 사이에 무엇이 끼어도 배송비를 부르는 쪽은 그대로다.
회원에 붙은 isRemote처럼 받은 요청을 그대로 넘기기만 하는 메서드를 위임 메서드라 한다. 줄 수만 보면 늘어난 것 같지만, 늘어난 것은 이름이다. 점 세 개짜리 표현이 하던 일에 도서 지역이냐는 이름이 붙었고, 그 뜻이 바뀌는 날 손댈 자리가 한 곳으로 정해졌다.
말을 걸어도 되는 상대
이 기준을 규칙으로 정리한 것이 디미터 법칙(Law of Demeter)이다. 한 메서드는 정해진 상대에게만 말을 걸어야 한다는 내용이고, 그 상대는 넷이다. 자기 자신, 인자로 받은 객체, 자기가 직접 만든 객체, 자기 필드로 가진 객체다.
목록에 없는 것이 하나 있다. 그 상대들에게서 얻어 낸 객체다. 주문은 인자로 받았으니 말을 걸어도 되지만, 주문이 건네준 회원은 배송비 함수가 처음 보는 낯선 객체다. 친구에게만 말하고 낯선 사람에게는 말하지 말라는 표현이 여기서 나온다.
점의 개수를 세는 규칙으로 오해하기 쉬운데 그렇지 않다. 자기 필드를 두 번 거쳐 부르는 것과, 남이 꺼내 준 객체를 다시 캐는 것은 점 개수가 같아도 성격이 다르다. 세어야 할 것은 점이 아니라 이 코드가 몇 개의 클래스에 대해 안다고 가정하는가다.
점 두 개가 전부 위반은 아니다
체인이 길다고 무조건 고칠 일은 아니다.
const names = users.filter(u => u.active).map(u => u.name);
const q = new QueryBuilder().where("age", 20).orderBy("name").build();
배열 메서드는 매번 새 배열을 돌려주고, 빌더는 설정을 담은 자기 자신을 돌려준다. 앞에서 만든 것에 계속 말을 거는 모양이라 낯선 객체를 캐는 일이 아니다. 게다가 이런 자리에는 감출 내부 구조가 없다. 배열은 배열이라는 사실이 곧 계약이고, 외부 응답을 옮겨 담은 데이터 뭉치도 마찬가지다.
반대로 위임을 남발하면 다른 냄새가 난다. 클래스가 받은 호출을 옆으로 넘기기만 하고 스스로 하는 일이 없어지면 중간자(Middle Man)가 된다. 이름만 다른 통과 창구가 스무 개 생긴 클래스는 없느니만 못하다.
구분하는 질문은 하나다. 새로 만든 메서드가 도메인의 언어인가, 아니면 안쪽 메서드를 그대로 베낀 이름인가. isRemote는 배송 정책이 쓰는 말이라 남지만, getAddressZipCode는 점 하나를 메서드로 옮겨 적은 것뿐이라 아무것도 감추지 못한다. 감출 것이 없는 자리에 위임을 끼워 넣으면 코드만 길어진다.