오류 처리 패턴은(는) 무엇을 하나요?
프로덕션 애플리케이션을 위한 일관되고 강력한 오류 처리 패턴입니다.
활성화 시점
- 새 모듈이나 서비스를 위한 오류 타입 또는 예외 계층 구조를 설계할 때
- 신뢰할 수 없는 외부 의존성에 재시도 로직이나 회로 차단기를 추가할 때
- API 엔드포인트에서 오류 처리가 누락되었는지 검토할 때
- 사용자 대상 오류 메시지 및 피드백을 구현할 때
- 연쇄 실패나 조용한 오류 무시를 디버깅할 때
핵심 원칙
- 빠르고 명확하게 실패하라 — 오류가 발생한 경계에서 표면화하라; 감추지 마라
- 문자열 메시지보다 타입화된 오류 — 오류는 구조를 가진 일급 값이다
- 사용자 메시지 ≠ 개발자 메시지 — 사용자에게는 친숙한 텍스트를 보여주고, 서버 측에는 전체 컨텍스트를 로깅하라
- 절대 오류를 조용히 삼키지 마라 — 모든
catch블록은 처리하거나, 다시 던지거나, 로깅해야 한다 - 오류는 API 계약의 일부이다 — 클라이언트가 받을 수 있는 모든 오류 코드를 문서화하라
TypeScript / JavaScript
타입화된 오류 클래스
// 도메인에 대한 오류 계층 구조 정의
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly details?: unknown,
) {
super(message)
this.name = this.constructor.name
// 트랜스파일된 ES5 JavaScript에서 올바른 프로토타입 체인 유지.
// 내장 Error 클래스를 확장할 때 `instanceof` 검사(예: `error instanceof NotFoundError`)가
// 올바르게 작동하도록 하는 데 필요합니다.
Object.setPrototypeOf(this, new.target.prototype)
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404)
}
}
class ValidationError extends AppError {
constructor(message: string, details: { field: string; message: string }[]) {
super(message, 'VALIDATION_ERROR', 422, details)
}
}
class UnauthorizedError extends AppError {
constructor(reason = 'Authentication required') {
super(reason, 'UNAUTHORIZED', 401)
}
}
class RateLimitError extends AppError {
constructor(public readonly retryAfterMs: number) {
super('Rate limit exceeded', 'RATE_LIMITED', 429)
}
}
Result 패턴 (throw 없는 스타일)
실패가 예상되고 흔한 작업(구문 분석, 외부 호출)의 경우:
type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E }
function ok<T>(value: T): Result<T> {
return { ok: true, value }
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error }
}
// 사용법
async function fetchUser(id: string): Promise<Result<User>> {
try {
const user = await db.users.findUnique({ where: { id } })
if (!user) return err(new NotFoundError('User', id))
return ok(user)
} catch (e) {
return err(new AppError('Database error', 'DB_ERROR'))
}
}
const result = await fetchUser('abc-123')
if (!result.ok) {
// TypeScript는 여기서 result.error를 알고 있음
logger.error('Failed to fetch user', { error: result.error })
return
}
// TypeScript는 여기서 result.value를 알고 있음
console.log(result.value.email)
API 오류 처리기 (Next.js / Express)
import { NextRequest, NextResponse } from 'next/server'
function handleApiError(error: unknown): NextResponse {
// 알려진 애플리케이션 오류
if (error instanceof AppError) {
return NextResponse.json(
{
error: {
code: error.code,
message: error.message,
...(error.details ? { details: error.details } : {}),
},
},
{ status: error.statusCode },
)
}
// Zod 유효성 검사 오류
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
},
},
{ status: 422 },
)
}
// 예상치 못한 오류 — 세부 정보 로깅, 일반 메시지 반환
console.error('Unexpected error:', error)
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
{ status: 500 },
)
}
export async function POST(req: NextRequest) {
try {
// ... 핸들러 로직
} catch (error) {
return handleApiError(error)
}
}
React 오류 경계
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
fallback: ReactNode
onError?: (error: Error, info: ErrorInfo) => void
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info)
console.error('Unhandled React error:', error, info)
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
// 사용법
<ErrorBoundary fallback={<p>문제가 발생했습니다. 새로고침 해주세요.</p>}>
<MyComponent />
</ErrorBoundary>
Python
사용자 정의 예외 계층 구조
class AppError(Exception):
"""기본 애플리케이션 오류."""
def __init__(self, message: str, code: str, status_code: int = 500):
super().__init__(message)
self.code = code
self.status_code = status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404)
class ValidationError(AppError):
def __init__(self, message: str, details: list[dict] | None = None):
super().__init__(message, "VALIDATION_ERROR", 422)
self.details = details or []
FastAPI 글로벌 예외 처리기
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.code, "message": str(exc)}},
)
@app.exception_handler(Exception)
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
# 전체 세부 정보 로깅, 일반 메시지 반환
logger.exception("Unexpected error", exc_info=exc)
return JSONResponse(
status_code=500,
content={"error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}},
)
Go
센티넬 오류 및 오류 래핑
package domain
import "errors"
// 타입 검사를 위한 센티넬 오류
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)
// 컨텍스트로 오류 래핑 — 원래 오류를 절대 잃지 마십시오
func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
user, err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("querying user %s: %w", id, err)
}
return user, nil
}
// 핸들러 수준에서 래핑을 풀어 응답 결정
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.service.GetUser(r.Context(), chi.URLParam(r, "id"))
if err != nil {
switch {
case errors.Is(err, domain.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, domain.ErrUnauthorized):
writeError(w, http.StatusForbidden, "forbidden", "Access denied")
default:
slog.Error("unexpected error", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
}
return
}
writeJSON(w, http.StatusOK, user)
}
지수 백오프를 이용한 재시도
interface RetryOptions {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
retryIf?: (error: unknown) => boolean
}
async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
retryIf = () => true,
} = options
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt === maxAttempts || !retryIf(error)) throw error
const jitter = Math.random() * baseDelayMs
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1) + jitter, maxDelayMs)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw lastError
}
// 사용법: 일시적인 네트워크 오류는 재시도하지만, 4xx 오류는 재시도하지 않음
const data = await withRetry(() => fetch('/api/data').then(r => r.json()), {
maxAttempts: 3,
retryIf: (error) => !(error instanceof AppError && error.statusCode < 500),
})
사용자 대상 오류 메시지
오류 코드를 사람이 읽을 수 있는 메시지로 매핑합니다. 기술적 세부 사항은 사용자에게 보이는 텍스트에서 제외하세요.
const USER_ERROR_MESSAGES: Record<string, string> = {
NOT_FOUND: '요청한 항목을 찾을 수 없습니다.',
UNAUTHORIZED: '계속하려면 로그인해 주세요.',
FORBIDDEN: '이 작업을 수행할 권한이 없습니다.',
VALIDATION_ERROR: '입력을 확인하고 다시 시도해 주세요.',
RATE_LIMITED: '요청이 너무 많습니다. 잠시 기다린 후 다시 시도해 주세요.',
INTERNAL_ERROR: '저희 쪽에 문제가 발생했습니다. 나중에 다시 시도해 주세요.',
}
export function getUserMessage(code: string): string {
return USER_ERROR_MESSAGES[code] ?? USER_ERROR_MESSAGES.INTERNAL_ERROR
}
오류 처리 체크리스트
오류 처리를 건드리는 코드를 병합하기 전에:
- 모든
catch블록이 처리, 재던지기, 또는 로깅 중 하나를 수행 — 조용히 삼키지 않음 - API 오류가 표준 형식
{ error: { code, message } }을 따름 - 사용자 대상 메시지에 스택 트레이스나 내부 세부 정보가 포함되지 않음
- 전체 오류 컨텍스트가 서버 측에 로깅됨
- 사용자 정의 오류 클래스가
code필드와 함께 기본AppError를 확장함 - 비동기 함수가 호출자에게 오류를 표면화 — 폴백 없이 fire-and-forget 사용 금지
- 재시도 로직이 재시도 가능한 오류만 재시도 (4xx 클라이언트 오류 제외)
- React 컴포넌트가 렌더링 오류에 대해
ErrorBoundary로 래핑됨