이전 글 – TypeScript: 클래스(Class)

클래스와 인터페이스

지금껏 JavaScript만을 다뤄본 개발자라면 인터페이스라는 개념은 익숙치 않을 것이다. 하지만 Java나 C# 등의 정적 타입 언어에서는 이미 많이 쓰이는 개념이다. 인터페이스는 클래스에서 구현부가 빠졌다고 이해하면 편하다. 즉, 어떠한 객체가 이러이러한 프로퍼티 혹은 메소드를 가진다고 선언하는 것이다. 실질적인 구현은 이를 구현한다고 선언하는 클래스에 맡긴다.

interface Shape {
getArea(): number;
}
class Rect implements Shape {
width : number;
height: number;
constructor(width, height) {
this.width = width;
this.height = height;
}
} // error: Class ‘Rect’ incorrectly implements interface ‘Shape’. Property ‘getArea’ is missing in type ‘Rect’.

위의 코드에서는 Rect라는 클래스가 implements 키워드를 통하여 Shape라는 인터페이스를 구현할 것이라고 선언하는 것이다. 일단 인터페이스를 구현하기로 했으면, 해당 인터페이스에 있는 프로퍼티 및 메소드를 전부 가지거나 구현해야 한다. 여기에서는 getArea라는 메소드를 구현하지 않았으므로 에러가 발생한 모습이다.

덕 타이핑(Duck typing)

동적 타이핑 중에서 덕 타이핑이라는 것이 있다. JavaScript는 동적 타입 언어이므로 이 개념을 활용해서 코딩하게 된다.

class Duck {
quack() {
console.log(‘꽥!’);
}
}
class Person {
quack() {
console.log(‘나도 꽥!’);
}
}
function makeSomeNoiseWith(duck) {
duck.quack();
}
makeSomeNoiseWith(new Duck()); // OK
makeSomeNoiseWith(new Person()); // OK

만약 위와 같은 상황에서 Person 클래스에 quack 메소드가 구현되어 있지 않으면 어떻게 될까? 바로 런타임 에러를 발생시키며 프로그램이 종료될 것이다. 이와 같은 런타임 에러를 방지하기 위해서 메소드를 실행시키기 전에 검사할 수도 있지만 코드가 너무 장황해져 덕 타이핑의 장점이 사라진다.

TypeScript에서는 인터페이스를 활용하면 덕 타이핑을 할 때 메소드를 검사하지 않고도 런타임 에러를 막을 수 있다. TypeScript의 덕 타이핑은 어떤 객체가 특정 인터페이스에서 명시하는 메소드를 가지고 있다면 해당 객체가 그 인터페이스를 구현한 것으로 보는 것이다. 설명이 너무 장황하므로 코드 예제를 잠깐 보자.

interface Quackable {
quack(): void;
}
class Duck implements Quackable {
quack() {
console.log(‘꽥!’);
}
}
class Person {
quack() {
console.log(‘나도 꽥!’);
}
}
function makeSomeNoiseWith(duck: Quackable): void {
duck.quack();
}
makeSomeNoiseWith(new Duck()); // OK
makeSomeNoiseWith(new Person()); // OK

Duck 클래스는 명시적으로 Quackable 인터페이스를 구현한다고 선언하였으므로 Quackable 객체만 인자로 받는 makeSomeNoiseWith에 인자로 넘겨지는 것이 이상하지 않다. 그러나 Person 클래스는 조금 이상하다. 분명 Quackable 인터페이스를 구현한다고 선언하지 않았지만 문제없이 인자로 넘어간다.

이게 아까 말한대로 TypeScript에서의 덕 타이핑이다. 그냥 Quackable에서 명시했던 quack 메소드만 구현되어 있다면 Quackable 객체로 보는 것이다. Person 클래스에서 quack 메소드를 제거하면 컴파일 에러가 난다. 개발자는 런타임에 메소드 검사를 하지 않고도 런타임 에러를 방지할 수 있다.

물론 조금 더 strict하게 타이핑을 하고 싶다면 implements 키워드를 사용하여 명시적으로 선언해주는 것도 여전히 좋은 방법이다. 참고로, Go에서도 이와 유사한 방식의 덕 타이핑을 활용할 수 있다. 이런 방식의 덕 타이핑은 구조적 타이핑(Structural typing) 이라고도 한다. 기회가 되면 덕 타이핑에서 더 자세히 다뤄보는 포스트를 작성하도록 하겠다.

Optional 프로퍼티

