Harry Park's Blog

TypeScript와 Duck Typing의 관계 쉽게 설명하기

JavaScript, TypeScript 그리고 Duck Typing에 대해서 구글링하면 많은 양의 검색 결과물을 볼 수 있다. 하지만 적절한 설명 없이 부정확한 용어를 혼용하기도 하고 (동적 타이핑, 다형성 등), 특히 Dynamic Programming Languages와 Dynamically Typed Languages를 동의어로 여기는 등 명확함이 떨어지거나 아예 틀린 설명을 길게 늘어 놓은 글들도 많이 확인 할 수 있었다.

이 포스트에서는 필요한 용어들만 사용해 TypeScript의 Typing 방식과 Duck Typing에 대해서 쉽게 설명한다.

TypeScript에서는 구조가 같으면 할당 가능하다

Java, C# 개발자에게 다음과 같은 코드를 보여주면 매우 기겁하면서 도망을 가려고 할 것이다.

class ClassA {
  /*...*/
}
class ClassB {
  /*...*/
}

let a = new ClassA()
let b = new ClassB()
a = b
b = a

Java를 기준으로 b는 명백하게 타입 ClassB로 부터 만들어진 객체이며 타입 ClassA의 인스턴스를 할당할 수 없다. 하지만 이 것이 놀랍게도 TypeScript에서는 가능하다.

이처럼 구조가 같으면 같은 타입으로 간주하는 방식을 Structural Typing, Java/C# 등과 같이 이름을 기준으로 타입을 나누는 방식을 Nominal Typing이라 한다.

Structural Typing은 구조 이용해 타입의 할당 가능성(Assignability)을 결정하는 방식이기 때문에 전통적인 Nominal Typing 방식보다 덜 엄격하고 개발자가 의도치 않은 실수를 할 가능성이 있다. 그럼에도 불구하고 왜 TypeScript를 Nominal Typing 방식을 사용하지 않았을까? 답은 저 아래...

TypeScript는 Duck Typing 방식을 사용한다

위에서 설명했듯 TypeScript은 Structural Typing 방식을 사용하며, 타입의 할당 가능성 판단을 위해서 컴파일 타임Duck Typing 방식을 사용한다. Duck Typing은 일종의 프로그래밍 패턴인데 다음에서 조금 더 자세하게 설명한다.

키보드와 관련 없는 Typing의 의미

Duck Typing을 처음 들었을 때 연상되는 뜻은 "키보드를 치고 있는 오리"였다.

키보드치는 오리, 출처: https://theburningmonk.com/2015/05/why-i-like-golang-interfaces/
키보드치는 오리, 출처: https://theburningmonk.com/2015/05/why-i-like-golang-interfaces/

C/C++과 Java를 주로 다루던 때였기에 int, float 등의 데이터 타입을 정하는 것은 너무나도 당연한 것이었고 Typing이라는 표현자체가 생소했다. Typing의 정확한 뜻은 Wikipedia에서 찾을 수 있다.

Assigning a data type, termed typing, gives meaning to a sequence of bits such as a value in memory or some object such as a variable.

약간의 초월 번역을 하자면, Typing은 메모리 안의 (의미를 알 수 없는) 값들에 데이터 타입을 할당함으로써 의미를 부여하는 것이다. 이 과정이 없었다면 아마도 변수과 같은 객체의 적절한 크기를 정하는 것도 어려웠을 것이다.

Duck Test 와 귀추법

Duck Typing은 Duck Test로 부터 유래 되었다. Duck Test는 논리학의 추론 형식 중 귀추법(Abductive reasoning)을 이해하기 쉽게 비유한 것이다.

그 것이 오리인지 100% 확실하지는 않다. 하지만 오리처럼 생겼고, 오리처럼 걷고, 오리처럼 헤엄치며, 오리처럼 꽥꽥 소리를 낸다. 이 정도의 추론 단서라면 내가 보는 것이 오리라고 판단해도 전혀 무리는 없을 것이다.

Duck Test 기법은 확실한 증거가 없을 때 유용한 판단 도구이다. 현실에 어떤 문제가 주어졌을 때 정보의 부족으로 인하여 항상 이상적인 해답을 구할 수 만은 없기 때문이다. 이런 경우 현실적으로 만족할만한 해답을 찾아야 한다. 이와 관련한 문제 해결법으로 발견법(Heuristic)이라는 것이 있으니 참고하자.

Duck Typing

Duck Test로 부터 유레된 Duck Typing은 일종의 프로그래밍 패턴이다. 아래의 JavaScript 코드는 someAnimal이 오리인지 아닌지 판단하기 위한 기준으로써 appearancequack 함수를 체크한다.

var duck = {
  appearance: "feathers", // 깃털을 가졌다.
  quack: function duck_quack(what) {
    console.log(what + " quack-quack!")
  }, // 꽥꽥거리는 기능을 가졌다.
  color: "black", // 검은색이다.
}

var someAnimal = {
  appearance: "feathers", // 깃털을 가졌다
  quack: function animal_quack(what) {
    console.log(what + " whoof-whoof!")
  }, // 꽥꽥거리는 기능을 가졌다. 소리가 좀 다를 뿐.
  eyes: "yellow", // 눈이 노랗다.
}

