왜 any 대신 제네릭을 써야 하는가
any는 타입 검사를 끄는 스위치다
TypeScript를 처음 배울 때 제일 유혹적인 탈출구는 any입니다. 컴파일러가 시끄럽게 굴 때 타입 자리에 any를 박아 넣으면 조용해집니다. 문제는 조용해지는 이유가 "문제가 해결돼서"가 아니라 "검사를 포기해서"라는 데 있습니다.
function firstElement(arr: any) {
return arr[0];
}
const num = firstElement([1, 2, 3]);
const str: string = num; // 컴파일 통과. 실행하면 1이 string 자리에 들어간다.firstElement는 배열의 첫 원소를 돌려주는 아주 단순한 함수인데, any를 쓰는 순간 반환값이 무슨 타입인지에 대한 정보가 통째로 사라집니다. 위 코드에서 num을 string에 대입해도 컴파일러는 아무 말도 하지 않습니다. any는 타입 시스템 안에서 "이 값에 대해서는 어떤 검사도 하지 마라"는 명령이기 때문입니다.
unknown은 대안이 아니라 다른 문제를 푼다
any 대신 unknown을 쓰라는 조언을 자주 봤을 겁니다. 맞는 조언이지만 맥락이 다릅니다. unknown은 "타입을 모르는 값이 들어왔을 때, 쓰기 전에 반드시 확인하게 만드는" 용도입니다.
function parseJson(text: string): unknown {
return JSON.parse(text);
}
const data = parseJson('{"id": 1}');
data.id; // 에러: 'data' is of type 'unknown'.
if (typeof data === "object" && data !== null && "id" in data) {
console.log((data as { id: unknown }).id);
}unknown은 안전하지만, 입력과 출력 사이에 "타입이 그대로 유지되어야 한다"는 관계를 표현하지는 못합니다. firstElement 예시로 돌아가면, 우리가 정말 원했던 건 "무슨 타입이든 상관없지만, 배열에 들어있던 타입과 반환값의 타입은 같아야 한다"는 규칙입니다. unknown으로는 이 규칙을 쓸 수 없습니다. 반환 타입을 unknown으로 선언하면 호출부에서 다시 타입 단언을 해야 하니, 결국 any를 쓸 때와 크게 다르지 않은 상황이 됩니다.
제네릭은 "타입 사이의 관계"를 표현하는 도구다
제네릭이 하는 일은 값이 아니라 타입을 매개변수로 받는 것입니다. firstElement를 제네릭으로 다시 쓰면 이렇게 됩니다.
function firstElement<T>(arr: T[]): T {
return arr[0];
}
const num = firstElement([1, 2, 3]); // number
const str = firstElement(["a", "b"]); // string
const bad: string = firstElement([1, 2]); // 에러: Type 'number' is not assignable to type 'string'.T는 호출할 때마다 실제 값의 타입으로 채워지는 자리표시자입니다. [1, 2, 3]을 넘기면 T는 number가 되고, 반환 타입도 자동으로 number가 됩니다. any처럼 검사를 끄는 게 아니라, "입력 배열의 원소 타입과 반환값의 타입이 같다"는 관계 자체를 컴파일러에게 알려주는 겁니다. 이게 제네릭과 any의 근본적인 차이입니다 — any는 정보를 지우고, 제네릭은 정보를 보존한 채로 유연성을 얻습니다.
언제 제네릭이 필요한지 판단하는 기준
실무에서 제네릭을 쓸지 말지 고민될 때, 저는 이 질문 하나로 정리합니다. "이 함수의 입력 타입과 출력 타입 사이에, 호출할 때마다 달라지지만 서로 연결된 관계가 있는가?"
- 배열을 받아서 그 원소를 그대로 돌려주는 함수 → 관계가 있다 → 제네릭
- 두 숫자를 더하는 함수 → 입력도 출력도 항상
number→ 제네릭 필요 없음 Record<string, unknown>을 받아서 로그만 찍고 아무것도 반환하지 않는 함수 → 관계가 없다 → 제네릭 필요 없음
세 번째 사례가 실무에서 은근히 자주 틀리는 부분입니다. 함수가 복잡해 보인다고, 혹은 "나중에 확장할지도 모르니까"라는 이유로 제네릭을 붙이는 경우를 많이 봤는데, 관계가 없는 곳에 제네릭을 붙이면 타입 매개변수는 그냥 장식이 됩니다. 이 부분은 마지막 챕터에서 구체적인 사례로 다시 다룹니다.
flowchart TD A["입력과 출력 타입이 함께 변하는가?"] -->|아니오| B["구체 타입 또는 any/unknown 검토"] A -->|예| C["타입 관계를 표현해야 하는가?"] C -->|아니오, 아무 타입이나 허용| D["unknown + 타입 가드"] C -->|예, 관계를 보존해야 함| E["제네릭"]
any는 타입 검사를 포기하는 것이고, unknown은 확인을 강제하는 것이며, 제네릭은 관계를 보존하는 것입니다. 셋 중 무엇을 쓸지는 취향이 아니라 "무엇을 표현하고 싶은가"로 결정됩니다. 다음 챕터에서는 컴파일러가 실제로 이 T 자리를 어떻게 채우는지, 그 추론 순서를 뜯어봅니다.