본문으로 건너뛰기

제네릭(Generic)

질문

TypeScript의 제네릭(Generic)은 무엇이며, 왜 사용하는 건가요?

답변 초안

제네릭(Generic)은 타입을 값처럼 매개변수화(parameterize)하는 기능입니다.

함수나 클래스, 인터페이스를 작성할 때 타입을 미리 고정하지 않고, 사용하는 시점에 타입을 전달받아 재사용할 수 있게 해줍니다.

이를 통해 any처럼 타입 검사를 포기하지 않으면서도 다양한 타입을 처리할 수 있습니다.

한 줄 비유 제네릭은 "빈 틀(template)"입니다. 붕어빵 틀 하나로 팥 붕어빵도 만들고 슈크림 붕어빵도 만들 수 있듯이, 같은 코드를 여러 타입에 대해 재사용할 수 있습니다.

제네릭이 필요한 이유

타입을 그대로 반환하는 함수를 만들어 보겠습니다.

function identity(value: string): string {
return value;
}

이 함수는 문자열만 처리할 수 있습니다.

숫자도 처리하려면 별도의 함수를 만들어야 합니다.

function identityString(value: string): string {
return value;
}

function identityNumber(value: number): number {
return value;
}

하지만 함수 로직은 완전히 동일합니다.

타입만 다를 뿐입니다.

이를 해결하기 위해 any를 사용할 수도 있습니다.

function identity(value: any): any {
return value;
}

하지만 any는 타입 검사를 포기합니다.

const result = identity('hello');

result.toFixed(); // 컴파일 통과, 런타임 에러

제네릭은 타입 안정성을 유지하면서 재사용성을 제공합니다.

function identity<T>(value: T): T {
return value;
}
const str = identity('hello'); // string
const num = identity(123); // number

TypeScript가 전달된 값을 보고 T를 자동 추론합니다.

제네릭 문법

제네릭 타입 매개변수는 꺾쇠 괄호(< >) 안에 선언합니다.

function identity<T>(value: T): T {
return value;
}

여기서 T는 특별한 의미가 있는 키워드가 아닙니다.

관례적으로 Type을 의미하는 T를 사용하지만 원하는 이름을 사용할 수 있습니다.

function identity<ValueType>(value: ValueType): ValueType {
return value;
}

타입 추론(Type Inference)

대부분의 경우 타입 인수를 직접 적지 않아도 됩니다.

function identity<T>(value: T): T {
return value;
}

const str = identity('hello');

컴파일러는 전달된 인수로부터 Tstring임을 추론합니다.

물론 명시적으로 작성할 수도 있습니다.

const str = identity<string>('hello');

둘은 동일하게 동작합니다.

여러 개의 타입 매개변수

제네릭은 여러 타입을 동시에 받을 수도 있습니다.

function pair<T, U>(first: T, second: U) {
return [first, second];
}
const result = pair('hello', 123);

// [string, number]

실제로 React Query나 Axios 등의 라이브러리에서도 여러 개의 제네릭을 자주 사용합니다.

Promise<User>;
Map<string, User>;

제네릭 제약(Constraint)

때로는 모든 타입을 허용하면 안 되는 경우가 있습니다.

예를 들어 length 프로퍼티를 사용하는 함수는 길이를 가진 타입만 받아야 합니다.

function printLength<T>(value: T) {
console.log(value.length);
}

에러가 발생합니다.

printLength(123);

number에는 length가 없기 때문입니다.

이때 extends를 사용해 제약을 걸 수 있습니다.

interface HasLength {
length: number;
}

function printLength<T extends HasLength>(value: T) {
console.log(value.length);
}
printLength('hello');
printLength([1, 2, 3]);

printLength(123); // 에러

extends는 "상속"이라기보다는

"이 조건을 만족하는 타입만 허용한다"

에 가깝게 이해하면 됩니다.

keyof와 함께 사용하기

제네릭은 keyof와 함께 자주 사용됩니다.

function getProperty<T, K extends keyof T>(
obj: T,
key: K
) {
return obj[key];
}
const user = {
name: 'Kim',
age: 20,
};

getProperty(user, 'name');

존재하지 않는 키는 컴파일 단계에서 막을 수 있습니다.

getProperty(user, 'email');
// 에러

이 패턴은 TypeScript의 대표적인 제네릭 활용 예시입니다.

제네릭 인터페이스

인터페이스에도 제네릭을 적용할 수 있습니다.

interface ApiResponse<T> {
data: T;
success: boolean;
}
interface User {
id: number;
name: string;
}

const response: ApiResponse<User> = {
data: {
id: 1,
name: 'Kim',
},
success: true,
};

실무에서는 API 응답 타입을 정의할 때 매우 자주 사용됩니다.

제네릭 클래스

클래스도 타입을 매개변수로 받을 수 있습니다.

class Storage<T> {
private data: T[] = [];

add(item: T) {
this.data.push(item);
}

getAll() {
return this.data;
}
}
const stringStorage = new Storage<string>();

stringStorage.add('hello');
stringStorage.add('world');

이제 Storage<string>에는 문자열만 저장할 수 있습니다.

실무에서 가장 많이 보는 제네릭

Promise

Promise<User>

Promise<T>에서 T는 미래에 resolve될 값의 타입입니다.

const user = await fetchUser();
// User 타입

Array

Array<string>

사실 아래 문법과 동일합니다.

string[]

React useState

const [user, setUser] = useState<User | null>(null);

User | null이 제네릭 인수로 전달된 것입니다.

제네릭과 any의 차이

많은 사람들이 제네릭을 any와 혼동합니다.

function identity(value: any): any {
return value;
}

any는 타입 정보를 잃어버립니다.

const result = identity('hello');

// any
result.toFixed();

반면 제네릭은 타입 정보를 유지합니다.

function identity<T>(value: T): T {
return value;
}

const result = identity('hello');

// string
result.toUpperCase();

즉,

  • any → 타입 검사 포기
  • Generic → 타입을 전달받아 유지

라는 차이가 있습니다.

실무 주의점

  • 제네릭을 남용하면 오히려 타입이 복잡해질 수 있습니다.
  • 단순히 타입을 하나 더 감싸기만 하는 제네릭은 불필요할 수 있습니다.
  • T, U, K, V 같은 이름은 관례일 뿐이며 의미 있는 이름을 사용하는 것이 가독성에 도움이 되는 경우도 있습니다.
  • 제네릭을 사용할 때는 "정말 타입을 재사용해야 하는가?"를 먼저 고민하는 것이 좋습니다.

면접 답변

"제네릭은 타입을 매개변수처럼 전달받아 재사용할 수 있게 해주는 기능입니다. any와 달리 타입 정보를 유지하기 때문에 타입 안정성을 확보하면서도 다양한 타입을 처리할 수 있습니다. 함수, 인터페이스, 클래스에서 사용할 수 있으며, 실무에서는 Promise<T>, Array<T>, React의 useState<T> 같은 형태로 자주 사용됩니다. 또한 extends, keyof와 조합해 타입 제약을 걸거나 안전한 프로퍼티 접근을 구현할 수 있습니다."