본문 바로가기
C.W.K.
Stream
Lesson 02 of 06 · published

상태 코드 선택 — 상황을 정확히 설명하는 응답

~11 min · semantics, status-codes, dispatch, refinement

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"첫 자리만 읽어도 응답의 큰 방향은 알 수 있어. 하지만 201과 204, 301과 308, 401과 403, 400과 422를 구분하는 나머지 디테일이 그럭저럭 작동하는 API와 믿고 쓸 수 있는 API를 갈라."

2xx — 성공을 설명하는 다섯 가지 방식

2xx는 요청이 성공 계열로 처리됐음을 나타내. 다만 202처럼 처리가 아직 끝나지 않은 경우도 있으므로, 구체적인 코드가 어떤 방식으로 성공했는지까지 설명해 줘.

  • 200 OK — 가장 일반적인 성공 응답이야. 보통 응답 본문에 결과가 들어 있어. GET, 변경된 상태를 반환하는 수정 요청, 그 밖의 일반적인 성공에 사용해.
  • 201 Created — 요청의 결과로 새 리소스가 생성됐다는 뜻이야. 새 리소스의 URI를 Location 헤더로 알려주면 클라이언트가 후속 요청을 만들기 쉽고, 본문에 생성된 표현을 함께 보내기도 해. POST로 생성하거나, 존재하지 않던 대상 URI에 PUT으로 리소스를 만들었을 때 사용할 수 있어.
  • 202 Accepted — 요청을 접수했지만 처리는 아직 끝나지 않았다는 뜻이야. 본문이나 Location 헤더로 작업 상태를 조회할 URI를 제공하면 클라이언트가 완료 여부를 확인할 수 있어. 정산, 영상 변환, Council 마무리처럼 오래 걸리는 작업에 잘 맞아.
  • 204 No Content — 요청은 성공했지만 응답 본문을 보내지 않는다는 뜻이야. 삭제 뒤나, 변경된 리소스를 되돌려주지 않는 PUT·PATCH 뒤에 자주 사용해. 모든 성공 응답에 무조건 response.json()부터 호출하는 클라이언트는 여기서 실패해.
  • 206 Partial Content — 범위 요청에 대한 성공 응답이야. 영상 스트리밍, 중단된 다운로드 이어받기, 대용량 파일 전송에 쓰이며 본문에는 전체 리소스가 아니라 요청한 바이트 범위만 들어 있어.

3xx — 리디렉션 뒤에도 메서드를 유지할 것인가

리디렉션에서 흔히 생기는 버그가 있어. POST를 보냈는데 새 URI를 따라간 다음 요청이 GET으로 바뀌는 경우야. 301과 302는 역사적인 호환성 때문에 일부 사용자 에이전트가 POST를 GET으로 바꿀 수 있어. 원래 메서드와 본문을 반드시 유지해야 한다면 307이나 308을 선택해야 해.

  • 301 Moved Permanently — 대상 리소스의 URI가 영구적으로 바뀌었다는 뜻이야. 클라이언트는 Location에 제시된 URI를 앞으로 사용할 수 있어. 다만 역사적 동작 때문에 POST가 GET으로 바뀔 가능성을 고려해야 해.
  • 308 Permanent Redirect — 301과 같은 영구 리디렉션이지만 메서드와 본문을 보존해. POST는 POST로 유지되므로 GET이 아닌 엔드포인트를 영구 이전할 때 적합해.
  • 302 Found — 임시 리디렉션이야. 역사적으로 일부 클라이언트가 후속 요청의 메서드를 GET으로 바꿀 수 있어.
  • 307 Temporary Redirect — 임시 리디렉션이면서 메서드와 본문을 보존해. GET이 아닌 엔드포인트를 잠시 다른 URI로 보낼 때 사용해.
  • 304 Not Modified — 캐시한 표현을 계속 사용할 수 있다는 뜻이야. 조건부 요청에 대한 응답이며 본문을 보내지 않아. 자세한 내용은 이 트랙의 네 번째 레슨에서 다뤄.

4xx — 자주 혼동하는 코드들

비슷해 보이지만 계약은 분명히 다른 대표적인 경우를 짚어 보자.

