본문으로 건너뛰기

프론트엔드

TypeScript 제네릭과 extends·keyof 제약

2026. 06. 29. 월요일 오전 11시 0분

제네릭(Generic)은 함수 이름 옆에 붙는 <T> 같은 꺾쇠 괄호 때문에 처음 접하면 진입장벽이 높게 느껴지는 문법입니다. 라이브러리 타입 정의에서 제네릭을 마주치면 건너뛰고 싶은 마음이 들기 쉽습니다.

제네릭 없이 여러 타입을 받는 코드를 만들면 any를 쓰게 되기 쉽습니다. any를 쓴 코드는 TypeScript의 타입 검사를 받지 못합니다. 이 글에서는 제네릭이 왜 필요한지, 언제 쓰는지, 어떻게 직접 설계하는지를 정리합니다.

타입 파라미터

제네릭의 <T>는 타입 파라미터(Type Parameter)입니다. 함수나 타입을 선언할 때는 T 자리에 어떤 타입이 올지 정하지 않습니다. T는 실제로 호출하거나 사용하는 시점에 구체 타입으로 정해집니다. 같은 함수나 타입 선언을 여러 타입에 재사용할 수 있는 이유가 여기 있습니다.

타입 파라미터가 왜 필요한지는, 타입 파라미터 없이 만든 코드가 어떻게 타입 정보를 잃는지 보면 분명해집니다.

any의 타입 정보 손실

가장 단순한 함수부터 시작하겠습니다. 인자로 받은 값을 그대로 돌려주는 함수입니다. 타입을 어떻게 붙여야 할지 막막하면, 처음엔 any가 떠오릅니다.

// 받은 값을 그대로 돌려주는 함수. 타입을 any로 대충 막았다.
function identity(value: any): any {
  return value;
}
 
// number를 넣었다.
const price = identity(1000);
// 하지만 price의 타입은 number가 아니라 any다.
 
// any라서 아래 코드가 컴파일 에러 없이 통과한다.
price.toUpperCase(); // number에는 없는 메서드인데 컴파일러가 막지 않는다
// 런타임에 TypeError: price.toUpperCase is not a function

any를 쓰면 함수에 들어간 number라는 정보가 함수를 나오면서 사라집니다. 반환값 price의 타입은 any가 되고, any는 무엇이든 허용하기 때문에 toUpperCase 같은 엉뚱한 호출도 컴파일러가 막지 못합니다. 오류는 런타임에 가서야 드러납니다.

any는 타입 검사 자체를 끄는 기능에 가깝습니다. 단순히 타입을 모른다는 것과는 다릅니다.

제네릭으로 타입 정보 보존하기

같은 함수를 제네릭으로 바꾸면 이 정보가 보존됩니다. 함수 이름 옆에 <T>라는 타입 파라미터를 하나 선언하고, 인자와 반환값을 모두 같은 타입 파라미터로 묶습니다.

// <T>는 타입 파라미터를 하나 선언하겠다는 뜻이다.
// 인자 타입과 반환 타입을 같은 T로 묶었다.
function identity<T>(value: T): T {
  return value;
}
 
// 1000을 넣으면 T가 리터럴 타입 1000으로 추론된다.
const price = identity(1000);
// price: 1000  (any가 아니다!)
 
// 'claude'를 넣으면 같은 함수인데 T가 리터럴 타입 'claude'로 추론된다.
const name = identity('claude');
// name: 'claude'
 
// 이제는 잘못된 호출을 컴파일러가 막아준다.
price.toUpperCase();
// Property 'toUpperCase' does not exist on type '1000'.

value: T와 반환 타입 T는 같은 타입 파라미터입니다. any처럼 정보를 버리지 않고, 입력 타입이 곧 반환 타입이라는 관계를 컴파일러에게 알려줍니다.

비교해 보면 차이가 분명합니다.

구분입력 타입반환 타입잘못된 호출 차단
any 버전버려짐any막지 못함
제네릭 버전보존됨입력과 동일막아줌

타입 파라미터 선언 위치

타입 파라미터는 함수뿐 아니라 여러 곳에 선언할 수 있습니다. 선언 위치에 따라 타입 파라미터가 구체 타입으로 정해지는 시점이 달라집니다.

함수의 타입 파라미터 추론

함수의 타입 파라미터는 보통 우리가 직접 적지 않아도 **인자를 보고 추론(Inference)**됩니다. 위 예제에서 identity(1000)라고만 썼는데도 T가 1000으로 정해진 것이 이 추론입니다.

