프로필 로고
2026-04-16

TypeScript 제네릭

TypeScript 제네릭이 타입을 함수 인자처럼 받아 실제 타입 결정을 호출 시점까지 미루는 장치임을 any·유니온과 비교해 설명하고, 타입 추론, 여러 타입 변수, 인터페이스 제네릭, extends 제약 조건, keyof와 인덱스 접근 타입까지 다룬다.

  • TypeScript
  • 제네릭

제네릭은 타입을 함수 인자처럼 전달받도록 추상화해, 실제 타입 결정을 사용 시점까지 미루는 장치다.

왜 필요한가

  1. 타입스크립트를 쓰는 목적은 결국 어떤 값이 어떤 타입인지를 컴파일 타임에 알기 위해서다.

  2. 그런데 실제 코드에서는 같은 로직이 여러 타입에 걸쳐 반복되는 상황이 자주 발생한다.

  3. 예를 들어 배열의 첫 요소를 꺼내는 함수는 숫자 배열, 문자열 배열, 객체 배열 모두에 동일하게 필요하다.

  4. 가장 쉬운 해결책은 any를 쓰는 것이지만, any를 반환하면 컴파일러는 그 반환값의 타입 검사를 포기하게 된다.

  5. 그 결과 함수 내부의 타입 정보는 호출 지점까지 이어지지 못하고 끊겨버린다.

  6. 그렇다고 getFirstNumber, getFirstString처럼 타입마다 함수를 따로 만드는 것은 명백한 중복이다.

  7. 제네릭은 이 딜레마를 정면으로 해결한다.

  8. 핵심 아이디어는 타입을 함수 인자처럼 받아서, 어떤 타입으로 호출되든 그 정보를 시그니처 전체에 일관되게 흘려보내자는 것이다.

  9. 실제 타입은 호출 시점에 결정되며, 이 결정은 모두 컴파일 타임에 이뤄진다.

기본 문법과 타입 추론

  1. 제네릭은 함수 이름 뒤에 꺾쇠 <T>를 붙여 타입 변수를 선언하는 것으로 시작한다.

  2. 여기서 T는 실제 타입이 아니라 "아직 결정되지 않은 타입을 가리키는 이름표"다.

  3. T가 함수 시그니처 안에서 입력과 출력을 연결하는 역할을 한다.

    // T는 호출 시점에 결정되는 타입 변수다
    // 입력 타입과 반환 타입이 모두 T로 묶여 있다
    function identity<T>(value: T): T {
      return value;
    }
  4. 위 함수는 T가 무엇이든 받은 값과 동일한 타입을 돌려준다는 계약을 명시한다.

  5. 호출할 때 타입을 명시적으로 넘길 수도 있고, 그냥 생략하고 추론에 맡길 수도 있다.

    // 1) 명시적으로 타입 전달
    const a = identity<string>("hello"); // a: string
     
    // 2) 타입 추론: 인수 42를 보고 T = number로 자동 결정
    const b = identity(42); // b: number
  6. 후자의 경우 타입스크립트가 인수 42를 보고 T = number임을 스스로 추론한다.

  7. 이 타입 추론 덕분에 실제 코드에서는 꺾쇠를 직접 쓸 일이 생각보다 많지 않다.

  8. 중요한 점은 any와 달리 타입 정보가 함수를 통과한 뒤에도 그대로 살아있다는 것이다.

배열을 다루는 함수에서의 활용

  1. 제네릭의 효과가 가장 직관적으로 드러나는 곳이 배열 처리 함수다.

  2. 배열에서 첫 요소를 꺼내는 단순한 함수를 any와 제네릭으로 각각 비교해보자.

    // any 버전: 꺼낸 순간 타입 검사가 포기된다
    function firstAny(arr: any[]): any {
      return arr[0];
    }
     
    const val = firstAny([1, 2, 3]); // val: any
    val.toUpperCase(); // 컴파일러가 막아주지 못한다 → 런타임 에러
  3. any 버전에서는 꺼낸 값에 .toFixed()를 써도 .toUpperCase()를 써도 컴파일러가 아무 경고를 하지 않는다.

  4. 잘못된 메서드 호출이 런타임에서야 터지는 것은 타입스크립트가 막아야 할 바로 그 상황이다.

    // 제네릭 버전: 요소 타입 T가 반환 타입에 그대로 연결된다
    function first<T>(arr: T[]): T {
      return arr[0];
    }
     
    const num = first([1, 2, 3]);       // num: number
    const str = first(["a", "b", "c"]); // str: string
  5. 제네릭 버전은 first([1,2,3])의 결과가 number로 추론되어, 이후 코드도 타입 안전하게 작성할 수 있다.

  6. arr: T[]와 반환 타입 T라는 시그니처가 "입력 요소 타입과 반환 타입이 같다"는 관계를 컴파일러에게 알려주는 핵심이다.

