Waylog Blog
← 목록으로 돌아가기

TypeScript의 브레이크를 한계까지: 고급 타입 시스템과 실무 적용 패턴 완전 정복

TypeScript

TypeScript Advanced Types

TypeScript 고급 타입 시스템: 더 안전하고 우아한 코드를 향하여

JavaScript의 자유로움에 타입 안정성을 결합한 **TypeScript(타입스크립트)**는 이제 현대 프론트엔드 및 백엔드 웹 개발 환경에서 선택이 아닌 필수가 되었습니다. 단순한 타입을 선언하고 interface를 작성하는 수준을 넘어, TypeScript의 진정한 힘은 '타입 레벨 프로그래밍(Type-Level Programming)'이라고 불릴 정도로 강력하고 유연한 고급 타입 시스템에 있습니다.

이 글에서는 제네릭, 조건부 타입, 매핑된 타입, 템플릿 리터럴 타입 등 TypeScript가 제공하는 강력한 고급 타입 기능들의 원리를 깊이 있게 분석하고, 이를 실무 아키텍처에 어떻게 적용하여 "타입에 의해 주도되는 안전한 설계"를 달성할 수 있는지 총망라하여 알아보고자 합니다.

1. 제네릭(Generics): 재사용성의 핵심

제네릭은 어떤 함수나 클래스, 인터페이스가 다룰 데이터의 타입을 미리 지정하지 않고, 외부에서 사용되는 시점에 타입을 동적으로 결정하도록 만드는 기법입니다. 제네릭을 적절히 사용하면 중복 코드를 극단적으로 줄이면서도 타입 안정성을 100% 보장할 수 있습니다.

1.1 기본적인 제네릭의 한계와 제약(Constraints)

단순한 제네릭 <T>는 너무 자유롭기 때문에 컴파일러 입장에서는 해당 타입이 배열인지, 객체인지 확신할 수 없습니다. 따라서 실무에서는 항상 extends 키워드를 활용해 제네릭에 제약을 걸어야 합니다.

// 잘못된 예: T가 무엇인지 모르기 때문에 length 속성에 접근할 수 없음
function getLength<T>(arg: T): number {
  // Error: Property 'length' does not exist on type 'T'.
  return arg.length; 
}

// 올바른 예: T는 반드시 length 속성을 가진 타입이어야 함을 명시
interface HasLength {
  length: number;
}

function getLengthOpt<T extends HasLength>(arg: T): number {
  return arg.length;
}

getLengthOpt("hello"); // 정상 동작 (string은 length 속성을 가짐)
getLengthOpt([1, 2, 3]); // 정상 동작 (array는 length 속성을 가짐)
// getLengthOpt(100); // 에러: number 타입은 length 속성이 없음

이처럼 제네릭 제약을 사용하면, 함수의 재사용성은 유지하면서도 런타임에 발생할 수 있는 'undefined의 프로퍼티를 읽을 수 없습니다'와 같은 치명적인 예외를 컴파일 타임에 완벽히 차단할 수 있습니다.

1.2 멀티 제네릭과 타입 추론

두 개 이상의 제네릭을 조합할 때 TypeScript의 타입 추론 능력이 극대화됩니다. 객체의 특정 키를 이용해 값을 추출하는 유틸리티 함수를 생각해 보겠습니다.

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

const user = { id: 1, name: "Alice", age: 28 };

const userName = getProperty(user, "name"); // 타입이 자동으로 string으로 추론됨
const userAge = getProperty(user, "age");   // 타입이 자동으로 number로 추론됨
// getProperty(user, "email");              // 에러: "email"은 user 객체의 키가 아님

여기서 keyof 연산자는 객체 타입 T의 모든 키를 문자열 유니온 타입으로 추출합니다. K extends keyof T는 파라미터 key가 반드시 객체 obj에 존재하는 속성 이름이어야 함을 강제합니다. 이 패턴은 API 응답 데이터를 파싱하거나 상태 관리 라이브러리의 Selector 패턴을 구현할 때 매우 유용하게 사용됩니다.

