TypeScript 5.x 실전 타입 패턴 — 유지보수 비용을 줄이는 방법

게시일: 2025년 3월 21일 · 16분 읽기

프로젝트가 커질수록 타입스크립트의 any가 전염병처럼 퍼진다. 6개월 전만 해도 깨끗했던 코드가, 지금은 어디서 any가 터져 나올지 모른다. 나는 지난 18개월간 50만 줄 규모의 TypeScript 프로젝트를 유지보수하면서 배운 패턴들을 공유한다.

문제: any가 전염병처럼 퍼진다

any를 쓰는 이유는 대부분 이렇다:

"지금은 시간이 없다. 나중에 고치자."

하지만 "나중에"는 절대 오지 않는다. 이 any는 다음 개발자에게 영향을 준다. 그 개발자도 any를 전파한다. 몇 개월 후 코드베이스는 any로 가득 찬다.

우리 팀의 실제 사례:

any를 줄이려면 처음부터 좋은 타입 패턴을 써야 한다.

패턴 1: Discriminated Union으로 상태 관리하기

많은 API 응답 타입은 이렇게 정의된다:

interface ApiResponse {
    success: boolean;
    data?: any;
    error?: any;
}

이건 최악이다. success가 true일 때 data는 항상 존재하고, false일 때 error가 존재한다. 하지만 타입스크립트는 이걸 알 수 없다.

Discriminated Union을 쓰자:

interface SuccessResponse<T> {
    success: true;
    data: T;
}

interface ErrorResponse {
    success: false;
    error: {
        code: string;
        message: string;
    };
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

// 사용처에서:
function handleResponse<T>(response: ApiResponse<T>) {
    if (response.success) {
        // 여기서 response.data는 안전하게 T 타입
        console.log(response.data);
    } else {
        // 여기서 response.error는 안전하게 에러 객체
        console.error(response.error.message);
    }
}

차이는 다음과 같다:

RuntimeError: Cannot read property 'data' of undefined
vs
TypeScript compile error: Property 'data' does not exist on type 'ErrorResponse'

전자는 프로덕션에서 터진다. 후자는 개발 중에 잡힌다.

패턴 2: Template Literal Types로 문자열 상수 강제

이벤트 이름이나 상태 문자열에 any를 쓰는 경우가 많다:

// ❌ 나쁨
const eventName = 'user_login';  // 문자열은 아무거나 가능
emit(eventName);

// 2개월 후
const eventName = 'user-login';  // 오타, 이제 이벤트가 안 들음
emit(eventName);

Template Literal Types로 정확한 형식을 강제하자:

type EventName =
    | `user:${string}:${string}`
    | `session:${string}`
    | `payment:${string}:success`
    | `payment:${string}:failed`;

const emit = (name: EventName) => { /* ... */ };

emit('user:login:success');           // ✅ OK
emit('user:logout:success');          // ✅ OK
emit('payment:123:success');          // ✅ OK
emit('payment:123:failed');           // ✅ OK
emit('user-login-success');           // ❌ Type error

API 상태 코드도 마찬가지:

type HttpStatus =
    | `2${number}${number}`  // 2xx 성공
    | `3${number}${number}`  // 3xx 리다이렉트
    | `4${number}${number}`  // 4xx 클라이언트 에러
    | `5${number}${number}`;  // 5xx 서버 에러

const statusCode: HttpStatus = 200;  // ✅ OK
const statusCode2: HttpStatus = 201; // ✅ OK
const statusCode3: HttpStatus = 600; // ❌ Type error

패턴 3: satisfies 연산자로 타입 검증

TypeScript 4.9에서 추가된 satisfies는 "이 값이 이 타입을 만족하는가?"를 체크한다:

interface Config {
    api: {
        baseUrl: string;
        timeout: number;
    };
    logging: {
        level: 'debug' | 'info' | 'warn' | 'error';
    };
}

const config = {
    api: {
        baseUrl: 'https://api.example.com',
        timeout: 5000,
    },
    logging: {
        level: 'verbose',  // ❌ Type error with satisfies
    },
} satisfies Config;

satisfies를 안 쓰면?

// ❌ 타입 에러를 못 잡음
const config: Config = {
    api: {
        baseUrl: 'https://api.example.com',
        timeout: 5000,
    },
    logging: {
        level: 'verbose',
    },
};

또는 아예 타입을 정의 안 했으면?

// ❌ 완전한 어둠
const config = {
    api: {
        baseUrl: 'https://api.example.com',
        timeout: 5000,
    },
    logging: {
        level: 'verbose',
    },
};

satisfies는 자동 완성도 제공한다. VS Code가 여정 중에 값의 형태를 안다.

패턴 4: Conditional Types로 복잡한 타입 관계 표현

API 응답이 입력에 따라 달라지는 경우가 있다:

type ApiResponse<T> =
    T extends { includeMetadata: true }
        ? { data: string; metadata: Record<string, unknown> }
        : { data: string };

const response1 = null as ApiResponse<{ includeMetadata: true }>;
// response1.metadata는 안전하게 접근 가능

const response2 = null as ApiResponse<{ includeMetadata: false }>;
// response2.metadata는 타입 에러

이 패턴은 React의 제네릭 컴포넌트에서도 유용하다:

interface TableProps<T extends boolean = false> {
    data: string[];
    selectable: T;
    onSelectionChange?: T extends true
        ? (selected: string[]) => void
        : never;
}

// selectable={true}를 넘기면 onSelectionChange가 필수
// selectable={false}를 넘기면 onSelectionChange는 제공하면 타입 에러

패턴 5: 제너릭 제약(Generic Constraints)으로 타입 범위 좁히기

제너릭은 강력하지만 너무 열려있다:

// ❌ 너무 넓음
function getValue<T>(obj: T, key: string): any {
    return obj[key];
}

// ✅ 더 좋음
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: 'John', age: 30 };
const name = getValue(user, 'name');  // string 타입으로 추론됨
const age = getValue(user, 'age');    // number 타입으로 추론됨
getValue(user, 'email');              // ❌ Type error, 'email'은 user에 없음