제네릭과 유니온의 차이

  1. 제네릭을 처음 배울 때 가장 흔한 혼동은 유니온 타입과의 차이다.

  2. 둘 다 "여러 타입을 처리한다"는 점에서 비슷해 보이지만, 의미는 완전히 다르다.

  3. 같은 항등 함수를 유니온과 제네릭으로 각각 써보면 차이가 분명해진다.

    // 유니온 버전: 인자 타입과 반환 타입은 별개로 선언된 정보다
    function identityU(x: string | number): string | number {
      return x;
    }
     
    // 제네릭 버전: T를 통해 인자 타입과 반환 타입이 한 변수로 묶인다
    function identityG<T>(x: T): T {
      return x;
    }
  4. 유니온 버전에서 identityU("hi")의 반환 타입은 여전히 string | number이며, 컴파일러는 그 값이 문자열이었다는 사실을 잊는다.

  5. 반면 제네릭 버전에서 identityG("hi")Tstring으로 확정되어 반환 타입도 string이 된다.

  6. 즉 유니온은 "여러 타입 중 어느 하나가 들어올 수 있다"는 가능성의 집합을 의미한다.

  7. 제네릭은 "호출마다 하나의 구체 타입이 결정되고, 그 타입이 시그니처 전체에 일관되게 적용된다"는 관계를 의미한다.

  8. 이 차이 덕분에 제네릭은 타입 정보를 함수 호출 전후로 연결하는 다리 역할을 할 수 있다.

여러 타입 변수와 타입 간의 관계 표현

  1. 타입 변수는 하나로 제한되지 않으며, 여러 개를 동시에 선언해 타입 사이의 관계를 표현할 수 있다.

  2. 두 값을 받아 쌍으로 묶는 함수는 두 타입이 서로 독립적으로 결정되어야 한다.

    // K와 V는 각각 독립적으로 추론된다
    function zip<K, V>(key: K, value: V): [K, V] {
      return [key, value];
    }
     
    const p1 = zip("age", 30);    // [string, number]
    const p2 = zip(true, [1, 2]); // [boolean, number[]]
  3. KV는 서로 영향을 주지 않고 각자 추론되므로 조합 가능한 타입의 범위가 매우 넓다.

  4. 반대로 같은 타입 변수를 여러 자리에 쓰면, 그 자리들이 같은 타입을 공유한다는 의도를 시그니처에 새길 수 있다.

    function pickOne<T>(a: T, b: T): T {
      return Math.random() > 0.5 ? a : b;
    }
     
    pickOne(1, 2);     // T = number
    pickOne("a", "b"); // T = string
     
    // 주의: 타입을 명시하지 않으면 T가 string | number로 추론되어 통과된다
    pickOne(1, "b");
     
    // 같은 타입만 받도록 강제하려면 호출 시점에 T를 고정한다
    pickOne<number>(1, "b"); // 오류: "b"는 number가 아니다
  5. 즉 같은 타입 변수를 반복하는 것은 강제라기보다 의도의 표현에 가깝다.

  6. 실제 강제는 호출 시 타입을 고정하거나 별도의 제약 조건을 거는 방식으로 달성한다.

  7. 여러 타입 변수든 반복되는 타입 변수든, 결국 핵심은 인자와 반환 사이의 관계를 시그니처에 새겨두는 것이다.

인터페이스에 제네릭 적용하기

  1. 제네릭은 함수뿐 아니라 타입의 구조를 정의하는 인터페이스에도 그대로 적용된다.

  2. 실무에서 가장 흔한 패턴은 API 응답 래퍼다.

  3. 응답의 뼈대인 status, message는 항상 같은데, 실제 데이터의 모양만 엔드포인트마다 달라지는 상황이 제네릭이 필요한 전형적인 시점이다.

    // 공통 구조는 고정, data의 타입만 T로 빼낸다
    interface ApiResponse<T> {
      data: T;
      status: number;
      message: string;
    }
     
    // 각 엔드포인트에 맞게 T만 교체하면 된다
    type UserResponse = ApiResponse<{ id: number; name: string }>;
    type PostListResponse = ApiResponse<{ title: string; body: string }[]>;
  4. UserResponsePostListResponse는 외형을 공유하면서도 data 필드의 타입은 완전히 다르다.

  5. 이 패턴은 타입 중복을 제거하는 동시에, 새 엔드포인트가 생겨도 T만 바꿔 끼우면 된다는 확장성을 제공한다.

