thenullpage.com

설정값을 담아 두는 저장소 클래스를 예로 든다. 키를 주면 값을 돌려주고, 키와 값을 주면 저장한다. 원격 저장소는 읽기만 되기 때문에 저장을 막은 자식 클래스를 하나 두었다.


class Store {
private data = new Map<string, string>([["user", "kim"]]);
load(key: string) { return this.data.get(key) ?? ""; }
save(key: string, value: string) { this.data.set(key, value); }
}

class ReadOnlyStore extends Store {
save(key: string, value: string) { throw new Error("읽기 전용이다"); }
}

function backup(store: Store) {
store.save("user.bak", store.load("user"));
console.log("백업 완료");
}

backup(new Store()); // 백업 완료
backup(new ReadOnlyStore()); // Error: 읽기 전용이다


첫 줄은 백업 완료가 찍히고 둘째 줄에서 프로그램이 멈춘다. backup 함수는 한 글자도 바뀌지 않았고 받는 타입도 Store 그대로다. 컴파일러도 아무 불평을 하지 않았다.

타입이 맞는데 프로그램이 깨진다

ReadOnlyStore는 Store를 상속했으니 Store가 필요한 자리 어디에나 들어갈 수 있다. 타입 검사기가 확인하는 것은 메서드 이름과 인자 모양뿐이다. save라는 이름으로 문자열 둘을 받으면 통과이고, 그 안에서 실제로 저장을 하는지는 검사 대상이 아니다.


부모 타입을 쓰는 코드에 자식을 넣어도 프로그램이 하던 일을 그대로 해야 한다는 원칙을 리스코프 치환 원칙(Liskov Substitution Principle, LSP)이라 한다. 지켜야 하는 것은 시그니처가 아니라 약속이다. 부모가 무엇을 요구하고 무엇을 돌려주기로 했는지, 자식은 그 약속을 똑같이 지키거나 더 후하게 지켜야 한다.


어기는 방식은 둘이다. 부모보다 까다로워지거나, 부모보다 덜 주거나. 방금 본 코드는 뒤쪽이고, 예외라는 신호가 눈에 띄기라도 한다. 더 잡기 어려운 것은 아래 같은 경우다.


class StrictStore extends Store {
load(key: string) {
const v = super.load(key);
if (v === "") throw new Error("없는 키: " + key);
return v;
}
}

function greet(store: Store) {
const nick = store.load("nickname");
console.log(nick === "" ? "손님" : nick + "님");
}

greet(new Store()); // 손님
greet(new StrictStore()); // Error: 없는 키: nickname


greet는 부모의 약속을 읽고 짠 코드다. 없는 키면 빈 문자열이 온다고 했으니 그 분기를 두었다. StrictStore는 약속을 어겼다. 조용히 넘어가는 동작이 허술해 보여서 더 엄격하게 고친 것인데, 엄격해진 쪽은 자식이고 대가는 부모를 믿고 짠 호출부가 치른다.


StrictStore만 떼어 놓고 보면 load는 더 나은 코드다. 없는 키를 감추지 않고 알려 주니까. 부모 자리에 놓는 순간 나쁜 코드가 된다. 좋고 나쁨이 클래스 안에서 정해지지 않는다.

예외로 막은 상속은 상속이 아니다

맨 앞의 예제 같은 코드를 만나면 대개 호출부에서 막는다.


function backup(store: Store) {
if (store instanceof ReadOnlyStore) return;
store.save("user.bak", store.load("user"));
}


터지지는 않는다. 대신 백업이 조용히 건너뛰어지고 아무도 모른다. Store를 받는 함수가 스무 개면 같은 검사가 스무 곳에 퍼진다. 부모 타입으로 받아 놓고 자식이 무엇인지 확인하는 코드는 상속이 잘못 걸렸다는 신호다.


물려받은 메서드를 자식이 예외로 막거나 빈 껍데기로 덮는 것을 거부된 유산(Refused Bequest)이라 부른다. 상속은 부모가 하는 일을 자식도 한다는 선언인데, 못 하는 일이 섞이면 그 선언이 거짓이 된다. 이 자식에서는 save를 부르지 말라고 문서에 적어 두었다면 이미 어긴 상태다.

할 수 있는 일로 타입을 나눈다

고치는 방향은 계층을 억지로 맞추는 것이 아니라 다시 긋는 것이다. 읽는 능력과 쓰는 능력은 별개이므로 타입도 따로 둔다.


interface Readable { load(key: string): string; }
interface Writable { save(key: string, value: string): void; }

class MemoryStore implements Readable, Writable {
private data = new Map<string, string>([["user", "kim"]]);
load(key: string) { return this.data.get(key) ?? ""; }
save(key: string, value: string) { this.data.set(key, value); }
}

class SnapshotStore implements Readable {
load(key: string) { return key === "user" ? "kim" : ""; }
}

function backup(store: Readable & Writable) {
store.save("user.bak", store.load("user"));
console.log("백업 완료");
}

function show(store: Readable) {
console.log("사용자: " + store.load("user"));
}

backup(new MemoryStore()); // 백업 완료
show(new SnapshotStore()); // 사용자: kim
// backup(new SnapshotStore()); save가 없어서 실행 전에 컴파일 에러


SnapshotStore에는 save가 아예 없다. 던질 예외도 없고 막을 if도 없다. 실수로 backup에 넘기면 실행하기 전에 편집기가 빨간 줄을 긋는다. 읽기만 하는 함수는 Readable만 받으니, 인자 타입만 보고도 저장을 하는 함수인지 알 수 있다.


약속을 잘게 쪼개면 각 타입은 지킬 수 있는 약속만 하게 된다. 상속을 쓰지 말라는 말은 아니다. 자식이 부모의 약속을 전부 지킬 수 있으면 상속은 여전히 맞는 도구다. 부모 자리에 이 자식을 넣어도 호출하는 쪽 코드가 그대로 맞는가. 아니라면 자식이 아니라 다른 타입이다.

컴파일러가 검사하지 않는 약속

인터페이스로 나눠도 구멍은 남는다. 약속 중에는 타입으로 적을 수 없는 것이 많다. 없는 키에 빈 문자열을 준다는 규칙부터가 그렇고, save가 던지는 예외의 종류나 저장이 즉시 반영되는지도 마찬가지다. 원격 구현이 메모리 구현과 이름도 인자도 똑같은데 응답에 3초가 걸린다면, 3밀리초를 전제로 짠 화면은 타입 검사를 통과하고도 멈춘 것처럼 보인다.


그래서 두 가지를 같이 한다. 하나는 약속을 인터페이스 옆에 문장으로 적어 두는 것이다. 없는 키는 빈 문자열, 예외는 연결 실패일 때만, 이 정도 두 줄이면 된다. 다른 하나는 인터페이스를 대상으로 쓴 테스트를 구현마다 그대로 돌리는 것이다.


마지막으로 조용한 실패를 경계한다. 예외 던지는 것이 거슬린다고 save를 빈 메서드로 두면 위반은 그대로인데 증상만 사라진다. 저장했다고 믿은 데이터가 실은 없는 상황은 예외로 멈추는 쪽보다 훨씬 늦게, 훨씬 비싸게 드러난다. 지킬 수 없는 약속이라면 조용히 어기는 것보다 처음부터 하지 않는 편이 낫다.