...
제네릭(Generics) 소개
우리가 프로그래밍을 할때 '변수' 라는 저장소를 사용하는 이유는 데이터 값의 유연성을 위해서이다. 변수 라는 단어는 변할 수 있는 것을 말하고 그반대인 상수는 항상 고정된 것을 말한다.
이러한 개념으로 봤을때 우리가 이때까지 number[] 며 string 이며 사용했던 타입은 항상 고정되어 절대 변하지 않는 타입을 사용해오고 있었던 것이다. 그리고 여기에 약간의 유연성을 가미한게 number | string | undefiened 유니온 타입이다.
하지만 이 프로그래밍 환경에서는 상황이 항상 고정되어 의도대로 흘러가지는 않는다. 언제 어디서 변할수 있는 변수가 항상 일어나는게 이 업계이다.
따라서 타입을 직접적으로 고정된 값으로 명시하지말고 '변수' 를 통해 언제든지 변할 수 있는 타입을 통해 보다 유연하게 코딩을 할 수 있는 장치가 필요한데 이것이 바로 오늘 다룰 제네릭(generic) 타이다.
간단하게 말하자면 타입을 변수화 한 것이라고 할 수 있다.
다음 코드 예제를 통해서 왜 타입스크립트에 제네릭 타입이 빠져서는 안될 녀석인지 간단하게 알아보자.
add() 라는 메소드를 만드는데 이 함수는 숫자도 더해줘서 정수로 만들어주고 문자열도 더해줘서 합쳐진 문자열을 만들어주는 다재다능한 멀티 메소드이다.
넘버와 스트링 두 타입을 동시에 다루니 유니온 타입을 통해 간단한게 다음과 같이 구성할 수 있다.
function add(x: string | number, y: string | number): string | number {
return x + y;
}
add(1, 2); // 3
add('hello', 'world'); // 'helloworld'
언뜻 보기에는 문제가 없어보이지만, 여기에는 함정이 있다.
바로 유니온이 함정이다.
우리는 x: string, y: string 또는 x: number, y: number 를 의도했지만 사실 x: string, y: number 또는 x: number, y: string 도 될수 있는 가능성이 있다.
따라서 컴파일러는 이러한 것들을 똑똑하게 캐치하여 우리에게 빨간줄로 에러를 알리게 된다.
이를 어떻게 해결할까?
이러한 멀티 메소드를 유니온 타입으로 선언해서 문제가 된 것이다. 그러면 함수를 분리해서 하나의 함수 타입에는 하나의 역할만 하도록 분배해주면 된다.
타입스크립트에서는 이걸 함수 오버로딩 이라고 부른다.
function add(x: string, y: string): string;
function add(x: number, y: number): number;
function add(x: any, y: any) {
return x + y;
}
add(1, 2); // 3
add('hello', 'world'); // 'helloworld'
// 오버로딩을 통해 다음 함수 호출은 일어날 수가 없다.
// add(1, '2');
// add('1', '2');
그러나 허용될 타입 갯수가 많아질수록 코드가 길어지게 되어 가독성 이 안좋아지는건 매한가지다.
이러한 한계 때문에 제네릭이 나온 것이다.
다음과 같이 꺾쇠 괄호와 대문자 T 변수로서 지정함으로서, 제네릭을 통해 코드에 선언한 타입을 변수화 하고, 나중에 타입을 정하는 식으로 유연하게 사용이 가능하다.
function add<T>(x: T, y: T): T { // 제네릭 : 꺾쇠와 문자 T를 이용해 표현. T는 변수명이라고 보면 된다.
return x + y;
}
add<number>(1, 2); // 제네릭 타입 함수를 호출할때 <number> 라고 정해주면, 함수의 T 부분이 number로 바뀌며 실행되게 된다.
add<string>('hello', 'world'); // 'helloworld'
고급 타입 - 제네릭(Generics)
이처럼 Generic 타입은 함수나 클래스의 선언 시점이 아닌, 사용 시점에 타입을 선언할 수 있는 방법을 제공한다.
선언할때 그냥 변수 문자만 적어주고, 생성하는 시점에 사용하는 타입을 결정함으로써 변수나 함수 인터페이스를 다양한 타입으로 재사용할 수 있는 원리이다.
제네릭의 특징을 열거하면 다음과 같다.
- 타입이 고정되는 것을 방지하고 재사용 가능한 요소를 선언할 수 있다.
- 타입 검사를 컴파일 시간에 진행함으로써 타입 안정성을 보장.
- 캐스팅 관련 코드를 제거할 수 있다.
- 제네릭 로직을 이용해 타입을 다르게 받을 수 있는 재사용 코드를 만들 수 있다.
제네릭은 원래 C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 타입이다. 특히, 한가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용된다.
실제로 타입스크립트 라이브러리의 메소드 타입 형태를 보면 모두 제네릭으로 이루어져 있다.
꺾쇠 <> 기호를 변수명, 함수명 앞에다 쓰면 '타입 단언' 이 되게 된다.
따라서 제네릭을 구현하려면 변수명, 함수명 뒤에다가 꺾쇠 괄호를 적어주어야 한다.
그리고 제네릭명은 꼭 T가 아니어도 된다.
내마음대로 붙일수는 있지만 관습적으로 대문자 알파벳 한글자로 처리하는 편이다. (T, U, K ...)
// 인수들을 받아서 배열로 만들어주는 메소드
function toArray<T>(a: T, b: T): T[] {
return [a, b];
}
// 만약 화살표 함수로 제네릭을 표현한다면 다음과 같이 된다.
const toArray2 = <T>(a: T, b: T): T[] => { ... }
toArray<number>(1, 2); // 숫자형 배열
toArray<string>('1', '2'); // 문자형 배열
toArray<string | number>(1, '2'); // 혼합 배열
// 사실 컴파일러는 전달하는 인수의 타입을 보고 스스로 추론하기 때문에 함수 호출할때 제네릭을 안써줘도 알아서 추론한다.
toArray(1, 2);
toArray('1', '2');
toArray<string | number>(1, '2'); // 하지만 가끔 자동 타입 추론이 잘안되는 경우가 있기 때문에 직접 제네릭을 선언해야한다.
보통 제네릭 함수를 호출할때 제네릭을 생략하는 위의 코드의 두 번째 방법이 가독성이 좋기 때문에 흔하게 사용된다.
하지만 코드가 복잡해서 컴파일러가 멍청하게 타입 추론을 잘못한다면 직접 제네릭을 지정해야 하는 경우도 있다.
주의해야할 점은, 만일 제네릭에서 인수를 배열로 받을 경우 따로 제네릭 처리를 T[] 나 Array<T> 로 해주어야 한다는 점이다. 그외에 일반 타입이나 객체 같은 경우는 따로 처리없이 그대로 받으면 된다.
function loggingIdentity<T>(arg: T[]): T[] {
console.log(arg.length); // 배열은 .length를 가지고 있다. 따라서 오류는 없다.
return arg; // 배열을 반환
}
function loggingIdentity2<T>(arg: Array<T>): Array<T> {
console.log(arg.length);
return arg;
}
loggingIdentity([1, 2, 3]);
loggingIdentity2([1, 2, 3]);
특히 제네릭은 인터페이스와 정말 많이 쓰인다.
둘이 천생연분 부부라고 느낌이 들정도로 앞으로 정말 정말 자주 보게 될 것이다.
// 제네릭 인터페이스
interface Mobile<T> {
name: string;
price: number;
option: T; // 제네릭 타입 - option 속성에는 다양한 데이터 자료가 들어온다고 가정
}
// 제네릭 자체에 리터럴 객테 타입도 할당 할 수 있다.
const m1: Mobile<{ color: string; coupon: boolean }> = {
name: 's21',
price: 1000,
option: { color: 'read', coupon: false }, // 제네릭 타입의 의해서 option 속성이 유연하게 타입이 할당됨
};
const m2: Mobile<string> = {
name: 's20',
price: 900,
option: 'good', // 제네릭 타입의 의해서 option 속성이 유연하게 타입이 할당됨
};
또한 type alias 로도 잘 어울린다.
type TG<T> = T[] | T;
const number_arr: TG<number> = [1, 2, 3, 4, 5];
const number_arr2: TG<number> = 12345;
const string_arr: TG<string> = ['1', '2', '3', '4', '5'];
const string_arr2: TG<string> = '12345';
제네릭 제약 조건 (extends)
제네릭은 사용하는 시점에 타입을 결정해줌으로써 사실상 아무 타입이나 집어넣어도 상관 없다.
function identity<T>(p1: T): T {
return p1;
}
identity(1);
identity('a');
identity(true);
identity([]);
identity({});
이렇게 입력값에 대한 유연성을 확보했지만 각 함수에 대해 사용처에 따라서 입력값을 제한 할 필요가 생긴다.
가장 대표적인 예로 forEach() 라는 메소드제네릭을 이용하여 만든다고 쳤을때, 이 forEach() 는 배열을 순회하는 고차 함수니 반드시 원본값을 배열로 받을 필요가 있다.
또한 리액트와 같은 라이브러리의 메소드를 구현할때에도 입력 가능한 값을 범위를 제한하여 만든다. 예를 들어 리액트의 속성값 전체는 객체 타입만 허용된다.
이를 위해 타입스크립트의 제네릭은 적용되는 타입의 종류를 제한할 수 있는 기능을 제공한다.
다음과 같이 제네릭에 extends 키워드를 이용하면 제네릭 타입으로 입력할 수 있는 타입의 종류를 제한할 수 있다.
제네릭의 extends는 인터페이스나 클래스의 extends 와 약간 정의가 다르다.
클래스의 extends는 상속의 의미로서 '확장' 의 정의를 가지지만, 제네릭의 extends는 '제한' 의 의미를 가진다는 차이점이 있다.
따라서 <T extends K> 형태의 제네릭이 있다면, T가 K에 할당 가능해야 한다 라고 정의하면 된다.
type numOrStr = number | string;
// 제네릭에 적용될 타입에 number | string 만 허용
function identity<T extends numOrStr>(p1: T): T {
return p1;
}
identity(1);
identity('a');
identity(true); //! ERROR
identity([]); //! ERROR
identity({}); //! ERROR
속성 제약조건
단순히 사용성을 위해 제네릭 타입을 제한 하는 것 뿐만 아니라 로직에 의해서 어쩔수 없이 제한해야 하는 경우도 있다.
예를 들어 다음 코드를 보면, T에는 .length 프로퍼티가 없다고 오류가 뜨는데, 왜냐하면 우리 입장에선 제네릭 타입이니 그럴려니 하겠지만 컴파일러 입장에선 T 타입이 대체 무엇인지 모르기 때문에 그런 것이다.
function loggingIdentity<T>(arg: T): T {
console.log(arg.length);
return arg;
}
이때는 타입 가드로 조건문을 통해 분기하는 방법도 있겠지만,
function loggingIdentity<T>(arg: T): T {
if(typeof arg === "string" || Array.isArray(arg)) console.log(arg.length);
return arg;
}
만일 length 같은 기본 프로퍼티가 아니라 사용가 커스텀 프로퍼티일 경우 제네릭 타입의 프로퍼티를 반드시 해당 속성을 포함하도록 지정을 해주어야 한다.
interface Lengthwise {
length: number;
}
// 제네릭 T 는 반드시 { length: number } 프로퍼티 타입을 포함해 있어야 한다.
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // 이제 .length 프로퍼티가 있는 것을 알기 때문에 더 이상 오류가 발생하지 않습니다.
return arg;
}
loggingIdentity(3); // 오류, number는 .length 프로퍼티가 없습니다.
loggingIdentity({ length: 10, value: 3 });
매개변수 제약조건
하나의 함수에서 제네릭은 여러개 지정해서 사용할 수 있다.
이를 이용해 각 매개변수마다 다른 제네릭 타입 조건 제한을 걸수 있다
function myfunc<T extends string, K extends number>(arg1: T, arg2: K): void {
console.log(typeof arg1); // string
console.log(typeof arg2); // number
}
myfunc('1', 2);
이를 응용하면 다음과 같이 로직을 짤 수 있다. (이부분은 약간 어려우니 집중해서 보길 바란다)
getProperty 라는 메소드가 있고, 이 함수는 객체와 key이름을 아규먼트로 받는데, 만일 객체에 존재하지 않는 key명을 입력받을 경우 오류를 내뿜는다.
조건 분기로 해결할수도 있겠지만 제네릭 자체에서 타입을 제한하면 된다.
여기서 핵심은 K extends keyof T 제네릭 타입인데, 제네릭 T에는 x변수(객체) 가 오게되는데 이 객체의 key값만 뽑아 keyof를 통해 유니온 타입으로 'a' | 'b' | 'c' | 'd' 만들어주고 K 제네릭에 제한을 건다.
그러면 K 제네릭은 반드시 'a' | 'b' | 'c' | 'd' 상수 타입만 올수있다. 이런식으로 타입 가드 장치를 거는것도 타입스크립트의 로직의 한 방법이다.
자주 응용되는 타입스크립트의 keyof / typeof 키워드 사용법은 이 글 을 참고하길 바란다.
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3, d: 4 };
getProperty(x, 'a'); // 성공
getProperty(x, 'm'); // 오류: 인수의 타입 'm' 은 'a' | 'b' | 'c' | 'd'에 해당되지 않음.
두번째 예제를 보면, 다음 swapProperty 메소드는 객체 타입을 받아서 서로의 key의 value를 스왑하는 함수이다.
전달받는 객체 타입의 형태는 { name: string, age: number, liveInSeoul: boolean } 이며, 이 객체 형태 대로 아규먼트로 전달해주면 문제가 없겠지만 만일 사용자가 객체 프로퍼티를 잘못 전달 했을 경우는 대비래 제네릭을 제한할 필요가 있다.
따라서 keyof Person 을 통해 name | age 유니온 타입을 만들고 이를 제네릭에 extends 해서 반드시 제네릭 T의 타입에 해당 key속성을 포함하도록 메소드 내에서 제한을 할 수 있다.
interface Person {
name: string;
age: number;
}
interface Korean extends Person {
liveInSeoul: boolean;
}
// type T1 = keyof Person;
function swapProperty<T extends Person, K extends keyof Person>(p1: T, p2: T, key: K): void {
// p1 객체에 있는 key의 value를 p2 객체에 있는 key의 value와 스왑하는 함수
// 그런데 당연히 p1 객체에 있는 key는 p2에도 존재해야 되기 때문에 key인수의 제네릭 K를 (keyof Person) 으로 제한
const temp = p1[key];
p1[key] = p2[key];
p2[key] = temp;
}
const p1: Korean = {
name: '홍길동',
age: 23,
liveInSeoul: true,
};
const p2: Korean = {
name: '김삿갓',
age: 31,
liveInSeoul: false,
};
swapProperty(p1, p2, 'age'); // 객체의 age 키의 값을 서로 스왑
/*
{ name: '홍길동', age: 31, liveInSeoul: true }
{ name: '김삿갓', age: 23, liveInSeoul: false }
*/
함수 제약조건
만일 일반 타입이나 인터페이스가 아닌 함수 자체를 제네릭 인자에서 받을수 있도록 제한하는 것이면 어떻게 선언할까?
매개변수에 콜백 함수를 받아들일때는 다음과 같이 제네릭 제약을 할 수 있다. (T 에는 해당 함수 자테 타입 형태로만 들어올 수 있다)
function translate<T extends (a: string) => number, K extends string>(x: T, y: K): number {
return x(y);
}
// 문자숫자를 넣으면 정수로 변환해주는 함수
const num = translate((a) => { return +a; }, '10');
console.log('num: ', num); // num : 10
제네릭 함수 타입
우리는 타입스크립트 함수 자체도 하나의 타입으로 지정할 수 있다고 배웠다.
예를 들어 다음과 같이 함수 타입 구조를 정해주고, 이 함수 타입 구조에 맞는 함수만 할당 가능하도록 체계적인 로직을 짤 수 있었다.
//* 인터페이스로 함수 타입을 지정
interface Add {
(x: number, y: number): number;
}
let myFunc: Add = (x, y) => {
return x + y;
};
제네릭 함수 역시 함주 자체 타입 구조로 만들어 할당 제한이 가능하다.
interface GenericIdentityFn {
<T>(arg: T): T; // 제네릭 함수 타입 구조
}
function identity<T>(arg: T): T {
return arg;
}
let myIdentity: GenericIdentityFn = identity;
myIdentity<number>(100);
myIdentity<string>('100');
다른 방식으로 아예 함수를 할당할때 제네릭을 결정하는 방식으로도 사용될 수 있다.
제네릭 <T> 가 인터페이스명 옆에 옮겨간걸 확인 할 수 있다.
interface GenericIdentityFn<T> {
(arg: T): T;
}
function identity<T>(arg: T): T {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;
let myIdentity2: GenericIdentityFn<string> = identity;
myIdentity(100);
myIdentity2('100');
혹은 인터페이스를 사용안하고 직접 리터럴로 함수 자체 타입을 타입 선언할 수 있다. (다만 가독성이 좋지 않아 많이 사용되지 않는다)
function logText<T>(text: T): T {
return text;
}
// #1
let str: <T>(text: T) => T = logText;
// #2 : 가독성 향상을 위해 중괄호로 묶어서 표현
let str: {<T>(text: T): T} = logText;
이와 같은 방식으로 제네릭 인터페이스 뿐만 아니라 클래스도 생성할 수 있다.
다만, 이넘(enum)과 네임스페이스(namespace)는 제네릭으로 생성할 수 없다.
제네릭 클래스 타입
제네릭 클래스에서도 제네릭을 사용하여 유연하게 클래스에서 다룰 타입을 지정할 수 있다.
앞에서 살펴본 제네릭 인터페이스와 비슷하다고 보면 된다. 이처럼 제네릭 클래스도 클래스 안에 정의된 속성들이 정해진 타입으로 잘 동작하게 보장할 수 있다.
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
constructor(v: T, cb: (x: T, y: T) => T) {
this.zeroValue = v;
this.add = cb;
}
}
let myGenericNumber = new GenericNumber<number>(0, (x, y) => {
return x + y;
});
let myGenericString = new GenericNumber<string>('0', (x, y) => {
return x + y;
});
myGenericNumber.zeroValue; // 0
myGenericNumber.add(1, 2); // 3
myGenericString.zeroValue; // '0'
myGenericString.add('hello ', 'world'); // 'hello world'
단, 클래스를 제네릭으로 관리할때, static 정적 멤버는 제네릭으로 관리할수 없다는 점은 유의하자.
자료구조 제네릭 활용
제네릭은 데이터의 타입에 다양성을 부여해주기 때문에 자료구조에서도 많이 사용된다.
다음은 스택 과 큐 를 제네릭 및 객체지향으로 구성한 예제이다.
class MyArray<A> {
// 자식으로 부터 제네릭 타입을 받아와 배열 타입을 적용
constructor(protected items: A[]) {}
}
class Stack<S> extends MyArray<S> {
// 저장
push(item: S) {
this.items.push(item);
}
// 호출
pop() {
return this.items.pop();
}
}
class Queue<Q> extends MyArray<Q> {
// 저장
offer(item: any) {
this.items.push(item);
}
// 추출
poll() {
return this.items.shift();
}
}
const numberStack = new Stack<number>([]);
numberStack.push(1);
numberStack.push(2);
const data_numberStack = numberStack.pop(); // 2
const stringStack = new Stack<string>([]);
stringStack.push('a');
stringStack.push('b');
const data_stringStack = stringStack.pop(); // 'b'
const numberQueue = new Queue<number>([]);
numberQueue.offer(1)
numberQueue.offer(2)
const data_numberQueue = numberQueue.poll(); // 1
const stringQueue = new Queue<string>([]);
stringQueue.offer('a');
stringQueue.offer('b');
const data_stringQueue = stringQueue.poll(); // 'a'
생성자 매개변수
함수 인자에 생성자만 오도록 제네릭으로 제한할때 다음과 같이 쓸 수 있다.
// 매개변수 x 는 생성자 타입만 올수 있게 제네릭 제한
function add<T extends abstract new (...args: any) => any>(x: T): T {
return x;
}
class A {}
add(A);
제네릭 라이브러리 만들어보기
지금까지 타입스크립트 제네릭 기본 사용법을 익혔다.
제네릭은 동적으로 타입 매개변수를 설정하게 해줌으로써 클래스나 함수에 타입을 사용할 수 있게 해주는 것이다.
이제 이 제네릭을 이용하여 자바스크립트의 forEach() 와 map() 고차함수를 제네릭을 첨가하여 타입스크립트에서도 사용할 수 있게 하도록 연습해보는 시간을 가져보자.
대부분의 타입스크립트 라이브러리도 이러한 방법으로 치환한것이며, 대표적으로 제이쿼리나 리액트도 기존의 자바스크립트 코드에서 제네릭을 첨가함으로써 타입스크립트에서도 사용할 수 있게 만들어 지원하는 것이다.
# 참고자료
https://heropy.blog/2020/01/27/typescript/
https://typescript-kr.github.io/pages/generics.html
이재승 타입스크립트 시작하기
제로초 타입스크립트 올인원
쉽게 시작하는 타입스크립트 (길벗, 2023, 캡틴판교 지음)
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.