FastAPI 교재 · 4편 · 에러와 상태코드

FastAPI 에러 처리와 상태 코드

요청이 잘못됐을 때 그냥 터지게 두면 안 된다. 무엇을, 어떤 번호로, 어떻게 돌려줄지가 좋은 API 의 절반이다.

HTTP 요청들이 색깔로 구분된 상태 코드 관문을 통과해 성공 경로와 에러 경로로 나뉘는 FastAPI 컨셉 아이소메트릭 일러스트

3편까지는 잘 돌아가는 경우만 다뤘다. 그런데 실제 서비스에서 손이 가장 많이 가는 곳은 일이 틀어졌을 때다. 없는 사용자 번호를 조회하면? 권한 없는 사람이 남의 데이터를 지우려 하면? 나이 자리에 글자를 보내면? 이때 서버가 무성의하게 500 하나 던지고 끝내면, 그 API 를 쓰는 프론트엔드 개발자는 원인을 알 길이 없다.

4편은 FastAPI 에서 에러를 다루는 정석을 잡는다. 먼저 HTTP 상태 코드의 뼈대를 빠르게 훑고, HTTPException 으로 의도한 에러를 던지는 법, 성공 응답의 코드를 바꾸는 법, 그리고 자동으로 떨어지는 422 검증 에러와 전역 예외 핸들러까지 본다. 코드는 길지 않다. 대신 "언제 어떤 번호를 고를지" 의 감각을 챙기는 게 목표다.

1. HTTP 상태 코드 빠르게 잡기

모든 HTTP 응답에는 세 자리 숫자가 붙는다. 이게 상태 코드(status code)다. 백 개 넘게 있지만 외울 필요는 없고, 앞자리 하나로 성격이 갈린다는 것만 알면 된다.

앞자리만 보면 절반은 끝2xx 는 성공(요청이 잘 처리됨), 4xx 는 클라이언트 잘못(보낸 쪽이 뭔가 틀렸음 — 주소·권한·데이터), 5xx 는 서버 잘못(요청은 멀쩡한데 서버가 처리하다 터짐). 4xx 인지 5xx 인지를 정확히 구분하는 것만으로도 디버깅 시간이 확 줄어든다.

실무에서 자주 마주치는 대표 코드만 추리면 이 정도다. 이 표의 코드들은 4편 내내 다시 등장하니 한 번 눈에 익혀 두자.

코드이름언제
200OK조회·수정이 정상 처리됨 (기본 성공)
201Created새 리소스가 만들어짐 (POST 생성 성공)
400Bad Request요청 자체가 말이 안 됨 (잘못된 형식·논리)
401Unauthorized로그인·인증이 안 된 상태
403Forbidden로그인은 했지만 권한이 없음
404Not Found요청한 리소스가 존재하지 않음
422Unprocessable Entity형식은 맞는데 검증 규칙에 걸림 (FastAPI 자동)
500Internal Server Error서버 코드가 예외로 터짐 (잡지 못한 에러)

401403 은 자주 헷갈린다. 401 은 "당신이 누군지 모르겠다"(로그인부터 하라), 403 은 "누군지는 알겠는데 그건 못 만진다"(권한 부족)다. 그리고 500 은 우리가 일부러 던지는 게 아니라, 잡지 못한 예외가 새어 나갈 때 FastAPI 가 자동으로 붙이는 번호라는 점도 기억해 두자.

2. HTTPException 으로 에러 던지기

없는 데이터를 조회했을 때를 보자. 보통 이렇게 짠다 — 데이터가 있으면 돌려주고, 없으면? 그냥 None 을 돌려주거나 빈 딕셔너리를 주면 클라이언트는 "성공했는데 내용이 없는 건지, 아예 없는 건지" 구분을 못 한다. 정답은 404 를 명확히 던지는 것이다.

FastAPI 는 이걸 위해 HTTPException 을 제공한다. import 하고, 조건에 안 맞으면 raise 하면 끝이다.

# main.py from fastapi import FastAPI, HTTPException app = FastAPI() items = {1: "사과", 2: "바나나"} @app.get("/items/{item_id}") def read_item(item_id: int): if item_id not in items: raise HTTPException(status_code=404, detail="아이템 없음") return {"item_id": item_id, "name": items[item_id]}

