프로젝트 개요
이번 프로젝트에서 만들 것:
- 타입 안전 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를 만드는 방법을 배웁니다.