시리즈의 다른 글 보기

오리처럼 꽥꽥거린다면 그건 오리다. 출처: 위키백과

구조적 타이핑

지난 글에서 타입스크립트를 집합이라고 표현하였는데요. 이를 조금 더 정확하게 말하면 구조적 서브타이핑(structural subtpying)이라고 합니다. 구조적 서브타이핑은 다른 말로 덕 타이핑 (duck typing)이라고도 하는데, "오리처럼 꽥꽥거리고 걷고, 헤엄치고, 날아다니는 새가 있다면 그 새를 오리라고 부를 것"이라는 말에서 유래되었다고 합니다. 즉 A 타입의 규칙(속성 또는 메서드)을 전부 가진 B라는 오브젝트가 존재한다면 오브젝트 B는 A 타입으로 본다는 말입니다.

type Duck = {
  quack: () => void;
  walk: () => void;
  swim: () => void;
  fly: () => void;
};

function quack(duck: Duck) {
  duck.fly();
}

const realDuck: Duck = {
  quack: () => console.log("Quack!"),
  walk: () => console.log("duck walk"),
  swim: () => console.log("duck swim"),
  fly: () => console.log("오리날다."),
};

const duckLikeThing = {
  quack: () => console.log("I'm not a real duck, but I can quack!"),
  walk: () => console.log("walk like a duck"),
  swim: () => console.log("swim like a duck"),
  fly: () => console.log("체리필터"),
  secret: () => console.log("i'm not a duck"),
};

quack(realDuck); // Quack!
quack(duckLikeThing); // I'm not a real duck, but I can quack!

위의 코드는 덕타이핑을 쉽게 알아볼 수 있게 타입스크립트 코드로 나타낸 것입니다. 타입스크립트 플레이그라운드에서 확인해 본다면 duckLikeThingquack함수에 넣어도 오류가 발생하지 않습니다. 이는 duckLikeThingDuck 타입이 가진 모든 멤버를 가지고 있기 때문인데요. 타입스크립트에서는 개발자의 편의와 실제 오류 가능성을 고려하여 구조적 서브타이핑을 채택하였습니다.

 

하지만 더 강력한 타입으로 발생 가능한 오류를 줄이려는 타입스크립트가 실제 Duck타입이 아니어도 오류로 감지하지 않기 때문에 이런 방식의 타입호환성을 지원하는 것은 이상해보입니다. 과연 덕 타이핑(구조적 서브타이핑)을 사용하였을 때 문제가 없을까요?

구조적 서브타이핑을 사용한 호환성이 필요한 이유

타입스크립트 구조적 서브타이핑을 지원하는 가장 큰 이유는 편의성입니다. 다음 코드를 보겠습니다.

function printBird(bird: Bird) {
  console.log(`Name: ${bird.name}, Age: ${bird.age}`);
}

interface Bird {
  name: string;
  age: number;
}

const notBird = {
  noName: "",
  noAge: "",
};

printBird(notBird); // 오류 발생

Bird 객체를 받아 이름과 나이를 출력하는 간단한 함수입니다. printBirdBird 타입을 인자로 받아 활용합니다. notBird처럼 완전히 다른 타입을 넣는 다면 오류를 발생시키는 것이 당연합니다.

 

이처럼 JAVA와 C# 같은 언어들은 명시적으로 상속관계가 아닌 이상 타입 호환성을 가지지 않도록 되어 있습니다. 이를 통해 타입 오류가 발생할 가능성을 완전히 배제하고자 합니다. 동시에 개발자의 의도를 명확하게 전달할 수 있습니다. 반면 타입스크립트에서는 명시적인 상속관계 없이도 타입 호환성을 인정하는 경우가 많습니다. 타입스크립트는 타입 오류가 발생하는지 아닌지 파악하기 위해 타입시스템이 타입을 검사합니다. 타입이 가진 각 멤버를 검사하여 파라미터의 타입이 할 수 있는 일을 인자(arguments)가 할 수 있는지 확인하여 타입 오류를 배제하고 개발자에게 편리함을 더해줍니다. 다음 코드를 살펴보겠습니다.

interface Duck {
  name: string;
  age: number;
  quack: () => void;
}

const duckObj: Duck = {
  name: "mr. quack",
  age: 3,
  quack() {
    console.log("quack");
  },
};

printBird(duckObj);

