재사용의 마법: 제네릭으로 유연하고 안전한 코드 만들기
왜 갑자기 제네릭이 필요할까요?
TypeScript를 배우다 보면 어느 순간 이런 장면을 만나게 됩니다.
number용 함수 하나, string용 함수 하나, boolean용 함수 하나. 처음엔 성실해 보입니다. 하지만 파일이 길어질수록 느낌이 이상해집니다.
"어라? 이거 로직은 똑같은데 타입만 다르네?"
예를 들어 값을 그대로 돌려주는 함수를 만든다고 해봅시다.
function returnNumber(value: number): number {
return value;
}
function returnString(value: string): string {
return value;
}지금은 두 개뿐이라 괜찮아 보이지만, 타입이 늘어날수록 함수도 같이 늘어납니다. 이건 복붙의 냄새입니다. 이런 중복은 코드를 지치게 만들고, 나중에 수정할 때 실수도 부르기 쉽습니다. 제네릭은 바로 이 지점에서 등장합니다. 같은 로직을 여러 타입에 재사용하면서도 타입 정보는 잃지 않게 해 주는 장치입니다.12
쉽게 말해 제네릭은 "타입을 나중에 정하는 함수, 타입, 클래스"를 만들게 도와줍니다.
제네릭이란 무엇인가요?
제네릭은 타입을 위한 매개변수라고 생각하면 쉽습니다. 함수의 매개변수는 값을 나중에 받죠. 제네릭의 타입 매개변수는 타입을 나중에 받습니다.
가장 유명한 예시는 identity 함수입니다. 들어온 값을 그대로 돌려주는 함수입니다.3
function identity<T>(value: T): T {
return value;
}여기서 T는 타입 이름입니다. 꼭 T일 필요는 없습니다. Item, Value, User처럼 더 읽기 쉬운 이름을 써도 됩니다. 다만 짧고 단순한 예제에서는 T를 많이 씁니다.
이 함수는 이렇게 읽으면 됩니다.
"identity는 어떤 타입 T를 받아서, 같은 타입 T를 그대로 돌려준다."
사용해 보면 느낌이 확 옵니다.
const a = identity<string>("hello");
const b = identity<number>(123);a는 string, b는 number가 됩니다.
그런데 TypeScript는 꽤 똑똑합니다. 보통은 타입을 직접 쓰지 않아도 알아서 추론합니다.
const a = identity("hello"); // string
const b = identity(123); // number이걸 타입 추론이라고 합니다. 값에서 타입을 유추하는 기능입니다. 대부분의 경우 제네릭은 추론에 맡기는 편이 더 자연스럽고 코드도 짧습니다.3
any와 제네릭은 무엇이 다를까요?
초보자 시절 가장 달콤한 유혹은 any입니다.
function identity(value: any): any {
return value;
}겉으로 보면 이것도 "아무 타입이나 받는 함수"입니다. 하지만 문제는 들어온 타입 정보를 잃어버린다는 점입니다.
const result = identity("hello");
// result는 any이제 result가 문자열인지, 숫자인지, 객체인지 TypeScript는 제대로 도와주지 못합니다. 자동 완성도 흐려지고, 실수도 잡기 어려워집니다.
반면 제네릭은 다릅니다.
function identity<T>(value: T): T {
return value;
}
const result = identity("hello");
// result는 string즉, 제네릭은 유연함과 안전함을 동시에 챙깁니다. any가 "일단 다 받아줄게"라면, 제네릭은 "다 받아줄 수는 있는데, 네가 준 타입은 끝까지 기억할게"에 가깝습니다.3
가장 먼저 익혀야 할 제네릭 문법
제네릭 함수의 기본 모양은 이렇습니다.
function 함수이름<타입이름>(매개변수: 타입이름): 타입이름 {
// ...
}조금 더 실전적인 예제를 보겠습니다.
값을 배열로 감싸기
function wrapInArray<T>(value: T): T[] {
return [value];
}
const numbers = wrapInArray(3); // number[]
const names = wrapInArray("철수"); // string[]하나의 함수로 숫자도, 문자열도, 객체도 처리할 수 있습니다.
두 값을 한 쌍으로 만들기
제네릭은 여러 개도 받을 수 있습니다.
function makePair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const pair1 = makePair("age", 20); // [string, number]
const pair2 = makePair(true, { name: "몽룡" });
// [boolean, { name: string }]이제 함수 하나가 아주 다양한 조합을 다룰 수 있습니다.
제네릭은 배열에서도 아주 잘 어울립니다
배열은 "같은 타입의 묶음"이므로 제네릭과 특히 궁합이 좋습니다.
function getFirstItem<T>(items: T[]): T | undefined {
return items[0];
}
const firstNumber = getFirstItem([10, 20, 30]);
// number | undefined
const firstName = getFirstItem(["봄", "여름", "가을"]);
// string | undefined여기서 중요한 점은 반환 타입입니다. 배열이 비어 있을 수도 있으니 undefined를 함께 고려해야 합니다. 제네릭은 이런 상황에서도 타입 정보를 자연스럽게 이어 줍니다.
이런 함수를 여러 타입별로 따로 만들 필요가 없습니다. getFirstNumber, getFirstString, getFirstUser를 줄줄이 만드는 대신, getFirstItem 하나면 충분합니다. 복붙 코드가 똑똑한 코드로 바뀌는 순간입니다.
제네릭 타입도 만들 수 있습니다
제네릭은 함수에만 쓰는 기능이 아닙니다. interface, type alias, class에도 붙일 수 있습니다.3
제네릭 인터페이스
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 100 };
const stringBox: Box<string> = { value: "쿠키" };Box<T>는 "무언가 하나를 담는 상자"입니다. 그런데 상자 안에 무엇을 담을지는 나중에 정합니다.
제네릭 타입 별칭
type ApiResult<T> = {
success: boolean;
data: T;
};
const userResult: ApiResult<{ id: number; name: string }> = {
success: true,
data: { id: 1, name: "하늘" }
};이런 형태는 실제 프로젝트에서 정말 자주 보게 됩니다. 응답 구조는 비슷한데 data 안에 들어가는 타입만 달라질 때 아주 유용합니다.
제네릭 클래스
class StorageBox<T> {
private item: T;
constructor(item: T) {
this.item = item;
}
getItem(): T {
return this.item;
}
}
const messageBox = new StorageBox("안녕하세요");
// StorageBox<string>
const scoreBox = new StorageBox(99);
// StorageBox<number>같은 클래스인데, 담는 데이터의 타입에 따라 다른 상자가 됩니다. 재사용성은 높고, 타입은 정확합니다.3
제네릭 제약 조건: 아무나 다 받지는 않겠습니다
여기서 한 단계 더 나아가 봅시다.
제네릭은 유연하지만, 때로는 최소한의 조건이 필요합니다. 예를 들어 어떤 값의 length를 출력하고 싶다고 해봅시다.
function printLength<T>(value: T): void {
console.log(value.length);
}이 코드는 오류입니다. 왜냐하면 T가 숫자일 수도 있기 때문입니다. 숫자에는 length가 없죠.
이럴 때 제약 조건을 사용합니다.
interface HasLength {
length: number;
}
function printLength<T extends HasLength>(value: T): void {
console.log(value.length);
}T extends HasLength는 이렇게 읽습니다.
"T는 아무 타입이나 될 수 있지만, 적어도 length는 가지고 있어야 한다."
이제 이런 값은 됩니다.
printLength("hello");
printLength([1, 2, 3]);
printLength({ length: 10, name: "줄자" });하지만 이런 값은 안 됩니다.
printLength(123); // 오류제네릭 제약 조건은 유연함을 유지하면서도 필요한 안전장치만 거는 방법입니다.4
객체를 더 안전하게 다루는 제네릭
실전에서 정말 자주 쓰는 패턴 하나를 소개하겠습니다.
객체에서 특정 속성 값을 꺼내는 함수입니다.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}처음 보면 약간 마법 주문 같지만, 천천히 뜯어보면 괜찮습니다.
T는 객체 타입입니다. K extends keyof T는 K가 T의 키 중 하나여야 한다는 뜻입니다. T[K]는 그 키에 해당하는 값의 타입입니다.
사용해 보면 매우 똑똑합니다.
const user = {
name: "다정",
age: 28,
isAdmin: false
};
const name = getProperty(user, "name");
// string
const age = getProperty(user, "age");
// number존재하지 않는 키를 넣으면 바로 막아 줍니다.
getProperty(user, "email"); // 오류이 패턴의 좋은 점은, 런타임에서 "어? 그런 속성 없는데?" 하고 당황하기 전에 컴파일 단계에서 실수를 막아 준다는 것입니다.4
제약 조건은 언제 쓰면 좋을까요?
제약 조건은 제네릭에 브레이크를 다는 일입니다. 자동차에 브레이크가 있다고 해서 느린 차는 아니죠. 오히려 더 믿고 달릴 수 있습니다.
다음과 같은 상황에서 특히 유용합니다.
특정 속성이 반드시 있어야 할 때
특정 객체 형태만 허용하고 싶을 때
키 접근을 안전하게 만들고 싶을 때
재사용 가능한 클래스나 저장소를 만들 때
예를 들어 id가 있는 데이터만 저장하는 저장소를 만들 수 있습니다.
interface HasId {
id: number;
}
class Repository<T extends HasId> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
}이제 Repository는 아무 데이터나 받지 않습니다. 적어도 id는 있어야 합니다.
const userRepo = new Repository<{ id: number; name: string }>();
userRepo.add({ id: 1, name: "영희" });
const productRepo = new Repository<{ id: number; title: string; price: number }>();
productRepo.add({ id: 101, title: "키보드", price: 50000 });같은 클래스를 재사용하면서도, 각 데이터의 구조는 정확히 유지됩니다. 이런 맛에 제네릭을 씁니다.2
타입 인수를 직접 쓸 때와 생략할 때
보통은 TypeScript가 타입을 잘 추론하므로 생략해도 됩니다.
const result = wrapInArray("망고");하지만 가끔은 직접 써 주는 편이 더 분명하거나, 추론이 애매한 경우가 있습니다.
const result = wrapInArray<string | number>("망고");초보 단계에서는 이렇게 기억하면 충분합니다.
추론이 잘 되면 생략한다.
원하는 타입을 더 분명하게 보여 주고 싶으면 직접 쓴다.
추론 결과가 너무 좁거나 넓으면 직접 조정한다.
즉, 제네릭 타입 인수는 항상 직접 써야 하는 것이 아니라, 필요할 때 꺼내 쓰는 도구입니다.3
좋은 제네릭은 "최소한만 요구"합니다
제네릭을 배웠다고 해서 모든 함수에 <T>를 붙일 필요는 없습니다. 오히려 과하면 코드가 더 어려워집니다.
좋은 제네릭 설계를 위해 다음 원칙을 기억해 두면 좋습니다.
같은 로직이 여러 타입에 반복될 때만 제네릭을 고려한다.
필요한 조건만 제약으로 건다. 너무 많은 조건은 재사용성을 떨어뜨린다.
가능하면 타입 추론이 자연스럽게 일어나도록 설계한다.
T,U도 괜찮지만, 의미가 중요하면Item,Key,Value처럼 이름을 더 읽기 쉽게 짓는다.
예를 들어 이런 함수는 제네릭이 필요 없습니다.
function double(value: number): number {
return value * 2;
}이 함수는 오직 숫자만 다루면 되니까 그냥 number로 두는 편이 가장 좋습니다.
반대로 이런 함수는 제네릭이 잘 어울립니다.
function pickOne<Item>(items: Item[]): Item | undefined {
return items[0];
}여러 타입의 배열에 공통으로 쓸 수 있기 때문입니다.
초보자가 자주 하는 실수
제네릭을 배울 때 많이 겪는 실수도 잠깐 짚고 가겠습니다.
any로 도망가기
제네릭을 써야 할 자리에 any를 써 버리면, 당장은 편하지만 TypeScript의 도움을 잃습니다.
제약 없이 속성 꺼내기
T에서 바로 id나 length를 꺼내면 오류가 납니다. 그 속성이 있다는 보장이 없기 때문입니다. 이럴 때는 extends로 조건을 명시해야 합니다.
너무 복잡한 제네릭 만들기
처음부터 멋진 범용 라이브러리를 만들겠다는 마음으로 시작하면 오히려 길을 잃기 쉽습니다. 간단한 함수 하나를 제네릭으로 바꾸는 연습부터 하는 편이 좋습니다.
제네릭을 보는 눈이 생기면 달라지는 것
제네릭을 이해하면 코드가 이렇게 바뀝니다.
예전에는 "타입이 다르면 함수를 새로 만든다"였고, 이제는 "로직이 같으면 타입을 매개변수로 뺀다"가 됩니다.
예전에는 any로 얼버무렸다면, 이제는 "유연하지만 타입은 유지하는 방법"을 고를 수 있습니다.
예전에는 재사용이 복붙이었다면, 이제는 설계가 됩니다.
이 변화는 생각보다 큽니다. 함수 하나를 예쁘게 만드는 수준이 아니라, 코드베이스 전체를 덜 중복되고 더 믿을 수 있게 만듭니다.1
정리
이번 장에서 배운 핵심은 단순합니다.
제네릭은 타입을 나중에 정하게 해 주는 도구입니다.
같은 로직을 여러 타입에 재사용하면서 타입 안전성도 유지합니다.
extends를 사용하면 필요한 조건을 제네릭에 걸 수 있습니다.함수뿐 아니라
interface,type,class에도 적용할 수 있습니다.
한마디로 요약하면 이렇습니다.
제네릭은 복붙을 줄이고, any를 멀리하게 만들며, 재사용 가능한 코드를 더 똑똑하게 설계하게 해 주는 TypeScript의 핵심 무기입니다.
다음 장에서는 이 제네릭 감각을 바탕으로 타입을 더 정교하게 조립하는 방법을 배웁니다. 이제 TypeScript의 타입 시스템은 "타입 붙이기"를 넘어, "타입 설계하기" 단계로 들어갑니다.
참고
1TypeScript Generics Explained: Beginner's Guide With Code Examples
2Understanding Generics in TypeScript for Improved Code Reusability
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.