제네릭(Generics)
제네릭(Generics)
출처: TypeScript 공식문서
도입
소프트웨어 엔지니어링에서 아주 중요한 일 중 하나는, API가 잘 정의되고 일관성 있으면서도 재사용 가능한 컴포넌트를 만드는 거예요. 오늘의 데이터도, 내일의 데이터도 모두 처리할 수 있는 컴포넌트를 만들면, 큰 소프트웨어 시스템을 만들 때 훨씬 유연해지죠.
C#이나 Java 같은 언어에서 재사용 컴포넌트를 만들 때 쓰는 핵심 도구 중 하나가 바로 *제네릭(Generics)*이에요. 쉽게 말하면, 하나의 타입에만 묶여 있지 않고 여러 타입에 대해 동작하는 컴포넌트를 만들 수 있게 해 주는 기능이랍니다. 이렇게 하면 사용자들이 자기만의 타입을 넣어서 쓸 수도 있어요.
본문
Hello World of Generics
제네릭의 '헬로 월드'로는 identity 함수가 딱이에요. identity 함수는 받은 값을 그대로 돌려주는 함수인데, echo 명령과 비슷하다고 생각하면 돼요.
제네릭 없이 이 함수를 만들려면, identity 함수에 특정 타입을 정해 주거나:
function identity(arg: number): number { return arg;}
any 타입으로 표현하는 수밖에 없어요:
function identity(arg: any): any { return arg;}
any를 쓰면 arg의 타입으로 뭐든 다 받아들이니 그럴듯해 보이지만, 함수가 값을 돌려줄 때 그 타입에 대한 정보를 잃어버려요. number를 넣었다 해도, 결과로는 '뭐든 될 수 있다'는 정보만 남거든요.
그래서 인자의 타입을 잡아 두고, 그걸 다시 반환 타입을 표현하는 데 쓸 수 있는 방법이 필요해요. 여기서 등장하는 게 **타입 변수(type variable)**예요. 값이 아니라 타입을 대상으로 동작하는 특별한 변수죠.
function identity<Type>(arg: Type): Type { return arg;}
한결같이 identity 함수에 타입 변수 Type을 추가했어요. 사용자가 넘겨준 타입(예: number)을 Type이 잡아 두고, 나중에 그 정보를 쓸 수 있게 하는 거예요. 여기서는 Type을 반환 타입으로도 다시 쓰고 있는데, 이제 인자 타입과 반환 타입이 같은 타입임을 명확히 알 수 있어요. 함수 한쪽에서 타입 정보를 받아서 다른 쪽으로 그대로 흘려 보내 주는 셈이에요.
이제 이 identity 함수는 여러 타입에 걸쳐 동작하니 '제네릭'하다고 말할 수 있어요. 게다가 any와 달리, 처음에 number로만 동작했던 identity 함수만큼이나 정밀해서 정보를 잃지 않아요.
제네릭 identity 함수를 만들었으니 이제 두 가지 방법으로 호출할 수 있어요. 첫 번째는 타입 인자까지 포함해 모든 인자를 다 넘기는 방법:
let output = identity<string>("myString");
여기서는 호출의 인자로 <> 안에 Type을 string으로 명시해 줬어요. 참고로 타입 인자는 ()가 아니라 <>로 감싼다는 게 포인트예요.
두 번째 방법이 오히려 더 흔한데, 바로 **타입 인자 추론(type argument inference)**이에요. 즉 컴파일러가 우리가 넘긴 인자의 타입을 보고 Type을 알아서 정해 주길 바라는 방식이죠:
let output = identity("myString");
보시다시피 각괄호(<>)에 타입을 굳이 명시하지 않았어요. 컴파일러가 값 "myString"을 보고 Type을 그 타입으로 정해 준 거예요. 타입 인자 추론은 코드를 짧고 읽기 쉽게 만들어 주는 유용한 도구지만, 컴파일러가 타입을 추론하지 못하는 더 복잡한 경우에는 앞선 예처럼 타입 인자를 명시적으로 넘겨야 할 수도 있어요.
Working with Generic Type Variables
제네릭을 쓰기 시작하면 알게 되는 게 있어요. identity 같은 제네릭 함수를 만들면, 컴파일러가 함수 본문에서 제네릭 타입 파라미터를 올바르게 다루도록 강제한다는 점이에요. 즉 그 파라미터들을 '뭐든 될 수 있는 타입'으로 취급해야 한다는 거죠.
아까 만든 identity 함수를 다시 볼게요:
function identity<Type>(arg: Type): Type { return arg;}
여기에 인자 arg의 길이를 호출할 때마다 콘솔에 같이 찍고 싶다고 해 볼게요. 그러면 아마 이렇게 쓰고 싶어질 거예요:
function loggingIdentity<Type>(arg: Type): Type { console.log(arg.length); return arg;}
이렇게 하면 컴파일러가 arg의 .length 멤버를 쓴다고 에러를 냅니다. 어디에서도 arg에 그 멤버가 있다고 말해 주지 않았잖아요. 아까 말했듯 타입 변수는 모든 타입을 대신하니까, 누군가 .length가 없는 number를 넘겨줄 수도 있는 거예요.
그럼 아예 이 함수가 Type 그 자체가 아니라 Type의 배열에서 동작하도록 의도했다고 해 볼게요. 배열을 다루니까 이제 .length 멤버를 쓸 수 있어요. 다른 타입의 배열을 만들 때처럼 똑같이 표현해 주면 돼요:
function loggingIdentity<Type>(arg: Type[]): Type[] { console.log(arg.length); return arg;}
이제 loggingIdentity의 타입은 "제네릭 함수 loggingIdentity는 타입 파라미터 Type을 받고, Type들의 배열인 인자 arg를 받아 Type들의 배열을 반환한다"로 읽을 수 있어요. 숫자 배열을 넘기면 Type이 number에 묶여 숫자 배열이 그대로 돌아오죠. 이렇게 하면 제네릭 타입 변수 Type을 타입 전체가 아니라 우리가 다루는 타입의 일부로써 쓸 수 있어서 훨씬 유연해져요.
같은 예제를 이렇게도 쓸 수 있어요:
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> { console.log(arg.length); // Array has a .length, so no more error return arg;}
이런 타입 스타일은 다른 언어에서 이미 익숙한 분도 많을 거예요. 다음 섹션에서 Array<Type> 같은 제네릭 타입을 직접 만드는 방법을 다룰게요.
Generic Types
앞 섹션에서는 여러 타입에 걸쳐 동작하는 제네릭 identity 함수를 만들었어요. 이번에는 함수 자체의 타입이 어떻게 생겼는지, 그리고 제네릭 인터페이스를 어떻게 만드는지 살펴볼게요.
제네릭 함수의 타입은 제네릭이 아닌 함수의 타입과 같되, 타입 파라미터가 함수 선언처럼 맨 앞에 온다는 점만 달라요:
function identity<Type>(arg: Type): Type { return arg;}
let myIdentity: <Type>(arg: Type) => Type = identity;
타입에서 제네릭 타입 파라미터의 이름을 다르게 써도 돼요. 타입 변수의 개수와 사용 방식만 맞으면 되니까요:
function identity<Type>(arg: Type): Type { return arg;}
let myIdentity: <Input>(arg: Input) => Input = identity;
제네릭 타입을 객체 리터럴 타입의 호출 시그니처로도 쓸 수 있어요:
function identity<Type>(arg: Type): Type { return arg;}
let myIdentity: { <Type>(arg: Type): Type } = identity;
이렇게 하다 보면 첫 제네릭 인터페이스를 쓰게 돼요. 아까의 객체 리터럴을 인터페이스로 옮겨 볼게요:
interface GenericIdentityFn { <Type>(arg: Type): Type;}
function identity<Type>(arg: Type): Type { return arg;}
let myIdentity: GenericIdentityFn = identity;
비슷한 예로, 제네릭 파라미터를 인터페이스 전체의 파라미터로 옮기고 싶을 수도 있어요. 이러면 우리가 어떤 타입(들)에 대해 제네릭한지 한눈에 보여요(예: 그냥 Dictionary가 아니라 Dictionary<string>). 타입 파라미터가 인터페이스의 다른 멤버들에게 모두 보이게 되는 거예요:
interface GenericIdentityFn<Type> { (arg: Type): Type;}
function identity<Type>(arg: Type): Type { return arg;}
let myIdentity: GenericIdentityFn<number> = identity;
주의할 점은 예제가 조금 달라졌다는 거예요. 제네릭 함수를 표현하는 대신, 이제는 제네릭 타입의 일부인 '제네릭이 아닌' 함수 시그니처가 됐어요. GenericIdentityFn을 쓸 때는 대응하는 타입 인자(여기서는 number)를 지정해 줘야 해서, 기저의 호출 시그니처가 쓸 타입이 사실상 고정되는 거죠. 타입 파라미터를 호출 시그니처에 바로 둘지, 인터페이스 자체에 둘지를 잘 판단하는 게, 어떤 부분이 제네릭인지 설명하는 데 도움이 돼요.
제네릭 인터페이스뿐 아니라 제네릭 클래스도 만들 수 있어요. 다만 제네릭 enum이나 namespace는 만들 수 없다는 점만 기억해 두세요.
Generic Classes
제네릭 클래스는 제네릭 인터페이스와 형태가 비슷해요. 클래스 이름 뒤에 각괄호(<>)로 감싼 제네릭 타입 파라미터 목록이 온다는 점이 특징이에요:
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) { return x + y;};
이건 GenericNumber 클래스를 꽤나 직역에 가깝게 쓴 예인데, 숫자 타입만 쓰도록 제한된 게 아니다는 걸 눈치챘을 거예요. string이나 더 복잡한 객체를 써도 됐을 거예요:
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) { return x + y;};
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
인터페이스에서처럼, 타입 파라미터를 클래스 자체에 두면 클래스의 모든 프로퍼티가 같은 타입을 다루고 있음을 보장할 수 있어요.
클래스 섹션에서 다루듯, 클래스 타입에는 정적(static) 측면과 인스턴스(instance) 측면 두 가지가 있어요. 제네릭 클래스는 정적 측면이 아니라 인스턴스 측면에 대해서만 제네릭하기 때문에, 클래스의 정적 멤버는 클래스의 타입 파라미터를 사용할 수 없어요.
Generic Constraints
아까 예시를 기억한다면, 때로는 그 타입 집합이 가진 능력에 대해 어느 정도 알고 있는 타입 집합에서 동작하는 제네릭 함수를 쓰고 싶을 때가 있어요. loggingIdentity 예시에서 우리는 arg의 .length 프로퍼티에 접근하고 싶었지만, 컴파일러는 모든 타입이 .length를 가진다고 증명할 수 없으니 그렇게 가정할 수 없다고 경고했죠:
function loggingIdentity<Type>(arg: Type): Type { console.log(arg.length); return arg;}
뭐든 다 받아들이는 대신, 이 함수를 .length 프로퍼티를 갖는 타입에서만 동작하도록 제한하고 싶어요. 타입에 이 멤버가 있기만 하면 허용하되, 최소한 이 멤버는 있어야 하는 조건을 거는 거예요. 그러려면 Type이 될 수 있는 것에 제약을 걸어야 해요.
제약을 표현하기 위해 인터페이스를 만들 거예요. 여기서는 단일 .length 프로퍼티를 가진 인터페이스를 만든 뒤, extends 키워드를 써서 그 인터페이스를 제약으로 지정할게요:
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}
제네릭 함수가 이제 제약을 받으니, 더 이상 모든 타입에 대해 동작하지 않아요:
loggingIdentity(3);
대신 필요한 프로퍼티를 모두 가진 타입의 값을 넘겨야 해요:
loggingIdentity({ length: 10, value: 3 });
Using Type Parameters in Generic Constraints
어떤 타입 파라미터를 다른 타입 파라미터로 제약할 수도 있어요. 예를 들어, 객체에서 그 이름을 받아 프로퍼티를 꺼내는 함수를 만든다고 해 볼게요. 실수로 obj에 존재하지 않는 프로퍼티를 꺼내지 않도록, 두 타입 사이에 제약을 걸 거예요:
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
getProperty(x, "a");
getProperty(x, "m");
두 번째 호출처럼 존재하지 않는 키 "m"을 넘기면 컴파일러가 에러를 냅니다.
Using Class Types in Generics
TypeScript에서 제네릭을 이용해 팩토리를 만들 때는, 클래스 타입을 그 생성자 함수로 참조해야 해요. 예를 들면:
function create<Type>(c: { new (): Type }): Type {
return new c();
}
조금 더 고급 예제로는 prototype 프로퍼티를 이용해 생성자 함수와 클래스 타입의 인스턴스 측면 사이의 관계를 추론하고 제약하는 방법이 있어요:
class BeeKeeper {
hasMask: boolean = true;
}
class ZooKeeper {
nametag: string = "Mikle";
}
class Animal {
numLegs: number = 4;
}
class Bee extends Animal {
numLegs = 6;
keeper: BeeKeeper = new BeeKeeper();
}
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;
이 패턴은 mixins 디자인 패턴의 기반으로 쓰여요.
Generic Parameter Defaults
제네릭 타입 파라미터에 기본값을 선언해 두면, 대응하는 타입 인자를 선택적으로 넣을 수 있어요. 예를 들어 새 HTMLElement를 만드는 함수가 있다고 해 볼게요. 인자 없이 호출하면 HTMLDivElement를 만들고, 첫 인자로 요소를 넘기면 그 인자 타입의 요소를 만들어요. 자식 목록을 선택적으로 넘길 수도 있죠. 원래는 이렇게 함수를 정의해야 했어요:
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
element: T,
children: U[]): Container<T, U[]>;
제네릭 파라미터 기본값을 쓰면 이렇게 줄일 수 있어요:
declare function create<T extends HTMLElement = HTMLDivElement, U extends HTMLElement[] = T[]>(
element?: T,
children?: U): Container<T, U>;
const div = create();
const p = create(new HTMLParagraphElement());
제네릭 파라미터 기본값에는 다음 규칙이 적용돼요:
- 기본값이 있는 타입 파라미터는 선택적(optional)으로 간주돼요.
- 필수 타입 파라미터는 선택적 타입 파라미터 뒤에 올 수 없어요.
- 타입 파라미터에 대한 기본 타입은, 제약이 있다면 그 제약을 만족해야 해요.
- 타입 인자를 지정할 때는 필수 타입 파라미터에 대해서만 지정하면 돼요. 지정하지 않은 타입 파라미터는 기본 타입으로 결정돼요.
- 기본 타입이 지정되어 있고 추론이 후보를 고르지 못하면, 기본 타입이 추론돼요.
- 기존 클래스/인터페이스 선언과 병합(merge)하는 클래스/인터페이스 선언은 기존 타입 파라미터에 기본값을 도입할 수 있어요.
- 기존 클래스/인터페이스 선언과 병합하는 클래스/인터페이스 선언은, 기본값을 지정하는 한 새로운 타입 파라미터를 도입할 수 있어요.
Variance Annotations
공변성과 반공변성은 두 제네릭 타입 사이의 관계가 무엇인지 설명하는 타입 이론 용어예요. 개념을 짧게 짚고 넘어갈게요.
예를 들어 어떤 타입을 make하는 객체를 나타내는 인터페이스가 있다고 해 볼게요:
interface Producer<T> {
make(): T;
}
Producer<Animal>이 기대되는 자리에 Producer<Cat>을 쓸 수 있어요. Cat은 Animal이니까요. 이 관계를 *공변(covariance)*이라고 불러요. Producer<T>에서 Producer<U>로 가는 관계가 T에서 U로 가는 관계와 같기 때문이에요.
반대로, 어떤 타입을 consume하는 인터페이스가 있다면:
interface Consumer<T> {
consume: (arg: T) => void;
}
Consumer<Cat>이 기대되는 자리에 Consumer<Animal>을 쓸 수 있어요. Animal을 받아들일 수 있는 함수는 반드시 Cat도 받아들일 수 있으니까요. 이 관계를 *반공변(contravariance)*이라고 해요. Consumer<T>에서 Consumer<U>로 가는 관계가 U에서 T로 가는 관계와 같거든요. 공변과 달리 방향이 뒤집힌다는 점에 주목하세요! 그래서 반공변은 '스스로 상쇄'되지만 공변은 그렇지 않아요.
TypeScript처럼 구조적(structural) 타입 시스템에서는 공변성과 반공변성이 타입 정의에서 자연스럽게 스며 나오는 동작이에요. 제네릭이 없어도 공변(그리고 반공변) 관계를 볼 수 있어요:
interface AnimalProducer {
make(): Animal;
}
// A CatProducer can be used anywhere an
// Animal producer is expected
interface CatProducer {
make(): Cat;
}
TypeScript는 구조적 타입 시스템을 쓰므로, 병렬로 Producer<Cat>이 Producer<Animal>이 기대되는 곳에서 쓸 수 있는지를 볼 때, 보통 두 정의를 구조적으로 펼쳐서 구조를 비교해요. 그런데 variance 덕분에 아주 유용한 최적화가 가능해져요. Producer<T>가 T에 대해 공변이라면, Cat과 Animal만 비교해도 돼요. Producer<Cat>과 Producer<Animal>이 같은 관계를 가질 거라는 걸 알고 있으니까요.
단, 이 논리는 같은 타입의 두 인스턴스화를 비교할 때만 쓸 수 있어요. Producer<T>와 FastProducer<U>가 있으면 T와 U가 이 타입들에서 반드시 같은 위치를 가리킨다고 보장할 수 없으니, 이런 비교는 항상 구조적으로 수행돼요.
variance는 구조적 타입의 자연스러운 속성이라서, TypeScript는 모든 제네릭 타입의 variance를 자동으로 추론해요. 어떤 종류의 순환 타입을 다루는 극히 드문 경우에는 이 측정이 부정확할 수 있어요. 그럴 때 타입 파라미터에 variance 어노테이션을 추가해 특정 variance를 강제할 수 있어요:
// Contravariant annotation
interface Consumer<in T> {
consume: (arg: T) => void;
}
// Covariant annotation
interface Producer<out T> {
make(): T;
}
// Invariant annotation
interface ProducerConsumer<in out T> {
consume: (arg: T) => void;
make(): T;
}
이건 구조적으로 그래야 하는 variance를 쓸 때만 해야 해요.
variance 어노테이션은 인스턴스화 기반 비교 중에만 효과가 있다는 점을 꼭 강조하고 싶어요. 구조적 비교 중에는 아무 효과가 없어요. 예를 들어 variance 어노테이션으로 타입을 실제로 불변(invariant)이 되게 '강제'할 수는 없어요:
// DON'T DO THIS - variance annotation
// does not match structural behavior
interface Producer<in out T> {
make(): T;
}
// Not a type error -- this is a structural
// comparison, so variance annotations are
// not in effect
const p: Producer<string | number> = {
make(): number {
return 42;
}
}
여기서 객체 리터럴의 make 함수는 number를 반환해요. number는 string | number가 아니니 에러가 나리라 기대할 수도 있어요. 하지만 객체 리터럴은 익명 타입이지 Producer<string | number>가 아니기 때문에, 이건 인스턴스화 기반 비교가 아니에요.
variance 어노테이션은, 왜 쓰는지, 한계가 무엇인지, 언제 효과가 없는지를 정확히 알 때만 쓰는 게 매우 중요해요. TypeScript가 인스턴스화 기반 비교를 쓸지 구조적 비교를 쓸지는 명세화된 동작이 아니라 정확성이나 성능 때문에 버전마다 바뀔 수 있어요. 그러니 타입의 구조적 동작과 일치할 때만 variance 어노테이션을 쓰는 게 좋아요. 특정 variance를 '강제'하려고 variance 어노테이션을 쓰면 안 돼요. 그건 코드에서 예측 못 할 동작을 만들 거예요.
기억하세요. TypeScript는 제네릭 타입으로부터 variance를 자동으로 추론할 수 있어요. variance 어노테이션을 써야 할 일은 거의 없고, 구체적인 필요를 확인했을 때만 써야 해요. variance 어노테이션은 타입의 구조적 동작을 바꾸지 않으며, 상황에 따라 인스턴스화 기반 비교를 기대했는데 구조적 비교가 일어날 수도 있어요. 이런 구조적 문맥에서 타입이 동작하는 방식을 바꾸는 데 variance 어노테이션을 쓸 수 없으니, 어노테이션이 구조적 정의와 같을 때가 아니라면 쓰지 말아야 해요. 이건 제대로 맞추기 어렵고, TypeScript가 대부분의 경우 variance를 올바르게 추론하므로, 일반적인 코드에서 variance 어노테이션을 적어야 할 일은 없을 거예요.
'타입 디버깅' 상황에서는 일시적인 variance 어노테이션이 유용할 수 있어요. variance 어노테이션은 검사되니까요. 식별 가능하게 잘못된 variance를 적으면 TypeScript가 에러를 냅니다:
// Error, this interface is definitely contravariant on T
interface Foo<out T> {
consume: (arg: T) => void;
}
다만 variance 어노테이션은 더 엄격해도 허용돼요(예: 실제 variance가 공변이어도 in out은 유효해요). 디버깅이 끝나면 variance 어노테이션을 제거하는 걸 잊지 마세요.
마지막으로, 타입체킹 성능을 최대로 끌어올리려 한다면 그리고 프로파일러를 돌렸고 그리고 특정 타입이 느리다는 걸 식별했고 그리고 variance 추론 자체가 느리다고 식별했고 그리고 쓰려는 variance 어노테이션을 신중히 검증했다면, 아주 복잡한 타입에서 variance 어노테이션을 추가해 약간의 성능 이득을 볼 수도 있어요.