위의 코드와 같이 (명목적으로는 상속관계가 아니기 때문에)Bird 타입과 아무 상관 없어 보이는 Duck이라는 타입을 가진 duckObjectprintBird의 인자로 전달하면 어떻게 해야 될까요? 분명 서로 관련없는 타입이기 때문에 printBirdDuck은 인자로 넣으면 안되도록 할 수 있습니다. JAVA와 C# 같은 언어들이 그렇습니다. 하지만 Duck 타입을 자세히 본다면 Bird 타입의 멤버인 nameage를 모두 가지고 있습니다. 이 말은 printBirdBird를 인자로 받았을 때 오류가 발생하지 않는다면 Duck 타입을 넣었을 때도 오류가 발생하지 않는다는 뜻입니다! 그렇다면 굳이 DuckBird의 자리에 들어가는 것을 막을 필요가 없지 않을까요? 오히려 이를 막지 않음으로 더 유연한 코드 작성이 가능해집니다. 그래서 타입스크립트는 보다 유연한 구조적 타이핑을 채택하였습니다. 개발자의 편의를 위해서죠.

구조적 서브타입이 작동하지 않는 상황

interface Duck {
  quack: () => void;
}

function duckQuack(duck: Duck) {
  duck.quack();
}

duckQuack({
  quack() {
    console.log("quack");
  },
  walk() {
    console.log("뒤뚱뒤뚱"); // Object literal may only specify known properties, and 'walk' does not exist in type 'Duck'.
  },
});

위의 코드를 타입스크립트 플레이그라운드에서 확인한다면 오류가 발생합니다. 분명 duckQuack 함수의 인자로 들어간 오브젝트의 리터럴은 quack을 포함하고 있지만 말이죠.

 

우리들의 친절한 타입스크립트는 의도적으로 리터럴을 더욱 엄격하게 검사합니다. 오류 메세지에서 나타나듯이 객체 리터럴은 알려진 속성 즉, 타입에 정해진 속성만 사용할 수 있습니다. 객체 리터럴에서 멤버를 더욱 엄격하게 검사하는 이유는 객체 리터럴은 재사용을 할 수 없기 때문입니다. 재사용할 수 없기 때문에 타입에서 불필요한 멤버를 추가하는 코드는 명백한 낭비가 됩니다. 유연할 필요가 없는 것이죠. 유연성을 주는 대신 엄격하게 검사하여 낭비를 저지르는 개발자들에게 낭비를 줄여라고 충고해 주는 셈입니다. 이는 다음과 같은 상황에서도 똑같이 적용됩니다.

const a: Duck  = {
  quack() {},
  쓸모없는것: "낭비", // Object literal may only specify known properties, and '쓸모없는_것' does not exist in type 'Duck'.ts(2353)
(property) 쓸모없는것: string
}

타입스크립트는 내부적으로 신선도(Freshness)라는 개념을 이용하여 타입을 검사할 때 리터럴을 따로 구분합니다.

구조적 서브 타이핑을 사용하지 않아야 하는 상황

구조적 서브 타이핑이 일반적인 상황에선 편리하긴 하지만 특수한 상황에서는 버그를 발생시킬수도 있습니다.

interface TimeToSecondParam {
  hour: number;
  minute: number;
  second: number;
}

function timeToSecond(time: TimeToSecondParam) {
  const { hour, minute, second } = time;
  return hour * 60 * 60 + minute * 60 + second;
}

const hour = 1;
const minute = 23;
const second = 4;

timeToSecond({ hour: second, minute: hour, second: minute });

시간을 초로 바꾸어 계산하는 함수 TimeToSecond의 인자에 시, 분, 초가 각각 뒤섞였으나 타입스크립트로는 오류를 감지할 수 없습니다. 변수의 이름을 명확하게 주면 이런 실수는 하지 않을 수 있다고 생각하겠지만 타입스크립트가 이런 실수까지 감지하여 준다면 더욱 좋을 것입니다. 하지만 구조적 서브타이핑에서는 hour, minute, second가 전부 number타입이기 때문에 서로 다른 위치에 인자가 위치하여도 오류로 감지하지 않습니다. 그래서 타입스크립트에서는 타입 호환을 허용하지 않도록 강제하는 방법도 제공하고 있습니다. 바로 "Branding type"라고 불리는 방법으로 __brand프로퍼티를 추가하면 타입스크립트는 더 엄격한 타입 검사를 제공합니다.

type Brand<T, K extends string> = T & { __brand: K };
type Hour = Brand<number, "hour">;
type Minute = Brand<number, "minute">;
type Second = Brand<number, "second">;