API 클라이언트에도 적용할 수 있다:

interface ApiMethods {
    'GET /users': { response: User[] };
    'POST /users': { request: User; response: User };
    'DELETE /users/:id': { response: { success: boolean } };
}

function apiCall<T extends keyof ApiMethods>(
    method: T,
    data?: ApiMethods[T]['request']
): ApiMethods[T]['response'] {
    // 구현...
    return null as any;
}

// 사용처
const users = apiCall('GET /users');           // User[] 타입
apiCall('POST /users', { name: 'John' });      // User 타입 반환
// apiCall('GET /users', { ... });             // ❌ Type error, POST가 아님

패턴 6: infer로 타입 추출하기

Promise의 내부 타입을 뽑아내는 경우:

type Awaited<T> = T extends Promise<infer U> ? U : T;

type A = Awaited<Promise<string>>;       // string
type B = Awaited<Promise<Promise<number>>>; // Promise<number>

배열의 요소 타입을 뽑아내기:

type ArrayElement<T> = T extends (infer E)[] ? E : never;

type StringArray = ArrayElement<string[]>;  // string
type NumberArray = ArrayElement<number[]>; // number

이 패턴을 쓰면 any를 완전히 제거할 수 있다.

실전 팁: 타입 가드(Type Guard) 함수

런타임에 타입을 확인하는 함수를 만들자:

function isUser(obj: unknown): obj is User {
    return (
        typeof obj === 'object' &&
        obj !== null &&
        'name' in obj &&
        'email' in obj &&
        typeof obj.name === 'string' &&
        typeof obj.email === 'string'
    );
}

function processData(data: unknown) {
    if (isUser(data)) {
        // 여기서 data는 안전하게 User 타입
        console.log(data.name);
    }
}

팀 규칙: any를 제거하자

우리 팀의 코드 리뷰 규칙:

1. any를 쓴 코드는 머지 불가
2. any가 필요하면 // @ts-ignore 대신 as 타입으로 최소화
3. any가 있는 함수는 주석으로 이유를 명시
4. 월 1회 any 제거 리팩토링 스프린트

이 규칙 덕분에 우리 프로젝트의 any 밀도는 오히려 매달 0.5% 감소하고 있다.

결론

TypeScript 5.x는 충분히 강력하다. any를 줄이는 건 거래가 아니라 투자다. 초기에 타입을 잘 정의하면, 3개월 후에는 리팩토링 비용이 절반 이하로 줄어든다.

오랜 개발 경험에서 배운 게 있다면: 컴파일 타임 에러 하나는 프로덕션 런타임 버그 열 개보다 낫다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.