2. 조건부 타입(Conditional Types): 타입 레벨의 if-else

TypeScript 2.8에 도입된 조건부 타입은 타입 시스템을 튜링 완전(Turing Complete)에 가깝게 만들어준 가장 혁명적인 기능입니다. 조건부 타입은 입력된 타입에 따라 다른 타입을 반환하는 "타입 레벨의 논리 연산"을 가능하게 합니다.

기본 문법은 삼항 연산자와 완벽히 동일합니다.
T extends U ? X : Y

2.1 조건부 타입의 기본 활용

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<123>;     // false

2.2 infer 키워드: 타입 패턴 매칭과 추출

조건부 타입이 진정으로 강력해지는 순간은 infer 키워드와 만날 때입니다. infer는 조건부 타입의 조건식 내부에서 특정 부분을 임의의 타입 변수로 선언하고, 참일 경우 그 타입을 추출해내는 패턴 매칭 기능입니다.

어떤 함수의 반환 타입을 추출하는 유틸리티 ReturnType의 내부 구현을 살펴보겠습니다.

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function fetchUser() {
  return { id: 1, name: "Bob" };
}

// MyReturnType은 함수의 시그니처에서 반환 타입 부분(R)을 infer를 통해 정확히 추출해냅니다.
type UserResponse = MyReturnType<typeof fetchUser>; 
// { id: number, name: string }

이와 반대로 파라미터 타입을 추출하는 Parameters, Promise가 감싸고 있는 내부 값을 추출하는 Awaited 타입들도 모두 이 infer 키워드를 활용해 만들어졌습니다.

// Promise가 반환하는 내부의 진짜 데이터 타입을 추출 (Awaited의 간소화 버전)
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type Data = UnpackPromise<Promise<string[]>>; // string[]

2.3 분배적 조건부 타입 (Distributive Conditional Types)

제네릭 파라미터가 유니온 타입으로 전달될 때, 조건부 타입은 각 유니온 멤버에게 조건식을 '분배'하여 개별적으로 평가한 뒤 다시 유니온으로 묶어버립니다. 이를 분배적 조건부 타입이라고 합니다.

type Exclude<T, U> = T extends U ? never : T;

type MyUnion = "a" | "b" | "c";
// "a" extends "a" | "c" ? never : "a"  ==> never
// "b" extends "a" | "c" ? never : "b"  ==> "b"
// "c" extends "a" | "c" ? never : "c"  ==> never
// 결과적으로 never | "b" | never 가 되며, never는 유니온에서 사라집니다.
type Result = Exclude<MyUnion, "a" | "c">; // "b"

이러한 분배적 특성 덕분에 우리는 손쉽게 유니온 타입 안에서 불필요한 값을 걸러내거나 조합할 수 있습니다.

3. 매핑된 타입 (Mapped Types): 타입 변환의 연금술

매핑된 타입은 기존의 타입을 바탕으로 속성을 순회(iterate)하면서 완전히 새로운 타입을 찍어내는 기능입니다. 이는 실무에서 수백 개의 속성을 가진 DTO(Data Transfer Object)나 API 페이로드의 타입을 한 번에 변환할 때 필수적으로 쓰입니다.

3.1 기본 문법과 빌트인 유틸리티

가장 대표적인 예시는 Partial 타입입니다. Partial은 모든 속성을 선택적(optional)으로 만들어줍니다.

type MyPartial<T> = {
  [P in keyof T]?: T[P];
};

interface User {
  id: number;
  name: string;
  email: string;
}

// 모든 속성이 선택적으로 변경됨
type UpdateUserDto = MyPartial<User>;
/*
{
  id?: number | undefined;
  name?: string | undefined;
  email?: string | undefined;
}
*/

반대로 Readonly 유틸리티는 다음과 같이 구현할 수 있습니다.

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

3.2 매핑 수정자 (Mapping Modifiers)

매핑된 탕입은 +- 기호를 사용해 속성의 특성(readonly 또는 ?)을 추가하거나 제거할 수 있습니다. 예를 들어 어떤 타입의 모든 readonly? 구문을 강제로 제거하고 싶다면 아래와 같이 작성합니다.

