TypeScriptTypeScript 기초 · 8기초

TypeScript 실전 프로젝트 — 타입 안전 API 클라이언트

TypeScript프로젝트API클라이언트fetch에러처리

프로젝트 개요

이번 프로젝트에서 만들 것:

  • 타입 안전 HTTP 클라이언트
  • 에러 타입 계층구조
  • 요청/응답 인터셉터
  • 실사용 예시

에러 타입 설계

// src/errors.ts
export class HttpError extends Error {
    constructor(
        public status: number,
        public statusText: string,
        message: string,
    ) {
        super(message);
        this.name = "HttpError";
    }
}

export class NetworkError extends Error {
    constructor(message: string, public cause?: Error) {
        super(message);
        this.name = "NetworkError";
    }
}

export class TimeoutError extends Error {
    constructor(public timeoutMs: number) {
        super(`요청이 ${timeoutMs}ms 내에 완료되지 않았습니다.`);
        this.name = "TimeoutError";
    }
}

export type ApiClientError = HttpError | NetworkError | TimeoutError;

응답 타입 정의

// src/types.ts
export interface ApiResponse<T> {
    data: T;
    status: number;
    headers: Record<string, string>;
}

export interface PaginatedResponse<T> {
    items: T[];
    total: number;
    page: number;
    pageSize: number;
    hasNext: boolean;
}

export interface RequestConfig {
    baseUrl: string;
    headers?: Record<string, string>;
    timeoutMs?: number;
}

export interface RequestOptions {
    headers?: Record<string, string>;
    params?: Record<string, string | number>;
    timeoutMs?: number;
}

API 클라이언트 구현

// src/api-client.ts
import { HttpError, NetworkError, TimeoutError } from "./errors";
import type { ApiResponse, RequestConfig, RequestOptions } from "./types";

export class ApiClient {
    private config: Required<RequestConfig>;

    constructor(config: RequestConfig) {
        this.config = {
            timeoutMs: 30_000,
            headers: {},
            ...config,
        };
    }

    private buildUrl(path: string, params?: Record<string, string | number>): string {
        const url = new URL(path, this.config.baseUrl);
        if (params) {
            Object.entries(params).forEach(([k, v]) =>
                url.searchParams.set(k, String(v))
            );
        }
        return url.toString();
    }

    async request<T>(
        method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
        path: string,
        body?: unknown,
        options?: RequestOptions,
    ): Promise<ApiResponse<T>> {
        const controller = new AbortController();
        const timeoutMs = options?.timeoutMs ?? this.config.timeoutMs;
        const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

        try {
            const response = await fetch(
                this.buildUrl(path, options?.params),
                {
                    method,
                    headers: {
                        "Content-Type": "application/json",
                        ...this.config.headers,
                        ...options?.headers,
                    },
                    body: body ? JSON.stringify(body) : undefined,
                    signal: controller.signal,
                }
            );

            clearTimeout(timeoutId);

            if (!response.ok) {
                throw new HttpError(
                    response.status,
                    response.statusText,
                    `HTTP ${response.status}: ${response.statusText}`,
                );
            }

            const data = await response.json() as T;
            const headers: Record<string, string> = {};
            response.headers.forEach((v, k) => { headers[k] = v; });

            return { data, status: response.status, headers };

        } catch (error) {
            clearTimeout(timeoutId);

            if (error instanceof HttpError) throw error;

            if (error instanceof DOMException && error.name === "AbortError") {
                throw new TimeoutError(timeoutMs);
            }

            throw new NetworkError(
                "네트워크 오류가 발생했습니다.",
                error instanceof Error ? error : undefined,
            );
        }
    }

    get<T>(path: string, options?: RequestOptions) {
        return this.request<T>("GET", path, undefined, options);
    }

    post<T>(path: string, body: unknown, options?: RequestOptions) {
        return this.request<T>("POST", path, body, options);
    }

    put<T>(path: string, body: unknown, options?: RequestOptions) {
        return this.request<T>("PUT", path, body, options);
    }

    patch<T>(path: string, body: unknown, options?: RequestOptions) {
        return this.request<T>("PATCH", path, body, options);
    }

    delete<T>(path: string, options?: RequestOptions) {
        return this.request<T>("DELETE", path, undefined, options);
    }
}

도메인 모델 및 사용

// src/models.ts
export interface User {
    id: number;
    name: string;
    email: string;
    createdAt: string;
}

export type CreateUserDto = Omit<User, "id" | "createdAt">;
export type UpdateUserDto = Partial<CreateUserDto>;

// src/user-api.ts
import { ApiClient } from "./api-client";
import type { PaginatedResponse } from "./types";
import type { User, CreateUserDto, UpdateUserDto } from "./models";

export class UserApi {
    constructor(private client: ApiClient) {}

    async list(page = 1, pageSize = 20) {
        const { data } = await this.client.get<PaginatedResponse<User>>(
            "/users",
            { params: { page, pageSize } },
        );
        return data;
    }

    async get(id: number) {
        const { data } = await this.client.get<User>(`/users/${id}`);
        return data;
    }

    async create(dto: CreateUserDto) {
        const { data } = await this.client.post<User>("/users", dto);
        return data;
    }

    async update(id: number, dto: UpdateUserDto) {
        const { data } = await this.client.patch<User>(`/users/${id}`, dto);
        return data;
    }

    async delete(id: number) {
        await this.client.delete(`/users/${id}`);
    }
}

사용 예시 및 에러 처리

// src/main.ts
import { ApiClient } from "./api-client";
import { UserApi } from "./user-api";
import { HttpError, NetworkError, TimeoutError } from "./errors";

const client = new ApiClient({
    baseUrl: "https://api.example.com",
    headers: { "Authorization": `Bearer ${process.env.API_TOKEN}` },
});

const userApi = new UserApi(client);

async function main() {
    try {
        // 목록 조회
        const users = await userApi.list(1, 10);
        console.log(`전체 ${users.total}명, ${users.items.length}명 조회`);

        // 새 사용자 생성
        const newUser = await userApi.create({
            name: "철수",
            email: "kim@example.com",
        });
        console.log(`생성됨: ${newUser.id}`);

        // 수정
        const updated = await userApi.update(newUser.id, { name: "김철수" });
        console.log(`수정됨: ${updated.name}`);

    } catch (error) {
        if (error instanceof HttpError) {
            console.error(`HTTP 오류 ${error.status}: ${error.message}`);
        } else if (error instanceof TimeoutError) {
            console.error(`타임아웃: ${error.timeoutMs}ms`);
        } else if (error instanceof NetworkError) {
            console.error(`네트워크 오류: ${error.message}`);
        } else {
            throw error;
        }
    }
}

main();

학습 정리

TypeScript 기초 시리즈에서 배운 내용:

주제
1편TypeScript 소개 및 환경 설정
2편기본 타입 (string, number, array, enum)
3편인터페이스와 타입 별칭, 유니온·인터섹션
4편함수와 제네릭
5편클래스와 접근 제어자
6편유틸리티 타입
7편타입 가드, 조건부·맵드 타입
8편실전 프로젝트

다음은 React 기초 — TypeScript와 함께 컴포넌트 기반 UI를 만드는 방법을 배웁니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.