401 Unauthorized와 403 Forbidden. 401은 유효한 인증 정보가 필요하다는 뜻이고, 403은 서버가 요청 주체를 알고 있어도 해당 작업을 허용하지 않는다는 뜻이야. 인증되지 않은 요청에 무조건 403을 주면 리소스 존재 여부를 불필요하게 드러낼 수 있고, 인증에는 성공했는데 401을 주면 클라이언트가 잘못된 복구 절차를 밟게 돼. 401 응답에는 WWW-Authenticate 헤더로 적용 가능한 인증 방식을 제시해야 해.

400 Bad Request와 422 Unprocessable Content. 400은 잘못된 JSON처럼 서버가 요청을 올바르게 해석할 수 없거나 요청 형식이 유효하지 않을 때 사용해. 422는 문법과 형식은 이해했지만 내용이 검증 규칙을 만족하지 못할 때 적합해. 예를 들어 이메일 필드에 "not-an-email"이 들어 있거나 수량이 음수인 경우야. 둘을 모두 400으로 뭉뚱그리면 클라이언트가 오류를 진단하고 수정할 단서를 잃어.

404 Not Found와 410 Gone. 404는 현재 리소스를 찾을 수 있다는 정보가 없다는 뜻이야. 과거에 존재했는지, 나중에 다시 생길지는 말하지 않아. 410은 리소스가 존재했지만 의도적으로 제거됐고 영구적으로 사라졌음을 알릴 때 사용해. 클라이언트는 410을 받으면 해당 URI를 인덱스에서 제거하는 판단을 내릴 수 있어.

409 Conflict. 리소스의 현재 상태와 요청이 충돌해 적용할 수 없을 때 사용해. 활성 종속 리소스 때문에 삭제할 수 없거나 고유성 제약을 위반하는 생성 요청이 대표적이야. If-Match 같은 조건부 헤더가 실패한 경우에는 더 구체적인 412 Precondition Failed를 사용해야 해.

구체적인 상태 코드는 계열별 처리 위에 쌓는 세부 신호야. 첫 자리를 기준으로 처리하는 클라이언트는 처음 보는 상태 코드도 성공, 리디렉션, 클라이언트 오류, 서버 오류라는 큰 범주 안에서 다룰 수 있어. 그 위에 429면 대기 후 재시도하고, 401이면 인증 정보를 갱신하며, 201이면 Location을 확인하는 식의 세부 동작을 더하면 돼. 순서는 계열이 먼저, 개별 코드가 그다음이야.

5xx — 서버 측 실패, 재시도는 조건부

  • 500 Internal Server Error — 서버가 예상하지 못한 오류를 만났다는 뜻이야. 멱등한 요청이거나 중복 방지 장치가 있는 요청이라면 로그와 정책을 확인한 뒤 백오프를 적용해 재시도할 수 있어. 무조건 재시도하면 같은 버그를 반복하거나 부작용을 중복 실행할 수 있어.
  • 502 Bad Gateway — 게이트웨이나 프록시가 상위 서비스에서 유효한 응답을 받지 못했다는 뜻이야. 일시적 장애일 수 있으므로 요청의 재시도 안전성을 확인한 뒤 백오프를 적용할 수 있어.
  • 503 Service Unavailable — 서버가 과부하이거나 점검 중이어서 현재 요청을 처리할 수 없다는 뜻이야. 서버가 Retry-After를 제공하면 클라이언트는 그 시간을 존중해야 해.
  • 504 Gateway Timeout — 게이트웨이가 상위 서비스의 응답을 제한 시간 안에 받지 못했다는 뜻이야. 상위 서비스의 일시적 지연일 수 있지만, 이 경우에도 메서드의 멱등성과 중복 실행 가능성을 확인하고 재시도해야 해.

cwkPippa의 실제 상태 코드

cwkPippa는 새 대화를 만들 때 Location이 포함된 201을, 비동기 Council 마무리에는 클라이언트가 완료 여부를 주기적으로 확인할 수 있는 202를 사용해. archive-folder 삭제에는 204, 형식이 잘못된 chat 페이로드에는 400, 인증 정보가 없으면 401, 관리자가 아닌 사용자가 관리자 라우트를 요청하면 403, Pydantic 필드 검증 실패에는 422를 반환하지. OpenAI나 Anthropic 상위 서비스의 요청 한도에는 Retry-After가 포함된 429, 처리되지 않은 예외에는 500, 백엔드 브레인인 Codex나 Gemini를 사용할 수 없을 때는 Retry-After가 포함된 503을 사용해. 각 선택은 실제 운영에서 클라이언트가 올바르게 대응하도록 다듬어진 결과야.