제약 조건 extends

  1. 제네릭의 타입 변수는 호출 시점에 무엇이든 들어올 수 있으므로, 함수 내부에서 그 값의 구조를 가정할 수 없다.

  2. 그래서 프로퍼티 접근이나 메서드 호출처럼 특정 구조를 가정하는 동작은 컴파일러가 막는다.

    // T가 어떤 타입인지 모르므로 length의 존재를 보장할 수 없다
    function getLength<T>(value: T): number {
      return value.length; // 오류: 'length' 속성이 T에 존재한다고 볼 수 없음
    }
  3. 단 값을 그대로 돌려주거나, 다른 함수에 넘기거나, 동등 비교하는 등 구조에 의존하지 않는 동작은 여전히 가능하다.

  4. 이때 extends로 타입 변수에 제약 조건을 걸면, "이 조건을 만족하는 타입만 받겠다"고 선언할 수 있다.

    // T는 length: number를 가진 타입이어야 한다
    function getLength<T extends { length: number }>(value: T): number {
      return value.length; // 정상: length 존재가 보장됨
    }
     
    getLength("hello");   // 5  — string에는 length가 있다
    getLength([1, 2, 3]); // 3  — 배열에도 length가 있다
    getLength(42);        // 오류 — number에는 length가 없다
  5. 이 함수를 말로 풀면, length 속성이 number인 타입만 T로 받고, 그 T 타입 값을 인자로 받아 .lengthnumber로 반환한다는 의미가 된다.

  6. extends { length: number }는 "내부에서 .length를 쓸 테니, 없는 타입은 들어오지 마라"고 컴파일러에게 미리 알리는 장치다.

  7. 제약 조건은 제네릭의 자유도를 줄이는 것처럼 보이지만, 실제로는 함수 내부에서 구조에 기반한 동작을 가능하게 만드는 통로다.

keyof와 함께, 객체 접근을 타입 안전하게

  1. extends는 다양한 곳에서 쓰이는데, 그중 keyof와 결합되는 패턴은 특히 자주 등장한다.

  2. keyof T는 타입 T의 모든 키를 유니온 타입으로 꺼내주는 연산자다.

  3. 예를 들어 keyof { id: number; name: string }"id" | "name"이라는 유니온 타입이 된다.

    type User = { id: number; name: string; age: number };
    type UserKey = keyof User; // "id" | "name" | "age"
  4. K extends keyof T로 제약을 걸면, K가 반드시 T의 실제 키 중 하나여야 한다고 강제할 수 있다.

    // K는 T의 키 중 하나여야 하고, 반환 타입은 그 키에 대응하는 값의 타입이다
    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
     
    const user = { id: 1, name: "Alice", age: 30 };
     
    const n = getProperty(user, "name");  // n: string
    const a = getProperty(user, "age");   // a: number
    getProperty(user, "email");           // 오류: "email"은 user의 키가 아니다
  5. 반환 타입 T[K]는 인덱스 접근 타입으로, "T라는 객체 타입에서 K라는 키에 해당하는 값의 타입"을 의미한다.

  6. 그래서 "name"을 넘기면 string이, "age"를 넘기면 number가 정확히 추론된다.

  7. 이 패턴은 객체를 동적으로 접근해야 하는 상황에서 타입 안전성을 잃지 않는 핵심 도구다.

  8. any로 도배된 obj[key] 접근을 깔끔하게 대체할 수 있다.

정리

  1. 제네릭의 본질은 타입을 함수 인자처럼 전달받도록 추상화해, 실제 타입 결정을 사용 시점까지 미루는 것이다.

  2. 이로써 any처럼 타입 검사를 포기하지 않고도, 같은 로직을 여러 타입에 대해 재사용할 수 있다.

  3. 유니온이 "여러 타입 중 하나"라는 가능성을 의미한다면, 제네릭은 "호출마다 결정되는 하나의 구체 타입"이라는 관계를 의미한다.

  4. 타입 추론이 동작하므로 대부분의 호출 지점에서는 꺾쇠를 직접 쓸 필요가 없다.

  5. 같은 타입 변수를 여러 자리에 쓰면 타입 간의 관계를 표현할 수 있고, 여러 타입 변수를 두면 서로 독립적인 타입을 다룰 수 있다.

  6. 자유도가 너무 높아 함수 내부에서 구조를 가정할 수 없을 때는 extends로 제약을 걸어 필요한 속성만 보장받는다.

  7. keyof와 결합하면 객체 접근까지 타입 안전하게 표현할 수 있어, 라이브러리 수준의 유틸리티에서 자주 등장하는 패턴이 된다.