thenullpage.com
실패를 알리는 자리
요청이 실패했을 때 서버가 무엇을 돌려주는지는 성공했을 때만큼 자주 쓰인다. 화면에 띄울 안내 문구, 다시 시도할지 말지, 로그인 창을 열지 말지가 모두 이 응답 하나에서 갈린다. 그런데 실패 응답은 개발하는 동안 눈에 잘 띄지 않아서 형식이 제각각인 채로 남기 쉽다.
가장 흔한 잘못은 실패인데 200으로 내려보내는 것이다. 본문에만 실패라고 적고 상태줄은 성공이라고 말하는 형태다.
HTTP/1.1 200 OK
{"success": false, "message": "권한이 없습니다"}
이러면 상태 코드만 보는 쪽은 전부 속는다. 클라이언트의 요청 함수는 정상 경로로 넘기고, 중간 프록시나 CDN은 성공 응답인 줄 알고 캐시에 담으며, 오류율을 세는 모니터링은 0퍼센트를 보고한다. 실패는 상태줄로 먼저 말해야 한다.
코드를 고르는 기준
어느 쪽 잘못인지가 첫 갈림길이다. 요청을 고치면 성공할 수 있는 상황이면 4로 시작하는 코드, 요청은 멀쩡한데 서버가 처리하지 못한 상황이면 5로 시작하는 코드를 쓴다. 이 구분이 중요한 이유는 클라이언트의 행동이 달라지기 때문이다. 4로 시작하면 그대로 다시 보내 봐야 결과가 같으니 멈추고 사용자에게 알리고, 5로 시작하면 잠시 뒤 다시 시도할 여지가 있다.
세부 코드는 실패한 지점에 맞춘다. 본문이 JSON 형식조차 아니면 400, 형식은 맞는데 값이 규칙에 어긋나면 422, 누구인지 확인이 안 되면 401, 확인은 됐지만 권한이 없으면 403, 대상이 없으면 404, 현재 상태와 부딪히면 409, 요청이 너무 잦으면 429다. 지워진 뒤 앞으로도 없을 것이 확실한 자원은 404 대신 410으로 답해 검색 엔진이 목록에서 빼도록 할 수 있다.
서버 코드에서 예외가 났다고 전부 500으로 묶으면 안 된다. 사용자가 이상한 값을 넣어 터진 것까지 500이 되면 클라이언트는 고칠 수 있는 요청을 계속 재시도하고, 서버 쪽 장애 지표에도 잡히지 않아야 할 건이 섞인다.
본문에 담기는 항목
코드 하나로는 부족하다. 400만 받은 클라이언트는 어느 값이 왜 잘못됐는지 알 수 없다. 본문에는 네 가지가 들어간다. 오류 종류를 가리키는 고정된 식별자, 종류를 요약한 짧은 제목, 이번 건에 한정된 상세 설명, 그리고 어느 요청이었는지 가리키는 값이다.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://example.com/errors/validation",
"title": "입력값 검증 실패",
"status": 422,
"detail": "title은 1자 이상 200자 이하여야 한다",
"instance": "/articles"
}
이 네 필드에 Content-Type을 더한 형태가 RFC 9457이 정한 Problem Details 형식이다. 표준을 그대로 따르지 않더라도 담기는 정보는 같아야 하고, 무엇보다 한 서비스 안에서 모든 오류가 같은 모양이어야 한다. 어떤 곳은 message, 어떤 곳은 error, 어떤 곳은 문자열만 오면 클라이언트는 오류를 다루는 코드를 경로마다 따로 짜게 된다.
기계가 읽는 값과 사람이 읽는 문장
오류 응답에는 독자가 둘이다. 하나는 분기 조건을 찾는 코드고, 하나는 화면 앞의 사람이다. 이 둘을 한 필드에 섞으면 문제가 생긴다.
if (res.error.message === "권한이 없습니다") { ... }
문구를 다듬거나 다국어를 붙이는 순간 이 조건은 조용히 거짓이 된다. 종류를 나타내는 값은 한 번 정하면 바꾸지 않는 짧은 문자열이나 URI로 두고, 사람이 읽는 문장은 별도 필드에 담아 언제든 고칠 수 있게 한다. 분기는 앞의 값으로만 한다.
여러 건이 한꺼번에 틀렸을 때
가입 폼처럼 입력이 여럿인 요청은 오류도 여럿이다. 첫 오류에서 멈추고 하나만 돌려주면 사용자는 고치고 보내기를 항목 수만큼 반복한다. 검증은 끝까지 돌린 뒤 틀린 항목을 모아 내려주는 편이 낫다.
{
"type": "/errors/validation",
"title": "입력값 검증 실패",
"status": 422,
"errors": [
{ "field": "email", "code": "format", "message": "형식이 올바르지 않다" },
{ "field": "password", "code": "too_short", "message": "8자 이상이어야 한다" }
]
}
필드 이름은 요청 본문에 쓴 이름과 같아야 화면에서 해당 입력 칸을 찾아 표시할 수 있다.
밖으로 내보내면 안 되는 것
많은 프레임워크가 기본 설정에서 예외 내용을 응답 본문에 그대로 붙인다. 개발 중에는 편하지만 운영에 그대로 나가면 파일 경로, 실행한 쿼리문, 라이브러리 이름과 버전이 외부에 공개된다. 공격자가 서버 구성을 파악하는 데 필요한 정보가 대부분 여기 들어 있다. 운영 환경에서는 상세 내용을 로그에만 남기고 응답에는 짧은 문장만 내보낸다.
존재 여부가 새는 것도 같은 종류의 누출이다. 로그인 실패에 없는 계정이라고 답하면 그 응답만 모아도 가입된 아이디 목록을 만들 수 있다. 남의 글을 열었을 때 403을 주면 그 번호에 글이 있다는 사실이 드러난다. 두 경우 모두 구분하지 않고 같은 응답으로 답하는 편이 안전하다.
추적할 값과 재시도 안내
사용자가 오류 화면을 캡처해 보내와도 로그에서 그 건을 찾지 못하면 소용이 없다. 요청마다 고유 값을 하나 만들어 로그에 남기고 응답에도 같이 실으면 그 값 하나로 해당 요청의 기록을 곧장 찾을 수 있다.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-Request-Id: 9f8c1e2a
{ "type": "/errors/rate-limit", "title": "요청이 너무 잦다", "status": 429 }
기다렸다 다시 시도해야 하는 오류라면 얼마나 기다릴지도 알려 준다. 429나 503에 이 값이 없으면 클라이언트는 대개 곧바로 다시 보내고, 그 재시도가 몰려 상황을 더 나쁘게 만든다.
확인하는 방법
일부러 틀린 요청을 보내 보면 된다. curl에 i 옵션을 주면 상태줄과 헤더가 본문과 함께 나온다.
curl -i -X POST https://example.com/api/articles -H "Content-Type: application/json" -d '{"title":""}'
curl -i https://example.com/api/articles/99999999
curl -i https://example.com/api/me
첫 줄이 200인데 본문에 실패라고 적혀 있으면 첫 번째 문제가 그대로 남아 있는 것이다. 없는 번호에 404 대신 500이 오거나, 토큰 없이 부른 요청에 401 대신 403이나 200이 오는 것도 같은 자리에서 드러난다.
본문 모양은 여러 경로를 나란히 불러 비교한다. 필드 이름이 경로마다 다르거나 어떤 곳은 JSON, 어떤 곳은 HTML 오류 페이지가 오면 형식이 통일되지 않은 것이다. 브라우저 개발자 도구 Network 탭에서 실패한 요청을 골라 응답 본문을 보면 스택 추적이나 쿼리문이 섞여 나가는지도 함께 확인할 수 있다.