TypeScript generic과 keyof로 타입 안전성 높이기

TypeScript generic과 keyof로 타입 안전성 높이기

D
dongAuthor
7 min read

TypeScript로 작업하다 보면 다양한 타입에 대응할 수 있는 재사용 가능한 코드를 작성하고 싶을 때가 있죠. 바로 이럴 때 제네릭(Generics)이 필요합니다. 그리고 keyof 연산자와 함께 사용하면 객체의 속성을 안전하게 다룰 수 있어요. 이 글에서는 TypeScript의 제네릭과 keyof를 함께 사용하는 방법을 실용적인 예제와 함께 살펴볼게요. 기본 개념부터 실무에서 바로 적용할 수 있는 패턴까지, 단계별로 이해할 수 있도록 구성했습니다.


TypeScript 제네릭의 기초

제네릭은 재사용 가능한 컴포넌트를 만드는 핵심 도구이며 다양한 타입에 대응하면서 타입 안정성을 확보합니다. 하나의 타입이 아닌 다양한 타입에 대응할 수 있는 컴포넌트를 만들 수 있게 해주죠. 가장 기본적인 예제인 identity 함수를 볼까요?

function identity<T>(arg: T): T {
  return arg;
}

여기서 <T>는 타입 변수입니다. 함수를 호출할 때 전달되는 타입을 캡처해서, 입력과 출력의 타입을 일치시켜요. 타입 변수의 이름은 자유롭게 정할 수 있지만, 관례적으로 T(Type의 약자)를 많이 사용합니다.

제네릭 함수 호출하기

제네릭은 명시적 타입 인자 또는 타입 추론으로 호출할 수 있으며, 가능하면 타입 추론을 권장합니다. 제네릭 함수는 두 가지 방법으로 호출할 수 있어요.

명시적 타입 인자 전달:

let output = identity<string>("myString");

타입 추론 활용:

let output = identity("myString");

두 번째 방법이 더 간결하고 가독성이 좋아서, TypeScript가 자동으로 타입을 추론할 수 있다면 이 방식을 권장합니다.

타입 변수가 주는 유연성

제네릭은 다양한 타입을 안전하게 수용하고, 불일치한 타입 조합은 컴파일 타임에 차단합니다.

function toArray<T>(a: T, b: T) {
  return [a, b];
}

toArray<string>("hello", "world"); // string[] 타입
toArray<number>(1, 2); // number[] 타입

만약 서로 다른 타입의 인자를 전달하면 컴파일 시점에 에러가 발생합니다:

toArray<string>("hello", 1); // 타입 에러!

keyof 연산자로 제네릭 제약하기

keyof는 객체 타입의 키 유니온을 제공하고, 제네릭 제약과 결합해 속성 접근을 타입 안전하게 만듭니다. keyof 연산자는 객체 타입의 키들을 리터럴 타입의 유니온으로 가져옵니다. 이를 제네릭과 함께 사용하면 강력한 타입 제약을 만들 수 있어요.

keyof의 기본 동작

interface Person {
  name: string;
  age: number;
}

type PersonKeys = keyof Person; // "name" | "age"

제네릭 제약으로 타입 안전성 확보하기

Key extends keyof Type 제약을 통해 존재하지 않는 속성 접근을 컴파일 단계에서 차단할 수 있습니다.

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}

const person = {
  name: "Anna",
  age: 30
};

getProperty(person, "name"); // ✅ 정상 동작
getProperty(person, "email"); // ❌ 타입 에러!
🩼

핵심 팁
제네릭 제약은 “사용을 허용할 키의 집합”을 타입으로 강제합니다. 즉, 존재하지 않는 키는 애초에 함수 인자로 허용되지 않습니다.

속성 설정 함수 구현하기

T[K] 인덱스 접근 타입을 활용하면 값의 타입까지 정확히 제약할 수 있습니다.

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

const user = {
  name: "John",
  age: 25
};

setProperty(user, "name", "Jane"); // ✅ 정상
setProperty(user, "name", 30); // ❌ 타입 에러! (string 타입이어야 함)
setProperty(user, "email", "test@test.com"); // ❌ 타입 에러! (존재하지 않는 속성)

제네릭 인터페이스와 클래스

함수뿐 아니라 인터페이스와 클래스에도 제네릭을 적용해 유연한 API를 설계할 수 있습니다.

제네릭 인터페이스

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: GenericIdentityFn<number> = identity;

제네릭 클래스

class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) {
  return x + y;
};

실무 예제:

class User<P> {
  constructor(public payload: P) {
    this.payload = payload;
  }

  getPayload() {
    return this.payload;
  }
}

const user1 = new User<{ name: string; age: number }>({
  name: "Kim",
  age: 28
});

const user2 = new User<string[]>(["admin", "user"]);

제네릭 제한사항

TypeScript에서는 제네릭 enum과 namespace는 만들 수 없습니다. 이는 TypeScript의 설계상 제한사항이에요.


기본 타입 파라미터

제네릭 기본값을 지정하면 API 사용성을 높이고 일반 케이스를 간결하게 처리할 수 있습니다.

function createHTMLElement<T extends HTMLElement = HTMLDivElement>(
  element?: T
): T {
  return element || (document.createElement('div') as T);
}

// 타입 인자를 명시하지 않으면 HTMLDivElement가 사용됨
const div = createHTMLElement();