인터페이스는 클래스와 매우 흡사한 모습을 가지고 있지만 주 용도는 다르다. 인터페이스로는 객체 인스턴스를 생성할 수 없으므로 주로 타입 검사를 위해서 활용된다. ES2015에 클래스는 있지만 인터페이스는 없다는 것이 그 사실을 방증한다. 그 차이가 Optional 프로퍼티에서 잘 드러난다. 인터페이스의 모든 프로퍼티 및 메소드는 구현하는 클래스에서 기본적으로 가지고 있어야 될 것들이지만, Optional 프로퍼티는 말 그대로 선택적으로 구현하는 프로퍼티다. 프로퍼티 식별자 뒤에 간단하게 ?를 붙여서 사용한다.

interface Shape {
width? : number;
height?: number;
radius?: number;
getArea(): number;
}
class Rect implements Shape {
width : number;
height: number;
constructor(width, height) {
this.width = width;
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Circle implements Shape {
radius: number;
constructor(radius) {
this.radius = radius;
}
getArea(): number {
return Math.PI * this.radius * this.radius;
}
}

위 코드에서 알 수 있듯이, 인터페이스를 구현하는 클래스에서는 Optional 프로퍼티를 가지고 있지 않더라도 에러가 발생하지 않는다.

Indexable

JavaScript의 객체는 프로퍼티 접근자(Property accessor)를 두 가지 제공한다. 하나는 점 표기법(Dot notation)이고, 다른 하나는 괄호 표기법(Bracket notation)이다. 기본적으로 점 표기법을 자주 사용하기는 하지만, 동적으로 프로퍼티에 접근하려는 경우 문자열으로 프로퍼티에 접근할 수 있는 괄호 표기법을 사용하기도 한다.

그러나 TypeScript는 괄호 표기법으로 프로퍼티에 접근하려고 하면 애로사항이 꽃 핀다. 다음의 코드를 보자.

const dict = {
foo: 1,
bar: 2
};
Object.keys(dict)
.forEach(k => console.log(dict[k]));

예제가 조금 이상하긴 하지만 JavaScript 개발을 조금이라도 해봤다면 동적으로 프로퍼티에 접근하는 상황이 종종 있었을 것이다. JavaScript에서 이 코드는 정상적으로 동작한다. 그러나 TypeScript에서는 dict의 프로퍼티를 동적으로 접근하는 부분(console.log 인자 부분)에서 error: Index signature of object type implicitly has an 'any' type. 이라는 에러가 나면서 컴파일이 되지 않는다.1 TypeScript가 괄호 표기법을 제공하지 않는 것은 아니지만, 동적인 키 값을 사용하게 되면 에러가 발생한다.

에러 원인은 간단하다. 어떤 타입의 프로퍼티에 접근하는 지 알 수 없기 때문에 리턴 값을 묵시적으로 any 타입으로 변환하므로 에러를 띄우는 것이다. 이를 해결하기 위해서는 noImplicitAny 값을 false로 바꾸던지, 객체 자체를 Indexable 하게 만드는 방법밖에 없다. 객체를 Indexable 하게 만드려면 인덱스 시그니처(Index signature)를 사용하면 된다.

interface Indexable {
[key: string]: any;
}
const dict: Indexable = {
foo: 1,
bar: 2
};
Object.keys(dict)
.forEach(k => console.log(dict[k])); // OK

위에 새로 추가된 인터페이스 내부 [key: string]: any라는 문장이 바로 인덱스 시그니처다. 괄호 표현법과 함께 string으로 접근하게 되면 any타입의 무언가를 돌려줄 것이라는 의미다. 여기서는 묵시적으로 any 타입을 리턴하지 않으므로 에러가 발생하지 않는다. 하지만 키 값으로 numberSymbol 같은 다른 타입을 넘기게 되면 다시 에러가 발생하기 때문에 마찬가지로 필요하다면 인덱스 시그니처를 정의해야 한다.

함수 인터페이스

TypeScript의 인터페이스는 함수의 인터페이스를 정의할 수도 있다.

interface numberOperation {
(arg1: number, arg2: number): number;
}
const sum: numberOperation = (arg1: number, arg2: number): number => {
return arg1 + arg2;
};
const multiply: numberOperation = (arg1, arg2) => {
return arg1 * arg2;
};
const toArray: numberOperation = (arg1: any, arg2: any): any[] => { // error: Type ‘(arg1: any, arg2: any) => any[]’ is not assignable to type ‘numberOperation’. Type ‘any[]’ is not assignable to type ‘number’.
return [arg1, arg2];
};

문법은 식별자 없이, 받아야할 인자의 타입과, 리턴 타입만을 표기하면 된다. 쉽게 예상할 수 있겠지만, 이 인터페이스를 구현하는 함수는 반드시 정의했던 타입의 인자를 받아 정의했던 타입을 리턴해야만 에러없이 컴파일이 된다.

multiply처럼, 정의했던 인터페이스대로 구현된 함수는 굳이 타입을 명시할 필요는 없다. 이상한 타입만 명시하지 않으면 된다. 여기서 이상한 타입이라 함은 any와 애초에 인터페이스에서 선언했던 number를 제외한 타입들을 말한다.

타입을 명시하지 않으면 함수를 실제로 사용할 때 인자로 이상한 타입을 넘겨도 될 것 같지만, 그렇게 하면 타입이 맞지 않아 에러가 뜬다.

생성자 인터페이스

JavaScript에서 함수는 일급 시민이므로 다른 함수에 인자로 넘길 수 있다. 생성자도 마찬가지로 함수이므로 인자로 넘기는 것이 가능하다.

class Dog {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
class Cat {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
function createAnimal(cstr, name, age) {
return new cstr(name, age);
}
console.log(createAnimal(Dog, ‘팔랑’, 15));
console.log(createAnimal(Cat, ‘쭈쭈’, 10));

createAnimal 이라는 함수의 윤리적인 이슈에 대해서 다루기엔 너무 양이 길테니 일단 넘어가도록 하자. 이 예제는 그냥 JavaScript 코드인데 문제없이 잘 돌아간다. 간단한 예제지만 TypeScript에서 구현하기가 간단하지 않다. 가장 큰 문제는 createAnimal의 첫 번째 인자로 받는 생성자 함수의 타입을 어떻게 정의하냐는 문제다. 일단 생성자는 함수이니, Function으로 정의하면 될 거 같다. 한 번 시도해보자.

class Dog {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Cat {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
function createAnimal(cstr: Function, name: string, age: number) {
return new cstr(name, age); // error: Cannot use ‘new’ with an expression whose type lacks a call or construct signature.
}
createAnimal(Dog, ‘팔랑’, 15);
createAnimal(Cat, ‘쭈쭈’, 10);

하지만 위의 코드는 컴파일이 안된다. TypeScript에서는 new와 함께 일반 함수를 호출할 수 없기 때문이다. 따라서 TypeScript가 생성자로 인식할 만한 어떤 타입을 써주어야 한다. 이 때 new라는 키워드를 이용해서 생성자의 인터페이스를 정의할 수 있다.

interface Animal {
name: string;
age : number;
}
interface AnimalConstructor {
new (name: string, age: number): Animal;
}
class Dog implements Animal {
name: string;
age : number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
class Cat implements Animal {
name: string;
age : number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
function createAnimal(cstr: AnimalConstructor, name: string, age: number) {
return new cstr(name, age); // OK
}
createAnimal(Dog, ‘팔랑’, 15);
createAnimal(Cat, ‘쭈쭈’, 10);

당연히 생성자는 그것으로 인해 생성된 객체 인스턴스와 다른 타입이다. 이 코드에서는 객체 인스턴스가 Animal 타입, 그리고 생성자는 AnimalConstructor 타입이다. AnimalConstructornew라는 키워드와 함께 함수 인터페이스 문법을 사용하는데 이것이 TypeScript에서 생성자를 정의하는 문법이다. 앞에 new 키워드가 들어간다는 걸 제외하면, 일반 함수 인터페이스 문법과 다른 점이 없다. 다만 Animal 타입을 리턴한다는 것을 명시적으로 선언해줘야 한다.

하이브리드 타입

하이브리드 타입은 함수이기도 하면서 객체이기도 한 인터페이스다. 예를 들면, jQuery의 $는 객체이기도 하면서 쿼리 셀렉터로 기능하기도 하는 함수이기도 하다. 이 경우에 jQuery의 인터페이스를 정의하면 다음과 같이 쓸 수 있을 것이다.2

interface jQueryElement {
// …
}
interface jQueryInterface {
(string: query): jQueryElement;
each: Function;
ajax: Function;
// …
}

위 처럼 함수 인터페이스의 문법과 일반 인터페이스 프로퍼티의 문법을 함께 사용할 수 있다. 사용하려는 JavaScript 라이브러리가 jQuery처럼 함수이면서도 객체로 구현된 경우, 이런 형태로 인터페이스를 정의할 수 있을 것이다.

그 외

TypeScript의 인터페이스는 위에 설명한 것 이외에도 많은 기능을 갖추고 있다. 먼저 인터페이스가 인터페이스를 extends 키워드를 통해 확장할 수 있으며, 인터페이스끼리 다중 상속도 가능하다. 또한 인터페이스를 통해 클래스도 확장이 가능하다. 이런 기능들은 굳이 예제를 들어 설명해야할 정도로 이해하기 어려운 기능은 아니므로 따로 지면을 할애하지는 않았다. 실은 좀 귀찮아서..


  1. 1.컴파일 옵션 중 noImplicitAny 옵션을 false로 설정하는 경우 에러가 나지 않는다. 디폴트 값은 true이다.
  2. 2.단순히 예제일 뿐이므로 실제와는 다를 수 있다.