thenullpage.com
급여 명세를 뽑는 코드를 예로 든다. 직원은 월급을 받고, 계약직은 일한 날수에 일당을 곱해 받는다. 계산 방식이 다르니 클래스를 나누고, 계약직 쪽에서 계산 메서드를 다시 정의했다.
class Employee {
constructor(protected name: string) {}
pay() { return 3000000; }
describe() { return this.name + ": " + this.pay() + "원"; }
}
class Contractor extends Employee {
constructor(name: string, private days: number) { super(name); }
pay() { return this.days * 150000; }
}
const staff: Employee[] = [new Employee("김"), new Contractor("이", 12)];
for (const s of staff) console.log(s.describe());
// 김: 3000000원
// 이: 1800000원
여기서 처음 배우는 사람이 자주 멈추는 지점이 있다. describe는 Employee에만 적혀 있고 그 안의 this.pay()도 Employee 안에 쓴 코드다. 그런데 두 번째 줄에서는 Contractor가 다시 정의한 계산이 실행됐다.
변수에 적힌 타입과 실제로 담긴 객체
staff 배열의 타입은 Employee다. 하지만 이 타입은 그 자리에 무엇을 넣어도 되는지를 정할 뿐, 어떤 코드가 실행될지를 정하지 않는다. this.pay()라는 한 줄은 실행되는 순간에야 this가 가리키는 객체를 보고 그 객체가 가진 pay를 찾아 실행한다. 두 번째 원소의 this는 Contractor 인스턴스이므로 Contractor의 pay가 걸린다.
이렇게 호출할 메서드를 실행 시점에 고르는 방식을 동적 디스패치(dynamic dispatch)라 하고, 같은 요청에 객체마다 다르게 답하는 성질을 다형성(polymorphism)이라 한다. 부모가 같은 이름으로 정의해 둔 메서드를 자식이 다시 정의하는 것은 재정의(overriding)다.
이 구조에서 눈여겨볼 것은 방향이다. describe를 짜는 시점에 Contractor는 존재하지 않아도 된다. 나중에 인턴이든 시급직이든 새 클래스가 붙어도 describe는 손대지 않는다. 부모가 큰 흐름을 적어 두고 달라지는 한 칸만 자식이 채우는 형태다.
오버로딩은 다형성이 아니다
이름이 비슷해서 재정의(overriding)와 오버로딩(overloading)을 같은 것으로 아는 경우가 많다. 오버로딩은 같은 이름의 함수를 인자의 타입이나 개수로 구분해 여러 벌 두는 것이다.
function pay(e: Employee): number;
function pay(e: Contractor): number;
function pay(e: Employee): number {
if (e instanceof Contractor) return e.pay();
return 3000000;
}
겉보기에는 두 종류를 따로 처리하는 것 같지만 실제로 실행되는 몸통은 맨 아래 하나뿐이다. 어느 시그니처를 쓸지는 컴파일 단계에서 인자에 적힌 타입만 보고 정해지고, 구분은 결국 함수 안의 instanceof 검사가 한다. 인턴이 추가되면 이 함수를 열어야 한다.
재정의는 반대다. 판단이 함수 안이 아니라 객체 쪽에 있어서, 새 종류가 생겨도 호출하는 코드는 그대로다. 이름은 비슷하지만 하나는 코드를 계속 열게 만들고 하나는 닫아 둔다.
생성자 안에서 재정의된 메서드를 부르면
다형성이 편리한 만큼 조용히 어긋나는 자리도 있다. 부모 생성자에서 자식이 재정의할 메서드를 부르는 경우다.
class Report {
title: string;
constructor() { this.title = this.buildTitle(); }
buildTitle() { return "보고서"; }
}
class SalesReport extends Report {
period = "7월";
buildTitle() { return this.period + " 매출 보고서"; }
}
console.log(new SalesReport().title); // undefined 매출 보고서
7월이 들어갈 자리에 undefined가 찍힌다. 객체가 만들어지는 순서 때문이다. 자식 생성자는 부모 생성자를 먼저 끝낸 뒤에 자기 필드를 채운다. 부모 생성자가 buildTitle을 부르는 시점에 이미 그 호출은 자식의 buildTitle로 걸리지만, period는 아직 값이 들어가기 전이다. 제목은 그 상태로 굳어 버리고, 뒤늦게 period에 7월이 들어가도 title은 바뀌지 않는다.
이 오류가 성가신 이유는 두 클래스를 따로 읽으면 어느 쪽도 이상해 보이지 않는다는 데 있다. 생성자에서는 재정의될 수 있는 메서드를 부르지 않는 것이 원칙이다. 값이 필요하다면 미리 계산해 저장하지 말고 읽는 시점에 계산하면 된다.
class Report {
get title() { return "보고서"; }
}
class SalesReport extends Report {
period = "7월";
get title() { return this.period + " 매출 보고서"; }
}
console.log(new SalesReport().title); // 7월 매출 보고서
자식이 반드시 답해야 하는 자리
처음 예제의 Employee에는 숨은 문제가 하나 있다. pay가 3000000이라는 기본값을 들고 있다는 점이다. 인턴 클래스를 추가하면서 pay 재정의를 빠뜨리면 인턴에게 정규직 월급이 계산된다. 오류도 경고도 없이 숫자만 조용히 틀린다.
모든 자식에게 옳은 기본 답이 없다면 부모는 답을 비워 두고 자식에게 넘겨야 한다. 이때 쓰는 것이 추상 클래스(abstract class, 직접 만들 수 없고 물려받아 완성해야 하는 클래스)다.
abstract class Employee {
constructor(protected name: string) {}
abstract pay(): number;
describe() { return this.name + ": " + this.pay() + "원"; }
}
class FullTime extends Employee {
pay() { return 3000000; }
}
class Intern extends Employee {}
// 오류: Intern은 pay를 구현하지 않았다
abstract pay는 이름과 반환 타입만 적고 내용을 비워 둔 선언이다. 이제 pay를 빠뜨린 클래스는 실행해 보기 전에 걸린다. 잘못된 금액이 나가는 사고가 컴파일 오류로 바뀐 것이다. describe처럼 모든 자식이 똑같이 쓸 코드는 부모에 남겨 두고, 달라지는 것만 비워 두는 것이 기준이다.
재정의를 허락한다는 것
메서드를 재정의할 수 있게 열어 두는 것은 누군가 그 자리에 다른 코드를 끼워 넣어도 된다는 약속이다. 어디를 열었는지는 정해 두어야 한다. 타입스크립트에는 자바의 final 같은 잠금 표시가 없으니, 내부 계산은 private 메서드로 감춰 재정의 대상에서 빼는 편이 낫다.
판단 기준은 단순하다. 이 메서드를 자식이 다르게 구현할 이유가 있는가. 있다면 추상 메서드로 비워 두고 이름과 반환 값을 분명히 적는다. 없다면 열어 두지 않는다. 다형성은 갈라지는 지점을 넓게 만드는 기술이 아니라, 갈라지는 지점을 한 칸으로 좁혀 두고 나머지를 공유하는 기술이다.