// 배열의 첫 요소를 돌려주는 함수.
// 요소 타입을 모르므로 T로 둔다.
function first<T>(items: T[]): T | undefined {
  // 빈 배열이면 undefined가 나올 수 있으므로 반환 타입에 명시했다.
  return items[0];
}
 
// number[]를 넣으면 T가 number로 추론된다.
const n = first([1, 2, 3]);
// n: number | undefined
 
// string[]를 넣으면 T가 string으로 추론된다.
const s = first(['a', 'b']);
// s: string | undefined
 
// 추론이 애매하면 <>로 직접 채워 넣을 수도 있다.
const empty = first<boolean>([]);
// empty: boolean | undefined

대부분은 추론에 맡기면 되고, 추론 결과가 의도와 다를 때만 first<boolean>([])처럼 타입 인자를 직접 지정합니다.

타입 별칭과 인터페이스의 타입 파라미터

타입 자체에도 타입 파라미터를 둘 수 있습니다. API 응답처럼 "겉 구조는 같은데 안에 담기는 데이터만 다른" 경우에 특히 잘 맞습니다.

// 응답의 공통 구조는 고정하고, data에 담길 타입만 T로 둔다.
interface ApiResponse<T> {
  status: number; // 어떤 응답이든 동일
  message: string; // 어떤 응답이든 동일
  data: T; // 응답마다 달라지는 부분
}
 
interface User {
  id: number;
  name: string;
}
 
// 사용할 때 타입 파라미터를 User로 채운다.
const userRes: ApiResponse<User> = {
  status: 200,
  message: 'ok',
  data: { id: 1, name: 'claude' },
};
 
// userRes.data는 User로 좁혀져 있다.
userRes.data.name; // string
 
// 타입 파라미터를 User[]로 채우면 목록 응답 타입이 된다.
const listRes: ApiResponse<User[]> = {
  status: 200,
  message: 'ok',
  data: [{ id: 1, name: 'claude' }],
};

ApiResponse라는 타입 선언은 한 번만 만들고, ApiResponse<User>, ApiResponse<User[]>처럼 타입 파라미터만 바꿔 여러 응답 타입을 만듭니다. 제네릭이 없었다면 UserResponse, UserListResponse처럼 거의 똑같은 타입을 응답 종류마다 따로 선언해야 했을 것입니다.

클래스의 타입 파라미터

클래스에도 같은 방식으로 타입 파라미터를 둘 수 있습니다. 자료구조를 만들 때 흔히 쓰는 패턴입니다.

// 어떤 타입이든 담을 수 있는 스택. 담길 타입을 T로 둔다.
class Stack<T> {
  private items: T[] = [];
 
  push(item: T): void {
    this.items.push(item);
  }
 
  // 비어 있을 수 있으므로 T | undefined를 돌려준다.
  pop(): T | undefined {
    return this.items.pop();
  }
}
 
// 인스턴스를 만들 때 T를 number로 채운다.
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const popped = numberStack.pop();
// popped: number | undefined
 
numberStack.push('3');
// Argument of type 'string' is not assignable to parameter of type 'number'.

Stack<number>로 만든 인스턴스에는 number만 들어갈 수 있습니다. 타입 파라미터가 정해지면, 그 클래스 인스턴스 전체가 해당 타입에 맞춰 고정됩니다.

extends 제약

여기까지의 T는 어떤 타입이든 받습니다. 그런데 "아무 타입이나"는 종종 너무 헐겁습니다. 예를 들어 길이를 재는 함수라면, 들어오는 값이 적어도 length 속성은 가지고 있어야 합니다.

이때 쓰는 것이 **extends 제약(Constraint)**입니다. 타입 파라미터에 "최소한 이 형태는 만족해야 한다"는 조건을 겁니다.

// T는 length: number를 가진 타입이어야 한다는 제약을 걸었다.
function logLength<T extends { length: number }>(value: T): T {
  // 제약 덕분에 value.length 접근이 안전하다.
  console.log(value.length);
  return value;
}
 
logLength('hello'); // OK - string에는 length가 있다
logLength([1, 2, 3]); // OK - 배열에도 length가 있다
logLength({ length: 10, name: 'box' }); // OK - length를 가진 객체
 
logLength(42);
// Argument of type 'number' is not assignable to
// parameter of type '{ length: number; }'.
// number에는 length가 없으므로 막힌다.

제약이 없다면 함수 안에서 value.length를 쓸 때 "T에 length가 있는지 모른다"며 컴파일러가 막습니다. extends { length: number }를 붙이면, 컴파일러는 "들어오는 T는 적어도 length를 가진 타입"이라고 판단하고 안전하게 접근을 허용합니다.

