
웹 개발을 처음 배울 때 HTTP 상태 코드는 보통 요청의 성공과 실패를 나타내는 숫자라고 배운다.
200; // 성공
400; // 클라이언트 오류
500; // 서버 오류
HTTP 통신의 기본 구조를 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하면 성공과 실패만으로는 처리 결과를 충분히 설명할 수 없다.
상태 코드는 단순히 성공과 실패를 구분하는 숫자가 아니다. 서버가 요청을 어떻게 처리했으며 클라이언트가 다음에 무엇을 해야 하는지 합의된 방식으로 전달하는 언어다.
실제 서비스에서 HTTP 상태 코드는 처리 결과의 종류와 클라이언트의 다음 행동을 전달하는 계약이다.
크리스가 쇼핑몰에서 결제를 요청한다고 생각해 보자.
서버가 모든 처리 결과를 200 OK로 반환할 수도 있다.
return Response.json(
{
success: true,
paymentId: "payment_42",
},
{
status: 200,
}
);
응답 본문을 읽으면 결제가 성공했다는 사실을 알 수 있다.
문제는 결제가 생성된 경우, 비동기 처리가 시작된 경우와 이미 존재하는 결과를 조회한 경우가 모두 같은 상태 코드로 표현될 수 있다는 점이다.
return Response.json(
{
success: true,
message: "Payment processing has started.",
},
{
status: 200,
}
);
클라이언트는 본문의 문자열을 해석해야만 요청이 완료된 것인지 처리 중인지 판단할 수 있다.
성공 응답도 처리 결과에 따라 구분해야 한다.
return Response.json(
{
paymentId: "payment_42",
status: "completed",
},
{
status: 201,
headers: {
Location: "/api/payments/payment_42",
},
}
);
201 Created는 새로운 결제 리소스가 생성되었다는 의미를 전달한다. Location 헤더는 생성된 리소스를 어디에서 조회할 수 있는지도 알려준다.
비동기 처리가 시작되었지만 아직 완료되지 않았다면 다른 응답이 필요하다.
return Response.json(
{
paymentId: "payment_42",
status: "processing",
statusUrl: "/api/payments/payment_42",
},
{
status: 202,
}
);
202 Accepted는 요청을 접수했지만 처리가 완료되었다고 보장하지 않는다. 따라서 클라이언트가 처리 상태를 확인할 수 있는 방법도 함께 제공해야 한다.
상태 코드는 같은 성공 범주 안에서도 서버가 어디까지 처리했는지를 구분한다.
대표적인 성공 상태 코드는 서로 다른 결과를 표현한다.
| 상태 코드 | 의미 | 결제 서비스 예시 |
|---|---|---|
200 OK | 요청이 정상적으로 처리되었고 응답 본문이 있음 | 결제 상세 조회 |
201 Created | 새로운 리소스가 생성됨 | 결제 생성 완료 |
202 Accepted | 요청을 접수했지만 처리는 아직 진행 중 | 외부 결제사 승인 대기 |
204 No Content | 처리는 완료되었지만 반환할 본문이 없음 | 저장된 결제수단 삭제 |
결제수단 삭제가 완료된 뒤 응답 데이터가 필요하지 않다면 다음처럼 반환할 수 있다.
return new Response(null, {
status: 204,
});
204 No Content 응답에는 본문을 포함하지 않는다. 클라이언트도 JSON 본문이 있을 것이라고 가정해서는 안 된다.
const response = await fetch(
"/api/payment-methods/method_42",
{
method: "DELETE",
}
);
if (response.status === 204) {
removePaymentMethodFromScreen("method_42");
}
이 코드는 삭제가 완료되었다는 상태 자체를 처리 결과로 사용한다.
적절한 성공 코드를 선택하려면 단순히 작업이 성공했는지가 아니라 새 리소스가 만들어졌는지, 처리가 끝났는지, 반환할 표현이 있는지를 판단해야 한다.
결제 요청이 서버에서 정상적으로 처리되었더라도 카드 승인이 거절될 수 있다.
const paymentResult =
await paymentGateway.authorize({
amount: 12000,
currency: "AUD",
paymentMethodId: "method_42",
});
외부 결제사와의 통신은 정상적으로 완료되었지만 결과가 declined일 수 있다.
이를 무조건 서버 장애로 반환하면 처리 결과를 잘못 설명하게 된다.
return Response.json(
{
code: "CARD_DECLINED",
message: "The payment was declined.",
},
{
status: 500,
}
);
500 Internal Server Error는 서버가 예상하지 못한 문제 때문에 요청을 처리하지 못했다는 의미에 가깝다. 정상적으로 예상되는 카드 거절과는 성격이 다르다.
대신 결제 요청의 현재 상태와 클라이언트가 취할 행동을 명확하게 전달해야 한다.
return Response.json(
{
code: "CARD_DECLINED",
message: "Please use another payment method.",
paymentStatus: "declined",
},
{
status: 422,
}
);
이 설계에서는 요청 형식은 유효하지만 제공된 결제수단으로는 결제를 완료할 수 없다는 의미로 422 Unprocessable Content를 사용했다.
서비스에 따라 결제 시도를 정상적으로 생성한 뒤 201 또는 200 응답 안에 declined라는 도메인 상태를 표현할 수도 있다.
중요한 것은 특정 코드 하나를 모든 서비스에 기계적으로 적용하는 것이 아니다. 팀이 다음 두 결과를 의식적으로 구분하는 것이다.
상태 코드는 HTTP 처리 결과를 표현하고, 응답 본문의 도메인 코드는 구체적인 업무 결과를 설명한다.
다음 결제 요청은 올바른 JSON이 아니다.
{
"amount": 12000,
"currency": "AUD",
서버가 요청 본문 자체를 읽을 수 없다면 400 Bad Request로 응답할 수 있다.
return Response.json(
{
code: "INVALID_JSON",
message: "The request body is not valid JSON.",
},
{
status: 400,
}
);
이 응답은 요청 형식이 HTTP 처리 단계에서 유효하지 않다는 사실을 알려준다.
반면 JSON 구조는 읽을 수 있지만 필드 값이 서비스 규칙을 만족하지 않을 수 있다.
const result = createPaymentSchema.safeParse(
await request.json()
);
if (!result.success) {
return Response.json(
{
code: "INVALID_PAYMENT_INPUT",
fields: result.error.flatten().fieldErrors,
},
{
status: 422,
}
);
}
이 코드는 본문을 해석할 수 있지만 금액, 통화 또는 결제수단 값이 요구 조건을 만족하지 않는 경우를 표현한다.
일부 API는 입력 검증 실패를 모두 400으로 통일한다. 다른 API는 문법적으로 해석할 수 없는 요청은 400, 구조는 유효하지만 처리할 수 없는 입력은 422로 구분한다.
두 방법 중 하나만 절대적으로 옳다고 보기보다 팀 전체에서 같은 기준을 일관되게 적용하는 것이 중요하다.
클라이언트는 일관된 기준이 있을 때 일반 요청 오류와 필드별 검증 오류를 안정적으로 구분할 수 있다.
401 Unauthorized라는 이름 때문에 권한 부족을 의미한다고 오해하기 쉽다.
실제 API 설계에서는 일반적으로 다음과 같이 구분한다.
401 Unauthorized: 유효한 인증 정보가 없어 사용자를 확인할 수 없음403 Forbidden: 사용자는 확인했지만 해당 행동을 허용할 수 없음크리스가 로그인하지 않은 상태에서 결제 내역을 요청했다고 생각해 보자.
const session = await getSession(request);
if (!session) {
return Response.json(
{
code: "AUTHENTICATION_REQUIRED",
message: "Please sign in.",
},
{
status: 401,
}
);
}
서버는 현재 요청을 보낸 사용자가 누구인지 확인할 수 없다.
반면 로그인한 크리스가 다른 사용자의 결제를 조회한다면 인증은 완료되었지만 권한이 부족하다.
const payment =
await paymentRepository.findById(
"payment_42"
);
if (payment.userId !== session.userId) {
return Response.json(
{
code: "PAYMENT_ACCESS_DENIED",
message:
"You cannot access this payment.",
},
{
status: 403,
}
);
}
클라이언트는 401을 받으면 로그인 화면으로 이동하거나 인증을 갱신할 수 있다. 403을 받으면 로그인 반복 대신 접근 권한이 없다는 안내를 보여줄 수 있다.
다만 다른 사용자의 데이터 존재 여부를 숨겨야 하는 서비스에서는 권한이 없는 리소스에 404 Not Found를 반환할 수도 있다.
상태 코드 선택에는 기능적 정확성뿐 아니라 정보 노출 정책도 반영되어야 한다.
다음 URL의 라우트가 존재하지 않는다면 서버는 404 Not Found를 반환한다.
GET /api/unknown-payments
하지만 라우트는 존재하고 특정 결제 데이터만 찾을 수 없는 경우에도 404를 사용할 수 있다.
const payment =
await paymentRepository.findById(
paymentId
);
if (!payment) {
return Response.json(
{
code: "PAYMENT_NOT_FOUND",
message: "The payment was not found.",
},
{
status: 404,
}
);
}
첫 번째 경우는 엔드포인트가 존재하지 않는다. 두 번째 경우는 요청한 결제 리소스가 존재하지 않는다.
클라이언트에는 둘 다 요청한 대상을 찾을 수 없다는 의미를 전달하지만, 서버 로그와 내부 오류 코드는 원인을 더 구체적으로 구분할 수 있다.
사용자에게 전달할 정보와 운영자가 진단에 사용할 정보를 같은 수준으로 노출할 필요는 없다.
크리스가 이미 환불된 결제를 다시 환불하려고 한다고 생각해 보자.
입력 형식도 올바르고 로그인과 권한에도 문제가 없다. 하지만 현재 결제 상태에서는 해당 작업을 수행할 수 없다.
if (payment.status === "refunded") {
return Response.json(
{
code: "PAYMENT_ALREADY_REFUNDED",
message:
"This payment has already been refunded.",
},
{
status: 409,
}
);
}
409 Conflict는 요청이 현재 리소스 상태와 충돌한다는 사실을 표현할 수 있다.
같은 주문을 두 번 결제하려는 요청도 현재 상태와 충돌할 수 있다.
if (order.paymentStatus === "paid") {
return Response.json(
{
code: "ORDER_ALREADY_PAID",
orderId: order.id,
paymentId: order.paymentId,
},
{
status: 409,
}
);
}
클라이언트 화면에 표시된 주문 상태는 시간이 지난 복사본일 수 있다.
결제 가능 여부를 결정하는 Source of Truth는 서버가 관리하는 최신 주문과 결제 상태다. 서버는 요청을 처리하기 직전에 현재 상태를 다시 확인해야 한다.
409 응답에는 충돌 사실만 넣기보다 클라이언트가 화면을 최신 상태로 맞출 수 있는 식별자나 현재 상태를 함께 제공하는 것이 유용하다.
결제 API에는 중복 클릭, 자동화된 공격 또는 과도한 재시도를 제한하는 정책이 필요할 수 있다.
if (rateLimit.exceeded) {
return Response.json(
{
code: "TOO_MANY_PAYMENT_ATTEMPTS",
message:
"Please wait before trying again.",
},
{
status: 429,
headers: {
"Retry-After": "30",
},
}
);
}
429 Too Many Requests는 일정 시간 동안 너무 많은 요청이 들어왔음을 나타낸다.
Retry-After 헤더는 클라이언트가 언제 다시 시도할 수 있는지 알려준다.
if (response.status === 429) {
const retryAfter = Number(
response.headers.get("Retry-After") ?? 30
);
showRetryMessage(retryAfter);
}
클라이언트는 같은 요청을 즉시 반복하는 대신 재시도 버튼을 잠시 비활성화하거나 남은 시간을 안내할 수 있다.
상태 코드와 헤더를 함께 사용하면 실패 사실뿐 아니라 복구 방법까지 전달할 수 있다.
결제 서버의 데이터베이스 연결이 끊겼다고 생각해 보자.
try {
await paymentRepository.save(payment);
} catch (error) {
logger.error("Failed to save payment", {
paymentId: payment.id,
error,
});
return Response.json(
{
code: "PAYMENT_SERVICE_UNAVAILABLE",
message:
"Payment is temporarily unavailable.",
},
{
status: 503,
headers: {
"Retry-After": "60",
},
}
);
}
503 Service Unavailable은 서버가 일시적으로 요청을 처리할 수 없다는 의미를 전달한다.
사용자는 카드 번호를 수정한다고 이 문제를 해결할 수 없다. 클라이언트는 잠시 후 다시 시도하도록 안내해야 한다.
예상하지 못한 서버 오류에는 500 Internal Server Error를 사용할 수 있다.
return Response.json(
{
code: "INTERNAL_SERVER_ERROR",
message:
"An unexpected error occurred.",
requestId,
},
{
status: 500,
}
);
응답에는 스택 트레이스, 데이터베이스 쿼리와 내부 파일 경로를 노출하지 않는다.
자세한 오류 정보는 서버 로그에 남기고, 사용자에게는 안전한 메시지와 고객 지원에서 추적할 수 있는 요청 ID를 전달한다.
외부 입력은 검증 전까지 신뢰할 수 없지만, 모든 실패를 외부 입력 탓으로 돌려서도 안 된다. 입력 문제는 4xx, 서버가 책임져야 하는 예상 밖의 장애는 5xx 범주로 구분한다.
상태 코드만으로 모든 비즈니스 실패를 표현하려 하면 구체적인 의미가 사라진다.
return Response.json(
{
error: true,
},
{
status: 400,
}
);
이 응답만으로는 카드가 거절되었는지, 금액이 잘못되었는지, 결제가 이미 완료되었는지 알 수 없다.
상태 코드는 넓은 처리 범주를 전달하고, 응답 본문의 오류 코드는 서비스에 특화된 원인을 전달해야 한다.
type ApiError = {
code:
| "INVALID_PAYMENT_INPUT"
| "CARD_DECLINED"
| "ORDER_ALREADY_PAID"
| "PAYMENT_SERVICE_UNAVAILABLE";
message: string;
fields?: Record<string, string[]>;
requestId?: string;
};
예를 들면 다음과 같이 나눌 수 있다.
| HTTP 상태 | 도메인 오류 코드 | 클라이언트 행동 |
|---|---|---|
422 | INVALID_PAYMENT_INPUT | 잘못된 필드 표시 |
422 | CARD_DECLINED | 다른 결제수단 안내 |
409 | ORDER_ALREADY_PAID | 최신 주문 상태 조회 |
429 | TOO_MANY_PAYMENT_ATTEMPTS | 일정 시간 후 재시도 |
503 | PAYMENT_SERVICE_UNAVAILABLE | 일시적 장애 안내 |
이 구조에서는 HTTP 라이브러리와 인프라가 상태 코드를 사용할 수 있고, 화면은 도메인 오류 코드를 사용해 구체적인 경험을 제공할 수 있다.
message 문자열 자체를 조건문에서 비교하지 않아야 한다. 사용자 메시지는 번역이나 문구 개선으로 바뀔 수 있지만 안정적인 오류 코드는 프로그램의 계약으로 유지할 수 있다.
상태 코드는 단순히 오류 메시지 색상을 결정하는 값이 아니다.
async function submitPayment(
input: PaymentInput
) {
const response = await fetch(
"/api/payments",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}
);
const body = await response.json();
switch (response.status) {
case 201:
return {
next: "show-receipt",
payment: body,
};
case 202:
return {
next: "show-processing",
payment: body,
};
case 409:
return {
next: "refresh-order",
error: body,
};
case 422:
return {
next: "correct-input",
error: body,
};
case 429:
return {
next: "wait-before-retry",
error: body,
};
default:
return {
next: "show-temporary-error",
error: body,
};
}
}
이 코드는 상태 코드에 따라 영수증 표시, 처리 대기, 주문 새로고침, 입력 수정과 재시도 대기를 선택한다.
상태 코드가 구체적이고 일관될수록 클라이언트는 오류 메시지를 추측하지 않고 다음 행동을 결정할 수 있다.
다만 모든 4xx와 5xx를 무조건 같은 방식으로 처리하지 않아야 한다. 인증 갱신, 입력 수정, 충돌 해결과 일시적 장애 재시도는 서로 다른 흐름이다.
다음과 같이 모든 서버 오류를 자동으로 재시도할 수 있다.
if (response.status >= 500) {
return submitPayment(input);
}
결제 요청에서는 위험한 구현이다.
첫 번째 요청이 외부 결제사에서 승인되었지만 내부 응답 과정에서 오류가 발생했다면, 같은 요청을 다시 보내 중복 결제를 만들 수 있다.
재시도 여부는 다음 정보를 함께 고려해야 한다.
| 응답 | 일반적인 판단 | 추가 조건 |
|---|---|---|
400 | 같은 요청 재시도 불필요 | 요청 형식을 수정해야 함 |
401 | 인증 갱신 후 재시도 가능 | 갱신 실패 시 로그인 필요 |
409 | 최신 상태 확인 후 판단 | 같은 명령을 즉시 반복하지 않음 |
422 | 입력 또는 결제수단 수정 필요 | 수정 전 재시도 불필요 |
429 | 대기 후 재시도 가능 | Retry-After 준수 |
503 | 일시적 재시도 가능 | 멱등성과 중복 실행 방지 필요 |
결제 생성과 같이 중복 실행의 영향이 큰 요청에는 상태 코드와 별도로 멱등성 키가 필요하다.
await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(input),
});
같은 결제 작업을 재시도할 때는 새로운 키를 만들지 않고 동일한 키를 유지해야 한다.
상태 코드는 재시도 판단에 필요한 단서를 제공하지만, 요청의 멱등성, 서버 구현과 비즈니스 영향까지 대신 판단해 주지는 않는다.
결제 요청의 처리 결과는 화면 하나에서만 사용되지 않는다.
flowchart LR
A[결제 화면] --> B[API 요청]
B --> C[입력·인증 검증]
C --> D[결제 도메인 로직]
D --> E[외부 결제사]
D --> F[데이터베이스]
D --> G[HTTP 상태 코드와 응답]
G --> H[사용자 다음 행동]
G --> I[재시도 정책]
G --> J[로그·모니터링]
상태 코드는 여러 계층에 같은 처리 결과를 전달한다.
4xx와 5xx 비율을 추적한다.따라서 상태 코드가 실제 결과와 맞지 않으면 화면뿐 아니라 재시도, 모니터링과 장애 분석까지 왜곡된다.
4xx와 5xx를 구분해 모니터링하는가?return Response.json({
success: false,
error: "Payment failed",
});
HTTP 수준에서는 성공으로 기록되므로 API 클라이언트, 모니터링과 프록시가 실제 실패를 제대로 인식하기 어렵다.
처리 결과에 맞는 HTTP 상태 코드와 도메인 오류 코드를 함께 반환해야 한다.
return Response.json(error, {
status: 400,
});
입력 형식 오류, 인증 실패, 권한 부족과 상태 충돌을 모두 같은 코드로 처리하면 클라이언트의 다음 행동을 결정하기 어렵다.
오류의 원인과 복구 방법이 다르다면 상태 코드도 구분할 가치가 있다.
if (payment.status === "declined") {
return Response.json(
{
code: "CARD_DECLINED",
},
{
status: 500,
}
);
}
카드 거절은 정상적으로 발생할 수 있는 업무 결과다. 이를 서버 장애로 기록하면 장애 지표가 왜곡되고 불필요한 경보가 발생한다.
return Response.json(null, {
status: 422,
});
클라이언트는 입력 오류인지 카드 거절인지 알 수 없다.
상태 코드는 넓은 범주를 표현하고 도메인 오류 코드가 구체적인 원인을 설명해야 한다.
return Response.json(
{
error: databaseError.stack,
query: databaseError.query,
},
{
status: 500,
}
);
스택 트레이스, SQL과 내부 경로는 공격자에게 시스템 구조를 노출할 수 있다.
사용자에게는 안전한 메시지와 요청 ID를 제공하고 상세 정보는 접근이 제한된 로그에 남긴다.
if (response.status === 503) {
return submitPayment(input);
}
일시적 서버 오류처럼 보여도 결제 승인이 이미 처리되었을 수 있다.
멱등성 키, 결과 조회와 중복 처리 방지가 준비된 경우에만 안전한 재시도 정책을 적용해야 한다.
HTTP 상태 코드를 처음 배울 때는 다음과 같은 범주만으로도 전체 구조를 이해할 수 있다.
2xx = 성공
4xx = 클라이언트 오류
5xx = 서버 오류
하지만 실제 서비스를 개발할 때는 더 구체적인 질문에 답해야 한다.
상태 코드만으로 모든 비즈니스 의미를 표현할 수는 없다. 그렇다고 모든 결과를 200과 임의의 메시지로 반환해서도 안 된다.
상태 코드는 처리 결과의 공통 범주를 전달하고, 도메인 오류 코드는 서비스에 특화된 원인을 설명하며, 응답 본문과 헤더는 다음 행동에 필요한 정보를 제공한다.
결국 상태 코드를 선택한다는 것은 다음 질문에 답하는 일이다.
서버는 이 요청을 어디까지 처리했으며, 클라이언트는 이제 무엇을 해야 하는가?
상태 코드는 성공과 실패를 나타내는 숫자가 아니다.
서버의 처리 결과와 클라이언트의 다음 행동을 합의된 방식으로 전달하는 계약이다.