function brandedTimeToSecond(hour: Hour, minute: Minute, second: Second) {
  return hour * 60 * 60 + minute * 60 + second;
}

const brandedHour = 1 as Hour;
const brandedMinute = 23 as Minute;
const brandedSecond = 4 as Second;

brandedTimeToSecond(brandedHour, brandedMinute, brandedSecond); // 정상 작동
brandedTimeToSecond(brandedSecond, brandedMinute, brandedHour); // 오류 발생

위와 같이 __brand 키를 추가한다면 타입스크립트의 타입 시스템이 구조적 타이핑이 아닌 명목적 타이핑으로 타입을 검사하게 됩니다. 그래서 시, 분, 초가 모두 숫자 자료형이라고 구분할 수 있게 됩니다. 타입스크립트 플레이그라운드에서 코드를 확인하실 수 있습니다.

 

브랜드조차 유니크한 타입 만들기

하지만 브랜드가 너무 많아진다면 __brand의 값으로 똑같은 리터럴을 넣어버리는 실수를 저지를 수 있습니다. 각 브랜드를 모두 다른 것으로 구분하기 위해 Symbol을 브랜드의 값으로 넣어준다면 해당 문제를 피할 수있습니다.

type Brand<K, T> = K & { __brand: T };

type UserBrand1 = Brand<string, "user">;
type UserBrand2 = Brand<string, "user">;

function userFunc(user: UserBrand1) {}

const user1 = "user" as UserBrand1;
const user2 = "user" as UserBrand2;

userFunc(user1); // 정상 작동
userFunc(user2); // 오류 없음

const UserIdSymbol1 = Symbol("user");
const UserIdSymbol2 = Symbol("user");

type UniqueBrand<K, T extends Symbol> = K & { __brand: T };

type UserUniqueBrand1 = UniqueBrand<string, typeof UserIdSymbol1>;
type UserUniqueBrand2 = UniqueBrand<string, typeof UserIdSymbol2>;

function uniqueUserFunc(user: UserUniqueBrand1) {}

const uniqueUser1 = "user" as UserUniqueBrand1;
const uniqueUser2 = "user" as UserUniqueBrand2;

uniqueUserFunc(uniqueUser1); // 정상 작동
uniqueUserFunc(uniqueUser2); // 오류 발생

userFuncUserBrand1을 인자로 받지만 UserBrand2의 브랜드 또한 UserBrand1과 같기 때문에 오류가 발생하지 않습니다. 대신 Brand를 unique하게 보장하도록 Symbol을 넣은 UniqueBrand는 같은 문자열을 넣어도 서로 다른 브랜드로 인식됩니다. 타입스크립트 플레이그라운드에서 코드를 확인하실 수 있습니다.

 

다른 방법으로 유니크한 브랜드 만들기

사실 브랜드는 타입에 특정한 값이 있는 것으로 취급한다는 점에서 다음과 같이 만들어도 동작합니다.

// unique symbol 이용하기
type BrandedNumber1 = number & { readonly __userId: unique symbol };
type BrandedNumber2 = number & { readonly __userId: unique symbol };

function numberFunc(number: BrandedNumber1) {}

const num = 1;
const brandedNumber1 = 1 as BrandedNumber1;
const brandedNumber2 = 1 as BrandedNumber2;

numberFunc(num); // 오류
numberFunc(brandedNumber1); // 정상 작동
numberFunc(brandedNumber2); // 오류 발생

// 클래스 이용하기
class Num1Class {
  private readonly type = "Num1Class";
  constructor(public value: number) {}
}
class Num2Class {
  private readonly type = "Num2Class";
  constructor(public value: number) {}
}

function num1ClassFunc(number: Num1Class) {}

num1ClassFunc(new Num1Class(1)); // 정상 작동
num1ClassFunc(new Num2Class(1)); // 오류 발생

클래스를 이용해 유니크한 브랜드를 만들 때, 클래스의 내부 구성이 같다면 서로 다른 클래스라도 덕타이핑으로 인해 호환되기 때문에 이로 인한 문제가 발생한다면 클래스에 유니크한 더미 프로퍼티를 주는 것도 하나의 방법이 될 수는 있습니다. 하지만 말 그대로 더미 데이터가 추가되기 때문에 피할 수 있으면 피하는 것이 좋을 것 같습니다.

참고 자료

typescript, Type Compatibility
Toss Tech, TypeScript 타입 시스템 뜯어보기: 타입 호환성
TypeScript Deep Dive, 신선도(Freshness)

+ Recent posts