TypeScriptTypeScript 기초강의 8개 중 8번째난이도 기초

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

발행 읽는 시간 약 1 분

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

강의 소개

TypeScript 기초를 총정리하는 실전 프로젝트입니다. 제네릭, 유틸리티 타입, 에러 처리를 결합해 타입 안전한 API 클라이언트를 만듭니다.

프로젝트 개요#

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

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

에러 타입 설계#

TypeScript
// 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;

응답 타입 정의#

TypeScript
// 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 클라이언트 구현#

TypeScript
// 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);
    }
}

도메인 모델 및 사용#

TypeScript
// 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}`);
    }
}

사용 예시 및 에러 처리#

TypeScript
// 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를 만드는 방법을 배웁니다.

Follow · Ask · Work

이 글이 도움이 됐다면

짧은 작업 기록과 데모는 인스타그램에 올립니다. 만들고 싶은 것이 있다면 프로젝트 의뢰나 협업 제안도 편하게 보내 주세요.

글쓴이

파란여우

직접 만든 AI 앱·제품의 개발·배포를 공개하는 실전형 테크 크리에이터

파란여우 소개 Instagram @bluefox._.hi

이어서 보기

만들고 싶은 것이 있다면 프로젝트 의뢰로 알려 주세요.