Code

FastAPI에서 상황에 맞는 상태 코드 보내기·python
# Server 쪽 — 각 상황에 맞는 code 발신
from fastapi import FastAPI, HTTPException, status, Response
from fastapi.responses import JSONResponse

app = FastAPI()

@app.post('/users', status_code=201)
async def create_user(payload: dict, response: Response):
    user_id = create_in_db(payload)
    response.headers['Location'] = f'/users/{user_id}'
    return {'id': user_id, **payload}  # 201 + Location

@app.post('/videos/transcode', status_code=202)
async def transcode(payload: dict, response: Response):
    job_id = enqueue_job(payload)
    response.headers['Location'] = f'/jobs/{job_id}'
    return {'job_id': job_id, 'status': 'queued'}  # 202 + poll Location

@app.delete('/users/{uid}', status_code=204)
async def delete_user(uid: str):
    delete_from_db(uid)
    return  # 204 — body 없음

@app.put('/users/{uid}', status_code=308)  # method-preserving 영구 redirect
async def relocated(uid: str, response: Response):
    response.headers['Location'] = f'/v2/users/{uid}'  # POST→POST, PUT→PUT
    return None
운영 클라이언트의 분기 순서: 계열 먼저, 개별 코드 나중·python
# Client 쪽 — family 숫자로 분기, specific code 로 refine
import httpx

def handle(resp: httpx.Response):
    family = resp.status_code // 100
    code = resp.status_code

    if family == 2:
        if code == 201:
            return {'created': True, 'location': resp.headers['Location'], **resp.json()}
        if code == 202:
            return {'async': True, 'poll': resp.headers['Location']}
        if code == 204:
            return None  # .json() 안 함 — body 비어있음
        return resp.json()  # 200, 206 등

    if family == 4:
        if code == 401: refresh_auth_and_retry()
        elif code == 403: raise PermissionError(resp.text)
        elif code == 404: return None  # missing 으로 다룸
        elif code == 409: handle_conflict(resp)
        elif code == 410: forget_url_forever(resp.request.url)
        elif code == 422: raise ValidationError(resp.json())
        elif code == 429:
            wait = int(resp.headers.get('Retry-After', 1))
            return retry_after(wait)
        raise ClientError(code, resp.text)

    if family == 5:
        if code == 503:
            wait = int(resp.headers.get('Retry-After', 5))
            return retry_after(wait)
        return retry_with_backoff()
201·204·429 응답의 실제 형태·bash
# 각 status code 가 실전에서 어떻게 보이는지
# 201 with Location
curl -i -X POST https://api.example.com/users -d '{"name":"Pippa"}'
# HTTP/1.1 201 Created
# Location: /users/u_abc123
# Content-Type: application/json
# ...

# 204 — body 없음
curl -i -X DELETE https://api.example.com/users/42
# HTTP/1.1 204 No Content
# (빈 body)

# 429 — Retry-After 존중
curl -i https://api.example.com/heavy-endpoint
# HTTP/1.1 429 Too Many Requests
# Retry-After: 60
# Content-Type: application/json
# {"error":"rate_limited","window_seconds":60}

External links

Exercise

서로 다른 상태 코드를 반환하는 엔드포인트 다섯 개로 작은 FastAPI 서버를 만든다. POST /items는 201 + Location, POST /jobs는 202 + Location, DELETE /items/{id}는 204를 반환하게 해. GET /items/{id}에는 선택적인 ?force_410=true를 두어 404와 410을 구분하고, POST /strict-create는 페이로드에 필수 필드가 없으면 422를 반환하게 만든다. 각 엔드포인트를 curl -i로 호출해 응답을 확인한 뒤, 계열과 개별 코드에 맞게 분기하는 Python 클라이언트 함수를 작성해.
Hint
FastAPI에서는 라우트 decorator의 status_code와 처리기 안의 Response.status_code로 응답 코드를 정할 수 있어. Location을 동적으로 설정하려면 response: Response injection을 사용해. 클라이언트는 먼저 상태 코드 계열로 분기하고, 그 안에서 필요한 개별 코드를 세분화해야 해. 처음 보는 코드라고 곧바로 else: raise Exception으로 끝내지 말고 해당 계열의 기본 처리로 대체할 수 있게 설계해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.