HTTP 상태 코드, 실제로 구분해서 써야 하는 건 몇 개 안 된다
401과 403, 400과 422처럼 헷갈리는 상태 코드를 클라이언트 동작 기준으로 구분하고, 잘못 쓰면 재시도와 모니터링에서 무슨 일이 생기는지 정리했습니다.
200과 500 사이에 있는 것들
API를 만들 때 상태 코드를 어디까지 구분해야 하는지 애매할 때가 있습니다. 목록에는 수십 개가 있지만 실무에서 실제로 골라 쓰는 것은 열 개 남짓입니다.
기준은 하나입니다. 받는 쪽이 다르게 행동해야 하면 구분하고, 같은 행동을 하면 구분할 필요가 없습니다. 클라이언트가 어차피 "오류 메시지 보여주기"만 한다면 세분화는 장식입니다. 반대로 재시도할지, 로그인 화면으로 보낼지, 입력을 고치게 할지가 갈린다면 반드시 구분해야 합니다.
200번대: 성공
- 200 OK — 가장 흔한 성공. 본문에 결과를 담습니다.
- 201 Created — 새 자원을 만들었을 때. 만들어진 자원의 위치를
Location헤더에 담는 것이 관례입니다. - 204 No Content — 성공했지만 돌려줄 본문이 없을 때. 삭제가 대표적입니다. 204는 본문을 보내면 안 됩니다. 여기에 JSON을 담으면 일부 클라이언트에서 파싱 오류가 납니다.
201과 200을 구분하는 실익은 크지 않습니다. 다만 생성 요청에 201을 쓰면 클라이언트가 응답만 보고 "새로 만들어졌구나"를 알 수 있어, 생성과 갱신을 같은 엔드포인트에서 처리할 때 유용합니다.
400번대: 요청한 쪽 문제
여기가 실제로 구분이 필요한 구간입니다.
- 400 Bad Request — 요청 형식 자체가 잘못됨. JSON이 깨졌거나 필수 필드가 없는 경우입니다.
- 401 Unauthorized — 인증이 안 됐다. 이름과 달리 "인증 실패"를 뜻합니다. 로그인하지 않았거나 토큰이 만료된 상태입니다.
- 403 Forbidden — 인증은 됐는데 권한이 없다. 로그인은 했지만 남의 글을 지우려는 경우입니다.
- 404 Not Found — 자원이 없음.
- 409 Conflict — 현재 상태와 충돌. 이미 쓰이는 이메일로 가입하거나, 남이 먼저 수정한 자원을 덮어쓰려는 경우입니다.
- 422 Unprocessable Content — 형식은 맞지만 값이 규칙에 어긋남. 이메일 칸에 이메일이 아닌 문자열이 온 경우입니다.
- 429 Too Many Requests — 요청 제한 초과.
401과 403의 차이가 클라이언트 동작을 가릅니다. 401이면 로그인 화면으로 보내거나 토큰을 갱신해야 하고, 403이면 다시 로그인해도 소용없으니 "권한이 없습니다"를 보여줘야 합니다. 이걸 섞어 쓰면 권한 없는 사용자가 로그인 화면을 무한히 오가는 상황이 생깁니다.
400과 422도 자주 헷갈립니다. 실용적인 기준은 파싱 단계에서 실패했으면 400, 파싱은 됐는데 검증에서 실패했으면 422입니다. 둘을 합쳐 400으로만 쓰는 프로젝트도 많고 그것도 문제없습니다. 다만 하나로 정하고 일관되게 쓰는 게 중요합니다.
429는 반드시 Retry-After 헤더를 함께 보내세요. 이것이 없으면 클라이언트는 언제 다시 시도해야 할지 몰라 그냥 계속 두드립니다.
404를 권한에 쓰는 경우
의도적으로 403 대신 404를 보내는 경우가 있습니다. 남의 비공개 글에 접근했을 때 403을 주면 "그 ID의 글이 존재한다"는 사실이 노출됩니다. 존재 자체를 숨겨야 한다면 404가 맞습니다.
즉 403은 "있는데 못 본다", 404는 "없거나, 없는 것처럼 취급한다"입니다. 관리자 페이지나 비공개 자원에서는 후자를 택하는 편이 안전합니다.
500번대: 서버 쪽 문제
- 500 Internal Server Error — 예상 못 한 오류. 처리되지 않은 예외가 여기로 옵니다.
- 502 Bad Gateway / 504 Gateway Timeout — 앞단 프록시가 뒤쪽 서버에서 응답을 못 받음. 애플리케이션이 아니라 인프라가 보내는 경우가 많습니다.
- 503 Service Unavailable — 일시적으로 처리 불가. 점검 중이거나 과부하 상태입니다.
여기서 중요한 구분이 있습니다. 클라이언트 입장에서 4xx는 재시도해도 소용없고, 5xx는 재시도해볼 만합니다. 그래서 서버 코드에서 검증 실패를 500으로 흘려보내면, 클라이언트가 고쳐야 할 요청을 계속 재시도하게 됩니다. 예상 가능한 실패는 반드시 4xx로 내려야 합니다.
실무에서 자주 하는 실수
모든 오류를 200으로 보내고 본문에 성공 여부를 담는 방식이 있습니다. {"success": false} 같은 형태입니다. 이렇게 하면 HTTP 캐시, 재시도 로직, 모니터링 도구가 전부 "성공"으로 인식합니다. 오류율 지표가 0으로 나오는데 실제로는 절반이 실패하고 있는 상황이 만들어집니다.
리다이렉트 코드의 혼용도 있습니다. 301은 영구, 302는 임시입니다. 301은 브라우저가 강하게 캐시하므로 잘못 내보내면 되돌리기 어렵습니다. 확신이 없으면 302를 쓰세요. 그리고 POST 요청의 리다이렉트에서 메서드를 유지해야 한다면 307, 강제로 GET으로 바꾸려면 303을 씁니다. 폼 제출 후 결과 페이지로 보낼 때 303을 쓰는 것이 표준적인 처리입니다.
상태 코드만으로 충분하지 않다
상태 코드는 분류이지 설명이 아닙니다. 422를 받은 클라이언트는 "값이 잘못됐다"까지만 알고, 어느 필드가 왜 잘못됐는지는 모릅니다.
그래서 본문에 구조화된 오류를 함께 담습니다.
{
"error": "validation_failed",
"message": "입력값을 확인해주세요.",
"fields": { "email": "이메일 형식이 올바르지 않습니다." }
}
error는 코드가 분기할 기계용 식별자, message는 사람에게 보여줄 문장으로 분리하는 것이 좋습니다. 사람이 읽을 문장으로 분기하면 문구를 고칠 때마다 클라이언트가 깨집니다.
사용할 코드를 몇 개로 줄여도 되나요?
됩니다. 200, 201, 400, 401, 403, 404, 409, 429, 500 정도면 대부분의 서비스를 감당할 수 있습니다. 중요한 것은 개수가 아니라 일관성입니다. 같은 상황에 같은 코드가 나오는지가, 코드를 많이 아는 것보다 훨씬 중요합니다.
404와 400 중에 헷갈리면 어떻게 하나요?
"경로에 있는 식별자가 가리키는 것이 없다"면 404, "본문이나 쿼리에 담긴 값이 잘못됐다"면 400이나 422입니다. /posts/999에서 999번 글이 없으면 404이고, /posts?limit=abc처럼 파라미터가 잘못됐으면 400입니다.
상태 코드를 잘못 쓰면 실제로 무슨 문제가 생기나요?
가장 크게 드러나는 곳은 재시도와 모니터링입니다. 4xx여야 할 것을 5xx로 내면 클라이언트와 큐가 무의미한 재시도를 반복하고, 반대로 5xx여야 할 것을 200으로 내면 장애가 지표에 잡히지 않습니다. 둘 다 코드가 아니라 운영에서 대가를 치릅니다.
직접 호출해보면 금방 안다
자기 API를 curl -i로 몇 번 호출해보면 어떤 코드가 나오는지 바로 보입니다. 특히 일부러 틀린 요청을 보내봤을 때 무엇이 오는지 확인해보세요. 의도한 코드가 아닌 경우가 생각보다 많습니다.
만든 프로젝트가 API를 제공한다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 다른 개발자가 직접 호출해보고 남기는 피드백은 문서만 읽고는 알 수 없는 부분을 짚어줍니다.