여기서의 extends는 인터페이스를 상속할 때 쓰는 extends(예: interface Dog extends Animal)와 키워드만 같고 역할은 다릅니다. 인터페이스 상속의 extends는 "이 타입을 물려받겠다"는 선언이고, 제네릭의 extends는 "타입 파라미터가 이 조건을 만족해야 한다"는 제약입니다. 같은 단어지만 문맥으로 구분합니다.

제약을 걸어도 타입 정보는 그대로 유지됩니다. 위 함수는 반환 타입을 T로 뒀기 때문에, logLength('hello')의 결과 타입은 string으로 넓어지지 않고 입력 그대로인 'hello'입니다.

keyof와 제네릭의 조합

keyof는 객체 타입의 키를 유니온 타입으로 뽑아내는 연산자입니다.

interface User {
  id: number;
  name: string;
  active: boolean;
}
 
// keyof User는 'id' | 'name' | 'active' 라는 유니온 타입이다.
type UserKey = keyof User;
// UserKey: 'id' | 'name' | 'active'

keyof를 제네릭과 함께 쓰면, 객체에서 키로 값을 꺼내는 함수의 반환 타입까지 정확하게 만들 수 있습니다. 먼저 any로 만든 버전이 정보를 어떻게 잃는지 봅니다.

// any로 막은 버전. 키가 진짜 있는지도, 값 타입도 알 수 없다.
function getValueBad(obj: any, key: string): any {
  return obj[key];
}
 
const user = { id: 1, name: 'claude', active: true };
 
const name = getValueBad(user, 'name');
// name: any  (string이라는 정보가 사라졌다)
 
const typo = getValueBad(user, 'nmae');
// 오타지만 컴파일러가 막지 못한다. 런타임에 undefined가 나온다.

여기에 제네릭과 keyof, 그리고 앞에서 본 extends 제약을 결합하면 두 문제를 한 번에 잡습니다.

// T: 객체 타입 (타입 파라미터)
// K: 그 객체의 키 중 하나여야 한다는 제약 (K extends keyof T)
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  // T[K]는 "T에서 K 키에 해당하는 값의 타입"이다. (인덱스드 액세스 타입)
  return obj[key];
}
 
const user = { id: 1, name: 'claude', active: true };
 
const name = getValue(user, 'name');
// name: string  (정확한 타입으로 추론된다)
 
const id = getValue(user, 'id');
// id: number
 
const active = getValue(user, 'active');
// active: boolean
 
// 존재하지 않는 키는 컴파일 타임에 막힌다.
const typo = getValue(user, 'nmae');
// Argument of type '"nmae"' is not assignable to
// parameter of type '"name" | "id" | "active"'.

여기서 일어나는 일을 한 줄씩 풀어 보면 이렇습니다. K extends keyof T는 "두 번째 인자 key는 반드시 obj가 실제로 가진 키여야 한다"는 제약입니다. 덕분에 'nmae' 같은 오타는 키 목록에 없으니 컴파일 단계에서 걸러집니다. 그리고 반환 타입 T[K]는 **인덱스드 액세스 타입(Indexed Access Type)**으로, "그 키가 가리키는 값의 타입"을 그대로 가져옵니다. 'name'을 넣으면 string, 'id'를 넣으면 number가 정확히 추론됩니다.

any 버전은 "키가 있는지"도 "값 타입이 무엇인지"도 확인하지 않지만, 제네릭 버전은 둘 다 컴파일러가 보장합니다.

재사용 가능한 타입 설계

지금까지 다룬 내용을 모아, 작은 라이브러리 수준의 함수를 설계합니다. 객체 배열을 특정 키 기준으로 묶어주는 groupBy입니다. 로그를 레벨별로, 주문을 상태별로 묶는 식으로 실무에서 자주 쓰는 도구입니다.

요구사항을 타입으로 옮기면 이렇습니다.

  • 어떤 객체 배열이든 받는다 → 요소 타입을 T로 둔다.
  • 묶는 기준은 그 객체가 실제로 가진 키여야 한다 → K extends keyof T.
  • 키의 값은 그룹 이름으로 쓰이므로, 객체 키가 될 수 있는 타입이어야 한다 → T[K] extends PropertyKey.
