iBetter Books
수정

왜 제네릭이 필요한가

창고를 운영한다고 상상해봅시다. 어떤 물건이든 넣을 수 있는 "만능 상자"가 있습니다. 상자는 뭐든 받아줍니다. 그런데 물건을 꺼낼 때 문제가 생깁니다. 상자에서 꺼낸 것이 책인지 시계인지 신발인지 모릅니다. 꺼내기 전까지는 알 수 없습니다. 그래서 꺼낼 때마다 "이게 책이 맞나?" 확인해야 합니다. 실수로 시계를 책처럼 다루다가 망가뜨릴 수도 있습니다.

이것이 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

firstany 타입이라서 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가 무엇인지 자동으로 추론합니다.

숫자 배열을 넘기면 Tnumber가 됩니다. 그러면 반환 타입도 number입니다. 문자열 배열을 넘기면 Tstring이 됩니다. 반환 타입도 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> 문법을 자세히 살펴봅니다. 타입 매개변수를 선언하는 방법, 호출할 때 추론되는 방식, 명시적으로 지정하는 방법을 배웁니다.