type MutableRequired<T> = {
  -readonly [P in keyof T]-?: T[P];
};

interface Config {
  readonly url?: string;
  readonly retries?: number;
}

type StrictConfig = MutableRequired<Config>;
/*
{
  url: string;
  retries: number;
}
*/

3.3 Key Remapping (키 리매핑)

TypeScript 4.1부터 향상된 기능으로, 매핑된 타입 내에서 as 키워드를 사용해 키 이름 자체를 조작할 수 있습니다. 템플릿 리터럴 타입과 결합하면 getter 함수들의 타입을 우아하게 자동 생성할 수 있습니다.

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

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

// Person 타입을 기반으로 Getter 인터페이스를 즉시 생성
type PersonGetters = Getters<Person>;
/*
{
  getName: () => string;
  getAge: () => number;
}
*/

이러한 메타 프로그래밍 수준의 타입 제어를 통해, Redux와 같은 전역 상태 관리 모듈에서 Action Type이나 Handler 시그니처를 보일러플레이트 제로 상태로 자동 생성할 수 있습니다.

4. 명목적 타이핑 (Nominal Typing) 패턴 극복하기: Branded Types

TypeScript는 구조적 타이핑(Structural Typing, 또는 Duck Typing)을 따릅니다. 즉, 타입의 이름이 다르더라도 구조(속성)만 똑같으면 서로 동일한 타입으로 취급합니다.

type UserId = string;
type ProductId = string;

function deleteUser(id: UserId) { /* ... */ }

const pId: ProductId = "prod_123";
// 에러가 발생하지 않음! 구조적으로 UserId와 ProductId는 모두 string이기 때문.
deleteUser(pId); 

위 코드에서 ProductId를 사용자 삭제 API에 넘겼음에도 컴파일 에러가 나지 않습니다. 보안 사고나 비즈니스 로직 오류를 일으키는 가장 흔한 원인입니다. Java 나 C# 같은 명목적 타입(Nominal Typing) 시스템에서는 컴파일 에러가 났을 일입니다.

TypeScript에서 이를 방지하기 위한 가장 우아한 실무 패턴이 바로 Branded Types (또는 Opaque Types) 입니다. 교차 타입(Intersection Type, &)을 사용해 고유한 "브랜드 태그"를 가짜 속성으로 붙여주는 것입니다.

// 브랜드 유틸리티
type Brand<K, T> = K & { __brand: T };

// 고유한 타입 정의
type SafeUserId = Brand<string, "UserId">;
type SafeProductId = Brand<string, "ProductId">;

function deleteUserSafe(id: SafeUserId) { /* ... */ }

const safeUid = "user_456" as SafeUserId;
const safePid = "prod_123" as SafeProductId;

deleteUserSafe(safeUid); // 정상 통과
// deleteUserSafe(safePid); // 컴파일 에러 발생! __brand 태그가 일치하지 않음

실제 런타임 값은 순수한 문자열(string)일 뿐이므로 오버헤드가 전혀 없지만, 컴파일 단계에서는 완벽히 다른 두 타입으로 취급됩니다. 결제 시스템, 유저 식별 코드 등 극도의 안정성이 요구되는 도메인에서 Branded Type은 버그를 원천 차단하는 가장 든든한 방어막입니다.

5. 실전 아키텍처 적용: 안전한 API 라우팅 시스템 설계

지금까지 다룬 고급 타입 기능들(제네릭, 조건부 타입, 매핑된 타입)을 총동원하여, 프론트엔드에서 API 주소의 오타를 막고 URL 파라미터를 강제하는 라우트 빌더(Route Builder)를 설계해 보겠습니다.

