13.6 실전 고수 팁 — HTTP 에러 타입, OpenAPI 자동 생성, Hono
공통 HTTP 에러 타입 설계
// types/errors.ts
// HTTP 상태 코드 타입
type HttpStatusCode =
| 200 | 201 | 204
| 400 | 401 | 403 | 404 | 409 | 422 | 429
| 500 | 502 | 503
// 에러 코드 열거형
type ErrorCode =
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'RATE_LIMITED'
| 'INTERNAL_ERROR'
// 기본 에러 응답 형식
interface ApiErrorResponse {
error: {
code: ErrorCode
message: string
details?: Record<string, string[]>
requestId?: string
timestamp: string
}
}
// 에러 클래스 계층
class HttpError extends Error {
constructor(
public readonly statusCode: HttpStatusCode,
public readonly code: ErrorCode,
message: string,
public readonly details?: Record<string, string[]>
) {
super(message)
this.name = 'HttpError'
}
toResponse(): ApiErrorResponse {
return {
error: {
code: this.code,
message: this.message,
details: this.details,
timestamp: new Date().toISOString(),
},
}
}
}
class ValidationError extends HttpError {
constructor(details: Record<string, string[]>) {
super(422, 'VALIDATION_ERROR', '입력값이 올바르지 않습니다.', details)
}
}
class UnauthorizedError extends HttpError {
constructor(message = '인증이 필요합니다.') {
super(401, 'UNAUTHORIZED', message)
}
}
class ForbiddenError extends HttpError {
constructor(message = '접근 권한이 없습니다.') {
super(403, 'FORBIDDEN', message)
}
}
class NotFoundError extends HttpError {
constructor(resource: string) {
super(404, 'NOT_FOUND', `${resource}을(를) 찾을 수 없습니다.`)
}
}
class ConflictError extends HttpError {
constructor(message: string) {
super(409, 'CONFLICT', message)
}
}
OpenAPI 타입 자동 생성 (openapi-typescript)
REST API 스펙에서 TypeScript 타입을 자동으로 생성합니다.
npm install --save-dev openapi-typescript
# OpenAPI 스펙에서 타입 생성
npx openapi-typescript ./openapi.yaml --output src/generated/api.ts
# 원격 URL에서
npx openapi-typescript https://api.example.com/openapi.json --output src/generated/api.ts
// 생성된 타입 사용 (openapi-fetch와 함께)
import createClient from 'openapi-fetch'
import type { paths } from './generated/api'
const client = createClient<paths>({ baseUrl: 'https://api.example.com' })
// 타입 안전한 API 호출
const { data, error } = await client.GET('/users/{id}', {
params: {
path: { id: '123' }, // 경로 파라미터
query: { include: 'posts' }, // 쿼리 파라미터
},
})
// data: paths['/users/{id}']['get']['responses']['200']['content']['application/json']
// error: 에러 응답 타입
Hono — 엣지/풀스택을 위한 초경량 프레임워크
Hono는 Express보다 훨씬 가볍고 빠른 TypeScript-first 프레임워크입니다.
npm install hono
// src/app.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { jwt } from 'hono/jwt'
import { z } from 'zod'
const app = new Hono()
// 타입 안전한 환경변수
type Env = {
Bindings: {
DATABASE_URL: string
JWT_SECRET: string
}
Variables: {
userId: string
userRole: 'admin' | 'user'
}
}
const api = new Hono<Env>()
// 미들웨어
api.use('/protected/*', jwt({ secret: 'secret' }))
// Zod 검증
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
api.post(
'/users',
zValidator('json', CreateUserSchema), // 자동 검증 + 타입 추론
async (c) => {
const data = c.req.valid('json') // CreateUserSchema 타입 안전
const user = await createUser(data)
return c.json(user, 201)
}
)
api.get('/users/:id', async (c) => {
const id = c.req.param('id') // string
const user = await getUser(id)
if (!user) {
return c.json({ error: '사용자 없음' }, 404)
}
return c.json(user)
})
// RPC 스타일 (클라이언트 타입 생성)
const routes = app
.get('/health', (c) => c.json({ status: 'ok' }))
.get('/users', async (c) => c.json(await getUsers()))
.post('/users', zValidator('json', CreateUserSchema), async (c) => {
const data = c.req.valid('json')
return c.json(await createUser(data), 201)
})
// 클라이언트 타입 추출
export type AppType = typeof routes
Hono 클라이언트 (타입 공유)
// client/api.ts
import { hc } from 'hono/client'
import type { AppType } from '../server/app'
const client = hc<AppType>('http://localhost:3000')
// 완전한 타입 안전 API 호출
const res = await client.users.$get()
const users = await res.json() // 타입 자동 추론
const created = await client.users.$post({
json: { name: 'Alice', email: 'alice@example.com' },
})
공통 API 클라이언트 패턴
// lib/api-client.ts
class ApiClient {
constructor(
private baseUrl: string,
private getToken: () => string | null
) {}
private async request<T>(
method: string,
path: string,
options?: {
body?: unknown
params?: Record<string, string>
}
): Promise<T> {
const url = new URL(path, this.baseUrl)
if (options?.params) {
Object.entries(options.params).forEach(([key, value]) => {
url.searchParams.set(key, value)
})
}
const token = this.getToken()
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const response = await fetch(url, {
method,
headers,
body: options?.body ? JSON.stringify(options.body) : undefined,
})
if (!response.ok) {
const error: ApiErrorResponse = await response.json()
throw new Error(error.error.message)
}
return response.json() as T
}
get<T>(path: string, params?: Record<string, string>) {
return this.request<T>('GET', path, { params })
}
post<T>(path: string, body: unknown) {
return this.request<T>('POST', path, { body })
}
put<T>(path: string, body: unknown) {
return this.request<T>('PUT', path, { body })
}
delete<T>(path: string) {
return this.request<T>('DELETE', path)
}
}
// 사용
const api = new ApiClient('https://api.example.com', () => localStorage.getItem('token'))
const users = await api.get<User[]>('/users')
const user = await api.post<User>('/users', { name: 'Alice', email: 'alice@example.com' })
고수 팁
프레임워크 선택 가이드
Express: 레거시 프로젝트, 방대한 생태계, 느린 시작
Fastify: 성능 중요, 스키마 기반 검증
NestJS: 대규모 팀, DI 필요, 엔터프라이즈
Hono: 엣지 배포, 소규모 빠른 API, 타입 안전성