HTTP 상태 코드와 비즈니스 오류 코드의 차이
백엔드 API를 설계할 때 닉네임 중복, 닉네임 규칙 오류, 인증 실패, 리소스 부재와 같은 상황을 클라이언트에 전달해야 한다.
이때 단순히 오류 메시지만 반환하는 것은 좋은 설계가 아니다. 클라이언트가 오류의 종류를 식별하고 상황에 맞는 처리를 할 수 있도록 HTTP 상태 코드와 애플리케이션 오류 코드를 명확하게 정의해야 한다.
예를 들어 닉네임 중복과 닉네임 규칙 오류는 모두 회원가입 실패라는 공통점이 있지만, 발생 원인은 다르다.
- 닉네임 중복: 이미 다른 사용자가 해당 닉네임을 사용하고 있다.
- 닉네임 규칙 오류: 닉네임이 허용된 길이 또는 문자 규칙을 위반했다.
첫 번째는 서버의 현재 리소스 상태와 충돌하는 문제이고, 두 번째는 요청 데이터가 유효성 검증을 통과하지 못한 문제다. 따라서 두 오류는 서로 다른 HTTP 상태 코드로 표현하는 것이 적절하다.
HTTP 상태 코드의 주요 종류
HTTP 상태 코드는 서버가 요청을 처리한 결과를 의미적으로 분류하는 표준 규약이다.
2xx: 성공
| 상태 코드 | 의미 | 사용 예시 |
|---|---|---|
| 200 OK | 요청 성공 | 사용자 정보 조회, 수정 |
| 201 Created | 리소스 생성 성공 | 회원가입, 아이템 생성 |
| 202 Accepted | 처리 요청 접수 | 비동기 작업 요청 |
| 204 No Content | 요청 성공, 반환할 본문 없음 | 삭제, 응답 본문이 필요 없는 수정 |
4xx: 클라이언트 요청 오류
| 상태 코드 | 의미 | 사용 예시 |
|---|---|---|
| 400 Bad Request | 요청 형식 또는 값이 잘못됨 | 닉네임 규칙 오류, 잘못된 요청 본문 |
| 401 Unauthorized | 인증이 필요하거나 인증에 실패함 | Access Token 누락 또는 만료 |
| 403 Forbidden | 요청한 작업을 수행할 권한이 없음 | 관리자 전용 기능 접근 |
| 404 Not Found | 대상 리소스를 찾을 수 없음 | 존재하지 않는 사용자 조회 |
| 405 Method Not Allowed | 해당 HTTP 메서드를 지원하지 않음 | GET만 지원하는 API에 POST 요청 |
| 409 Conflict | 현재 리소스 상태와 요청이 충돌함 | 이미 사용 중인 닉네임으로 가입 시도 |
| 413 Content Too Large | 요청 본문이 허용 크기를 초과함 | 업로드 파일 용량 초과 |
| 415 Unsupported Media Type | 지원하지 않는 콘텐츠 형식 | JSON API에 지원하지 않는 미디어 타입 전송 |
| 422 Unprocessable Content | 요청 형식은 유효하지만 의미적 검증에 실패함 | 비즈니스 규칙 위반 |
400과 422는 모두 입력 검증 실패에 사용할 수 있다. 프로젝트에서 입력값 검증 실패를 400으로 통일할 수도 있고, 문법적 오류와 의미적 오류를 구분해 사용할 수도 있다. 중요한 것은 일관된 규칙을 유지하는 것이다.
5xx: 서버 오류
| 상태 코드 | 의미 | 사용 예시 |
|---|---|---|
| 500 Internal Server Error | 예상하지 못한 서버 오류 | 처리되지 않은 예외 |
| 502 Bad Gateway | 게이트웨이가 잘못된 응답을 받음 | 업스트림 서버의 비정상 응답 |
| 503 Service Unavailable | 서비스를 일시적으로 사용할 수 없음 | 서버 점검, 과부하 |
| 504 Gateway Timeout | 게이트웨이 요청 시간 초과 | 업스트림 서버 응답 지연 |
서버 오류는 클라이언트 요청이 잘못되었다는 의미가 아니다. 서버 내부 오류를 클라이언트 입력 오류로 반환하면 장애 원인 분석과 재시도 정책이 복잡해진다.
닉네임 중복과 닉네임 규칙 오류의 구분
닉네임 검증 API를 예로 들어보자.
| 상황 | HTTP 상태 코드 | 애플리케이션 오류 코드 |
|---|---|---|
| 닉네임 중복 | 409 Conflict | NICKNAME_ALREADY_EXISTS |
| 닉네임 길이 초과 | 400 Bad Request | NICKNAME_LENGTH_INVALID |
| 허용되지 않는 문자 포함 | 400 Bad Request | NICKNAME_INVALID_CHARACTERS |
| 닉네임 필드 누락 | 400 Bad Request | NICKNAME_REQUIRED |
| 회원가입 성공 | 201 Created | 오류 코드 없음 |
닉네임 규칙 오류는 요청값 자체가 규칙을 위반했으므로 400 Bad Request로 처리할 수 있다.
반면 닉네임 중복은 형식적으로 유효한 닉네임이 이미 사용 중인 상태이므로 409 Conflict가 적절하다.
여기서 애플리케이션 오류 코드는 HTTP 상태 코드만으로 구분하기 어려운 세부 원인을 표현한다.
HTTP 상태 코드와 애플리케이션 오류 코드를 함께 반환하기
실제 API에서는 HTTP 상태 코드와 JSON 응답 본문을 함께 사용하면 클라이언트가 오류를 구체적으로 식별할 수 있다.
예를 들어 닉네임 규칙 오류가 발생하면 다음과 같이 응답한다.
HTTP Response
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": "NICKNAME_INVALID_CHARACTERS",
"message": "닉네임에 허용되지 않는 문자가 포함되어 있습니다."
}
닉네임 중복이라면 다음과 같다.
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"code": "NICKNAME_ALREADY_EXISTS",
"message": "이미 사용 중인 닉네임입니다."
}
두 응답 모두 JSON 형식이지만 HTTP 상태 코드와 code 필드가 다르다.
- HTTP 상태 코드: 오류의 큰 분류를 표현한다.
code: 애플리케이션에서 정의한 구체적인 오류 원인을 표현한다.message: 사용자에게 표시하거나 로그에 활용할 설명을 제공한다.
클라이언트는 message 문자열을 비교해 분기하기보다 code를 기준으로 처리하는 편이 안전하다. 메시지는 문구 수정이나 다국어 지원에 따라 변경될 수 있지만, 오류 코드는 API 계약의 일부로 안정적으로 유지할 수 있기 때문이다.
Node.js와 TypeScript로 오류 응답 구현하기
Express와 TypeScript를 사용해 오류 코드를 중앙에서 관리하는 구조를 구현해 보자.
오류 코드 정의
export const ErrorCode = {
NICKNAME_REQUIRED: {
status: 400,
code: "NICKNAME_REQUIRED",
message: "닉네임을 입력해야 합니다.",
},
NICKNAME_LENGTH_INVALID: {
status: 400,
code: "NICKNAME_LENGTH_INVALID",
message: "닉네임 길이가 올바르지 않습니다.",
},
NICKNAME_INVALID_CHARACTERS: {
status: 400,
code: "NICKNAME_INVALID_CHARACTERS",
message: "닉네임에 허용되지 않는 문자가 포함되어 있습니다.",
},
NICKNAME_ALREADY_EXISTS: {
status: 409,
code: "NICKNAME_ALREADY_EXISTS",
message: "이미 사용 중인 닉네임입니다.",
},
} as const;
오류 응답에 필요한 HTTP 상태 코드, 애플리케이션 오류 코드, 메시지를 하나의 객체로 관리한다.
이렇게 구성하면 컨트롤러마다 상태 코드와 메시지를 개별적으로 작성할 필요가 없다. 또한 동일한 오류가 여러 API에서 발생하더라도 같은 규칙을 적용할 수 있다.
공통 API 오류 클래스
type ApiErrorDefinition = {
status: number;
code: string;
message: string;
};
export class ApiError extends Error {
public readonly status: number;
public readonly code: string;
constructor(definition: ApiErrorDefinition) {
super(definition.message);
this.name = "ApiError";
this.status = definition.status;
this.code = definition.code;
}
}
ApiError는 애플리케이션에서 의도적으로 발생시킨 API 오류를 표현한다.
일반적인 JavaScript Error와 달리 HTTP 상태 코드와 애플리케이션 오류 코드를 포함한다. 이를 통해 오류 발생 지점과 실제 HTTP 응답 생성 지점을 분리할 수 있다.
닉네임 검증 로직
import { ApiError } from "./ApiError";
import { ErrorCode } from "./ErrorCode";
export function validateNickname(nickname: string): void {
if (!nickname) {
throw new ApiError(ErrorCode.NICKNAME_REQUIRED);
}
if (nickname.length < 2 || nickname.length > 12) {
throw new ApiError(ErrorCode.NICKNAME_LENGTH_INVALID);
}
if (!/^[a-zA-Z0-9가-힣]+$/.test(nickname)) {
throw new ApiError(ErrorCode.NICKNAME_INVALID_CHARACTERS);
}
}
이 예제의 닉네임 규칙은 다음과 같다.
- 길이: 2~12자
- 허용 문자: 영문, 숫자, 한글
- 공백 및 특수문자: 허용하지 않음
검증에 실패하면 해당 오류 코드를 가진 ApiError를 발생시킨다.
단, 이 정규식은 단순한 예시다. 실제 서비스에서는 JavaScript의 문자열 길이가 UTF-16 코드 단위로 계산된다는 점과 한글 조합 문자, 유니코드 정규화 등의 정책을 고려해야 한다.
Express 전역 오류 처리 미들웨어
import type {
Request,
Response,
NextFunction,
} from "express";
import { ApiError } from "./ApiError";
export function errorHandler(
error: unknown,
req: Request,
res: Response,
next: NextFunction,
): void {
if (res.headersSent) {
next(error);
return;
}
if (error instanceof ApiError) {
res.status(error.status).json({
code: error.code,
message: error.message,
});
return;
}
console.error(error);
res.status(500).json({
code: "INTERNAL_SERVER_ERROR",
message: "서버 내부 오류가 발생했습니다.",
});
}
이 미들웨어는 ApiError를 HTTP 응답으로 변환한다. 예상하지 못한 오류는 500 Internal Server Error로 처리하며, 내부 예외 메시지를 그대로 클라이언트에 노출하지 않는다.
Express에서는 라우터 등록 뒤에 오류 처리 미들웨어를 등록해야 한다.
app.use("/api", apiRouter);
// 반드시 라우터 뒤에 등록한다.
app.use(errorHandler);
Express 5에서는 비동기 핸들러에서 발생한 거부된 Promise가 오류 처리 미들웨어로 전달된다. Express 4에서는 비동기 오류를 명시적으로 전달하는 래퍼 등을 사용해야 한다.
회원가입 API에서 사용하기
import { Router } from "express";
import { validateNickname } from "./validateNickname";
import { ApiError } from "./ApiError";
import { ErrorCode } from "./ErrorCode";
const router = Router();
router.post("/users", async (req, res) => {
const { nickname } = req.body;
validateNickname(nickname);
const existingUser = await userRepository
.findByNickname(nickname);
if (existingUser) {
throw new ApiError(
ErrorCode.NICKNAME_ALREADY_EXISTS,
);
}
const user = await userRepository.create({
nickname,
});
res.status(201).json({
id: user.id,
nickname: user.nickname,
});
});
위 코드는 userRepository가 별도로 구현되어 있다고 가정한 예시다. nickname이 문자열인지 확인하는 요청 스키마 검증도 실제 API에서는 먼저 수행해야 한다.
또한 닉네임 중복 여부를 조회한 뒤 생성하는 방식만으로는 동시성 문제를 완전히 방지할 수 없다. 두 요청이 동시에 중복 조회를 통과할 수 있기 때문이다. 따라서 데이터베이스에 닉네임 고유 제약 조건을 설정하고, 제약 조건 위반 오류를 409 Conflict로 변환해야 한다.
애플리케이션 오류 코드 설계 원칙
HTTP 상태 코드와 세부 오류 코드를 분리한다
400은 입력 오류라는 큰 범주를 나타낸다. NICKNAME_LENGTH_INVALID는 그중 어떤 검증에 실패했는지 구체적으로 나타낸다.
이 두 가지를 분리하면 오류 종류가 늘어나더라도 HTTP 상태 코드 체계를 불필요하게 확장할 필요가 없다.
오류 코드는 안정적인 식별자로 사용한다
오류 코드는 클라이언트와 서버 사이의 API 계약이다.
{
"code": "NICKNAME_LENGTH_INVALID",
"message": "닉네임은 2~12자여야 합니다."
}
사용자에게 보여주는 문구는 변경할 수 있지만, 클라이언트가 code를 기준으로 동작한다면 오류 코드는 호환성을 고려해 유지해야 한다.
내부 예외와 예상 가능한 비즈니스 오류를 구분한다
닉네임 중복은 예상 가능한 비즈니스 오류다. 데이터베이스 연결 실패는 서버 인프라 오류다.
두 상황을 모두 500으로 처리하거나 모든 예외를 400으로 처리하면 오류의 의미가 사라진다. 오류를 분류하고 적절한 상태 코드로 변환해야 한다.
오류 응답 형식을 통일한다
API마다 오류 응답 구조가 다르면 클라이언트의 오류 처리 코드가 복잡해진다.
다음과 같은 공통 형식을 사용할 수 있다.
type ApiErrorResponse = {
code: string;
message: string;
details?: unknown;
};
details는 필드별 검증 실패와 같이 추가 정보가 필요한 경우에만 사용한다. 데이터베이스 구조, 스택 트레이스, 내부 시스템 정보 등 민감한 내용은 노출하지 않는다.