// 1. URL 패턴에서 /:id 와 같은 파라미터를 문자열에서 추출해냅니다.
type ExtractRouteParams<T extends string> = 
  T extends `${infer Start}/:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractRouteParams<`/${Rest}`>]: string }
    : T extends `${infer Start}/:${infer Param}`
      ? { [K in Param]: string }
      : {};

// 2. 파라미터 존재 여부에 따라 빌더 함수의 서명을 조건부로 바꿉니다.
function buildUrl<Path extends string>(
  path: Path, 
  ...args: keyof ExtractRouteParams<Path> extends never 
    ? [] 
    : [params: ExtractRouteParams<Path>]
): string {
  let url = path as string;
  const params = args[0] as Record<string, string>;
  
  if (params) {
    for (const key in params) {
      url = url.replace(`:${key}`, params[key]);
    }
  }
  return url;
}

// ---- 사용 예시 ----

// 파라미터가 없으면 두 번째 인자를 요구하지 않음
const home = buildUrl('/home'); 

// :id 와 :tab 파라미터를 정확히 추론하여 요구함
const userTab = buildUrl('/users/:id/tabs/:tab', {
  id: "101",
  tab: "settings"
});

// buildUrl('/posts/:postId', {}); // 에러: postId가 누락됨
// buildUrl('/posts/:postId', { postId: "1", extra: "test" }); // 에러: 알 수 없는 extra 추가됨

이 코드는 실무에서 개발자 경험(DX)을 극대화하는 패턴의 정점입니다. 개발자의 사소한 오타나 데이터 누락을 IDE 인텔리센스 창에서 붉은 밑줄로 즉시 경고합니다. 런타임에러가 터질 확률은 0에 수렴합니다.

6. Never 타입의 활용: 완벽한 분기 처리(Exhaustiveness Checking)

타입 시스템에서 never는 "아무것도 들어올 수 없음"을 의미하는 바닥 타입(Bottom Type)입니다. 이 특성을 이용해 switchif-else 분기문에서 개발자가 특정 케이스를 누락하는 것을 완벽히 방지할 수 있습니다.

type Theme = 'light' | 'dark' | 'system';

function getThemeStyles(theme: Theme) {
  switch (theme) {
    case 'light': return { bg: '#fff' };
    case 'dark': return { bg: '#000' };
    case 'system': return { bg: 'transparent' };
    default:
      // 이 영역은 도달할 수 없는 코드가 되어야 합니다.
      // 만약 Theme 타입에 'dimmed'가 추가되었는데 이 switch 문을 수정하지 않는다면,
      // theme 변수의 타입은 'dimmed'가 되어 never에 할당할 수 없으므로 컴파일 에러가 발생합니다!
      const _exhaustiveCheck: never = theme;
      return _exhaustiveCheck;
  }
}

애자일한 환경에서 모델이나 유니온 타입은 빈번하게 변경됩니다. Exhaustiveness Checking 패턴을 사용해두면, 누군가 타입을 수정했을 때 관련된 비즈니스 로직(switch 문)을 수정하지 않았다면 컴파일 단계에서 강제로 빌드를 멈추게 할 수 있습니다.

7. 결론: 타입은 코드를 작성하는 "사고의 틀"이다

초급 개발자에게 TypeScript는 그저 짜증나는 "에러 경고기" 혹은 불필요한 타이핑 노동으로 느껴질 수 있습니다. any// @ts-ignore로 시스템을 회피하며 기능 구현에만 급급할 때가 많습니다.

하지만 이 글에서 살펴본 고급 타입 시스템을 완벽히 이해한다면, TypeScript는 오히려 시스템을 견고하게 설계하는 "나만의 캡틴 아메리카 방패"이자 사고를 구조화하는 도구로 변모합니다. 제네릭으로 추상화를 관리하고, 조건부/매핑 타입으로 생산성을 높이며, Branded Type과 Exhaustive Check로 무결성을 검증하십시오.

에러를 사전에 발견하고, 리팩터링의 두려움을 지워주는 고급 타입 시스템에 대해 끊임없이 탐구한다면, 단순히 '에러가 안 나는 코드'를 넘어 의도를 명확하게 보여주고 팀 전체의 신뢰감을 높이는 마스터피스를 탄생시킬 수 있을 것입니다.