[TS #3] 제네릭 — Java 제네릭과 뭐가 다른가

TypeScript 제네릭은 Java의 List<T>와 같은 개념이다. any를 쓰지 않고도 다양한 타입을 처리하는 방법, keyof 제약 조건, API 호출 패턴까지 정리한다.

-

Java의 List<T>, Optional<T>를 써봤다면 TypeScript 제네릭은 이미 절반은 아는 것이다. 개념은 동일하다 — 타입을 파라미터로 받아서 재사용 가능한 코드를 만든다. any를 쓰지 않고도 다양한 타입을 처리할 수 있다.


제네릭이 필요한 이유

any를 쓰면 일단 동작은 하지만 타입 정보가 사라진다.

// any — 타입 정보 소실
function identity(value: any): any {
  return value
}
const result = identity('hello') // 타입: any
result.toUpperCase()             // OK인지 런타임 전엔 모름

// 제네릭 — 타입 유지
function identity<T>(value: T): T {
  return value
}
const result = identity('hello')  // 타입: string (자동 추론)
const result = identity<number>(42) // 타입: number (명시)

<T>가 타입 파라미터다. Java의 <T>와 같다.


함수 제네릭

// 배열 첫 번째 요소 반환
function first<T>(arr: T[]): T | undefined {
  return arr[0]
}

first(['a', 'b', 'c']) // string | undefined
first([1, 2, 3])       // number | undefined

// 두 타입 파라미터
function swap<T, U>(a: T, b: U): [U, T] {
  return [b, a]
}

const [b, a] = swap('hello', 42) // [number, string]

인터페이스 제네릭 — API 응답 래퍼

실무에서 가장 자주 쓰이는 패턴이다. 모든 API 응답을 하나의 제네릭 인터페이스로 감싼다.

interface ApiResponse<T> {
  data: T
  message: string
  success: boolean
  timestamp: string
}

interface User { id: number; name: string }
interface Order { id: number; total: number }

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: '홍길동' },
  message: '조회 성공',
  success: true,
  timestamp: '2026-06-20',
}

const ordersResponse: ApiResponse<Order[]> = {
  data: [{ id: 1, total: 15000 }],
  message: '주문 목록 조회 성공',
  success: true,
  timestamp: '2026-06-20',
}

페이지네이션, Repository 패턴도 같은 방식으로 표현한다.

interface Paginated<T> {
  items: T[]
  total: number
  page: number
  size: number
  hasNext: boolean
}

interface Repository<T, ID> {
  findById(id: ID): Promise<T | null>
  findAll(): Promise<T[]>
  save(entity: Omit<T, 'id'>): Promise<T>
  update(id: ID, entity: Partial<T>): Promise<T>
  delete(id: ID): Promise<void>
}

제약 조건 — extends로 타입 파라미터를 제한

타입 파라미터가 특정 구조를 가져야 할 때 extends로 제약을 건다.

// T는 반드시 id 프로퍼티를 가져야 함
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id)
}

findById([{ id: 1, name: '홍' }], 1)  // OK
findById(['a', 'b'], 1)               // ❌ string에는 id가 없음

keyof 제약 — 실제 존재하는 키만 허용

keyof T는 객체 T의 키 타입을 추출한다. Java에는 없는 개념이다.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { name: '홍길동', age: 30 }
getProperty(user, 'name')   // string
getProperty(user, 'age')    // number
getProperty(user, 'email')  // ❌ 컴파일 오류 — email 키 없음

K extends keyof T는 “K는 T의 실제 키 중 하나"를 의미한다. 존재하지 않는 키로 접근하면 컴파일 오류가 난다.


기본값 타입

interface Container<T = string> { // T가 없으면 string
  value: T
}

const c1: Container = { value: '기본' }      // Container<string>
const c2: Container<number> = { value: 42 }  // Container<number>

실무 패턴 — API 호출 함수

async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url)
  const json = await res.json()
  return json as ApiResponse<T>
}

// 사용 — T만 바꾸면 모든 엔드포인트에 재사용
const userRes = await fetchApi<User>('/api/users/1')       // ApiResponse<User>
const ordersRes = await fetchApi<Order[]>('/api/orders')   // ApiResponse<Order[]>

타입 파라미터 하나로 모든 API 응답을 타입 안전하게 처리한다.


Java와의 차이점

Java 제네릭과 개념은 같지만 두 가지 차이가 있다.

1. 타입 소거(Type Erasure): Java는 런타임에 제네릭 타입 정보가 지워진다(List<String>이 런타임엔 그냥 List). TypeScript도 마찬가지다 — 컴파일 후 .js에는 타입 정보가 없다.

2. 구조적 타이핑: Java는 T extends Comparable<T> 처럼 명시적 인터페이스가 필요하지만, TypeScript는 구조만 맞으면 된다.

// Java라면 T extends HasId<T> 같은 선언 필요
// TypeScript는 그냥 구조로 제약
function findById<T extends { id: number }>(items: T[], id: number) {
  return items.find(item => item.id === id)
}

마치며

TypeScript 제네릭은 Java에서 이미 익숙한 개념이다. ApiResponse<T>, Paginated<T> 같은 공통 래퍼를 한 번 만들어두면 프로젝트 전체에서 타입 일관성이 생긴다.

다음 글에서는 TS가 기본 제공하는 유틸리티 타입들 — Partial, Pick, Omit, Record 등을 실무 API 패턴과 함께 살펴본다.

개인정보처리방침