// PropertyKey는 string | number | symbol을 가리키는 내장 타입이다.
// 그룹 이름(객체 키)으로 쓸 수 있는 값만 허용하기 위한 제약이다.
function groupBy<T, K extends keyof T>(
  items: T[],
  key: K,
): T[K] extends PropertyKey ? Record<T[K], T[]> : never {
  // 결과를 담을 객체. 아래에서 한 칸씩 채운다.
  const result = {} as Record<PropertyKey, T[]>;
 
  for (const item of items) {
    // item[key]의 타입은 T[K]. 이 값을 그룹 이름으로 쓴다.
    const groupName = item[key] as PropertyKey;
 
    // 해당 그룹이 처음이면 빈 배열을 만든다. (옵셔널 체이닝 대신 명시적 초기화)
    const bucket = result[groupName] ?? [];
    bucket.push(item);
    result[groupName] = bucket;
  }
 
  // 위에서 정의한 조건부 반환 타입에 맞춰 단언 없이 돌려준다.
  return result as T[K] extends PropertyKey ? Record<T[K], T[]> : never;
}

타입 선언이 다소 복잡하니 사용 예시로 결과를 확인합니다.

interface LogEntry {
  level: 'info' | 'warn' | 'error';
  message: string;
}
 
const logs: LogEntry[] = [
  { level: 'info', message: '서버 시작' },
  { level: 'error', message: 'DB 연결 실패' },
  { level: 'info', message: '요청 처리' },
  { level: 'warn', message: '응답 지연' },
];
 
// level 기준으로 묶는다.
const byLevel = groupBy(logs, 'level');
// byLevel: Record<'info' | 'warn' | 'error', LogEntry[]>
// 키가 정확히 세 레벨로 추론된다.
 
byLevel.info.length; // 2  - info 로그 두 개
byLevel.error[0].message; // 'DB 연결 실패' - 요소가 LogEntry로 추론된다
 
// 없는 키로 묶으려 하면 컴파일 타임에 막힌다.
const wrong = groupBy(logs, 'severity');
// Argument of type '"severity"' is not assignable to
// parameter of type 'keyof LogEntry'.

groupBy(logs, 'level')의 결과 타입은 Record<'info' | 'warn' | 'error', LogEntry[]>로, 단순한 Record<string, LogEntry[]>보다 키 후보까지 정확히 추론됩니다. byLevel.info처럼 실제로 존재할 수 있는 그룹 이름을 자동완성으로 받을 수 있고, 요소를 꺼내면 LogEntry로 좁혀져 .message에 안전하게 접근합니다.

이 함수는 LogEntry 전용으로 만든 것이 아닙니다. 타입 파라미터 덕분에 주문(status), 사용자(role) 등 어떤 객체 배열에도 타입 안전하게 재사용할 수 있습니다.

위 groupBy에서는 동적 키로 객체를 채우는 과정에서 컴파일러가 타입을 끝까지 추적하지 못하는 부분이 있어 두 군데에 단언을 사용했습니다. 이렇게 내부 구현이 복잡한 유틸리티는 함수 안에서 단언을 쓰더라도, 함수 시그니처(입력·출력 타입)를 정확히 설계해 두면 사용하는 쪽 코드는 완전히 타입 안전합니다. 단언의 범위는 함수 내부로 한정됩니다.

정리

이번 글에서는 제네릭을 왜·언제·어떻게 쓰는지 정리했습니다.

개념한 줄 요약
타입 파라미터 <T>사용 시점(함수 호출, 타입 표기, 인스턴스 생성)에 구체 타입으로 정해진다. 정보 손실 없이 타입을 묶는다
타입 추론함수는 보통 인자를 보고 T를 자동으로 채운다
extends 제약타입 파라미터에 "최소한 이 형태"라는 조건을 건다
keyof + 제네릭K extends keyof T로 객체의 실제 키만 받게 한다
인덱스드 액세스 T[K]키에 해당하는 값의 타입을 정확히 가져온다
  • any는 들어올 때 알던 타입까지 버린다. 제네릭은 입력 타입을 출력까지 보존한다.
  • 타입 파라미터는 함수·타입·클래스에 선언할 수 있고, 채워지는 시점만 다르다.
  • extends로 타입 파라미터에 제약을 걸면, 헐거운 T를 안전하게 다룰 수 있다.
  • keyof·T[K]와 엮으면, 객체에서 키로 값을 꺼내는 동작까지 타입으로 보장된다.

제네릭은 코드 구조를 한 번만 만들고 타입은 사용 시점마다 정하는 방식입니다. 처음에는 <T> 같은 꺾쇠 괄호가 낯설게 느껴지지만, 타입 파라미터가 사용 시점에 정해진다는 원리를 알고 나면 라이브러리 타입 정의를 읽기도 수월해집니다. 긴 글 읽어주셔서 감사합니다.