/items/1{"item_id": 1, "name": "사과"} 를 200 으로 주지만, /items/99 처럼 없는 번호를 부르면 함수는 return 까지 가지 못하고 멈춘다. 대신 FastAPI 가 이런 응답을 404 상태로 내보낸다.

{ "detail": "아이템 없음" }

핵심은 두 가지다. 첫째, raise 하는 순간 함수 실행이 끊기므로 그 뒤 코드는 신경 쓸 필요가 없다 — "없으면 일찍 던지고 빠진다(early return)" 패턴이 자연스럽게 나온다. 둘째, detail 에 넣은 값이 그대로 {"detail": ...} 형태로 JSON 응답이 된다. 문자열뿐 아니라 딕셔너리·리스트도 넣을 수 있어, 에러 코드와 메시지를 같이 담고 싶으면 이렇게 한다.

raise HTTPException( status_code=404, detail={"code": "ITEM_NOT_FOUND", "message": "아이템 없음"}, )

응답 헤더를 같이 보내야 할 때는 headers= 인자도 받는다. 예를 들어 인증 실패에 표준 헤더를 붙이려면 raise HTTPException(status_code=401, detail="인증 필요", headers={"WWW-Authenticate": "Bearer"}) 처럼 쓴다.

흔한 실수return HTTPException(...) 처럼 return 으로 돌려주면 안 된다. 그러면 에러가 발생하는 게 아니라 예외 객체 자체가 200 응답 본문으로 직렬화돼 버린다. 반드시 raise 다. 또 하나, detail 문구는 사용자에게 노출되므로 DB 비밀번호나 내부 경로 같은 민감 정보를 담지 말 것.

3. 성공 상태 코드 지정하기

FastAPI 는 기본적으로 성공 응답에 200 을 붙인다. 그런데 새 데이터를 만드는 POST 라면 200 보다 201 Created 가 의미상 정확하다. "처리는 됐다"가 아니라 "새 리소스가 생겼다"를 명시하는 것이라, REST API 규약에서 권장하는 관례다.

코드를 직접 외워 쓰지 말고, status 모듈의 상수를 쓰는 게 좋다. 숫자만 적은 201 보다 status.HTTP_201_CREATED 가 의도를 또렷이 드러내고 오타도 막는다.

# main.py from fastapi import FastAPI, status from pydantic import BaseModel app = FastAPI() class Item(BaseModel): name: str price: float @app.post("/items", status_code=status.HTTP_201_CREATED) def create_item(item: Item): # 실제로는 여기서 DB 에 저장한다 return {"created": item.name, "price": item.price}

status_code= 는 데코레이터 @app.post(...) 안에 적는다. 이렇게 하면 이 엔드포인트가 성공할 때 항상 201 을 돌려준다. /docs 의 자동 문서에도 "성공 응답 201" 이라고 반영되니 따로 적을 게 없다.

두 가지 상태 코드는 결이 다르다@app.post(..., status_code=...)"정상일 때의 기본 코드"를 정하는 것이고, 2절의 HTTPException(status_code=...)"문제가 생겼을 때 던질 코드"다. 하나는 데코레이터, 하나는 raise. 한 엔드포인트가 둘 다 가질 수 있다 — 평소엔 201, 실패하면 404 처럼.

참고로 본문이 아예 없는 응답(예: 삭제 성공)에는 204 No Content 를 쓴다. 이때는 @app.delete("/items/{id}", status_code=status.HTTP_204_NO_CONTENT) 로 지정하고 함수에서 return 없이 끝내거나 None 을 돌려주면 된다.

4. 검증 에러(422)와 커스텀 예외 핸들러

이제 우리가 직접 던지지 않아도 자동으로 나타나는 에러를 보자. 3절의 Item 모델에서 pricefloat 였다. 만약 클라이언트가 price 자리에 "비쌈" 같은 글자를 보내면? 함수 본문은 실행조차 되지 않고, FastAPI 가 자동으로 422 를 돌려준다.

