[카테고리:] 백엔드 개발

  • FastAPI 422에러, 코드오류를 탓하기 전에 다음 7가지 먼저 체크해보자

    요즘 빠르게 서비스를 하기위해 파이썬 언어를 많이 활용하다보니 자연스레FastAPI로 개발을 많이 하게 됩니다.

    근데 FastAPI로 API를 개발하다 보면 서버는 정상적으로 실행되고 있는데 요청만 보내면 422 Unprocessable Entity가 반환되는 경우가 종종있습니다.

    처음 이 오류를 보면 서버 내부에서 뭔가 코드상 문제가 발생한 것으로 생각되는데,보통 FastAPI의 422 오류는 대부분 요청 데이터가 API가 기대하는 형태와 맞지 않을 때 발생하곤 했습니다.

    특히 프론트엔드, 다른 서버, Docker Health Check처럼 외부에서 API를 호출하는 구조에서는 코드 자체보다 요청이 어디로 전달되고 어떤 값이 빠졌는지를 확인하는 것이 훨씬 중요했습니다.

    그래서 저는 FastAPI에서 422 오류가 발생하면 코드를 바로 수정하기보다 다음 순서대로 확인합니다.


    422 Unprocessable Entity는 어떤 오류인가

    예를 들어 다음과 같이 간단한 API가 있다고 가정해 보겠습니다.

    from fastapi import FastAPI
    
    app = FastAPI()
    
    @app.get("/search")
    async def search(searchkeyword: str):
        return {
            "keyword": searchkeyword
        }
    

    이 API는 searchkeyword라는 Query Parameter가 반드시 필요합니다.

    정상 요청은 다음과 같습니다.

    GET /search?searchkeyword=fastapi
    

    하지만 다음처럼 요청하면 문제가 발생합니다.

    GET /search
    

    FastAPI는 요청 자체는 정상적인 HTTP 요청으로 인식하지만 필요한 값이 없기 때문에 422를 반환합니다.

    응답은 보통 다음과 비슷합니다.

    {
      "detail": [
        {
          "type": "missing",
          "loc": [
            "query",
            "searchkeyword"
          ],
          "msg": "Field required",
          "input": null
        }
      ]
    }
    

    여기서 중요한 부분은 detail입니다.

    FastAPI의 422 오류를 만났을 때는 에러 메시지를 통째로 보기보다 먼저 loc을 확인합니다.

    여기서 loc 는 location를 가리키는 것같습니다.

    loc: ["query", "searchkeyword"]
    

    이 값만 봐도 FastAPI가 어디에서 값을 찾고 있었는지 알 수 있습니다.


    1. Query Parameter가 빠지지 않았는지 확인한다

    제가 가장 먼저 확인하는 부분입니다.

    다음 API를 보겠습니다.

    @app.get("/books")
    async def get_books(
        keyword: str,
        page: int,
        page_size: int
    ):
        return {
            "keyword": keyword,
            "page": page,
            "page_size": page_size
        }
    

    이 경우 세 값이 모두 필수입니다.

    keyword
    page
    page_size
    

    그런데 호출하는 쪽에서 다음처럼 요청했다면:

    /books?keyword=python
    

    pagepage_size가 없기 때문에 422가 발생합니다.

    이런 문제는 API 코드만 보고 있으면 의외로 찾기 어렵습니다.

    브라우저, Postman, Swagger, 프론트엔드 코드에서 실제로 전송되는 URL을 먼저 확인하는 편이 빠릅니다.

    물론 필수 값이 아니면 기본값을 지정하여 해당 부분의 에러를 미리 방지할 수 있습니다.

    @app.get("/books")
    async def get_books(
        keyword: str,
        page: int = 1,
        page_size: int = 10
    ):
        ...
    

    이렇게 하면 다음 요청도 정상적으로 처리됩니다.

    /books?keyword=python
    

    2. Body로 보내야 할 값을 Query로 보내고 있지 않은지 확인한다

    두 번째는 FastAPI에서 자주 헷갈리는 부분 중 하나입니다.

    다음과 같은 Pydantic 모델이 있다고 하겠습니다.

    (여기서 Pydantic 모델은 타입 힌트를 이용해서 데이터의 구조를 정의하고 입력된 데이터의 유효성 검증과 변환을 수행하는 파이썬 클래스라고 합니다.)

    보통 Body형식으로 호출할 때 거의 필수 적으로 사용합니다. (TMI)

    암튼 예시를 보면

    from pydantic import BaseModel
    
    class SearchRequest(BaseModel):
        keyword: str
        page: int = 1
    

    API는 다음과 같습니다.

    @app.post("/search")
    async def search(request: SearchRequest):
        return request
    

    이 API는 JSON Body를 기대합니다.

    정상 요청은 다음과 같습니다.

    {
      "keyword": "fastapi",
      "page": 1
    }
    

    그런데 호출하는 쪽에서 다음처럼 Query Parameter로 보내면:

    POST /search?keyword=fastapi&page=1
    

    FastAPI 입장에서는 Body가 비어 있습니다.

    결국 422가 발생합니다.

    실제로 API를 붙이다 보면 백엔드에서는 Body를 기대하고 있는데 프론트에서는 Query Parameter로 보내거나 그 반대인 경우가 꽤 있습니다.

    (네, 설마? 라고 생각하실 수 있는데 생각없이 하다보면 그런 경우도 발생합니다..)

    그래서 저는 422가 나오면 항상 먼저 확인합니다.

    이 값은 Query인가?
    Path인가?
    Body인가?
    Header인가?
    

    이 네 가지를 구분하는 것만으로도 원인을 빠르게 좁힐 수 있습니다.


    3. JSON 필드 이름이 정확히 맞는지 확인한다

    다음 모델을 사용한다고 하겠습니다.

    class BookRequest(BaseModel):
        book_title: str
        isbn: str
    

    FastAPI가 기대하는 JSON은 다음과 같습니다.

    {
      "book_title": "FastAPI 실전",
      "isbn": "9781234567890"
    }
    

    그런데 프론트에서 다음처럼 보내면:

    {
      "bookTitle": "FastAPI 실전",
      "isbn": "9781234567890"
    }
    

    사람이 보기에는 같은 의미지만 Pydantic 기준으로는 전혀 다른 필드입니다.

    book_title

    bookTitle

    은 다릅니다.

    이 경우 book_title이 누락된 것으로 판단되어 422가 발생할 수 있습니다.

    저는 이 문제가 의심되면 FastAPI 코드보다 먼저 브라우저 개발자 도구의 Network 탭이나 실제 Request Payload를 확인합니다.

    특히 JavaScript에서는 camelCase(낙타케이스?)를 많이 사용하고 Python에서는 snake_case(뱀 케이스)를 많이 사용하기 때문에 두 시스템을 연결할 때 이런 차이가 자주 발생합니다.

    사실 이 경우는 거의 있을까 말까한데 혹시 몰라서 정리해봅니다.


    4. 데이터 타입이 맞는지 확인한다

    필드는 존재하지만 타입 때문에 422가 발생하기도 합니다.

    @app.get("/books")
    async def get_books(page: int):
        return {"page": page}
    

    다음 요청은 정상입니다.

    /books?page=1
    

    하지만 다음처럼 숫자로 변환할 수 없는 값을 보내면 문제가 됩니다.

    /books?page="1"
    

    FastAPI는 pageint로 변환하려고 하지만 실패합니다.

    에러의 핵심 부분은 다음처럼 나타납니다.

    Input should be a valid integer
    

    이럴 때는 백엔드 타입을 무조건 str로 바꾸기보다 호출하는 쪽에서 왜 잘못된 값이 전달됐는지 먼저 확인하는 편이 좋습니다.

    특히 프론트엔드에서 다음 값들이 자주 문제가 됩니다.

    ""
    null
    undefined
    "null"
    "undefined"
    

    개발자 도구에서 보면 값이 있는 것처럼 보이지만 서버 입장에서는 기대한 데이터와 완전히 다를 수 있습니다.

    보통 데이터베이스에서 바로 호출해서 넘기는 경우에 많이 발생했습니다.

    데이터베이스에서 호출해서 나온 값을 한 번 점검하지 않고 바로 변수에 담아서 호출하면 이런 경우가 생겼고 코드가 길어지면 에러나는 부분이 어디인지부터 왜 에러가 났는지 한참 고민하는 경우도 생기더군요…

    개인적으로 반드시 타입을 점검하는 차원에서 한 번 객체로 묶어서 변수로 활용하면 해당 버그나 오류를 범할 확률을 확 줄일 수 있지 않을까 생각합니다.

    사소한 것 같은데 생각 보다 실무에서 많이 발생합니다. 물론 설계가 탄탄하면 괜찮겠지만.. 파이썬을 활용한다는거 자체가 빠르게 프로토타입으로 만들어서 개발 속도를 내는 것에 초점이 맞춰져 있다보니 조심해야되지 않을까 싶네요.


    5. Header가 필수로 설정돼 있지 않은지 확인한다

    API 인증이나 사용자 식별을 위해 Header 값을 사용하는 경우도 있습니다.

    보통 로그인 혹은 특정 사용자의 권한 여부 등을 살필 때 많이씁니다.

    예를 들어:

    from fastapi import Header
    
    @app.get("/profile")
    async def profile(
        x_user_id: str = Header(...)
    ):
        return {
            "user_id": x_user_id
        }
    

    이 API는 다음 Header가 필요합니다.

    X-User-ID
    

    하지만 Header 없이 요청하면 422가 발생할 수 있습니다.

    그래서 외부 시스템과 연결할 때는 다음 세 가지를 모두 확인합니다.

    특히 앞서 말한 서비스를 호출 할 때는 Header를 살펴보는게 센스입니다.

    URL
    Query Parameter
    Header
    

    특히 Swagger에서는 잘 동작하는데 프론트엔드나 다른 서버에서 호출했을 때만 422가 발생한다면 Header 차이를 확인해 볼 필요가 있습니다.

    Swagger에서 직접 테스트했을 때 성공한다는 것은 API 자체에는 사실상 문제가 없다는 의미일 가능성이 높다고 봐야합니다.

    그다음부터는 실제 클라이언트 요청과 Swagger 요청의 차이를 찾으면 됩니다.


    6. Path Parameter 이름과 URL을 확인한다

    Path Parameter에서도 실수가 발생합니다.

    @app.get("/library/{lib_code}")
    async def library(lib_code: str):
        return {
            "lib_code": lib_code
        }
    

    정상 요청:

    /library/1234
    

    하지만 호출하는 URL을 잘못 구성하면 원하는 API 자체가 호출되지 않거나 다른 라우트와 충돌할 수 있습니다.

    실제 서비스에서는 다음처럼 URL이 길어지는 경우가 많습니다.

    /search/check/{libCode}
    

    여기에 Query Parameter까지 붙으면:

    /search/check/1001?searchkeyword=python&page=1&pageSize=10
    

    한눈에 오류를 발견하기가 어려워집니다.

    물론 예시이니까 잉? 이정도로? 라고 생각하실 수 있습니다.

    다만, 서비스가 많아지고 엑세스 포인트가 많아지면 빠르게 봐야할 때 헷갈릴 수 있습니다.

    보통 개발자는 한 가지 경우보다 다양하고 방대한 양을 항상 고려해야 하기 때문입니다.

    저는 이런 경우 실제 요청 URL을 한 줄 그대로 복사해서 각각 분해합니다.

    Path
    /search/check/1001
    
    Query
    searchkeyword=python
    page=1
    pageSize=10
    

    그리고 FastAPI 함수 정의와 하나씩 비교합니다.


    7. Docker Health Check가 API 요구사항과 맞는지 확인한다

    근래 몇 년 전부터 Docker 활용을 많이 하는데

    그러다 보니 도커를 운영하는 상황에서 조금 다른 상황도 생깁니다.

    예를 들어 Health Check를 다음처럼 설정했다고 하겠습니다.

    HEALTHCHECK CMD curl -f http://localhost:8000/health || exit 1
    

    그런데 /health API가 특정 파라미터나 Header를 필요로 한다면 Health Check 요청에서 422가 발생할 수 있습니다.

    그냥 테스트용인데 여기에 다양하게 정보를 제공하겠다고 추가로 파라미터나 리턴 객체 값을 다양하게 줬던 적이 있었고 그 과정에서 불편함을 초래한 적이있는데

    가급적이면 Health Check API는 가능하면 복잡하게 만들지 않는 편이 좋았습니다.

    예를 들면:

    @app.get("/health")
    async def health():
        return {
            "status": "ok"
        }
    

    인증 Header나 사용자 정보, Query Parameter가 필요하지 않은 별도의 엔드포인트로 두는 방식입니다.

    Docker에서는 애플리케이션 자체는 정상인데 Health Check만 계속 실패하면 컨테이너 상태가 unhealthy로 표시될 수 있습니다.

    그래서 422 오류가 Health Check에서 발생했다면 애플리케이션 비즈니스 로직보다 먼저 Health Check가 실제로 어떤 요청을 보내고 있는지를 확인합니다.


    내가 422 오류를 확인하는 순서

    지금까지 Fastapi 422가 발생하는 경우를 살펴봤습니다. 그래서 정리하자면 저는 지금 422가 발생하면 다음 순서로 봅니다.

    1. 422 응답의 detail 확인
            ↓
    2. loc가 query / body / path / header 중 무엇인지 확인
            ↓
    3. Swagger에서 같은 요청 실행
            ↓
    4. 실제 클라이언트가 보낸 Request 확인
            ↓
    5. 필드 이름 비교
            ↓
    6. 데이터 타입 비교
            ↓
    7. 필수값과 기본값 확인
    

    특히 loc 확인이 중요합니다.

    예를 들어:

    "loc": ["body", "keyword"]
    

    라면 Body부터 봅니다.

    "loc": ["query", "page"]
    

    라면 Query Parameter부터 봅니다.

    "loc": ["header", "x-user-id"]
    

    라면 Header를 확인합니다.

    막연하게 코드를 처음부터 읽는 것보다 훨씬 빠르게 문제 범위를 줄일 수 있습니다.


    로그를 추가하면 원인 찾기가 훨씬 쉬워진다

    운영 서버에서는 클라이언트가 실제로 어떤 요청을 보냈는지 확인하기 어려울 수 있습니다. 그래서 반드시

    요청 정보를 로그로 남깁니다. 안 남기고 서비스하면 에러가 어디서 나는 추측하고 코드 다 들춰보느라 애먹습니다.

    예를 들어 아래와 같이 간단한 형태로 만들어두고 http 통신이 이뤄지는 경우 기록을 남기도록 반드시 처리합니다.

    from fastapi import Request
    
    @app.middleware("http")
    async def request_logger(request: Request, call_next):
        print(
            request.method,
            request.url
        )
    
        response = await call_next(request)
    
        return response
    

    단, 실제 운영 환경에서는 인증 토큰이나 개인정보가 로그에 그대로 남지 않도록 주의해야 합니다.

    괜히 그대로 남겼다가 나중에 보안 점검 들어오면… 보안 위험 노출로 애먹습니다. 간과하지말고 챙깁시다.

    모든 Body와 Header를 무조건 출력하기보다는 장애 분석에 필요한 항목만 선택적으로 남기는 편이 안전합니다.

    나중에 로그 쌓이면 서비스 장애가 날 수 도 있습니다.


    422를 만나면 서버 오류라고 단정하지 않는다

    FastAPI에서 422 오류를 여러 번 경험하면서 가장 크게 바뀐 점은 처음부터 서버 로직을 의심하지 않는 것이었습니다.

    초장기엔 뭐만하면 아 코드를 잘 못 짰네..

    누가 짰냐..

    이런게 머릿속에 항상 시작이었습니다.

    물론, 500 오류라면 애플리케이션 내부 오류를 먼저 확인하지만 422라면 우선 요청과 Validation 사이의 차이를 찾습니다.

    특히 다음 네 가지를 기억하면 대부분의 원인을 빠르게 좁힐 수 있습니다.

    Query
    Body
    Path
    Header
    

    그리고 FastAPI가 반환하는 detailloc에는 이미 문제의 위치가 상당히 구체적으로 들어 있습니다.

    코드를 무작정 수정하기보다 FastAPI가 “어디에서 어떤 값을 기대했는데 무엇을 받지 못했는가”를 먼저 읽는 것이 422 문제를 해결하는 가장 빠른 방법이었습니다.


    422 오류 확인 체크리스트

    마지막으로 같은 문제가 발생했을 때 확인하기 위한 항목을 정리합니다.

    • 요청 URL이 정확한가
    • HTTP Method가 GET/POST/PUT 등 API 정의와 일치하는가
    • 필요한 Query Parameter가 모두 전달됐는가
    • JSON Body가 실제로 전달됐는가
    • JSON Key 이름이 Pydantic 모델과 같은가
    • 데이터 타입이 맞는가
    • Path Parameter가 정확한가
    • 필수 Header가 누락되지 않았는가
    • Swagger에서는 같은 요청이 정상 동작하는가
    • Docker Health Check가 별도의 필수값을 요구하고 있지 않은가
    • 422 응답의 detailloc를 확인했는가

    FastAPI 422는 처음에는 막연하게 느껴지지만, 응답에 포함된 Validation 정보를 기준으로 접근하면 상당히 체계적으로 원인을 찾을 수 있습니다.