왜 제네릭이 필요한가
창고를 운영한다고 상상해봅시다. 어떤 물건이든 넣을 수 있는 "만능 상자"가 있습니다. 상자는 뭐든 받아줍니다. 그런데 물건을 꺼낼 때 문제가 생깁니다. 상자에서 꺼낸 것이 책인지 시계인지 신발인지 모릅니다. 꺼내기 전까지는 알 수 없습니다. 그래서 꺼낼 때마다 "이게 책이 맞나?" 확인해야 합니다. 실수로 시계를 책처럼 다루다가 망가뜨릴 수도 있습니다.
이것이 TypeScript에서 any를 쓰는 상황입니다.
문제 1: any의 함정
배열에서 첫 번째 요소를 반환하는 함수를 만들어봅시다.
// 새 파일: generic-problem.tsfunction getFirst(arr: any[]): any { return arr[0];}const firstNumber = getFirst([1, 2, 3]);const firstString = getFirst(["a", "b", "c"]);console.log(firstNumber + 10); // 11 — 작동합니다console.log(firstString.toUpperCase()); // "A" — 작동합니다
얼핏 보면 잘 동작합니다. 그런데 TypeScript의 도움을 전혀 받지 못하고 있습니다.
// 새 파일: generic-problem-bug.tsfunction getFirst(arr: any[]): any { return arr[0];}const first = getFirst([1, 2, 3]);// 컴파일 오류가 없습니다. 하지만 런타임 오류가 납니다.console.log(first.toUpperCase()); // TypeError: first.toUpperCase is not a function
first가 any 타입이라서 TypeScript는 .toUpperCase()를 쓸 수 있는지 검사하지 않습니다. 오류는 실행할 때 터집니다. 사용자가 직접 겪습니다.
any는 TypeScript의 타입 검사를 끄는 탈출구입니다. 써야 할 때도 있지만, 남용하면 TypeScript를 쓰는 이유가 없어집니다.
문제 2: 같은 로직인데 타입만 다르다
any를 쓰지 않으려면 타입별로 함수를 따로 만들어야 합니다.
// 새 파일: duplicate-functions.tsfunction getFirstNumber(arr: number[]): number { return arr[0];}function getFirstString(arr: string[]): string { return arr[0];}function getFirstBoolean(arr: boolean[]): boolean { return arr[0];}console.log(getFirstNumber([1, 2, 3])); // 1console.log(getFirstString(["a", "b", "c"])); // "a"console.log(getFirstBoolean([true, false])); // true
함수 안의 로직은 완전히 똑같습니다. return arr[0]. 그런데 타입만 다르다는 이유로 세 개를 만들었습니다. User, Product, Order 타입이 추가될 때마다 함수를 하나씩 더 만들어야 합니다. 이것도 답이 아닙니다.
비유로 이해하기 — 라벨이 붙은 상자
해결책은 "라벨이 붙은 상자"입니다. 상자를 만들 때 라벨을 붙입니다. "이 상자는 책용", "이 상자는 시계용". 넣을 때도, 꺼낼 때도 라벨에 맞는 물건만 들어가고 나옵니다. 꺼낼 때 뭐가 나올지 정확히 압니다.
제네릭이 바로 이 라벨입니다. 함수를 만들 때 타입을 확정하지 않고 "나중에 정할게요"라는 자리표시자를 둡니다. 함수를 호출할 때 그 자리에 실제 타입이 들어갑니다.
제네릭의 등장
// 새 파일: generic-intro.tsfunction getFirst<T>(arr: T[]): T { return arr[0];}const firstNumber = getFirst([1, 2, 3]);// TypeScript가 추론: T는 number// firstNumber의 타입은 numberconst firstString = getFirst(["a", "b", "c"]);// TypeScript가 추론: T는 string// firstString의 타입은 stringconsole.log(firstNumber + 10); // 11console.log(firstString.toUpperCase()); // "A"// 이제 잘못 쓰면 컴파일 오류가 납니다console.log(firstNumber.toUpperCase()); // 오류: 'number' 형식에는 'toUpperCase' 속성이 없습니다.
<T>가 타입 매개변수입니다. 함수 이름 뒤에 꺾쇠괄호로 선언합니다. 함수가 호출될 때 TypeScript가 전달된 배열을 보고 T가 무엇인지 자동으로 추론합니다.
숫자 배열을 넘기면 T는 number가 됩니다. 그러면 반환 타입도 number입니다. 문자열 배열을 넘기면 T는 string이 됩니다. 반환 타입도 string이 됩니다.
any vs 제네릭 — 차이가 분명합니다
두 방식을 나란히 놓고 비교해봅시다.
// 새 파일: any-vs-generic.ts// any 방식 — 타입 정보가 사라집니다function getFirstAny(arr: any[]): any { return arr[0];}const resultAny = getFirstAny([1, 2, 3]);// resultAny의 타입은 any// TypeScript가 아무것도 검사하지 않습니다resultAny.toUpperCase(); // 오류 없음 — 하지만 런타임에 터집니다// 제네릭 방식 — 타입 정보가 살아있습니다function getFirst<T>(arr: T[]): T { return arr[0];}const resultGeneric = getFirst([1, 2, 3]);// resultGeneric의 타입은 number// TypeScript가 완전히 검사합니다resultGeneric.toUpperCase(); // 컴파일 오류 — 미리 잡아냅니다
제네릭은 "유연함"과 "타입 안전성"을 동시에 얻는 방법입니다. any처럼 어떤 타입이든 받을 수 있으면서도, 받은 타입의 정보를 잃지 않습니다.
더 현실적인 예제
데이터를 감싸는 "래퍼" 함수를 생각해봅시다. API 응답에서 흔히 보는 패턴입니다.
// 새 파일: wrapper-example.ts// any 방식function wrapInArrayAny(value: any): any[] { return [value];}const wrappedAny = wrapInArrayAny(42);// wrappedAny의 타입은 any[] — 원소가 뭔지 모릅니다const first = wrappedAny[0];// first의 타입은 any// 제네릭 방식function wrapInArray<T>(value: T): T[] { return [value];}const wrappedNumber = wrapInArray(42);// wrappedNumber의 타입은 number[] — 원소가 number임을 압니다const firstNumber = wrappedNumber[0];// firstNumber의 타입은 numberconst wrappedString = wrapInArray("hello");// wrappedString의 타입은 string[]const firstString = wrappedString[0];// firstString의 타입은 stringconsole.log(firstNumber.toFixed(2)); // 42.00console.log(firstString.toUpperCase()); // HELLO
제네릭을 쓴 덕분에 wrapInArray(42)에서 반환된 배열의 원소가 number라는 것을 TypeScript가 알고 있습니다. .toFixed(2)를 안전하게 쓸 수 있습니다.
왜 T인가
타입 매개변수 이름으로 T를 쓰는 것은 관례입니다. Type의 첫 글자입니다. 다른 이름을 써도 됩니다.
// 모두 유효합니다function identity<T>(value: T): T { return value; }function identity<MyType>(value: MyType): MyType { return value; }function identity<TValue>(value: TValue): TValue { return value; }
다만 팀에서 코드를 공유할 때는 관례를 따르는 것이 좋습니다. 여러 타입 매개변수가 있을 때는 T, U, V 순으로 쓰거나, 역할을 드러내는 이름 TKey, TValue처럼 씁니다.
만능 상자에 라벨을 붙였습니다. 넣을 때 라벨을 확인하고, 꺼낼 때도 라벨을 확인합니다. 더 이상 꺼낸 물건이 뭔지 모르는 상황은 없습니다.
any는 편리하지만 TypeScript의 보호막을 걷어냅니다. 제네릭은 같은 유연함을 제공하면서 타입 정보를 끝까지 지킵니다. 이것이 제네릭이 필요한 이유입니다.
다음 장에서는 <T> 문법을 자세히 살펴봅니다. 타입 매개변수를 선언하는 방법, 호출할 때 추론되는 방식, 명시적으로 지정하는 방법을 배웁니다.