{ "detail": [ { "type": "float_parsing", "loc": ["body", "price"], "msg": "Input should be a valid number, ...", "input": "비쌈" } ] }

loc어디가 틀렸는지(본문의 price 필드), msg왜 틀렸는지를 알려준다. 이 응답을 우리가 짠 게 아니라 Pydantic 검증과 FastAPI 가 자동으로 만들어 준 것이다. 2편에서 본 경로·쿼리 검증 실패도 전부 이 422 로 통일된다. "형식은 맞는데 규칙에 걸렸다" 가 422 의 자리다.

전역 예외 핸들러 — 같은 에러를 한 곳에서

HTTPException 은 한 곳에서 던지는 일회성 에러에 좋지만, 여러 엔드포인트에서 똑같이 처리하고 싶은 에러도 있다. 예를 들어 "재고 부족" 상황이 여러 API 에서 발생한다면, 매번 같은 HTTPException 을 복사하는 대신 커스텀 예외 클래스 + 전역 핸들러로 한 번에 처리하는 게 깔끔하다.

# main.py from fastapi import FastAPI, Request from fastapi.responses import JSONResponse app = FastAPI() class OutOfStockError(Exception): def __init__(self, name: str): self.name = name @app.exception_handler(OutOfStockError) def out_of_stock_handler(request: Request, exc: OutOfStockError): return JSONResponse( status_code=409, content={"detail": f"{exc.name} 재고 부족"}, ) @app.post("/order/{name}") def order(name: str): raise OutOfStockError(name) # 어디서든 이 예외만 던지면

흐름을 보면 — OutOfStockError 라는 평범한 파이썬 예외를 하나 만들고, @app.exception_handler(OutOfStockError) 데코레이터로 "이 예외가 어디서 발생하든 이 함수로 받아라" 고 등록했다. 이제 어느 엔드포인트에서든 raise OutOfStockError(...) 한 줄만 던지면, 핸들러가 가로채 409 Conflict 와 일관된 메시지로 변환한다. 응답 형식을 한 군데에서 통제하니, 나중에 에러 포맷을 바꿀 때도 핸들러만 고치면 된다.

핸들러 함수는 반드시 Response 객체를 돌려줘야 한다. 위에서는 JSONResponse 를 썼고, status_codecontent(JSON 본문)를 직접 지정했다. raise 로 던지는 게 아니라 return 으로 돌려주는 자리라는 점에 주의하자 — 핸들러는 이미 "에러를 받아 응답으로 바꾸는" 단계이기 때문이다.

둘 중 무엇을 쓸까 — 한 함수 안에서 즉석으로 끝나는 단순한 에러는 HTTPException 이 빠르고 직관적이다. 반면 여러 곳에서 반복되거나, 응답 포맷을 통일하고 싶거나, 도메인 의미가 분명한 에러(재고 부족·결제 실패 등)는 커스텀 예외 + 전역 핸들러가 유지보수에 유리하다. 처음엔 HTTPException 으로 시작하고, 같은 에러가 세 번 복사되는 순간 핸들러로 승격하면 된다.

요약

4편을 한 번에 정리하면 — 상태 코드는 앞자리(2xx 성공 · 4xx 클라이언트 · 5xx 서버)로 성격이 갈리고, 의도한 에러는 raise HTTPException(status_code=..., detail=...) 으로 던지며 응답은 {"detail": ...} 형태가 된다. 성공 응답의 기본 코드는 데코레이터의 status_code=status.HTTP_201_CREATED 로 바꾸고, 형식은 맞지만 규칙에 걸린 입력은 FastAPI 가 자동으로 422 를 돌려준다. 같은 에러가 여러 곳에서 반복되면 커스텀 예외 클래스와 @app.exception_handler 로 한 곳에 모은다. 핵심은 "무엇이 잘못됐는지 클라이언트가 번호와 메시지로 알 수 있게 하는 것" 이다.

다음 편 예고 — 의존성 주입 Depends

DB 연결·인증 확인·공통 파라미터를 함수마다 베껴 쓰지 않고 한 번 정의해 끼워 넣는 FastAPI 의 핵심 기능, Depends 를 다룬다.

© 2026 주나이테크(주) @JUNAITECH