throw new Error()를 던지는 것이 아닙니다.API 요청
↓
문제 발생
↓
에러 분류
↓
표준 응답 반환
↓
로그 기록
↓
필요 시 Audit Log / Job 상태 저장
프론트에서 에러 메시지 처리 어려움
사용자에게 500만 표시됨
관리자가 원인을 알 수 없음
로그에 requestId가 없어 추적 어려움
개인정보/Secret이 에러 로그에 노출될 수 있음
외부 API 실패와 내부 서버 오류가 구분되지 않음
| 종류 | HTTP 상태 | 예시 |
|---|---|---|
| Validation Error | 400 | 전화번호 형식 오류 |
| Unauthorized | 401 | 로그인 필요 |
| Forbidden | 403 | 권한 없음 |
| Not Found | 404 | 상담 없음 |
| Conflict | 409 | 동시 수정 충돌 |
| Rate Limit | 429 | 요청 과다 |
| External API Error | 502/503 | 알림톡 API 장애 |
| Internal Error | 500 | 예상 못한 서버 오류 |
상담 신청:
전화번호 형식 오류
중복 신청
상품 비활성화
상담 저장 실패
관리자 상태 변경:
상담 없음
상태 전이 불가
권한 없음
동시 수정 충돌
엑셀 Export:
권한 없음
검색 조건 오류
ExportJob 생성 실패
파일 만료
알림톡:
템플릿 오류
외부 API timeout
수신자 번호 오류
재시도 초과
400 Bad Request:
요청값이 잘못됨
401 Unauthorized:
로그인이 필요함
403 Forbidden:
로그인은 했지만 권한이 없음
404 Not Found:
대상 데이터를 찾을 수 없음
409 Conflict:
현재 데이터 상태와 요청이 충돌함
422 Unprocessable Entity:
형식은 맞지만 업무 규칙상 처리 불가
429 Too Many Requests:
요청이 너무 많음
500 Internal Server Error:
예상하지 못한 서버 오류
502 Bad Gateway:
외부 API 응답 오류
503 Service Unavailable:
외부 서비스 또는 내부 서비스 일시 장애
잘못된 입력:
400
로그인 안 됨:
401
권한 없음:
403
상담/상품 없음:
404
중복 신청/동시 수정:
409
상태 전이 불가:
400 또는 422
외부 API 일시 장애:
502 또는 503
예상 못한 서버 오류:
500
{
"success": false,
"error": {
"code": "CONSULT_STATUS_CONFLICT",
"message": "이미 다른 관리자가 상담 상태를 변경했습니다.",
"details": null
},
"requestId": "req_20260831_abcd1234",
"timestamp": "2026-08-31T09:00:00.000Z",
"path": "/admin/consults/10/status"
}
| 필드 | 의미 |
|---|---|
success | 요청 성공 여부 |
error.code | 프론트/운영에서 식별할 에러 코드 |
error.message | 사용자 또는 관리자에게 보여줄 메시지 |
error.details | validation 상세 등 추가 정보 |
requestId | 추적용 ID |
timestamp | 발생 시각 |
path | 요청 경로 |
stack trace를 응답에 포함하지 않기
DB 에러 원문을 그대로 노출하지 않기
Secret/Token/전화번호 원본 포함 금지
운영 메시지와 사용자 메시지 구분
{
"success": true,
"data": {
"id": 10,
"status": "CALLED"
},
"requestId": "req_20260831_abcd1234"
}
{
"success": true,
"data": [
{
"id": 10,
"customerName": "김**",
"status": "NEW"
}
],
"meta": {
"page": 1,
"limit": 20,
"total": 142,
"totalPages": 8
},
"requestId": "req_20260831_abcd1234"
}
단건:
data
목록:
data + meta
비동기 작업:
jobId + status
에러:
error + requestId
meta 구조가 중요합니다.message는 사람이 읽는 문구이고, code는 시스템이 판단하는 식별자입니다.message보다 code를 기준으로 특별 처리를 하는 것이 좋습니다.VALIDATION_ERROR
UNAUTHORIZED
FORBIDDEN
RESOURCE_NOT_FOUND
CONSULT_NOT_FOUND
CONSULT_DUPLICATED
CONSULT_STATUS_TRANSITION_INVALID
CONSULT_STATUS_CONFLICT
PRODUCT_NOT_FOUND
PRODUCT_INACTIVE
PRODUCT_DELETED
EXPORT_JOB_NOT_FOUND
EXPORT_FILE_EXPIRED
EXPORT_PERMISSION_DENIED
NOTIFICATION_PROVIDER_TIMEOUT
NOTIFICATION_INVALID_TEMPLATE
NOTIFICATION_RETRY_EXCEEDED
ERROR
FAIL
BAD
SERVER_ERROR
UNKNOWN
NO
WRONG
문제:
원인 파악 어려움
프론트 분기 처리 어려움
로그 검색 어려움
운영 문서화 어려움
도메인_원인 형태
프론트 분기 가능한 수준
운영자가 검색 가능한 이름
너무 세밀하게 폭증하지 않게 관리
export class AppException extends HttpException {
constructor(params: {
statusCode: number;
code: string;
message: string;
details?: unknown;
}) {
super(
{
code: params.code,
message: params.message,
details: params.details ?? null,
},
params.statusCode,
);
}
}
export class ConsultNotFoundException extends AppException {
constructor() {
super({
statusCode: 404,
code: 'CONSULT_NOT_FOUND',
message: '상담을 찾을 수 없습니다.',
});
}
}
export class ConsultStatusConflictException extends AppException {
constructor() {
super({
statusCode: 409,
code: 'CONSULT_STATUS_CONFLICT',
message: '이미 다른 관리자가 상담 상태를 변경했습니다. 새로고침 후 다시 시도해주세요.',
});
}
}
에러 코드 일관성 유지
메시지 중복 감소
프론트 분기 쉬움
테스트에서 특정 에러 확인 가능
Controller / Use Case / Guard
↓
throw exception
↓
GlobalExceptionFilter
↓
표준 에러 응답 반환
↓
로그 기록
응답 구조 통일
예상 못한 에러 500 처리
requestId 포함
민감정보 응답 노출 방지
로그 형식 통일
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const requestId =
request.headers['x-request-id']?.toString() ?? randomUUID();
const timestamp = new Date().toISOString();
const path = request.url;
const normalized = this.normalizeException(exception);
this.logger.error({
event: 'HTTP_EXCEPTION',
requestId,
path,
method: request.method,
statusCode: normalized.statusCode,
code: normalized.code,
message: normalized.message,
details: normalized.safeDetails,
});
response.status(normalized.statusCode).json({
success: false,
error: {
code: normalized.code,
message: normalized.message,
details: normalized.clientDetails,
},
requestId,
timestamp,
path,
});
}
private normalizeException(exception: unknown) {
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const body = exception.getResponse();
if (typeof body === 'object' && body !== null) {
const value = body as Record<string, unknown>;
return {
statusCode,
code: String(value.code ?? this.defaultCode(statusCode)),
message: String(value.message ?? '요청 처리 중 오류가 발생했습니다.'),
clientDetails: value.details ?? null,
safeDetails: sanitizeErrorDetails(value.details),
};
}
return {
statusCode,
code: this.defaultCode(statusCode),
message: String(body),
clientDetails: null,
safeDetails: null,
};
}
return {
statusCode: 500,
code: 'INTERNAL_SERVER_ERROR',
message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
clientDetails: null,
safeDetails: null,
};
}
private defaultCode(statusCode: number) {
switch (statusCode) {
case 400:
return 'BAD_REQUEST';
case 401:
return 'UNAUTHORIZED';
case 403:
return 'FORBIDDEN';
case 404:
return 'NOT_FOUND';
case 409:
return 'CONFLICT';
default:
return 'ERROR';
}
}
}
모든 예외를 표준 응답으로 변환
requestId 포함
예상 못한 에러는 500으로 숨김
로그에는 필요한 정보만 안전하게 기록
응답에는 stack trace 미포함
ValidationPipe와 exceptionFactory를 이용해 표준화할 수 있습니다.app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors) => {
return new AppException({
statusCode: 400,
code: 'VALIDATION_ERROR',
message: '입력값을 확인해주세요.',
details: errors.map((error) => ({
field: error.property,
constraints: error.constraints,
})),
});
},
}),
);
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "입력값을 확인해주세요.",
"details": [
{
"field": "phone",
"constraints": {
"isString": "phone must be a string"
}
}
]
},
"requestId": "req_123",
"timestamp": "2026-08-31T09:00:00.000Z",
"path": "/consults"
}
영문 라이브러리 메시지를 그대로 노출할지 결정
프론트 표시용 한글 메시지 매핑 고려
민감 필드 validation 값 노출 금지
P2002:
Unique constraint failed
P2025:
Record not found
P2003:
Foreign key constraint failed
P2014:
Required relation violation
try {
return await this.consultRepository.create(command);
} catch (error) {
if (isPrismaUniqueConstraintError(error)) {
throw new AppException({
statusCode: 409,
code: 'CONSULT_DUPLICATED',
message: '이미 접수된 상담 신청입니다.',
});
}
throw error;
}
DB 테이블명/컬럼명 노출 주의
constraint 이름 그대로 노출 금지
SQL 상세 오류 응답 금지
운영 로그에도 개인정보 포함 여부 주의
| 구분 | 의미 | 예시 |
|---|---|---|
| Domain Error | 업무 규칙상 처리 불가 | 상태 전이 불가 |
| Infra Error | 시스템/외부 의존성 문제 | DB 연결 실패, 외부 API timeout |
상담 중복 신청
상태 전이 불가
권한 없음
상품 비활성화
파일 만료
PostgreSQL connection error
S3 upload failed
Alimtalk API timeout
Redis connection error
외부 Webhook signature config missing
Domain Error:
사용자/관리자가 조치할 수 있는 경우가 많음
Infra Error:
운영자/개발자가 확인해야 함
응답:
Infra Error는 내부 상세를 숨기고 requestId 제공
export type ExternalApiResult<T> =
| {
ok: true;
data: T;
providerMessageId?: string;
}
| {
ok: false;
errorKind: 'RETRYABLE' | 'NON_RETRYABLE' | 'AUTH' | 'RATE_LIMIT';
errorCode: string;
safeMessage: string;
};
const result = await this.alimtalkAdapter.sendTemplate(params);
if (result.ok) {
await this.notificationJobRepository.markSent({
jobId: job.id,
providerMessageId: result.providerMessageId,
});
return;
}
if (result.errorKind === 'RETRYABLE' || result.errorKind === 'RATE_LIMIT') {
await this.notificationJobRepository.markRetrying({
jobId: job.id,
errorCode: result.errorCode,
errorMessage: result.safeMessage,
});
return;
}
await this.notificationJobRepository.markFailed({
jobId: job.id,
errorCode: result.errorCode,
errorMessage: result.safeMessage,
});
외부 API 원본 에러 전체 저장 금지
Authorization header 로그 금지
전화번호 원본 로그 금지
safeMessage만 저장
Job claim
↓
작업 실행
↓
에러 발생
↓
재시도 가능 여부 판단
↓
RETRYING 또는 FAILED 저장
↓
로그 기록
try {
await this.processJob(job);
} catch (error) {
const safeError = normalizeWorkerError(error);
await this.jobRepository.markFailedOrRetry({
jobId: job.id,
errorCode: safeError.code,
errorMessage: safeError.message,
retryable: safeError.retryable,
});
this.logger.error({
event: 'JOB_FAILED',
jobId: job.id,
jobType: job.type,
errorCode: safeError.code,
retryable: safeError.retryable,
});
}
Worker가 죽지 않게 job 단위 try/catch
실패한 Job 상태 저장
retry 가능 여부 분류
로그에 jobId 포함
민감정보 로그 금지
{
"level": "error",
"event": "HTTP_EXCEPTION",
"requestId": "req_123",
"method": "POST",
"path": "/admin/consults/10/status",
"statusCode": 409,
"code": "CONSULT_STATUS_CONFLICT",
"adminId": 3,
"targetType": "CONSULT",
"targetId": "10"
}
전화번호 01012345678 고객 상태 변경 실패
Authorization: Bearer ...
DATABASE_URL=postgresql://...
알림톡 API key=...
상담 메모 전체 출력
포함:
requestId
adminId
method
path
statusCode
errorCode
targetType
targetId
durationMs
제외:
전화번호 원본
토큰
Secret
상담 메모 전체
DB 접속 문자열
{
"success": false,
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
"details": null
},
"requestId": "req_123",
"timestamp": "2026-08-31T09:00:00.000Z",
"path": "/admin/products/10"
}
관리자가 에러 화면 캡처
↓
requestId 확인
↓
CloudWatch/서버 로그에서 requestId 검색
↓
관련 API 로그 확인
↓
Audit Log/Job 상태와 연결
401:
로그인 만료 처리
403:
권한 없음 안내
404:
데이터 없음 또는 이전 페이지 이동
409:
충돌 안내 + 새로고침 유도
500:
일시 오류 안내 + requestId 표시
상담 상태 변경 요청
↓
409 CONSULT_STATUS_CONFLICT
↓
"이미 다른 관리자가 수정했습니다. 새로고침 후 다시 시도해주세요."
↓
상담 상세/목록 재조회
VALIDATION_ERROR
↓
field별 details 확인
↓
해당 input 아래 메시지 표시
message를 그대로 띄울 수도 있지만, 중요한 흐름은 error.code 기준으로 분기하는 것이 좋습니다.이미 다른 관리자가 상담 상태를 변경했습니다. 새로고침 후 다시 시도해주세요.
엑셀 파일이 만료되었습니다. 같은 조건으로 다시 다운로드 요청해주세요.
해당 상품은 현재 비활성화되어 상담 신청이 불가능합니다.
상담 엑셀 다운로드 권한이 없습니다. 관리자에게 권한을 요청해주세요.
Error
Failed
Server Error
Bad Request
Invalid
알 수 없는 오류
원인:
무엇이 문제인지
조치:
어떻게 해야 하는지
추적:
requestId 또는 jobId 제공
나쁜 예:
존재하지 않는 이메일입니다.
비밀번호가 틀렸습니다.
좋은 예:
이메일 또는 비밀번호가 올바르지 않습니다.
권한 없음:
접근 권한이 없습니다.
너무 자세한 내부 권한 구조 노출은 피하기
응답:
서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
로그:
내부 stack trace, requestId, errorCode 기록
권한 없는 엑셀 다운로드 시도
관리자 계정 관리 접근 실패
관리자 로그인 실패 반복
상품 삭제 실패
알림톡 수동 재발송 실패
Webhook signature 검증 실패
일반 validation error
일시적인 목록 조회 실패
외부 API timeout
단순 404
보안/권한 관련 실패:
Audit/Security Log 고려
일반 시스템 오류:
Application Log
운영 작업 실패:
Job 상태 + Application Log
Exception Filter:
에러 응답 변환
Logging Interceptor:
요청 처리 시간과 결과 기록
요청 시작 시간 기록
↓
Controller 처리
↓
응답 성공/실패
↓
durationMs 로그
Exception Filter:
에러 표준화
예상 못한 에러 숨김
에러 응답 반환
Logging Interceptor:
requestId
method/path
statusCode
durationMs
adminId
Public message:
서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
Internal message:
PrismaClientKnownRequestError P2002 on consults_phone_date_unique
사용자/관리자에게:
조치 가능한 안전한 메시지
로그에:
개발자가 원인 파악 가능한 내부 정보
응답에:
stack trace, SQL, Secret, 내부 경로 노출 금지
success
error.code
error.message
error.details
requestId
timestamp
path
완료 기준:
모든 API 에러 응답 구조가 통일됨
프론트 공통 에러 처리 가능
requestId로 로그 추적 가능
HttpException 처리
AppException 처리
Validation Error 처리
Unknown Error 500 처리
민감정보 응답 제거
완료 기준:
예상 못한 에러도 안전한 응답으로 반환
에러 로그가 구조화됨
stack trace가 응답에 노출되지 않음
CONSULT_NOT_FOUND
CONSULT_DUPLICATED
CONSULT_STATUS_TRANSITION_INVALID
CONSULT_STATUS_CONFLICT
PRODUCT_INACTIVE
EXPORT_FILE_EXPIRED
FORBIDDEN
완료 기준:
상담/상품/Export/권한 관련 주요 실패가 코드화됨
프론트가 code 기준으로 분기 가능
운영 로그 검색 가능
NOTIFICATION_PROVIDER_TIMEOUT
NOTIFICATION_INVALID_TEMPLATE
EXPORT_GENERATION_FAILED
S3_UPLOAD_FAILED
WEBHOOK_SIGNATURE_INVALID
완료 기준:
Job 실패 사유가 errorCode로 저장됨
retryable/non-retryable 구분 가능
관리자 화면에서 실패 원인 확인 가능
NestJS 관리자 API에 표준 Error Handling 구조를 추가해줘.
조건:
1. 모든 에러 응답은 success=false, error.code, error.message, error.details, requestId, timestamp, path 구조로 통일해줘
2. AppException 클래스를 만들어 statusCode, code, message, details를 받을 수 있게 해줘
3. ConsultNotFoundException, ConsultDuplicatedException, ConsultStatusConflictException 같은 도메인 예외 예시를 만들어줘
4. GlobalExceptionFilter를 만들어 HttpException, AppException, unknown error를 표준 응답으로 변환해줘
5. unknown error는 INTERNAL_SERVER_ERROR로 응답하고 stack trace는 응답에 노출하지 마
6. ValidationPipe exceptionFactory를 설정해 VALIDATION_ERROR 형태로 반환해줘
7. Prisma P2002 unique constraint는 필요한 Use Case에서 CONSULT_DUPLICATED 같은 도메인 에러로 변환해줘
8. 에러 로그에는 requestId, method, path, statusCode, code를 남기되 phone, token, Secret, Authorization header, DATABASE_URL은 남기지 마
9. 프론트가 401/403/409/500을 공통 처리할 수 있도록 에러 코드 목록을 정리해줘
10. 변경 후 테스트 케이스와 QA 체크리스트를 작성해줘
응답 구조가 모든 에러에서 일관되는가?
unknown error가 내부 정보를 노출하지 않는가?
Validation Error details가 프론트에서 쓰기 좋은가?
Prisma 에러 원문이 응답에 노출되지 않는가?
requestId가 응답과 로그에 모두 포함되는가?
로그에 민감정보가 남지 않는가?
도메인 에러 코드가 과하게 많지 않은가?
프론트 공통 처리 기준이 정리되었는가?
NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰 관리자 API의 Error Handling 구조를 설계하려고 해.
서비스 상황:
1. 고객 상담 신청, 관리자 상담 상태 변경, 상품 수정, 엑셀 Export 요청, 알림톡 Worker, Webhook 수신 기능이 있음
2. 프론트에서 모든 API 에러를 공통 처리할 수 있게 표준 에러 응답 구조가 필요함
3. 상담 중복 신청, 상담 없음, 상태 전이 불가, 동시 수정 충돌, 권한 없음, 상품 비활성화, Export 파일 만료 같은 도메인 에러가 있음
4. Prisma unique constraint, record not found 같은 DB 에러를 사용자 친화적인 도메인 에러로 변환하고 싶음
5. Validation Error는 필드별 details를 내려주고 싶음
6. unknown error는 내부 정보를 숨기고 requestId만 제공하고 싶음
7. Worker와 외부 API 실패는 retryable/non-retryable로 분류하고 Job 상태에 errorCode를 저장하고 싶음
8. 전화번호 원본, token, Secret, Authorization header, DATABASE_URL, 외부 API 원본 response는 응답/로그에 남기면 안 됨
9. requestId로 API 로그, Audit Log, Worker 로그를 연결하고 싶음
요청:
- 표준 성공/에러 응답 구조
- Error Code 설계 기준
- AppException 구조
- GlobalExceptionFilter 예시
- ValidationPipe exceptionFactory 예시
- Prisma Error 변환 기준
- Domain Error와 Infra Error 구분
- 외부 API/Worker 에러 처리 방식
- 에러 로그 필드 기준
- 프론트 공통 에러 처리 기준
- 관리자 화면 에러 UX
- 보안 체크리스트
를 실무 기준으로 정리해줘.
모든 에러를 500으로 처리하지 않는가?
표준 응답에 error.code와 requestId를 포함하는가?
unknown error에서 stack trace를 숨기는가?
Validation Error details 구조를 제안하는가?
Prisma 에러 원문을 응답에 노출하지 않게 하는가?
409 Conflict를 동시 수정/중복 상황에 사용하도록 설명하는가?
외부 API 실패와 도메인 에러를 구분하는가?
로그에 전화번호/토큰/Secret을 남기지 않도록 경고하는가?
프론트 공통 처리와 관리자 UX까지 연결하는가?
success=false, error.code, error.message, error.details, requestId, timestamp, path 같은 표준 구조로 통일하는 것이 좋습니다.message는 사람이 읽는 문구이고, code는 프론트와 운영자가 분기/검색할 수 있는 식별자입니다.AppException과 GlobalExceptionFilter를 만들어 에러 응답을 중앙에서 표준화할 수 있습니다.ValidationPipe의 exceptionFactory로 필드별 details를 내려주면 프론트 입력창 에러 처리에 유리합니다.CONSULT_DUPLICATED, CONSULT_NOT_FOUND 같은 도메인 에러로 변환해야 합니다.errorCode, errorMessage, retryCount를 저장해야 운영자가 확인할 수 있습니다.