// 명시적으로 다른 타입을 지정할 수도 있음
const button = createHTMLElement<HTMLButtonElement>(
  document.createElement('button')
);

실전 예제: 타입 안전한 데이터 접근 패턴

API 응답에서 특정 속성을 안전하게 추출하는 유틸을 만들면 런타임 오류를 컴파일 타임에 예방할 수 있습니다.

interface ApiResponse {
  data: {
    user: {
      id: number;
      name: string;
      email: string;
    };
    posts: Array<{
      id: number;
      title: string;
    }>;
  };
  meta: {
    timestamp: number;
    version: string;
  };
}

function extractData<T, K extends keyof T>(response: T, key: K): T[K] {
  return response[key];
}

const response: ApiResponse = {
  data: {
    user: { id: 1, name: "Lee", email: "lee@example.com" },
    posts: [{ id: 1, title: "First Post" }]
  },
  meta: {
    timestamp: Date.now(),
    version: "1.0"
  }
};

const data = extractData(response, "data"); // data 타입이 자동으로 추론됨
const meta = extractData(response, "meta"); // meta 타입도 정확히 추론됨

중첩 속성 접근하기

중첩 키를 단계적으로 제약하면 깊은 경로 접근도 안전하게 만들 수 있습니다.

function getNestedProperty<
  T,
  K1 extends keyof T,
  K2 extends keyof T[K1]
>(obj: T, key1: K1, key2: K2): T[K1][K2] {
  return obj[key1][key2];
}

const userName = getNestedProperty(response, "data", "user"); // user 객체 타입

Union 타입과 제네릭 제약

extends로 유니온을 지정하면 허용 타입을 정확히 한정할 수 있습니다.

function hello<T extends string | number>(msg: T): T {
  return msg;
}

hello(3); // ✅ number 타입
hello("hi"); // ✅ string 타입
hello([3, 5]); // ❌ 타입 에러! (배열은 허용되지 않음)

keyof와 typeof를 함께 사용하기

상수 객체로부터 값 유니온 타입을 추출하는 실무 패턴은 keyoftypeof의 조합으로 구현합니다.

const SUBJECT = {
  Math: 'Math',
  English: 'English',
  Science: 'Science'
} as const;

type Subject = typeof SUBJECT[keyof typeof SUBJECT];
// "Math" | "English" | "Science" 타입

function getSubjectName(subject: Subject): string {
  return subject;
}

getSubjectName("Math"); // ✅ 정상
getSubjectName("History"); // ❌ 타입 에러!

이 패턴은 다음과 같은 순서로 동작합니다: 1) typeof SUBJECT로 객체 타입을 가져옴 2) keyof typeof SUBJECT로 키들(“Math”, “English”, “Science”)을 추출 3) typeof SUBJECT[keyof typeof SUBJECT]로 값들의 리터럴 타입을 유니온으로 만듦


제네릭과 keyof의 실무 활용 이점

타입 안전성 향상: 컴파일 시점에 존재하지 않는 속성 접근을 방지할 수 있어요. 코드 재사용성: 하나의 함수나 클래스를 다양한 타입에 대해 재사용할 수 있습니다. 유지보수성 개선: 타입이 명확해 리팩토링 시 안정망 역할을 합니다. IDE 지원 강화: 자동완성과 타입 힌트가 정확하게 작동합니다.

🩼

현업 팁
동적 키 접근 유틸(예: getProperty, setProperty, getNestedProperty)을 만들어 두면 프로젝트 전반에 재사용하면서 런타임 오류를 컴파일 타임으로 이동시킬 수 있습니다.


자주 묻는 질문 (FAQ)

제네릭 타입 변수는 몇 개까지 사용할 수 있나요?

필요한 만큼 사용 가능하지만 과도한 타입 변수는 가독성을 해치므로 보통 2~3개가 적당합니다.

function transform<T, U, V>(input: T, mapper1: (x: T) => U, mapper2: (x: U) => V): V {
  return mapper2(mapper1(input));
}

keyof는 언제 사용하는 게 좋을까요?

동적 속성 접근, 매핑/변환 유틸, 타입 안전 이벤트 핸들러, 상태 관리 등에서 속성 유효성을 보장할 때 유용합니다. 객체의 속성에 안전하게 접근해야 할 때 특히 힘을 발휘합니다.

제네릭 제약이 너무 복잡해질 때는 어떻게 하나요?

타입 별칭으로 제약을 분리해 가독성을 높이세요.

type ValidKey<T> = keyof T;
type ValidValue<T, K extends ValidKey<T>> = T[K];

function update<T, K extends ValidKey<T>>(
  obj: T,
  key: K,
  value: ValidValue<T, K>
): void {
  obj[key] = value;
}

프로젝트에 적용해보세요

제네릭과 keyof는 타입 안전성과 코드 품질을 크게 향상시키는 강력한 도구입니다. 작은 유틸리티부터 적용 범위를 넓혀보세요. 실무에서 객체 속성에 접근하는 코드가 있다면, 그곳이 제네릭과 keyof를 적용하기 좋은 시작점이에요. 코드를 작성하다가 “이 속성이 실제로 존재하는지 확신이 안 된다”라고 느낀다면 바로 그때가 제네릭 제약을 추가할 타이밍입니다. TypeScript의 타입 시스템이 여러분의 코드를 더 안전하게 만들어줄 거예요 :)

References