에러 처리 철학
핵심 원칙
"에러는 항상 발생한다. 어떻게 처리하느냐가 서비스 품질을 결정한다."
1. 빠르게 실패하라 (Fail Fast)
- •문제를 발견하면 즉시 에러 발생
- •잘못된 상태로 계속 실행하지 않기
2. 사용자는 친절한 메시지, 개발자는 상세한 로그
- •사용자: "결제 처리 중 문제가 발생했습니다"
- •로그: "Payment gateway timeout after 30s, orderId: 12345, userId: 678"
3. 복구 가능한 에러 vs 치명적 에러
- •복구 가능: 재시도, 대체 방법 제공
- •치명적: 즉시 중단, 알림 발송
에러 분류 체계
1. 클라이언트 에러 (4xx)
typescript
// 입력 검증 실패
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public value: any
) {
super(message);
this.name = 'ValidationError';
}
}
throw new ValidationError(
'이메일 형식이 올바르지 않습니다',
'email',
'invalid-email'
);
// 사용자 메시지
{
"error": {
"type": "VALIDATION_ERROR",
"message": "입력값을 확인해주세요",
"field": "email",
"userMessage": "올바른 이메일 주소를 입력하세요"
}
}
2. 서버 에러 (5xx)
typescript
// 외부 서비스 장애
class ServiceUnavailableError extends Error {
constructor(
message: string,
public service: string,
public retryAfter?: number
) {
super(message);
this.name = 'ServiceUnavailableError';
}
}
throw new ServiceUnavailableError(
'Payment gateway timeout',
'toss-payments',
60
);
// 사용자 메시지
{
"error": {
"type": "SERVICE_UNAVAILABLE",
"message": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요",
"retryAfter": 60
}
}
실전 패턴
패턴 1: Try-Catch는 명확한 목적이 있을 때만
typescript
// ❌ 나쁜 예: 모든 것을 catch
async function getUser(id: string) {
try {
const user = await db.user.findUnique({ where: { id } });
return user;
} catch (error) {
console.error(error); // 그냥 삼켜버림
return null;
}
}
// ✅ 좋은 예: 특정 에러만 처리
async function getUser(id: string) {
try {
const user = await db.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundError('사용자를 찾을 수 없습니다', 'User', id);
}
return user;
} catch (error) {
// PrismaClientKnownRequestError만 처리
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2025') {
throw new NotFoundError('사용자를 찾을 수 없습니다', 'User', id);
}
}
// 예상하지 못한 에러는 상위로 전파
throw error;
}
}
패턴 2: 에러 경계 (Error Boundaries)
typescript
// React Error Boundary
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 에러 로깅 서비스로 전송
logger.error('React Error Boundary caught error', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>문제가 발생했습니다</h2>
<p>페이지를 새로고침하거나 고객센터로 문의해주세요</p>
<button onClick={() => window.location.reload()}>
새로고침
</button>
</div>
);
}
return this.props.children;
}
}
// 사용
<ErrorBoundary>
<App />
</ErrorBoundary>
패턴 3: 중앙 집중식 에러 핸들러
typescript
// Express 에러 핸들러
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// 에러 분류
if (err instanceof ValidationError) {
return res.status(400).json({
error: {
type: 'VALIDATION_ERROR',
message: err.message,
field: err.field,
},
});
}
if (err instanceof NotFoundError) {
return res.status(404).json({
error: {
type: 'NOT_FOUND',
message: err.message,
},
});
}
if (err instanceof UnauthorizedError) {
return res.status(401).json({
error: {
type: 'UNAUTHORIZED',
message: '로그인이 필요합니다',
},
});
}
// 예상하지 못한 에러
logger.error('Unexpected error', {
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: req.user?.id,
});
// 운영 환경에서는 상세 정보 숨김
res.status(500).json({
error: {
type: 'INTERNAL_SERVER_ERROR',
message: process.env.NODE_ENV === 'production'
? '서버 오류가 발생했습니다'
: err.message,
},
});
});
사용자 친화적 에러 메시지
❌ 개발자 중심 메시지
typescript
"Error: ECONNREFUSED 127.0.0.1:5432" "Uncaught TypeError: Cannot read property 'name' of undefined" "ValidationError: email must be a valid email"
✅ 사용자 친화적 메시지
typescript
const ERROR_MESSAGES = {
NETWORK_ERROR: {
title: '인터넷 연결을 확인해주세요',
description: '네트워크 연결이 불안정합니다',
action: '새로고침',
},
VALIDATION_EMAIL: {
title: '이메일 주소를 확인해주세요',
description: '올바른 이메일 형식이 아닙니다',
action: '다시 입력',
},
PAYMENT_FAILED: {
title: '결제 처리 중 문제가 발생했습니다',
description: '카드사 오류이거나 한도를 초과했을 수 있습니다',
action: '다시 시도',
},
SERVER_ERROR: {
title: '일시적인 오류가 발생했습니다',
description: '잠시 후 다시 시도해주세요',
action: '고객센터 문의',
},
};
// 컴포넌트에서 사용
<ErrorMessage>
<h3>{ERROR_MESSAGES.PAYMENT_FAILED.title}</h3>
<p>{ERROR_MESSAGES.PAYMENT_FAILED.description}</p>
<button>{ERROR_MESSAGES.PAYMENT_FAILED.action}</button>
</ErrorMessage>
재시도 전략
지수 백오프 (Exponential Backoff)
typescript
async function fetchWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
// 마지막 시도에서는 에러 throw
if (attempt === maxRetries - 1) {
throw error;
}
// 재시도 가능한 에러인지 확인
if (!isRetryableError(error)) {
throw error;
}
// 지수 백오프 대기
const delay = baseDelay * Math.pow(2, attempt);
await sleep(delay);
logger.warn('Retrying after error', {
attempt: attempt + 1,
maxRetries,
delay,
error: error.message,
});
}
}
throw new Error('Should not reach here');
}
function isRetryableError(error: any): boolean {
// 네트워크 에러, 타임아웃, 일시적 서버 에러
return (
error.code === 'ECONNRESET' ||
error.code === 'ETIMEDOUT' ||
error.response?.status === 503 ||
error.response?.status === 429
);
}
// 사용
const data = await fetchWithRetry(() =>
fetch('https://api.example.com/data').then(r => r.json())
);
에러 로깅 전략
로그 레벨
typescript
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.json(),
transports: [
// 에러 로그는 별도 파일
new winston.transports.File({
filename: 'error.log',
level: 'error',
}),
// 모든 로그
new winston.transports.File({
filename: 'combined.log',
}),
],
});
// 개발 환경에서는 콘솔 출력
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple(),
}));
}
// 에러 레벨별 사용
logger.error('Payment failed', {
orderId: '12345',
userId: '678',
amount: 50000,
error: error.message,
stack: error.stack,
});
logger.warn('Slow query detected', {
query: 'SELECT * FROM users',
duration: 3000,
});
logger.info('User logged in', {
userId: '678',
ip: req.ip,
});
logger.debug('Cache hit', {
key: 'user:678',
});
민감 정보 필터링
typescript
// ❌ 민감 정보 로깅 금지
logger.error('Login failed', {
email: 'user@example.com',
password: 'user-password', // ❌❌❌
ssn: '123456-1234567', // ❌❌❌
});
// ✅ 민감 정보 제거 후 로깅
const sanitized = {
...data,
password: undefined,
ssn: data.ssn ? '***-***-****' : undefined,
creditCard: data.creditCard ? '****-****-****-' + data.creditCard.slice(-4) : undefined,
};
logger.error('Login failed', sanitized);
에러 모니터링
Sentry 연동
typescript
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
});
// Express 에러 핸들러 전에 추가
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.errorHandler());
// 수동으로 에러 전송
try {
await processPayment();
} catch (error) {
Sentry.captureException(error, {
tags: {
section: 'payment',
},
extra: {
orderId: order.id,
amount: order.amount,
},
});
throw error;
}
API 에러 응답 표준
일관된 에러 형식
typescript
interface ErrorResponse {
error: {
// 에러 타입 (기계 판독용)
type: string;
// 사용자 메시지 (사람 판독용)
message: string;
// 상세 정보 (선택)
details?: Array<{
field: string;
message: string;
}>;
// 요청 ID (디버깅용)
requestId: string;
// 문서 링크 (선택)
docUrl?: string;
};
}
// 예시
{
"error": {
"type": "VALIDATION_ERROR",
"message": "입력값을 확인해주세요",
"details": [
{
"field": "email",
"message": "올바른 이메일 주소를 입력하세요"
},
{
"field": "password",
"message": "비밀번호는 8자 이상이어야 합니다"
}
],
"requestId": "req_abc123",
"docUrl": "https://docs.example.com/errors/validation"
}
}
실전 시나리오
시나리오 1: 결제 실패
typescript
async function processPayment(orderId: string) {
try {
// 1. 주문 조회
const order = await getOrder(orderId);
if (!order) {
throw new NotFoundError('주문을 찾을 수 없습니다', 'Order', orderId);
}
// 2. 재고 확인
const hasStock = await checkStock(order.items);
if (!hasStock) {
throw new BusinessError(
'일부 상품의 재고가 부족합니다',
'OUT_OF_STOCK'
);
}
// 3. 결제 처리 (재시도 가능)
const payment = await fetchWithRetry(
() => paymentGateway.charge(order.amount),
3,
1000
);
// 4. 주문 완료 처리
await completeOrder(orderId, payment.id);
return { success: true, paymentId: payment.id };
} catch (error) {
// 에러 분류 및 처리
if (error instanceof BusinessError) {
// 비즈니스 로직 에러 - 사용자에게 친절한 메시지
logger.warn('Business error during payment', {
orderId,
errorCode: error.code,
});
throw error;
}
if (error.response?.status === 402) {
// 결제 거부
logger.error('Payment declined', {
orderId,
reason: error.response.data.reason,
});
throw new BusinessError(
'결제가 거부되었습니다. 카드사로 문의해주세요',
'PAYMENT_DECLINED'
);
}
// 예상치 못한 에러
logger.error('Unexpected error during payment', {
orderId,
error: error.message,
stack: error.stack,
});
// Sentry로 알림
Sentry.captureException(error, {
tags: { section: 'payment' },
extra: { orderId },
});
throw new Error('결제 처리 중 문제가 발생했습니다');
}
}
시나리오 2: 외부 API 호출
typescript
async function fetchWeatherData(city: string) {
try {
const response = await axios.get(
`https://api.weather.com/forecast`,
{
params: { city },
timeout: 5000, // 5초 타임아웃
}
);
return response.data;
} catch (error) {
// Axios 에러 처리
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
// 타임아웃
throw new ServiceUnavailableError(
'날씨 정보를 불러올 수 없습니다',
'weather-api',
60
);
}
if (error.response) {
// 서버 응답 있음
if (error.response.status === 404) {
throw new NotFoundError('도시를 찾을 수 없습니다', 'City', city);
}
if (error.response.status === 429) {
throw new RateLimitError('요청 한도를 초과했습니다', 60);
}
} else if (error.request) {
// 요청은 보냈지만 응답 없음
throw new ServiceUnavailableError(
'날씨 서비스에 연결할 수 없습니다',
'weather-api',
60
);
}
}
// 예상치 못한 에러
throw error;
}
}
체크리스트
에러 처리 전
- • 이 에러는 복구 가능한가?
- • 사용자에게 어떤 메시지를 보여줄 것인가?
- • 재시도가 필요한가?
- • 로그에 어떤 정보를 남길 것인가?
- • 개발자에게 알림이 필요한가?
배포 전
- • 모든 예상 에러가 처리되었는가?
- • 사용자 메시지가 친절한가?
- • 민감 정보가 로그에 없는가?
- • 에러 모니터링이 설정되었는가?
- • 에러 응답 형식이 일관적인가?
마무리 원칙
"에러는 적이 아니라 피드백이다"
- •숨기지 마라: 에러를 무시하면 더 큰 문제가 된다
- •명확하게: 사용자도, 개발자도 이해할 수 있게
- •일관되게: 같은 패턴을 유지하라
- •준비하라: 예상 가능한 에러는 모두 처리하라
- •배우라: 에러 로그에서 개선점을 찾아라