// 오리인지 판단하는 함수. 깃털이 있고 꽥꽥거리는 기능이 있으면 오리이다.
function check(who) {
  if (who.appearance == "feathers" && typeof who.quack == "function") {
    who.quack("I look like a duck!\n")
    return true
  }
  return false
}

check(duck) // true
check(someAnimal) // true

Duck Typing이 아니더라도 JavaScript에서는 프로퍼티의 존재 유무(e.g. in)와 그 것의 타입을 체크(e.g. typeof)를 런타임에서 수행할 수 있는 기능을 제공하며 그것들은 방어코드 구현을 위해 흔하게 사용된다. 잘못된 인자 타입으로 함수를 호출하고 런타임 에러가 발생한 경우, 콘솔 창에 불친절한 에러메세지 하나 보여주고 앱은 더이상 동작하지 않는 상황을 막으려는 것이다.

개발자 본인이 만든 함수를 사용하는 경우는 그냥 적절하게 사용하면 되므로 방어 코드를 아예 없앨 수 있다. 하지만 외부에 공개해야하는 API 함수는 방어 코드가 많으면 많을수록 견고한 API를 만들 수 있다. 따라서 함수를 견고하게 구현하기 위해서 각종 assertion과 예외처리 코드를 넣다 보면 배보다 배꼽이 더 커지는 상황도 발생한다.

JavaScript의 큰 단점으로 보이는 위 상황은 추가적인 방어 코드를 제거하고도 타입을 검사할 수 있다면 해결할 수 있을 것이다. TypeScript 컴파일러는 컴파일 시점에 Duck Typing과 같은 방식으로 타입을 검사하여 컴파일 에러를 내 준다. 이 것을 Structural Typing이라 한다.

Structural Typing

뭔가 다를 것도 없이 컴파일 시점에 Duck Typing을 적용한 것이 Structural Typing이다. 어떤 타입의 이름(e.g. 클래스명, 인터페이스명)이나 그 것의 위치(e.g. 패키지, 모듈)와 상관 없이 내부적으로 같은 구조를 가지고 있다면 두 개의 타입은 같다고 보는 것이다.

따라서 다음과 같이 literal 표기법으로 만들어진 아무 이름 없는 객체 조차도 그 것의 구조가 interface A와 같기 때문에 할당하는데 아무런 문제가 없다.

interface A {
  innerObj: {
    name: string
  }
}

let a: A
a = {
  innerObj: {
    name: "hello",
  },
} // ok!

Nominal Typing in TypeScript

TypeScript는 Nominal Types를 지원하지 않는다. 조사 결과, 그것은 논란 중이며 TypeScript의 future roadmap에 하나의 항목으로 올라와 있다는 것을 알게 되었다. 몇 년안에 TypeScript가 Nominal Types를 지원하는 것을 기대해 볼 수도 있겠다. 정식 지원과는 별개로 TypeScript에서 Nominal Types 구현을 위한 몇가지 트릭이 있다. 그 중 하나를 첫 예제에 적용해 본다.

class ClassA {
  private __nominal: void
  constructor() {}
}
class ClassB {
  private __nominal: void
  constructor() {}
}

let a = new ClassA()
let b = new ClassB()
let c = new ClassB()
a = b // compile error!
b = a // compile error!
b = c // ok!

왜 TypeScript는 처음부터 훨씬 엄격한 Nominal Typing 방식을 지원하지 않은 것일까? 이에 대한 짧은 의견을 내어 본다. TypeScript는 JavaScript의 Superset이기도 하며 Gradual Typing 방식을 도입하여 필요한 타입들만 우선적으로 적용해도 문제가 없다. 만약 JavaScript에서 여러 타입의 객체를 파라미터로 받을 수 있는 함수가 있다면 자연스럽게 Duck Typing 방식으로 프로그래밍 했을 것이다.

하지만 TypeScript로 변환을 시작했을 때 그 것이 Nominal Typing 방식이면 기존의 방식과의 너무나도 갭이 너무 크기 때문에 한번에 수정해야할 부분이 너무 많아질 것 같다. 다시말해서, Structural Typing은 JavaScript와의 호환성을 높이기 위해서 채택된 것이 아닐까? 구체적인 예는 없가 없어서 설득력이 없지만 한번 조사해 봐야 하겠다.

위의 코드를 작성하면 a = b에서 다음과 같은 에러가 출력되는데 마치 Nominal Typing 방식에서나 볼 수 있을 법한 에러 메세지들 볼 수 있다.

TypeScript에서 Nominal Types 구현
TypeScript에서 Nominal Types 구현

참고로 다음 링크에서 TypeScript에서의 Nominal Types에 대한 조금 더 자세한 정보를 볼 수 있다.

정리

TypeScript는 컴파일 타임에 Duck Typing 방식을 적용하는 Structural Typing 방식을 사용한다. TypeScript에서도 Nominal Typing을 흉내내기 위한 트릭이 존재한다.

끝.