아마 타입스크립트를 입문할 때, 첫 번째 고비가 제네릭일 것이다.
그럴 것이 문법도 무언가 괴상하게 생겼다.
하지만, 제네릭을 완벽하게 이해하지 못하면 타입스크립트를 유연하게 사용하기 어려워진다.
이번 글에서는 제네릭의 두려움을 벗어나는 것을 목표로 제네릭을 낱낱이 알아보겠다.
1️⃣ 제네릭의 사전적 의미
우선 제네릭이 한국어로 무슨 뜻인지 살펴보자.
우리는 여기서 '포괄적인', '이름이 붙지 않은' 키워드에 초점을 두어야 한다.
타입스크립트에서 제네릭 문법은 위의 키워드를 내포하고 있다.
실제로, 제네릭 문법을 사용하면 타입을 어느 한 가지로 확정 짓는 것이 아니라, 개발자의 마음대로 유연하게 사용할 수 있게 해준다.
즉, 제네릭 문법은 타입의 이름을 붙이지 않고 다양한 타입에 대해 동작할 수 있는 포괄적인 코드를 작성할 수 있게끔 해준다.
아직 이 말이 이해가 되지 않더라도 우선 제네릭을 어떻게 사용하는지 이해한다면, 위의 문장이 더욱 와닿을 것이다.
2️⃣ 제네릭의 등장 배경
우선 제네릭을 사용하지 않고 코드를 구현했을 때 발견되는 문제점을 알아보자.
아래 예시를 보자.
function getValue(a: string): string {
return a;
}
let myString = getValue('abc');
console.log(myString.length); // 3 출력
위의 getValue 함수는 string 타입만을 인자로 받고 string 타입을 리턴한다.
만약, number 타입을 인자로 받고 싶으면 어떻게 해야 될까 ?
두 가지 방법이 떠오른다.
먼저 첫 번째 방법은 함수를 하나 더 만든는 것이다.
// string 타입을 받는 함수
function getStringValue(a: string): string {
return a;
}
// number 타입을 받는 함수
function getNumberValue(a: number): number{
return a
}
let myString = getStringValue('abc');
let myNumber = getNumberValue(1.23)
console.log(myString.length); // 3 출력
console.log(myNumber.toFixed(1)) // 1.2 출력
그런데, 위의 방식은 누가 봐도 문제가 있다.
만약 boolean 타입을 반환하고 싶다면 getBooleanValue 함수를 또 만들어야 한다.
같은 기능을 하는 함수를 의미 없이 코드를 중복시켜 생산하고 있는 것이다.
유지보수성, 재사용성, 확장성 측면에서 모두 최악의 코드이다.
이번에는 두 번째 방법인 유니온(Union) 타입을 이용해보자.
function getValue(a: string | number): string | number {
return a;
}
let myString = getValue('abc')
let myNumber = getValue(1.23)
얼핏 보면 괜찮아 보인다.
코드의 중복을 피하였고, 조금은 번거롭지만 만약 타입을 더 추가하고 싶다면 유니온(Union) 타입에 추가만 하면 된다.
하지만, 이 코드 역시 큰 문제점이 존재한다.
분명, myString에는 string 타입인 'abc' 가 할당되고
myNumber에는 number 타입인 1.23이 할당되었다.
그러나, 각 타입이 가지고 있는 메서드를 이용하려 하면 에러가 난다.
왜 그럴까 ?
바로, 타입스크립트 입장에서는 리턴 타입을 string | number 로 하였기 때문에
리턴된 것이 정확히 string 인지 number 인지 알 수가 없기 때문이다.
개발자만 알 수 있는 것이지, 타입스크립트 관점에서는 알 수가 없기에 각 타입에 적용할 수 있는 메서드를 제한한 것이다.
실제로, string 과 number 타입이 공통으로 가지고 있는 메서드는 이용 가능하다.
좀 더 나아가서 현재 상황에서 에러를 피할 수 있는 방법 역시 존재한다.
타입 가드(Type Guard), 타입 단언(Type Assertion) 을 사용하면 된다.
타입 가드와 타입 단언에 관한 내용은 이 글의 주제와 벗어나므로 생략하겠다.
다만, 지금 상황에서 굳이 타입 가드와 단언을 사용할 필요가 전혀 없다.
다시 본론으로 들어가면, 지금까지 하나의 함수에서 다양한 타입이 필요했을 때 이것을 해결할 수 있는 두 가지 방법에 대해 알아보았다.
그러나, 두 방법 모두 말끔히 해결하지 못하고 다른 문제를 야기시켰다.
이러한 상황에서 우리는 제네릭이 필요한 것이다.
제네릭을 사용하면 위의 문제점들을 완벽하게 해결하며, 코드의 유연성 및 재사용성을 높여준다.
제네릭의 강력한 기능들을 하나씩 알아보자.
3️⃣ 제네릭 기본 문법
제네릭은 마치 타입을 함수의 인자처럼 사용한다.
함수의 인자는 괄호 안에 넣는다면, 제네릭은 꺽쇠 기호 안에 넣어서 사용한다.
예시를 보겠다.
function getValue<T>(a: T): T {
return a;
}
관례상 제네릭의 첫 번째 인자는 T로 작명한다.
이제 getValue 함수를 사용할 때마다 T에 원하는 타입을 명시하여 사용하면 된다.
function getValue<T>(a: T): T {
return a;
}
let myString = getValue<string>('abc')
let myNumber = getValue<number>(1.23)
위의 코드의 getValue<string>과 getValue<number>는 아래와 같은 의미이다.
function getValue<T>(a: T): T {
return a;
}
// getValue<string>
function getValue(a: string): string {
return a;
}
//getValue<number>
function getValue(a: number): number {
return a;
}
제네릭을 사용하면 앞서 제네릭을 사용하지 않았을 때 발생했던 문제점을 말끔히 해결해준다.
동일한 기능을 하는 함수를 중복하여 생산할 필요도 없고, 타입 가드 및 타입 단언을 추가적으로 사용할 필요가 없다.
타입의 인자는 반드시 하나만 사용할 수 있는 것이 아니라, 함수와 마찬가지로 한 개 이상 사용할 수 있다.
function getTuple<T, K>(a: T, b: K): [T, K] {
return [a, b];
}
let tuple1 = getTuple<string, number>('a', 1);
let tuple2 = getTuple<boolean, string[]>(false, ['a', 'b', 'c']);
제네릭을 사용하면 매우 유연하고 확장성 있게 타입을 구현할 수 있다는 것이 확 와닿을 것이다.
4️⃣ 제네릭 활용
제네릭은 위의 예시에서 들었던 함수 이외에도 굉장히 다양한 기능에서 함께 사용된다.
하나씩 알아보자.
✅ 내장 객체에서 제네릭
JS의 내장 객체는 많지만 그 중 Promise만 살펴보겠다.
TS에서 Promise는 제네릭을 이용하여 타입을 지정한다.
그 이유는 Promise에 어떤 값을 resolve할지는 개발자가 정하는 것이므로 제네릭을 사용하여 타입의 유연성을 확보한 것이다.
function playGame(): Promise<string> {
const random: number = Math.random();
return new Promise((resolve, reject) => {
if (random >= 0.5) resolve('Game success');
else reject(new Error('Game failed'));
});
}
playGame()
.then((result) => console.log(result))
.catch((error) => console.log(error));
playGame 함수에 리턴 타입을 Promise<string> 해줌으로써 resolve에 string 타입을 인자로 전해줄 수 있게 해주었다.
✅ 인터페이스에서 제네릭
interface Data<T> {
id: number;
value: T;
}
let data1: Data<string> = {
id: 1,
value: 'abc',
};
let data2: Data<number> = {
id: 2,
value: 100,
};
제네릭을 활용하여 하나의 인터페이스만으로 다양한 타입을 구현하였다.
✅ 클래스에서 제네릭
class Data<T> {
static netxId = 1;
readonly id: number;
value: T;
constructor(value: T) {
this.id = Data.netxId++;
this.value = value;
}
getValue(): T {
return this.value;
}
}
let data1 = new Data<string>('hello');
let data2 = new Data<number>(100);
id는 auto increment 로 구현하였다.
제네릭을 사용하여 하나의 클래스로 다양한 타입의 인스턴스를 생성할 수 있다.
5️⃣ 제네릭 제약 조건
앞서, 제네릭을 활용하여 타입을 동적으로 지정함으로써, 코드의 재사용성을 높이고 타입 안정성을 유지하면서도 다양한 데이터 타입을 유연하게 처리할 수 있게 구현했다.
하지만, 모든 것이 양날의 검이듯 제네릭 또한 단점이 존재한다.
아래 예시를 확인하자.
function getDataLength<T>(data: T) {
return data.length;
}
T에 어떠한 타입이 올지 모르기 때문에 해당 에러가 발생한 것이다.
제네릭을 사용하면 length 속성을 갖고 있는 string이나 array 타입이외에도 number 타입 등이 올 수 있기에 에러가 발생해야 되는 것이 어찌보면 당연한 결과이다.
이런 상황일 때, 제약 조건을 걸어 T에 할당할 수 있는 타입을 제한할 수 있다.
이때 extends 문법을 통해 제한할 수 있다.
function getDataLength<T extends { length: number }>(data: T) {
return data.length;
}
let length1 = getDataLength<string[]>(['a', 'b', 'c', 'd']);
let length2 = getDataLength<number>(100);
number 타입에는 length 속성이 없기에 다음과 같은 에러가 발생한다.
getDataLength 에는 array 타입과 string 타입만 할당할 수 있는 것이다.
개인적으로 extends를 사용하여 제약 조건을 구현하는 것이 혼란스러웠다.
실제로 인터페이스에서 extends는 확장의 의미이다.
만약 확장의 의미를 위의 코드에 대입하면 T 타입에 {length: number} 속성을 확장하겠다라는 의미가 된다.
즉, T 타입에는 반드시 length 속성이 포함된다라는 의미로 해석되는 것이다.
만약, T에 number를 할당하면 number 타입에 {length:number} 속성을 포함하겠다는 의미가 되는 것이다.
처음에는 오히려 number 타입에 {length:number} 를 포함시키니 위의 에러가 발생하는 것이 아이러니하다고 생각하였다.
물론 number 타입에 length 속성은 존재하지 않고, number.length 자체가 자연스럽지 못한 것은 사실이다.
하지만, extends를 통해 확장을 하였으니 number에도 lengh 속성이 포함된다는 혼란에 빠진 것이었다.
그런데 사고를 조금 전환해서 객체제향적으로 생각하니 왜 다음과 같이 사용하는지 이해가 되었다.
흔히 객체지향에서 extends 키워드는 '상속'의 뜻을 내포하고 있다.
A extends B 하면 A는 B의 성질을 갖고 있으니 A is a B 라고 할 수 있다.
Dog extends Animal -> Dog is an Animal 이처럼 말이다.
제네릭 제약에서 extends 역시 is-a 구조로 생각하면 명확히 이해가 된다.
T extends {length: number} -> T is a {length: number} 라는 의미기 때문에 T 타입에는 length 속성이 있다는 의미가 되는 것이다.
이번 글을 정리해보겠다.
- 제네릭은 타입을 동적으로 지정하여 유연하고 확장성 있는 코드를 구현할 수 있게 해준다.
- 제네릭은 타입스크립트의 다양한 기능에서 활용된다.
- 제네릭 제약 조건을 사용하여 예상치 못한 에러를 방지할 수 있다.
'TypeScript' 카테고리의 다른 글
[TypeScript] 바닐라JS(TS)로 CSR에 기반한 컴포넌트 만들기 (1) | 2024.09.26 |
---|---|
[TypeScript] tsconfig.json 주요 옵션 정리 (1) | 2024.09.25 |
[TypeScript] 인터페이스(interface) 총 분석 (0) | 2024.09.20 |