블로그

  • FastAPI에서 async def를 썼는데도 빨라지지 않는 이유를 다시 생각해봤다

    FastAPI를 처음 사용하다 보면 defasync def 중 무엇을 써야 하는지 한 번쯤 고민하게 된다.

    나 역시 FastAPI 기반 검색·추천 API를 만들면서 대부분의 요청 처리 코드를 비동기로 구성했다. PostgreSQL에는 asyncpg를 사용했고, 데이터베이스 연결도 Connection Pool을 만들어 사용하는 구조였다.

    그래서 처음에는 상당히 단순하게 생각했다.

    @app.get("/search")
    async def search():
        ...
    

    일반적인 def가 아니라 async def를 사용했고, 데이터베이스 라이브러리 역시 비동기를 지원한다면 동시 요청을 처리할 때 자연스럽게 더 좋은 성능을 낼 것이라고 생각하기 쉽다.

    그런데 코드를 계속 보다 보니 한 가지가 걸렸다.

    함수 앞에 async를 붙이는 것 자체가 어떻게 코드를 빠르게 만드는 것일까?

    생각해보면 조금 이상하다.

    CPU가 수행해야 하는 계산량이 똑같고, PostgreSQL에서 같은 SQL을 실행하며, 외부 API에서도 같은 데이터를 받아와야 한다면 defasync def로 바꿨다는 이유만으로 실제 작업량이 줄어드는 것은 아니다.

    이 질문에서부터 비동기 코드를 다시 보게 됐다.

    그리고 내가 이해한 핵심은 async하나의 요청을 무조건 더 빨리 끝내는 기술이라기보다, 어떤 작업이 기다리고 있는 동안 다른 요청을 처리할 수 있도록 만드는 방식이라는 것이었다.

    FastAPI 공식 문서 역시 네트워크, 데이터베이스, 파일 시스템 등 결과를 기다려야 하는 작업과 async/await의 관계를 설명하고 있으며, 일반 def로 선언된 path operation은 서버를 막지 않도록 외부 thread pool에서 실행한다.

    이 차이를 이해하고 나니 async def가 def보다 빠르다라는 질문 자체가 조금 부족하게 느껴졌다.

    정말 먼저 봐야 할 것은 함수 안에서 무엇을 하고 있는가였다.


    처음에는 await를 “비동기 함수를 호출하는 문법” 정도로 생각하기 쉬웠다

    예를 들어 내가 사용한 asyncpg를 생각해보면 다음과 같은 코드가 나온다.

    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT * FROM books LIMIT 10"
        )
    

    처음 이런 코드를 작성할 때는:

    asyncpg가 비동기 라이브러리니까 await를 붙인다.

    정도로 이해해도 기능을 만드는 데는 문제가 없다.

    하지만 왜 await가 필요한지를 생각해보면 이 코드의 의미가 조금 달라진다.

    conn.fetch()를 호출한다고 Python이 혼자서 SQL 결과를 계산하는 것은 아니다.

    실제 Query는 PostgreSQL에서 처리된다.

    애플리케이션은 PostgreSQL에 요청을 보내고 결과가 돌아오기를 기다려야 한다.

    단순화하면 다음과 같다.

    FastAPI
       ↓
    SQL 요청 전송
       ↓
    PostgreSQL에서 Query 처리
       ↓
    결과가 올 때까지 대기
       ↓
    결과 수신
    

    여기에서 중요한 시간이 바로 대기시간이다.

    FastAPI 프로세스가 PostgreSQL 결과를 기다리는 동안 CPU가 해당 요청을 위해 계속 계산하고 있어야 하는 것은 아니다.

    그렇다면 그 시간에 다른 요청을 처리할 수 있다면 서버 입장에서는 훨씬 효율적이다.

    await를 이해할 때 내가 중요하게 보게 된 부분이 이것이다.

    Python asyncio의 event loop는 task가 멈추거나 제어권을 돌려주면 다른 작업을 실행할 수 있는 구조를 가지고 있다. 반대로 한 작업이 제어권을 오래 잡고 있으면 다른 작업들도 실행 기회를 얻지 못할 수 있다.

    await가 중요한 이유는 단순히:

    비동기 함수를 호출하기 위한 문법
    

    이 아니라,

    지금 이 작업은 결과를 기다려야 한다.
    
    그동안 다른 일을 처리할 수 있도록
    event loop에 실행권을 돌려준다.
    

    라는 의미를 가질 수 있기 때문이다.

    이렇게 생각하고 나니 async의 장점이 조금 더 명확해졌다.


    한 요청을 1초에서 0.5초로 만드는 것과는 다른 문제였다

    예를 들어 PostgreSQL Query가 결과를 반환하는 데 1초가 걸린다고 가정해보자.

    async를 사용한다고 PostgreSQL Query 자체가 갑자기 0.5초 만에 끝나는 것은 아니다.

    DB가 1초 동안 해야 하는 작업은 여전히 존재한다.

    대신 차이가 날 수 있는 부분은 여러 요청이 동시에 들어왔을 때다.

    설명을 위해 요청 A와 B가 거의 동시에 들어왔다고 하자.

    요청 A가 PostgreSQL 결과를 기다리는 동안 아무것도 할 수 없다면:

    요청 A
    [ DB 대기........ ]
                      ↓
                    완료
    
    요청 B
                      [ DB 대기........ ]
                                        ↓
                                      완료
    

    처럼 처리될 수 있다.

    반면 A가 DB 응답을 기다리는 시점에 실행권을 넘길 수 있다면:

    요청 A
    DB 요청 → 대기 ─────────→ 응답
    
    요청 B
              DB 요청 → 대기 ─────→ 응답
    

    처럼 대기 시간을 겹쳐 사용할 여지가 생긴다.

    이걸 보고 나서야 왜 FastAPI 같은 웹 API에서 비동기가 특히 자주 등장하는지 이해하기 쉬웠다.

    웹 API에는 생각보다 기다리는 작업이 굉장히 많기 때문이다.

    예를 들어 내 검색·추천 서비스만 생각해도 한 요청 안에 다음과 같은 작업이 들어갈 수 있다.

    PostgreSQL 조회
    
    Elasticsearch 검색
    
    Embedding API 요청
    
    LLM API 요청
    
    로그 저장
    

    이 중 상당수는 Python CPU가 직접 결과를 만들어내기보다 다른 시스템에 요청한 뒤 응답을 기다리는 성격을 가지고 있다.

    따라서 비동기의 장점은:

    하나의 Query를 마법처럼 빠르게 만든다.

    보다는:

    하나의 요청이 외부 시스템을 기다리는 동안 다른 요청이 처리될 기회를 만든다.

    로 이해하는 것이 더 정확했다.


    그렇다면 async def로 전부 바꾸면 되는 것 아닐까?

    여기까지 이해하면 또 다른 생각이 든다.

    대기시간을 효율적으로 사용할 수 있다면 모든 API를 async def로 바꾸면 되는 것처럼 보인다.

    나도 처음에는 이 방향이 훨씬 자연스럽다고 생각했다.

    하지만 async def 안에 무엇이 들어 있는지를 다시 생각하면 문제가 생긴다.

    예를 들어 다음 코드를 보자.

    import time
    
    @app.get("/test")
    async def test():
        time.sleep(5)
        return {"status": "ok"}
    

    함수 선언만 보면 분명히 비동기다.

    async def test():
    

    그런데 내부에서 사용하는 time.sleep(5)는 비동기 대기가 아니다.

    현재 실행 흐름을 그대로 5초 동안 멈춘다.

    이 구조에서는:

    async def
        ↓
    blocking operation
        ↓
    event loop가 다른 일을 처리할 기회를 얻지 못함
    

    이라는 문제가 생길 수 있다.

    Python 공식 asyncio 문서에서도 blocking I/O나 CPU-bound 작업을 event loop에서 그대로 실행하면 event loop를 막을 수 있으며, 필요할 경우 executor에서 실행하는 방법을 설명한다.

    이 부분에서 한 가지 중요한 결론이 나왔다.

    async def라고 적혀 있다고 그 안의 모든 코드가 비동기로 실행되는 것은 아니다.


    처음에는 함수 선언만 봤지만 이제는 내부 라이브러리부터 본다

    예전에는 코드를 보면 먼저 이것을 봤다.

    async def
    

    지금은 그보다 안쪽을 먼저 본다.

    예를 들어:

    async def get_book():
        result = some_library.get_data()
        return result
    

    라고 되어 있다고 하자.

    여기에서 중요한 질문은:

    get_book()async def인가?

    보다:

    some_library.get_data()가 호출되는 동안 실행권을 돌려줄 수 있는가?

    이다.

    외부 API를 호출하는 라이브러리라고 해도 동기 방식으로 통신한다면 네트워크 응답이 올 때까지 현재 실행 흐름을 붙잡고 있을 수 있다.

    반대로 비동기 HTTP client를 사용하고:

    result = await client.get(...)
    

    처럼 실제 I/O 대기 구간에서 await할 수 있다면 그 시간에 다른 task가 실행될 수 있다.

    FastAPI 공식 문서도 사용하는 서드파티 라이브러리가 await를 지원한다면 async def와 함께 사용하고, await를 지원하지 않는 blocking 라이브러리를 사용할 경우 일반 def path operation을 사용할 수 있다고 설명한다. 일반 def path operation은 FastAPI가 외부 thread pool에서 실행한다.

    이걸 이해한 뒤에는 async가 더 빠른가보다 내가 사용하는 라이브러리가 비동기 실행을 실제로 지원하는가가 훨씬 중요한 질문이 됐다.


    FastAPI에서 일반 def가 무조건 나쁜 것도 아니었다

    처음에는 FastAPI 프로젝트 안에서 다음 코드를 보면:

    def get_data():
        ...
    

    이걸 전부:

    async def get_data():
        ...
    

    로 바꿔야 성능이 좋아질 것 같은 느낌이 있었다.

    하지만 FastAPI의 동작을 보면 그렇게 단순하지 않다.

    FastAPI는 일반 def로 선언된 path operation을 event loop에서 직접 실행해 전체 서버를 막는 대신 외부 thread pool에서 실행한다.

    즉 다음과 같은 구조를 생각할 수 있다.

    async def endpoint
    
    → event loop에서 실행
    → await 가능한 I/O와 잘 맞음
    

    반대로:

    def endpoint
    
    → FastAPI가 thread pool에서 실행
    → blocking code가 event loop를 직접 잡지 않도록 처리
    

    이 차이를 모르고 모든 함수를 async def로 변경하면 오히려 blocking 코드를 event loop 안으로 직접 가져오는 상황도 만들 수 있다.

    그래서 지금은 def를 보면:

    비동기가 아니니까 느리다.

    라고 판단하지 않는다.

    먼저 그 함수가 어떤 작업을 하고 있는지 본다.


    내가 가장 조심하게 된 것은 “겉만 async인 코드”다

    개인적으로 더 위험하다고 느낀 코드는 이런 형태다.

    @app.get("/recommend")
    async def recommend():
        data = blocking_function()
    
        result = await async_function(data)
    
        return result
    

    코드에 async, await가 모두 있으니 전체적으로 비동기 처리가 잘 되어 있는 것처럼 보인다.

    하지만 실제 흐름을 보면:

    요청 시작
       ↓
    blocking_function()
       ↓
    여기서 event loop가 오래 잡힐 수 있음
       ↓
    그 뒤에야 await
    

    가 된다.

    즉 아래쪽에 await가 존재한다고 앞에서 발생한 blocking이 없어지는 것은 아니다.

    이 부분 때문에 이제는 비동기 코드를 볼 때 함수 전체보다 각 작업이 어디에서 기다리고 어디에서 CPU를 사용하는지를 나눠서 본다.


    await가 많다고 좋은 코드도 아니다

    반대 방향의 오해도 생길 수 있다.

    비동기 코드가 좋다고 하니 코드 곳곳에 await가 많으면 효율적인 코드라고 생각하기 쉽다.

    하지만 await의 개수 자체는 성능 지표가 아니다.

    예를 들어:

    result1 = await api_a()
    result2 = await api_b()
    result3 = await api_c()
    

    라고 되어 있다고 하자.

    세 요청이 서로의 결과에 의존한다면 이 순서가 맞다.

    하지만 서로 완전히 독립적인 요청이라면 지금 구조는:

    A 완료 기다림
    ↓
    B 시작
    ↓
    B 완료 기다림
    ↓
    C 시작
    

    처럼 동작한다.

    비동기 함수를 사용하고 있기는 하지만 서로 독립적인 대기를 순차적으로 수행하고 있는 셈이다.

    이 경우에는 실제 요구사항에 따라 독립 작업을 concurrent하게 실행할 수 있는지 검토할 수도 있다.

    중요한 것은:

    await를 썼는가?

    가 아니라:

    어떤 작업은 반드시 순서대로 기다려야 하고, 어떤 작업은 동시에 기다릴 수 있는가?

    이다.

    비동기 프로그램을 설계할 때 이 구분이 더 중요하다고 느끼게 됐다.


    내 검색 서비스에서도 순서가 필요한 작업과 그렇지 않은 작업이 있었다

    예를 들어 자연어 검색을 생각해보면 다음 과정은 순서가 필요하다.

    사용자 검색어
          ↓
    Embedding 생성
          ↓
    그 Embedding으로 KNN 검색
    

    Elasticsearch KNN 검색을 하려면 Query Vector가 먼저 있어야 한다.

    따라서:

    Embedding 완료 전
    KNN 실행
    

    은 불가능하다.

    이 경우에는 기다리는 것이 정상이다.

    반면 검색 결과가 이미 준비된 뒤 서로 독립적인 부가 데이터를 여러 곳에서 조회해야 한다면 이야기가 달라질 수 있다.

    이 차이를 생각하면서 비동기 설계를:

    무조건 동시에 실행한다
    

    가 아니라:

    의존성이 있는 작업은 순차
    
    독립적인 I/O는 필요하면 동시 처리
    

    로 보게 됐다.

    이런 관점이 없으면 비동기로 작성했는데도 전체 작업은 여전히 대부분 순차적으로 실행될 수 있다.


    Database Connection Pool도 async와 별개의 제한을 가지고 있었다

    asyncpg를 사용하면 많은 요청을 비동기로 처리할 수 있다.

    그렇다고 PostgreSQL Connection이 무한히 생기는 것은 아니다.

    내가 사용하던 구조에서는 Connection Pool에 최대 크기를 두고 있었다.

    예를 들면:

    pool = await asyncpg.create_pool(
        dsn=DATABASE_URL,
        min_size=1,
        max_size=50
    )
    

    여기에서 생각해야 하는 부분이 있다.

    FastAPI가 동시에 500개의 요청을 받아 처리할 수 있다고 해도 모든 요청이 동시에 PostgreSQL Connection을 확보할 수 있다는 뜻은 아니다.

    Pool에서 사용할 수 있는 Connection이 모두 사용 중이라면 나머지 요청은 Connection이 반환되기를 기다려야 한다.

    동시 API 요청 다수
            ↓
    DB 작업 필요
            ↓
    Connection Pool
            ↓
    사용 가능한 Connection이 있으면 실행
    
    없으면
            ↓
    Connection 반환 대기
    

    처음에는 이런 대기를 보면 Pool을 더 크게 만들면 해결될 것처럼 느껴질 수 있다.

    하지만 앞에서 PostgreSQL 연결 문제를 다루면서 생각했던 것처럼 Pool 역시 크게 할수록 무조건 좋은 설정은 아니다.

    FastAPI가 비동기로 많은 요청을 받을 수 있다는 것과 PostgreSQL이 동시에 많은 Query를 안정적으로 처리할 수 있다는 것은 서로 다른 문제이기 때문이다.

    애플리케이션의 concurrency와 데이터베이스의 처리 용량을 동일하게 보면 안 된다.


    비동기는 “대기를 없애는 것”이 아니라 “대기를 활용하는 것”이었다

    이 문장이 내가 async를 이해하면서 가장 중요하게 느낀 부분이다.

    예를 들어 DB가 1초 걸리는 작업을 비동기로 만든다고 1초라는 DB 처리시간 자체가 사라지는 것은 아니다.

    외부 LLM API가 결과를 보내는 데 3초가 걸린다면 await를 붙였다고 3초가 0초가 되는 것도 아니다.

    그 대신:

    DB를 기다리는 1초
    
    LLM을 기다리는 3초
    

    같은 시간 동안 Python 서버가 다른 요청을 처리할 수 있는 여지를 만드는 것이다.

    그래서 async를 성능 최적화라고 표현할 때도 조금 조심해야 한다고 생각한다.

    한 요청의 latency를 줄이는 관점과 서버 전체의 concurrency를 높이는 관점은 다르기 때문이다.


    CPU 작업에서는 이야기가 다시 달라진다

    여기까지는 대부분 I/O 대기에 관한 이야기였다.

    그런데 API 안에서 CPU를 오래 사용하는 작업을 실행한다면 비동기의 장점이 그대로 적용되지 않을 수 있다.

    예를 들어 설명을 위해 다음과 같은 작업이 있다고 하자.

    def heavy_calculation():
        result = 0
    
        for i in range(100_000_000):
            result += i * i
    
        return result
    

    이 작업은 외부 서버의 응답을 기다리고 있는 것이 아니다.

    CPU가 계속 계산해야 한다.

    이를 단순히:

    async def heavy_calculation():
        ...
    

    로 바꾼다고 계산량이 줄지 않는다.

    더 큰 문제는 이 계산을 event loop에서 오래 실행하면 그 시간 동안 다른 coroutine이 실행될 기회를 잃을 수 있다는 점이다.

    Python 공식 문서 역시 CPU-bound 작업은 event loop를 block할 수 있으며, 이런 작업에는 일반적으로 process pool 같은 별도 실행 방식을 고려하도록 설명한다.

    그래서 이제는 작업을 크게 두 종류로 나누려고 한다.

    기다리는 시간이 긴 작업

    DB
    외부 API
    네트워크
    일부 파일 I/O
    

    이런 작업에서는 async가 잘 맞을 가능성이 높다.

    CPU가 계속 계산해야 하는 작업

    대규모 데이터 계산
    이미지 처리
    무거운 변환
    복잡한 CPU 연산
    

    이런 작업은 단순히 async def로 감싼다고 해결되지 않는다.


    async def인데 서버가 같이 느려진다면 이렇게 생각할 수 있다

    만약 비동기로 작성한 API 한 개가 오래 실행될 때 다른 요청들까지 함께 느려진다면 나는 지금 다음 순서로 볼 것 같다.

    처음부터:

    FastAPI가 느리다.

    라고 생각하지 않는다.

    먼저 함수 안쪽을 본다.

    async def
       ↓
    어떤 라이브러리를 호출하는가?
    

    그 다음:

    실제로 await 가능한 I/O인가?
    
    아니면 blocking 함수인가?
    

    를 본다.

    그것도 아니라면:

    CPU를 오래 잡고 있는 계산은 없는가?
    

    를 확인한다.

    DB 문제라면:

    asyncpg Pool에서 Connection을 기다리고 있는가?
    

    도 본다.

    외부 API라면:

    상대 서버 자체가 느린 것인가?
    

    도 분리한다.

    이렇게 보면 같은 “API가 느리다”라는 현상 안에서도 원인이 완전히 다를 수 있다.


    응답시간 하나만 기록하는 것도 부족하다

    앞의 LLM 스트리밍 글에서 응답시간을 여러 구간으로 나눠야 한다고 정리했다.

    FastAPI 비동기 처리도 마찬가지다.

    예를 들어 검색 요청 하나가 4초 걸렸다고 하자.

    전체 응답시간 = 4초
    

    이 정보만 가지고는 async 문제인지 알 수 없다.

    실제로는:

    Embedding      0.5초
    Elasticsearch  0.2초
    PostgreSQL     0.1초
    LLM            3.0초
    기타           0.2초
    

    일 수도 있다.

    이 경우 FastAPI의 async def를 수정하는 것보다 LLM 호출이 전체 latency의 대부분을 차지한다는 사실을 먼저 보는 것이 맞다.

    반대로 특정 Python 처리 단계에서 3초를 CPU로 사용한다면 이야기가 달라진다.

    그래서 성능을 개선하려면:

    비동기냐 동기냐

    를 먼저 보는 것보다:

    실제 시간은 어디에서 소비되고 있는가?

    를 확인해야 한다.


    동시 요청 테스트를 해야 async의 차이를 볼 수 있는 이유

    비동기 코드를 테스트하면서 요청을 한 번만 보내는 것도 부족할 수 있다.

    예를 들어:

    요청 1개
    
    동기 코드 = 1.0초
    비동기 코드 = 1.0초
    

    라는 결과가 나왔다고 하자.

    그러면:

    async를 사용해도 아무 차이가 없네.

    라고 결론 내리기 쉽다.

    하지만 비동기의 장점이 대기시간 동안 다른 작업을 처리하는 것이라면 요청 하나만 있을 때는 그 장점이 나타날 상황 자체가 적다.

    따라서 실제로 확인하고 싶은 것은 동시 요청이 늘어날 때다.

    예를 들어:

    1 concurrent request
    
    10 concurrent requests
    
    50 concurrent requests
    
    100 concurrent requests
    

    처럼 요청 수를 늘려본다.

    그리고 단순 평균 response time뿐 아니라:

    전체 처리량
    
    p50
    
    p95
    
    오류 수
    
    DB Connection 사용량
    

    같은 값도 볼 수 있다.

    그래야 비동기 구조가 실제 동시 요청 상황에서 어떤 차이를 만드는지 확인할 수 있다.


    그런데 동시 요청 테스트도 잘못하면 다른 것을 측정하게 된다

    여기서 또 한 단계 생각해야 한다.

    예를 들어 DB를 사용하는 API에 500개의 요청을 동시에 보냈다고 하자.

    결과가 느려졌다.

    그렇다고 바로:

    FastAPI async 성능이 부족하다.

    고 판단할 수 없다.

    DB Pool이 최대 20개라면 대부분의 요청이 Connection을 기다린 결과일 수 있다.

    또 PostgreSQL Query 자체가 병목일 수도 있다.

    외부 LLM API에 rate limit이 걸렸을 수도 있다.

    결국 부하 테스트에서 얻는 숫자 하나도 어디에서 기다리는지와 함께 보지 않으면 의미가 부족하다.

    이 부분 때문에 성능 테스트는 숫자를 만드는 작업이라기보다 병목 위치를 설명할 수 있게 만드는 작업에 가깝다고 생각한다.


    지금 다시 FastAPI API를 작성한다면 판단 기준은 단순하다

    이제는 defasync def를 선택할 때:

    무엇이 더 빠르지?

    부터 묻지 않을 것 같다.

    대신 다음부터 본다.

    첫째, 사용하는 라이브러리가 비동기 API를 제공하는가?

    예를 들어 asyncpg처럼 await 가능한 DB 작업을 사용한다면 async def 흐름이 자연스럽다.

    둘째, 함수 내부에 오래 걸리는 blocking 코드가 있는가?

    있다면 async def 안에 그대로 넣는 것이 적절한지 다시 본다.

    셋째, CPU-bound 작업인가?

    CPU 계산이 주된 작업이라면 asyncio만으로 해결하려고 하지 않는다.

    넷째, 동시에 실행할 수 있는 독립적인 I/O가 있는가?

    순차적으로 기다릴 필요가 없는 작업이라면 concurrency 구조를 검토한다.

    다섯째, 실제 병목은 어디인가?

    FastAPI가 아니라 DB Pool, PostgreSQL, Elasticsearch, 외부 API일 수도 있다.


    결국 async는 문법보다 실행 흐름의 문제였다

    처음 FastAPI에서 async def를 사용했을 때는 이것을 일종의 성능 옵션처럼 생각했다.

    def
    보다
    async def가 빠르다.
    

    정도로 단순화해서 이해하기 쉬웠다.

    그런데 실제 검색·추천 API처럼 PostgreSQL, Elasticsearch, Embedding, LLM API가 한 요청 안에서 이어지는 구조를 보다 보니 이 구분만으로는 설명되지 않는 부분이 너무 많았다.

    중요한 것은 결국:

    어디에서 CPU를 사용하는가?
    
    어디에서 외부 결과를 기다리는가?
    
    기다리는 동안 실행권을 돌려줄 수 있는가?
    
    다른 요청과 동시에 진행할 수 있는 작업인가?
    
    어떤 자원에서 동시 실행 수가 제한되는가?
    

    였다.

    그래서 지금은 async def를 다음처럼 이해하는 편이 가장 편하다.

    코드를 자동으로 빠르게 만들어주는 문법이 아니라, 기다림이 많은 프로그램에서 그 대기시간을 다른 작업에 활용할 수 있도록 만드는 실행 방식이다.

    그리고 이 관점으로 보면 왜 async def를 사용했는데도 API가 빨라지지 않을 수 있는지도 설명된다.

    DB Query 자체가 느리면 Query를 개선해야 한다.

    Connection Pool이 가득 찼다면 Pool과 DB 용량을 봐야 한다.

    외부 API가 느리다면 그 시간을 별도로 측정해야 한다.

    동기 blocking 라이브러리가 event loop를 붙잡고 있다면 호출 구조를 바꿔야 한다.

    CPU 계산이 오래 걸린다면 async가 아니라 실행 환경 자체를 다시 생각해야 한다.

    결국 async는 해결책 하나가 아니라 병목이 어디에 있는지를 이해하고 나서 선택해야 하는 도구였다.

    이 사실을 이해하고 나니 FastAPI 성능 문제를 볼 때도 이제는 함수 선언부의 async부터 보지 않는다.

    먼저 그 함수 안에서 무엇을 기다리고 있고, 그 기다림 동안 서버가 다른 일을 할 수 있는 구조인지부터 확인한다.

    그 질문에 답할 수 있어야 async def를 사용한 이유도 설명할 수 있고, 실제로 성능 문제가 발생했을 때 어디부터 확인해야 할지도 정할 수 있기 때문이다.

  • FastAPI는 살아 있는데 Docker 컨테이너가 unhealthy로 표시됐던 이유

    Docker로 FastAPI 서비스를 운영하면서 처음에는 컨테이너 상태를 꽤 단순하게 생각했다.

    컨테이너가 실행되고 있고 FastAPI 프로세스도 죽지 않았다면 서비스는 정상이라고 생각했다. 실제로 docker ps를 실행했을 때 컨테이너가 Up 상태로 보이고, 로그에서도 Uvicorn이나 애플리케이션 프로세스가 정상적으로 실행되고 있다면 적어도 서버가 살아 있다는 뜻이라고 받아들이기 쉬웠다.

    그런데 Health Check를 적용하고 나면 조금 이상한 상황을 만날 수 있다.

    컨테이너는 분명히 실행 중이다.

    FastAPI 프로세스도 살아 있다.

    그런데 Docker에서는 상태가 이렇게 보인다.

    Up ... (unhealthy)
    

    처음 이 상태를 보면 자연스럽게 이런 생각이 든다.

    프로세스가 살아 있는데 왜 Docker는 이 컨테이너가 건강하지 않다고 판단하는 걸까?

    나도 처음에는 runninghealthy가 거의 같은 의미라고 생각했다.

    그런데 Docker의 Health Check가 무엇을 확인하는지를 다시 보면서 두 상태가 애초에 다른 질문에 대한 답이라는 것을 이해하게 됐다.

    Docker에서 컨테이너가 실행 중이라는 것은 기본적으로 컨테이너의 메인 프로세스가 종료되지 않았다는 것에 가깝다.

    반면 Health Check는 그 프로세스가 살아 있다는 사실에서 한 단계 더 나아가 우리가 미리 정의한 검사 명령을 실제로 통과하고 있는지를 본다. Docker 공식 문서에서도 Health Check는 프로세스가 살아 있어도 웹 서버가 무한 루프 등에 빠져 새로운 연결을 처리하지 못하는 상황을 감지할 수 있는 별도의 상태 검사라고 설명한다. Health Check가 설정되면 컨테이너는 일반 실행 상태와 별도로 starting, healthy, unhealthy 상태를 갖는다.

    이 차이를 이해하고 나니 unhealthy라는 상태를 보는 방식도 달라졌다.

    Docker가 컨테이너 프로세스가 죽었다고 말하는 것이 아니라, 내가 정의한 “정상 조건”을 현재 컨테이너가 만족하지 못하고 있다고 말하는 것이었다.


    처음에는 Health Check를 단순히 /health 한 번 호출하는 기능으로 생각했다

    FastAPI 서비스를 운영한다면 Health Check는 보통 복잡하게 시작하지 않는다.

    예를 들어 애플리케이션에 이런 endpoint를 만든다.

    from fastapi import FastAPI
    
    app = FastAPI()
    
    
    @app.get("/health")
    async def health():
        return {
            "status": "ok"
        }
    

    그리고 Docker에서는 일정 시간마다 이 주소를 호출한다.

    Compose라면 대략 다음과 같은 형태다.

    services:
      api:
        build: .
        healthcheck:
          test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
          interval: 30s
          timeout: 5s
          retries: 3
          start_period: 20s
    

    처음 보면 구조가 아주 단순하다.

    Docker
       ↓
    /health 호출
       ↓
    200 OK
       ↓
    healthy
    

    그래서 Health Check가 실패하면 자연스럽게 FastAPI 서버 자체가 죽었다고 생각하기 쉽다.

    그런데 실제로는 /health 요청 하나가 성공하기까지 여러 조건이 숨어 있다.

    우선 컨테이너 안에 curl 명령이 실제로 존재해야 한다.

    FastAPI가 8000 포트에서 정말 열려 있어야 한다.

    Health Check가 실행되는 위치에서 localhost:8000으로 접근할 수 있어야 한다.

    /health라는 경로가 실제로 존재해야 한다.

    그 endpoint가 인증이나 Query Parameter를 요구하지 않아야 한다.

    응답이 timeout 안에 돌아와야 한다.

    그리고 curl -f를 사용했다면 HTTP 오류 응답도 Health Check 실패로 처리될 수 있다. curl 공식 문서에서 -f/--fail은 HTTP 400 이상 응답을 오류로 처리하고 non-zero exit code를 반환하도록 설명되어 있다.

    결국 화면에서는 단순히:

    unhealthy
    

    라고 한 단어만 보이지만 그 한 단어 뒤에는 여러 종류의 실패가 숨어 있을 수 있었다.


    Health Check에서 422가 나왔을 때 처음에는 더 혼란스러웠다

    실제로 서비스를 구성하는 과정에서 Health Check 요청이 422로 떨어지는 상황을 본 적이 있었다.

    이게 처음에는 꽤 이상하게 느껴졌다.

    FastAPI 서버는 정상적으로 실행되고 있었다.

    프로세스가 죽은 것도 아니었다.

    그런데 Docker가 상태를 확인하려고 요청을 보내면 HTTP 422가 돌아왔다.

    이런 상황에서는 얼핏 보면 Docker Health Check 자체가 잘못된 것처럼 느껴진다.

    하지만 조금 다르게 생각해보면 중요한 단서가 하나 있다.

    HTTP 응답이 422로 돌아왔다는 것은 적어도 FastAPI 서버까지 요청이 도착했다는 뜻이다.

    포트 자체에 연결하지 못했다면 전혀 다른 형태의 연결 오류가 나왔을 가능성이 높다.

    서버가 요청을 받았고 FastAPI가 HTTP 응답까지 만들었다.

    그런데 그 응답이 200이 아니라 422였다.

    그렇다면 문제를 바라보는 위치가 달라진다.

    Docker
       ↓
    네트워크 연결
       ↓
    FastAPI까지 요청 도착
       ↓
    FastAPI가 요청 검증
       ↓
    422 반환
    

    이 구조라면:

    Docker가 FastAPI에 연결하지 못한다.

    보다:

    Health Check가 보내는 요청이 해당 FastAPI endpoint가 기대하는 요청 형식과 맞지 않는 것은 아닐까?

    라는 질문이 더 자연스럽다.

    당시의 정확한 endpoint 정의와 실패 조건을 지금 여기서 기억에 의존해 원인으로 확정하고 싶지는 않다.

    하지만 이 경험 때문에 한 가지는 분명해졌다.

    Health Check 실패라는 결과만 보고 서버가 죽었다고 판단하면 문제를 완전히 잘못된 위치에서 찾을 수 있다는 것이다.


    예를 들어 /health가 평범한 Health Check가 아니었다면 문제가 생길 수 있다

    가장 단순한 Health Check는 이런 형태다.

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

    아무것도 필요하지 않다.

    그냥 요청하면 된다.

    하지만 만약 endpoint가 다음처럼 만들어져 있다고 생각해보자.

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

    이 함수에서 lib_code에는 기본값이 없다.

    FastAPI는 이를 필수 Query Parameter로 해석할 수 있다.

    그런데 Docker는 다음 주소만 호출한다.

    http://localhost:8000/health
    

    그러면 Health Check가 기대하는 것은 단순하다.

    GET /health
    

    하지만 FastAPI endpoint가 기대하는 것은:

    GET /health?lib_code=...
    

    에 가깝다.

    결국 Docker 입장에서 보면 서버까지 연결은 성공했지만 Health Check 명령은 성공하지 못한다.

    curl -f를 사용한다면 4xx 응답은 실패 exit code로 처리되기 때문에 Docker는 이 Health Check를 실패로 기록할 수 있다.

    이 상황이 반복되면 컨테이너는 계속 실행되고 있음에도 unhealthy가 될 수 있다.

    이 예를 생각하고 나니 Health Check endpoint는 일반적인 비즈니스 API와 조금 다르게 설계해야겠다는 생각이 들었다.


    Health Check에는 왜 사용자 정보가 필요하지 않아야 할까

    예를 들어 일반 검색 API에는 사용자 정보가 필요할 수 있다.

    @app.get("/search")
    async def search(
        keyword: str,
        x_user_id: str = Header(...)
    ):
        ...
    

    이건 자연스럽다.

    검색 로그를 사용자별로 구분해야 할 수도 있고 사용 제한을 적용할 수도 있기 때문이다.

    그런데 Health Check에도 같은 Header를 요구한다면 이야기가 달라진다.

    Docker가 알고 싶은 것은:

    이 사용자가 누구인가?

    가 아니다.

    이 애플리케이션이 지금 요청을 받을 수 있는 상태인가?

    다.

    사용자 인증이나 비즈니스 데이터는 Health Check의 목적과 관계가 없다.

    그런데 일반 API에서 사용하던 dependency를 그대로 Health Check에도 적용하면 의도하지 않게 인증 Header나 DB 데이터가 필요한 endpoint가 될 수 있다.

    이렇게 되면 Health Check는 애플리케이션 자체의 생존 여부보다 훨씬 많은 것을 검사하기 시작한다.

    그리고 검사 대상이 많아질수록 unhealthy가 의미하는 것도 모호해진다.


    여기서 “어디까지 확인해야 건강한 것인가?”라는 질문이 생겼다

    처음에는 /health가 정상적으로 200을 반환하기만 하면 충분하다고 생각했다.

    그런데 서비스 구조를 생각해보면 FastAPI 프로세스만 살아 있어도 실제 서비스는 사용할 수 없는 경우가 있다.

    예를 들어 내 API가 PostgreSQL 없이는 아무것도 할 수 없다고 하자.

    FastAPI는 실행 중이다.

    /health도 그냥 문자열만 반환하기 때문에 정상이다.

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

    하지만 PostgreSQL은 죽어 있다.

    실제 사용자 검색 요청은 전부 실패한다.

    그런데 Health Check는 계속 이렇게 말한다.

    healthy
    

    그러면 이번에는 반대 문제가 생긴다.

    Health Check를 너무 단순하게 만들어서 애플리케이션 프로세스가 살아 있다는 사실만 확인하고 실제 서비스 가능 여부는 확인하지 못하고 있는 것이다.

    그래서 자연스럽게 이런 생각으로 이어진다.

    그렇다면 /health에서 PostgreSQL도 확인하고 Elasticsearch도 확인하고 외부 LLM API도 전부 확인해야 하는 것 아닐까?

    처음에는 이쪽이 훨씬 완벽해 보인다.


    그런데 모든 외부 의존성을 검사하면 또 다른 문제가 생긴다

    예를 들어 Health Check를 다음처럼 구성한다고 생각해보자.

    /health 호출
       ↓
    PostgreSQL 확인
       ↓
    Elasticsearch 확인
       ↓
    Redis 확인
       ↓
    외부 AI API 확인
       ↓
    모두 성공하면 200
    

    이렇게 하면 정말 서비스 전체가 정상인지 확인하는 것처럼 보인다.

    하지만 다시 생각해보면 Health Check 자체가 상당히 무거워졌다.

    30초마다 실행한다고 하면 PostgreSQL에도 30초마다 요청이 간다.

    Elasticsearch에도 간다.

    외부 API까지 호출한다면 불필요한 네트워크 요청이나 비용이 발생할 수도 있다.

    더 큰 문제는 외부 서비스 하나가 일시적으로 느려졌을 때다.

    FastAPI 프로세스에는 아무 문제가 없는데 외부 시스템의 응답이 늦어서 Health Check timeout을 넘는다.

    그러면 Docker는 FastAPI 컨테이너를 unhealthy로 기록한다.

    즉:

    외부 서비스 문제
            ↓
    Health Check 실패
            ↓
    FastAPI unhealthy
    

    가 된다.

    이것이 원하는 의미인지 생각해야 한다.

    내 애플리케이션 자체가 죽은 것과, 특정 외부 의존성이 일시적으로 사용할 수 없는 것은 운영 관점에서 서로 다른 문제일 수 있기 때문이다.


    그래서 Health Check 하나에 너무 많은 의미를 넣으면 안 된다고 생각하게 됐다

    이 지점에서 Health Check의 목적을 조금 나눠서 생각하는 편이 자연스러웠다.

    가장 단순한 검사는 이런 질문에 답한다.

    FastAPI 프로세스가 HTTP 요청을 받을 수 있는가?

    이 경우에는 매우 가벼운 endpoint면 충분하다.

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

    여기에는 DB Query도 필요하지 않고 인증도 필요하지 않다.

    반면 운영 모니터링에서는 별도의 질문도 중요하다.

    PostgreSQL에 연결할 수 있는가?

    Elasticsearch가 검색 요청을 받을 수 있는가?

    실제 핵심 기능을 수행할 수 있는가?

    이것들은 더 깊은 상태 확인이다.

    나는 지금 다시 구성한다면 이 모든 질문을 하나의 /health에 몰아넣기보다 목적에 따라 검사 수준을 나누는 방법을 먼저 생각할 것 같다.

    예를 들어 애플리케이션 자체의 가벼운 상태 확인과 외부 의존성까지 확인하는 상태를 분리하는 식이다.

    이렇게 해야 unhealthy가 떴을 때 그 상태가 무엇을 의미하는지도 더 명확해진다.


    starting 상태가 존재하는 이유도 실제로 생각해보면 자연스러웠다

    Health Check를 처음 켜면 컨테이너가 즉시 healthy가 되는 것이 아니라 starting 상태를 거친다.

    Docker 공식 문서에서는 Health Check가 있는 컨테이너는 처음 starting 상태를 가지며, 검사 성공 시 healthy, 정해진 횟수만큼 연속 실패하면 unhealthy로 전환된다고 설명한다.

    처음에는 이걸 단순한 상태 표시 정도로 생각했다.

    그런데 실제 애플리케이션 부팅 과정을 보면 starting이 왜 필요한지 이해하기 쉽다.

    컨테이너 프로세스가 시작됐다고 해서 애플리케이션이 바로 요청을 받을 준비가 끝나는 것은 아니다.

    FastAPI를 띄우기 전에 설정 파일을 읽을 수도 있다.

    DB Pool을 초기화할 수도 있다.

    모델이나 데이터를 메모리에 올릴 수도 있다.

    여러 초기화 작업이 끝난 뒤에야 실제 API가 정상적으로 동작하는 서비스도 있다.

    즉:

    Container Process Started
    

    와:

    Application Ready
    

    사이에 시간이 존재할 수 있다.

    Docker Compose 공식 문서도 docker compose up이 기본적으로 컨테이너가 running이 되는 것만 기다릴 뿐 내부 서비스가 실제 요청을 받을 준비가 끝나는 것까지 자동으로 기다리는 것은 아니라고 설명한다. 이런 경우 healthcheckdepends_on: condition: service_healthy를 이용해 의존 서비스의 준비 상태를 기준으로 다음 서비스를 시작할 수 있다.

    이 설명을 보고 나니 예전에 생각했던:

    컨테이너가 떴는데 왜 DB 연결에 실패하지?

    같은 현상도 이해하기 쉬워졌다.

    프로세스의 시작 순서와 서비스가 실제로 준비되는 순서는 같은 것이 아니었다.


    그래서 start_period를 단순 대기시간으로 보면 부족했다

    예를 들어 애플리케이션 부팅에 평소 15초 정도가 필요한데 Health Check가 컨테이너 시작 직후부터 실패를 세기 시작한다고 하자.

    아직 애플리케이션이 정상적으로 초기화 중인데 Docker 입장에서는:

    첫 검사 실패
    두 번째 검사 실패
    세 번째 검사 실패
    

    라고 기록할 수 있다.

    그러면 서비스가 정상적으로 준비되기도 전에 unhealthy가 될 수 있다.

    이 문제를 완화하기 위해 Docker Health Check에는 start_period가 있다.

    Docker 공식 문서에서는 start_period를 컨테이너가 초기화될 시간을 주는 옵션으로 설명하며, 이 기간 중의 probe 실패는 retry 횟수에 포함하지 않는다. 다만 이 기간 안에 한 번 성공하면 컨테이너가 시작된 것으로 보고 이후 실패는 정상적으로 계산한다.

    처음에는:

    start_period: 30s
    

    을 그냥 “30초 기다렸다 검사한다”고 이해하기 쉬웠다.

    하지만 실제 의미를 보면 조금 더 세밀하다.

    초기화 중 발생할 수 있는 정상적인 실패를 즉시 서비스 장애로 판단하지 않도록 유예 기간을 제공하는 것에 가깝다.

    이 차이를 이해하면 값을 정할 때도 단순히 크게 잡기보다 실제 애플리케이션이 정상적으로 부팅되는 시간을 먼저 측정하고 싶어진다.


    그렇다면 start_period를 아주 크게 잡으면 안전할까

    여기까지 생각하면 또 극단적인 해결책이 떠오른다.

    애플리케이션이 준비되는 데 시간이 필요하다면:

    start_period: 5m
    

    처럼 아주 크게 만들면 초기화 중 unhealthy가 되는 문제를 피할 수 있을 것 같다.

    하지만 그만큼 실제로 애플리케이션이 시작 단계에서 실패했을 때 문제를 발견하는 시간도 늦어질 수 있다.

    예를 들어 애플리케이션이 정상적으로는 10초 안에 준비되는데 잘못된 설정 때문에 영원히 제대로 실행되지 않는다고 하자.

    start_period를 과도하게 길게 잡으면:

    실제 문제 발생
        ↓
    하지만 아직 start period
        ↓
    실패가 retry에 반영되지 않음
        ↓
    문제 판단 지연
    

    이라는 상황이 될 수 있다.

    결국 start_period 역시:

    클수록 안전하다.

    가 아니라:

    정상적인 초기화에 필요한 시간은 허용하되, 비정상적인 초기화 실패는 너무 늦지 않게 발견할 수 있는 값

    으로 보는 편이 더 자연스러웠다.


    interval, timeout, retries도 각각 왜 있는지를 생각해야 했다

    Health Check 설정을 보면 숫자가 여러 개 있다.

    healthcheck:
      interval: 30s
      timeout: 5s
      retries: 3
    

    처음에는 인터넷 예제를 보고 거의 그대로 복사하기 쉽다.

    나 역시 이런 값들은 그냥 무난한 기본 설정 정도로 보기 쉬웠다.

    그런데 각각이 어떤 질문에 대한 설정인지 생각해보면 의미가 달라진다.

    interval은:

    얼마나 자주 상태를 확인할 것인가?

    에 대한 값이다.

    timeout은:

    검사 하나가 얼마나 오래 걸리면 실패라고 판단할 것인가?

    다.

    retries는:

    일시적인 한 번의 실패를 바로 장애로 볼 것인지, 몇 번 연속 실패해야 상태를 바꿀 것인지?

    를 결정한다.

    Docker 공식 문서에서도 Health Check 명령이 timeout을 넘기면 실패로 처리되고, 설정된 retries만큼 연속 실패해야 unhealthy가 된다고 설명한다.

    세 값을 따로 생각하면 Health Check가 결국 얼마나 민감하게 장애를 판단할 것인지 설정하는 것이라는 생각이 들었다.


    너무 민감한 Health Check는 정상 서비스도 unhealthy로 만들 수 있다

    예를 들어 평소 /health가 100ms 안에 응답하지만 가끔 서버 부하가 올라가 1초 정도 걸릴 수 있다고 하자.

    그런데:

    timeout: 500ms
    

    로 설정했다면 정상적으로 조금 느려진 상황도 실패로 볼 수 있다.

    interval도 매우 짧고 retries도 1이라면 일시적인 지연 한 번만으로 바로 unhealthy가 될 수 있다.

    반대로:

    interval: 10m
    retries: 10
    

    처럼 지나치게 느슨하면 실제 장애를 발견하는 데 너무 오래 걸릴 수 있다.

    그래서 Health Check 수치를 정할 때도 단순히 “30초, 5초, 3회가 일반적이다”라고 끝내면 부족하다.

    실제 애플리케이션이 정상일 때 /health가 어느 정도 시간 안에 응답하는지,

    순간적인 지연이 어느 정도 발생하는지,

    장애를 몇 초 안에 발견해야 하는지,

    검사 자체가 시스템에 어느 정도 부하를 만드는지를 생각해야 한다.

    결국 이 값들도 서비스의 정상 동작을 먼저 알아야 정할 수 있는 숫자였다.


    그런데 unhealthy의 원인은 애플리케이션 코드가 아닐 수도 있었다

    Health Check가 실패하면 개발자는 자연스럽게 FastAPI 코드를 먼저 본다.

    하지만 Health Check 명령 자체가 실행되지 않는 경우도 있다.

    예를 들어 Dockerfile을 가볍게 만들기 위해 최소한의 Python 이미지로 구성했고 그 안에 curl을 설치하지 않았다고 하자.

    그런데 healthcheck는 다음과 같다.

    test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
    

    FastAPI에는 아무 문제가 없다.

    /health도 정상이다.

    하지만 컨테이너 안에서 curl이라는 명령을 실행할 수 없다.

    그러면 Health Check는 실패한다.

    즉:

    FastAPI 정상
    
    /health 정상
    
    하지만
    
    healthcheck 명령 실행 불가
    
    → unhealthy
    

    가 가능하다.

    이걸 생각하고 나니 Health Check가 실패했을 때 가장 먼저 해야 할 일은 /health 코드를 수정하는 것이 아니라 Docker가 실제로 어떤 명령을 실행했고 그 명령이 왜 실패했는지를 직접 확인하는 것이라는 생각이 들었다.

    Docker 공식 문서는 Health Check 명령이 출력한 stdout/stderr 일부를 health status에 저장하며 docker inspect를 통해 확인할 수 있다고 설명한다.


    그래서 docker inspect가 훨씬 중요해졌다

    컨테이너가 unhealthy라고 보이면 docker ps만 계속 볼 필요는 없다.

    docker ps는 결과만 보여준다.

    내가 알고 싶은 것은 왜 실패했는가다.

    현재 상태를 확인하려면 예를 들어:

    docker inspect container_name
    

    으로 정보를 볼 수 있고 Health 관련 부분만 확인하도록 출력 범위를 줄일 수도 있다.

    Health Check 로그에는 검사 결과와 출력이 남기 때문에 이런 상황을 구분할 수 있다.

    curl: command not found
    

    인지,

    Connection refused
    

    인지,

    HTTP 422
    

    인지,

    Operation timed out
    

    인지에 따라 봐야 할 위치가 전혀 다르다.

    이 네 가지는 전부 최종적으로는:

    unhealthy
    

    라는 한 단어가 되지만 원인은 각각 다르다.

    curl이 없다면 이미지 구성 문제다.

    Connection refused라면 프로세스, 포트, bind 주소, 시작 시점 등을 본다.

    HTTP 422라면 요청 형식과 endpoint 정의를 본다.

    Timeout이라면 endpoint 안에서 무엇을 기다리고 있는지 본다.

    이렇게 생각하면 unhealthy는 오류의 원인이 아니라 여기서부터 조사를 시작하라는 상태 표시에 더 가까웠다.


    Health endpoint에서 DB까지 확인하도록 만들었다면 timeout도 다시 생각해야 했다

    예를 들어 /health를 다음처럼 만든다고 생각해보자.

    @app.get("/health")
    async def health():
        async with pool.acquire() as conn:
            await conn.fetchval("SELECT 1")
    
        return {"status": "ok"}
    

    이제 이 endpoint는 FastAPI만 확인하지 않는다.

    PostgreSQL Connection Pool에서 Connection을 하나 가져오고 실제 Query가 성공해야 200을 반환한다.

    이 방식의 장점은 분명하다.

    PostgreSQL까지 사용할 수 있어야 healthy가 된다.

    그런데 앞에서 Connection Pool 문제를 생각했던 내용을 연결해보면 한 가지 상황이 떠오른다.

    실제 사용자 요청이 급증해서 Connection Pool이 모두 사용 중이다.

    Health Check가 /health를 호출한다.

    /health도 Connection을 얻기 위해 기다린다.

    Health Check timeout은 3초다.

    3초 안에 Connection을 얻지 못한다.

    Health Check 실패.

    높은 DB 부하
        ↓
    Health Check도 DB Connection 대기
        ↓
    timeout
        ↓
    unhealthy
    

    여기에서 컨테이너가 unhealthy가 된 것은 틀린 정보일까?

    완전히 틀렸다고 하기도 어렵다.

    실제 서비스가 DB Connection을 제때 확보하지 못하고 있으니 사용자 요청도 느려지고 있을 수 있기 때문이다.

    하지만 이것을 FastAPI 프로세스 자체가 죽었다고 해석하면 역시 잘못이다.

    이처럼 Health Check endpoint가 무엇을 검사하느냐에 따라 unhealthy가 의미하는 범위가 달라진다.


    이때부터 Health Check는 “코드 한 줄”이 아니라 운영 정책이라고 생각하게 됐다

    처음에는 다음 한 줄이 Health Check라고 생각했다.

    test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
    

    그런데 실제로는 이 한 줄보다 그 뒤의 질문들이 더 중요했다.

    /health가 무엇을 검사하는가.

    HTTP 서버만 보는가.

    DB도 보는가.

    Elasticsearch도 보는가.

    외부 AI API도 보는가.

    얼마나 늦으면 실패인가.

    몇 번 실패하면 장애인가.

    부팅 중에는 얼마나 기다려줄 것인가.

    Health Check가 실패한 뒤 어떤 시스템이 그 상태를 이용하는가.

    Docker Compose에서는 의존 서비스의 Health Check를 service_healthy 조건으로 이용해 다른 서비스를 시작할 수도 있다. 즉 Health Check 결과는 단순 화면 표시를 넘어 서비스 시작 순서에도 영향을 줄 수 있다.

    이걸 보고 나니 Health Check를 그냥 “Docker에 넣는 좋은 설정 하나” 정도로 취급하면 안 된다는 생각이 들었다.

    무엇을 정상이라고 정의할 것인지 결정하는 운영 정책에 가까웠다.


    정상 기준을 너무 엄격하게 만들면 장애가 전파될 수도 있다

    예를 들어 FastAPI가 PostgreSQL과 Elasticsearch를 사용한다고 하자.

    Health Check가 둘 다 확인한다.

    FastAPI OK
    PostgreSQL OK
    Elasticsearch 실패
    

    그러면 컨테이너는 unhealthy가 된다.

    이 상태를 다른 자동화가 보고 해당 컨테이너를 계속 재시작하도록 구성되어 있다면 어떻게 될까?

    Elasticsearch가 잠깐 느렸던 문제 때문에 정상적인 FastAPI 프로세스까지 반복적으로 재시작될 수 있다.

    그 과정에서 DB Pool도 다시 만들어지고 진행 중인 요청도 영향을 받을 수 있다.

    즉 Health Check가 실제 문제를 감지한 것은 맞지만 문제에 대한 대응 방법이 적절한지는 또 다른 문제다.

    이 부분 때문에 Health Check와 restart policy를 하나로 생각하면 안 된다고 느꼈다.

    Health Check는 상태를 판단한다.

    그 상태를 보고 무엇을 할지는 별도의 운영 정책이다.


    반대로 너무 단순한 Health Check는 거짓으로 healthy라고 말할 수도 있다

    반대 상황도 존재한다.

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

    이 endpoint는 거의 실패하지 않을 수 있다.

    FastAPI event loop가 요청 자체를 받을 수만 있으면 200을 반환한다.

    그런데 실제 핵심 검색 API는 Elasticsearch 연결 문제 때문에 전부 500을 반환하고 있다고 하자.

    사용자 입장에서는 서비스 장애다.

    Health Check 입장에서는:

    healthy
    

    다.

    이런 상태를 보면 Health Check를 복잡하게 만들고 싶은 유혹이 다시 생긴다.

    그래서 결국 완벽한 하나의 endpoint를 만드는 문제라기보다 어떤 수준의 건강 상태를 어떤 목적으로 측정할 것인지 나누는 문제에 가깝다고 생각한다.


    지금 다시 만든다면 최소한 애플리케이션 자체 확인은 최대한 단순하게 둘 것 같다

    지금의 내가 Docker 자체 Health Check를 만든다면 기본 endpoint는 상당히 가볍게 유지하려고 할 것 같다.

    예를 들어:

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

    이 endpoint에는 사용자 Header를 요구하지 않는다.

    검색 Query Parameter도 필요하지 않다.

    사용자 인증도 붙이지 않는다.

    LLM API도 호출하지 않는다.

    그리고 가능하면 이 endpoint 자체가 다른 복잡한 서비스 로직에 의존하지 않도록 한다.

    이렇게 하는 이유는 단순히 구현이 쉽기 때문이 아니다.

    Docker가 unhealthy라고 말했을 때 의미를 명확하게 하고 싶기 때문이다.

    이 FastAPI 애플리케이션이 최소한 HTTP 요청에 응답할 수 있는가?

    에 집중한다.

    그다음 PostgreSQL, Elasticsearch 같은 의존성은 별도의 상세 상태 검사나 모니터링으로 확인하는 구조를 고려한다.


    그러면 422 같은 오류도 훨씬 줄이기 쉬워진다

    Health Check endpoint에 입력값이 없으면 Docker가 어떤 요청을 만들어야 할지 고민할 필요가 거의 없다.

    GET /health
    

    하나면 된다.

    반대로 Health endpoint가 일반 비즈니스 API와 같은 dependency 구조를 사용하면서:

    X-User-ID 필요
    
    libCode 필요
    
    Query Parameter 필요
    

    같은 요구사항이 붙기 시작하면 Docker Compose의 Health Check에도 그 모든 조건을 맞춰줘야 한다.

    그리고 애플리케이션 요구사항이 바뀔 때 Health Check 설정까지 같이 변경해야 한다.

    Health Check의 목적에 필요하지 않은 입력값 때문에 422가 발생한다면 운영상 불필요한 결합이 생긴 셈이다.

    그래서 예전에 Health Check에서 422를 봤던 경험을 다시 생각하면, 단순히:

    curl URL을 잘못 썼다.

    정도로만 기억하기보다 Health Check endpoint가 정말 Health Check에 필요한 만큼만 단순했는가라는 질문을 같이 하게 된다.


    직접 확인할 때도 Docker가 실행하는 환경에서 확인해야 했다

    호스트에서 다음 요청이 성공한다고 하자.

    curl http://localhost:8000/health
    

    그렇다고 Docker Health Check도 반드시 성공한다고 단정할 수는 없다.

    Health Check 명령은 컨테이너 안에서 실행된다. Docker 공식 HEALTHCHECK 문서도 검사 명령을 컨테이너 내부에서 실행해 상태를 판단한다고 정의한다.

    따라서 문제를 재현하려면 실제 컨테이너 안에서 같은 명령을 실행해보는 편이 더 직접적이다.

    docker exec -it container_name \
      curl -f http://localhost:8000/health
    

    이렇게 했을 때도 실패한다면 Docker가 Health Check에서 보고 있는 환경과 훨씬 가까운 조건에서 확인할 수 있다.

    여기서:

    curl 자체가 없음
    

    을 발견할 수도 있고,

    Connection refused
    

    를 볼 수도 있고,

    실제 FastAPI의:

    422 Unprocessable Entity
    

    를 볼 수도 있다.

    나는 지금이라면 unhealthy가 떴을 때 설정 파일을 바로 수정하기 전에 Docker가 실행하고 있는 Health Check 명령을 컨테이너 내부에서 그대로 한번 실행해보는 것부터 시작할 것 같다.

    복잡한 추측보다 훨씬 빠르게 문제 범위를 좁힐 수 있기 때문이다.


    결국 중요한 것은 healthy라는 초록색 상태 자체가 아니었다

    Health Check를 처음 적용할 때는 목표가 단순했다.

    docker ps에서:

    healthy
    

    라고 보이면 성공이고,

    unhealthy
    

    라고 보이면 실패라고 생각했다.

    그런데 운영 관점에서 보면 더 중요한 질문이 남는다.

    무엇을 확인했기 때문에 healthy라고 말할 수 있는가?

    아무것도 하지 않고 200 OK만 반환한다면 프로세스 생존 확인에는 도움이 되지만 실제 의존성 문제는 알 수 없다.

    반대로 DB, Elasticsearch, 외부 API까지 모두 검사한다면 실제 서비스 가능 여부는 더 잘 볼 수 있지만 Health Check 자체가 무거워지고 외부 장애까지 애플리케이션 장애로 표현할 수 있다.

    정답 하나가 존재한다기보다 서비스에서 Health Check를 어떤 의미로 사용할지 결정해야 한다.

    그래서 지금은 Health Check를 만들 때 코드부터 쓰기보다 먼저 문장으로 정의해보는 편이 좋다고 생각한다.

    예를 들면:

    이 Health Check의 목적은 FastAPI 프로세스가 HTTP 요청을 받을 수 있는 상태인지 확인하는 것이다.

    라고 정했다면 /health는 단순하게 만든다.

    반대로:

    이 검사는 사용자의 핵심 검색 요청을 실제로 처리할 수 있는 상태인지 확인한다.

    가 목적이라면 PostgreSQL이나 Elasticsearch까지 포함해야 할 수 있다.

    두 검사는 이름은 모두 Health Check라고 부를 수 있지만 실제로 대답하는 질문은 다르다.


    마무리

    FastAPI 컨테이너가 unhealthy라고 표시됐을 때 처음에는 Docker가 애플리케이션에 문제가 있다고 판단했다고 생각했다.

    하지만 Health Check의 동작을 다시 보고 나니 표현을 조금 바꾸는 것이 더 정확했다.

    Docker가 알고 있는 것은 내가 Health Check에 작성한 명령뿐이다.

    그 명령을 실행하고 exit code가 성공이면 healthy, 연속해서 정해진 횟수만큼 실패하면 unhealthy로 상태를 바꾼다. 검사 명령의 exit status 0은 성공, 1은 unhealthy를 의미하며, 출력 내용은 일정 범위까지 docker inspect로 확인할 수 있다.

    결국:

    unhealthy
    

    라는 말은

    FastAPI 전체가 죽었다.

    라는 진단 결과가 아니다.

    더 정확하게는:

    내가 정의한 Health Check 명령이 계속 실패하고 있다.

    에 가깝다.

    그래서 지금 다시 같은 상황을 만나면 unhealthy라는 단어 자체를 고치려고 하지 않을 것 같다.

    먼저 Docker가 실제로 무슨 명령을 실행했는지 확인한다.

    그 명령을 컨테이너 안에서 직접 실행해본다.

    HTTP 응답이 있는지 본다.

    응답이 422라면 FastAPI 요청 형식과 endpoint 정의를 본다.

    연결 자체가 되지 않는다면 프로세스와 포트를 확인한다.

    Timeout이라면 Health endpoint가 내부에서 무엇을 기다리는지 본다.

    초기화 중에만 실패한다면 start_period가 실제 부팅시간과 맞는지 확인한다.

    그리고 그 모든 것을 보기 전에 한 가지를 먼저 정한다.

    내가 이 Health Check로 확인하고 싶은 “건강함”은 정확히 무엇인가?

    이 질문이 명확하지 않으면 /health가 200을 반환해도 그것이 무엇을 보장하는지 설명하기 어렵고, 반대로 unhealthy가 되어도 실제로 어느 부분이 고장났는지 알기 어렵다.

    결국 Docker Health Check는 단순히 운영 환경에 붙이는 한 줄짜리 검사 명령이 아니었다.

    서비스에서 무엇을 정상이라고 정의할 것인지 코드로 표현하는 작은 운영 규칙에 더 가까웠다.

    그리고 그 정의가 단순하고 명확할수록 문제가 생겼을 때도 unhealthy라는 결과에서 실제 원인까지 훨씬 자연스럽게 따라갈 수 있다고 생각한다.

  • PostgreSQL too many clients already 오류를 겪고 연결 구조를 다시 본 이유

    FastAPI로 백엔드 서비스를 운영하면서 PostgreSQL 연결 문제를 겪은 적이 있다. API 자체의 로직이나 SQL 문법에서 발생한 오류가 아니라, 어느 순간 PostgreSQL에 새로운 연결을 만들 수 없다는 메시지가 나타났다.

    로그에서 가장 눈에 들어온 문구는 다음과 같았다.

    too many clients already
    

    처음에는 메시지 그대로 받아들였다. PostgreSQL이 허용하는 최대 연결 수를 모두 사용했으니 max_connections를 늘리면 해결할 수 있지 않을까 생각했다.

    실제로 PostgreSQL에서 동시에 허용할 수 있는 연결 수에는 제한이 있기 때문에 이 접근 자체가 틀린 것은 아니다. 문제는 왜 현재 서비스가 그 한계까지 연결을 사용하게 되었는지 설명하지 못한 상태에서 제한값만 높이는 것이었다.

    당시 백엔드는 FastAPI로 구성되어 있었고 PostgreSQL 연결에는 asyncpg를 사용하고 있었다. Connection Pool 설정도 이미 적용하고 있었다.

    내가 사용하던 설정은 대략 다음과 같은 형태였다.

    pool = await asyncpg.create_pool(
        dsn=DATABASE_URL,
        min_size=1,
        max_size=50
    )
    

    처음에는 이 설정을 보고 크게 문제될 것이 없다고 생각했다. 최소 연결은 1개만 유지하고, 요청이 늘어날 때 최대 50개까지 사용할 수 있으니 애플리케이션 하나가 PostgreSQL 연결을 무제한으로 만들지는 않을 것이라고 생각했기 때문이다.

    그런데 too many clients already 오류를 겪고 나니 max_size=50이라는 숫자를 바라보는 방식부터 다시 생각해야 했다.


    처음에는 PostgreSQL의 max_connections가 부족하다고 생각했다

    PostgreSQL의 최대 연결 수는 다음 명령으로 확인할 수 있다.

    SHOW max_connections;
    

    max_connections는 PostgreSQL 서버가 동시에 허용하는 클라이언트 연결 수를 결정한다.

    오류 메시지만 놓고 보면 해결 방향은 간단해 보인다.

    현재 연결 수가 한계에 도달했다.
    → max_connections를 늘린다.
    → 다시 연결할 수 있게 된다.
    

    당장 장애를 해소해야 하는 상황이라면 실제로 이 방법이 필요할 수도 있다.

    나 역시 처음에는 max_connections를 상당히 크게 올리는 방안까지 검토했다. 숫자를 충분히 크게 만들어 놓으면 적어도 같은 오류는 다시 발생하지 않을 것처럼 보였다.

    하지만 여기서 한 가지 의문이 생겼다.

    FastAPI 애플리케이션에서 이미 Connection Pool을 사용하고 있는데, 왜 PostgreSQL 연결 한계가 문제가 될 정도로 연결 수가 늘어난 것일까?

    이 질문을 해결하지 않은 상태에서 max_connections만 계속 높이는 것은 문제를 뒤로 미루는 것에 가까웠다.

    연결 수를 100에서 300으로 늘렸는데 애플리케이션 구조상 연결이 계속 증가한다면 언젠가는 300도 부족해질 수 있다. 300을 1,000으로 올린다고 해서 연결을 사용하는 방식이 바뀌는 것은 아니다.

    그래서 이후부터는 PostgreSQL 설정값 자체보다 먼저 애플리케이션이 DB 연결을 어떻게 사용하고 있는지를 보게 됐다.


    Connection Pool을 쓴다고 연결 문제가 자동으로 해결되는 것은 아니었다

    Connection Pool을 처음 적용할 때는 상당히 단순하게 이해했다.

    매 API 요청마다 PostgreSQL에 새로운 연결을 만드는 것은 비용이 크기 때문에 일정 수의 Connection을 미리 만들어 두고 재사용한다는 개념이다.

    흐름을 단순하게 표현하면 다음과 같다.

    API 요청
        ↓
    Connection Pool
        ↓
    사용 가능한 Connection 대여
        ↓
    SQL 실행
        ↓
    Connection 반환
    

    이 구조라면 요청이 끝날 때마다 DB 연결 자체를 닫았다가 새로 연결하지 않아도 된다.

    FastAPI처럼 비동기 요청을 처리하는 서비스에서는 특히 유용하다.

    문제는 Pool을 사용한다는 사실 하나만으로 연결 관리가 올바르다고 판단하면 안 된다는 것이었다.

    Pool에는 적어도 몇 가지를 함께 봐야 한다.

    • Pool을 언제 생성하는가
    • 애플리케이션 안에 Pool이 몇 개 존재하는가
    • 하나의 Pool에서 최대 몇 개의 연결을 허용하는가
    • Connection을 사용한 뒤 정상적으로 반환하는가
    • FastAPI 프로세스가 몇 개인가
    • 동일한 애플리케이션이 여러 컨테이너에서 실행되고 있는가
    • 같은 PostgreSQL을 사용하는 다른 프로그램이 있는가

    내 설정에 보이는 max_size=50이라는 숫자도 이 전체 구조에서 봐야 했다.


    max_size=50은 PostgreSQL 전체 연결이 50개라는 뜻이 아니다

    이 부분이 연결 문제를 다시 보면서 가장 중요하게 느껴졌던 부분이다.

    다음과 같은 설정이 있다고 하자.

    pool = await asyncpg.create_pool(
        min_size=1,
        max_size=50
    )
    

    코드만 보면 최대 연결은 50개다.

    그래서 얼핏 보면 PostgreSQL의 max_connections가 100 이상이라면 충분해 보인다.

    하지만 이것은 Pool 하나를 기준으로 한 숫자다.

    FastAPI 애플리케이션이 여러 프로세스로 실행되고 있다면 각 프로세스에서 별도의 Pool이 만들어질 수 있다.

    예를 들어 개념적으로 worker가 4개이고 각 worker마다 최대 50개의 Connection을 가진 Pool이 만들어진다면 계산은 달라진다.

    50 connections
    ×
    4 workers
    =
    최대 200 connections
    

    여기에 애플리케이션을 Docker 컨테이너 두 개로 운영한다면 이론적인 최대치는 더 커질 수 있다.

    50
    × 4 workers
    × 2 containers
    =
    최대 400 connections
    

    물론 실제 서비스에서 항상 이 최대치까지 Connection을 사용하는 것은 아니다.

    하지만 중요한 것은 코드에 max_size=50이라고 적혀 있다는 이유만으로 PostgreSQL에는 최대 50개만 연결된다고 생각하면 안 된다는 것이다.

    Pool은 프로세스의 메모리 안에 존재하기 때문에 프로세스가 여러 개라면 Pool도 여러 개가 될 수 있다.

    이후부터는 Pool 크기를 정할 때 다음과 같이 생각하게 됐다.

    Pool max_size
    ×
    프로세스 수
    ×
    컨테이너 수
    ×
    DB를 사용하는 애플리케이션 수
    

    이 값을 정확한 최대 연결 수로 단정할 필요는 없지만 적어도 PostgreSQL 전체 연결 규모를 예상하는 기준으로는 훨씬 유용했다.


    요청마다 Pool을 만드는 코드가 없는지도 확인해야 한다

    Connection Pool과 관련해서 가장 먼저 확인해야 하는 부분 중 하나는 Pool을 생성하는 위치다.

    예를 들어 아래 코드처럼 DB 함수 내부에서 Pool을 생성한다고 생각해 보자.

    async def get_books():
        pool = await asyncpg.create_pool(
            dsn=DATABASE_URL,
            min_size=1,
            max_size=50
        )
    
        async with pool.acquire() as conn:
            rows = await conn.fetch(
                "SELECT * FROM books"
            )
    
        return rows
    

    코드만 보면 asyncpg.create_pool()을 사용하고 있기 때문에 Connection Pool을 제대로 사용하고 있는 것처럼 보인다.

    하지만 get_books()가 API 요청마다 실행된다면 요청이 들어올 때마다 새로운 Pool 객체를 만드는 구조가 된다.

    Connection을 재사용하기 위해 Pool을 도입했는데 정작 Pool 자체를 계속 새로 만드는 셈이다.

    그래서 Pool은 일반적으로 애플리케이션이 시작될 때 한 번 생성하고 여러 API에서 공유하는 구조가 이해하기 쉽다.

    FastAPI의 lifespan을 사용하면 다음과 같은 형태로 구성할 수 있다.

    from contextlib import asynccontextmanager
    
    import asyncpg
    from fastapi import FastAPI
    
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        app.state.db_pool = await asyncpg.create_pool(
            dsn=DATABASE_URL,
            min_size=1,
            max_size=50
        )
    
        yield
    
        await app.state.db_pool.close()
    
    
    app = FastAPI(lifespan=lifespan)
    

    애플리케이션이 시작되면 Pool을 만들고:

    FastAPI 시작
    → asyncpg Pool 생성
    

    API 요청에서는 이미 만들어진 Pool을 사용한다.

    @app.get("/books")
    async def get_books():
        async with app.state.db_pool.acquire() as conn:
            rows = await conn.fetch(
                "SELECT * FROM books LIMIT 10"
            )
    
        return rows
    

    그리고 애플리케이션이 종료될 때 Pool도 닫는다.

    FastAPI 종료
    → Pool close
    

    이렇게 하면 적어도 DB Pool의 생명주기가 애플리케이션의 생명주기와 맞아 떨어진다.

    Pool을 어디에서 만들고 있는지가 불명확하면 연결 수 문제를 추적할 때도 어려워진다.


    Connection을 가져온 뒤 제대로 반환하는지도 중요했다

    Pool 자체가 하나만 존재하더라도 Connection을 가져온 뒤 정상적으로 반환하지 않으면 문제가 생길 수 있다.

    asyncpg에서는 다음과 같이 Connection을 가져올 수 있다.

    conn = await pool.acquire()
    

    이렇게 직접 가져왔다면 사용이 끝난 뒤 Pool에 돌려줘야 한다.

    await pool.release(conn)
    

    하지만 실제 코드에서는 DB 처리 도중 예외가 발생할 수 있다.

    예를 들면:

    conn = await pool.acquire()
    
    rows = await conn.fetch(...)
    result = process(rows)
    
    await pool.release(conn)
    

    conn.fetch()process()에서 예외가 발생한다면 마지막의 release()까지 코드가 실행되지 않을 수 있다.

    그래서 직접 acquire/release를 관리한다면 적어도 다음처럼 정리할 필요가 있다.

    conn = await pool.acquire()
    
    try:
        rows = await conn.fetch(...)
        return rows
    finally:
        await pool.release(conn)
    

    또는 가능하다면 다음과 같이 context manager를 사용하는 편이 코드 흐름이 명확하다.

    async with pool.acquire() as conn:
        rows = await conn.fetch(...)
    

    async with 블록을 벗어나면 Connection을 Pool에 반환하는 구조이기 때문에 예외 처리와 연결 반환 코드를 직접 관리해야 하는 부담도 줄어든다.

    too many clients already 같은 문제를 겪고 나면 이런 코드가 단순한 스타일 차이로 보이지 않는다.

    Connection은 생성하는 것만큼 반환하는 것도 중요하다.


    PostgreSQL에서는 전체 숫자보다 연결 상태를 같이 보는 것이 좋았다

    연결 문제가 발생했을 때 PostgreSQL에서 현재 몇 개의 Connection이 존재하는지 보는 가장 기본적인 방법은 pg_stat_activity다.

    전체 연결 개수만 확인한다면:

    SELECT COUNT(*)
    FROM pg_stat_activity;
    

    조금 더 중요한 것은 상태별로 보는 것이다.

    SELECT
        state,
        COUNT(*)
    FROM pg_stat_activity
    GROUP BY state;
    

    환경에 따라 다음과 같은 상태가 보일 수 있다.

    active
    idle
    idle in transaction
    

    여기에서 각 상태를 무조건 정상이나 비정상으로 나눌 수는 없다.

    Connection Pool을 사용하면 사용하지 않는 Connection이 idle 상태로 존재하는 것은 자연스러운 경우도 있다. Pool 자체가 Connection을 유지하면서 다음 요청에서 재사용하기 때문이다.

    그래서 idle이 있다는 사실 자체가 문제는 아니다.

    대신 다음과 같이 봐야 한다.

    현재 서비스 규모와 Pool 설정을 고려했을 때 이 숫자가 설명 가능한가?

    예를 들어 애플리케이션 하나가 max_size=10인 Pool 하나만 가지고 있다고 생각했는데 PostgreSQL에는 동일한 애플리케이션에서 들어온 연결이 예상보다 훨씬 많이 보인다면 다음 질문으로 넘어갈 수 있다.

    혹시 Pool이 여러 개 만들어지고 있나?
    
    worker가 여러 개인가?
    
    같은 컨테이너가 여러 개 떠 있나?
    
    다른 서비스도 같은 DB를 사용하고 있나?
    

    pg_stat_activity는 문제의 정답을 바로 알려주는 도구라기보다 현재 애플리케이션 구조에 대한 내 예상과 실제 PostgreSQL 상태가 일치하는지를 확인하는 도구로 보는 것이 유용했다.


    어떤 프로그램이 연결하고 있는지도 같이 보면 좋다

    Connection 개수만으로 원인을 찾기 어렵다면 조금 더 자세히 볼 수 있다.

    예를 들어 다음과 같이 확인할 수 있다.

    SELECT
        pid,
        usename,
        application_name,
        client_addr,
        state,
        backend_start,
        query_start,
        query
    FROM pg_stat_activity
    ORDER BY backend_start;
    

    여기서 확인하고 싶은 것은 여러 가지다.

    어떤 사용자로 연결했는가
    
    어느 주소에서 들어왔는가
    
    언제 Connection이 만들어졌는가
    
    현재 어떤 상태인가
    
    어떤 Query를 실행하고 있는가
    

    특히 PostgreSQL 하나를 여러 서비스가 공유하고 있다면 단순한 연결 총합만으로는 어느 서비스가 많은 Connection을 사용하고 있는지 알기 어렵다.

    서비스별로 PostgreSQL 계정을 분리하거나 application_name을 구분해 놓으면 이런 상황에서 훨씬 분석하기 쉽다.

    운영하면서 문제가 생긴 뒤에야 로그와 식별 정보의 중요성을 느끼는 경우가 많다.

    평소에는 단순히 연결만 되면 충분해 보이지만 장애가 발생하면 다음 질문에 답할 수 있어야 한다.

    이 Connection은 어디에서 만들어진 것인가?


    idle in transaction은 별도로 보는 편이 좋다

    pg_stat_activity를 보다 보면 idle과 별개로 idle in transaction 상태가 존재할 수 있다.

    이 상태는 Transaction을 시작한 뒤 Commit 또는 Rollback이 끝나지 않은 채 클라이언트가 기다리고 있는 상황에서 나타날 수 있다.

    예를 들어:

    async with pool.acquire() as conn:
        tx = conn.transaction()
        await tx.start()
    
        await conn.execute(...)
    
        # commit 또는 rollback이 정상적으로 수행되지 않는 흐름
    

    Transaction 관리가 복잡한 코드에서는 예외가 발생했을 때 Transaction 정리가 누락되는지도 확인할 필요가 있다.

    가능하면 다음처럼 context manager를 이용하면 코드 흐름을 줄일 수 있다.

    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(...)
    

    물론 idle in transaction이 발견됐다고 해서 무조건 이것이 too many clients already의 원인이라고 단정해서는 안 된다.

    다만 연결 수 문제를 보고 있는데 이런 상태가 오래 유지되는 Connection이 많다면 추가로 확인해 볼 가치가 있다.


    max_connections를 무작정 크게 만들지 않으려고 한 이유

    PostgreSQL의 max_connections는 높이면 높일수록 좋은 성능 옵션이 아니다.

    Connection마다 PostgreSQL 서버의 리소스가 필요하고, 동시에 많은 Query가 실행되면 메모리 사용량과 CPU 부하도 같이 고려해야 한다.

    예를 들어 work_mem은 Query 실행 과정에서 정렬이나 해시 작업 등에 사용될 수 있다.

    단순히 다음처럼 생각하면 안 된다.

    RAM이 많다
    → Connection도 몇 천 개로 올리면 된다
    

    실제 메모리 사용량은 Query 패턴, 동시 실행 수, PostgreSQL 설정 등에 따라 달라지기 때문이다.

    그래서 too many clients already가 나타났을 때:

    max_connections를 100에서 300으로 올렸다
    → 해결
    

    이라고 끝내기보다,

    왜 100개 이상의 Connection이 필요했는가?
    

    를 먼저 설명할 수 있어야 한다고 생각하게 됐다.

    물론 트래픽이 실제로 증가했고 정상적인 동시 요청을 처리하기 위해 더 많은 Connection이 필요한 상황이라면 max_connections 자체를 조정하는 것이 맞을 수 있다.

    중요한 것은 연결 누수와 정상적인 확장을 구분하는 것이다.


    Pool을 무조건 크게 잡는 것도 좋은 방법은 아니었다

    Pool 최대 크기를 크게 설정하면 요청이 몰렸을 때 더 많은 Query를 동시에 처리할 수 있으니 성능이 좋아질 것처럼 보인다.

    하지만 DB가 처리할 수 있는 양보다 많은 Query를 동시에 밀어 넣는다고 전체 시스템의 처리량이 반드시 높아지는 것은 아니다.

    예를 들어:

    FastAPI 요청 500개
    ↓
    DB Connection 500개
    ↓
    PostgreSQL Query 500개 동시 실행
    

    보다 경우에 따라:

    FastAPI 요청 500개
    ↓
    DB Pool 20개
    ↓
    PostgreSQL에는 최대 20개씩 처리
    ↓
    나머지는 Pool에서 Connection 대기
    

    가 오히려 데이터베이스를 안정적으로 운영하는 데 도움이 될 수 있다.

    이 때문에 Pool은 단순히 “연결을 많이 만들어 주는 기능”이 아니라 DB에 들어가는 동시 작업량을 제한하는 역할도 한다.

    max_size=50 역시 절대적인 정답은 아니다.

    서비스의 Query 속도, 요청량, PostgreSQL 사양, worker 수 등을 보면서 결정해야 한다.


    FastAPI worker를 늘릴 때 DB 연결 수도 같이 봐야 한다

    웹 API 성능을 높이려고 worker를 추가할 때도 DB 연결 수를 같이 확인해야 한다.

    예를 들어 Pool 설정이 다음과 같다고 하자.

    max_size=50
    

    worker가 하나일 때와 8개일 때 DB가 보는 구조는 같지 않다.

    worker 1
    → Pool 최대 50
    

    반면 각 worker가 독립적으로 Pool을 생성한다면:

    worker 8
    → Pool 최대 50 × 8
    

    가 될 가능성이 있다.

    그래서 애플리케이션 프로세스를 늘릴 때 단순히 CPU 사용률이나 API 처리량만 봐서는 안 된다.

    내가 지금 확인하는 식은 다음에 가깝다.

    예상 DB Connection 수
    =
    Pool max_size
    ×
    FastAPI worker 수
    ×
    애플리케이션 인스턴스 수
    

    여기에 다른 백엔드와 배치 프로그램이 같은 PostgreSQL을 사용한다면 그것까지 추가한다.

    이 계산은 정확한 사용량을 예측하려는 목적보다는 설정값 사이에 큰 모순이 없는지 확인하기 위한 것이다.


    하나의 PostgreSQL을 여러 API가 공유하면 더 복잡해진다

    서비스가 커지면서 다음과 같은 구조가 될 수도 있다.

    검색 API ─┐
    추천 API ─┤
    관리 API ─┼── PostgreSQL
    배치 작업 ─┤
    로그 API ─┘
    

    각 애플리케이션에서 별도로 Connection Pool을 사용하면 모든 Pool의 최대 연결 수를 합해서 생각해야 한다.

    예를 들어 각 서비스에서 편하게:

    max_size=50
    

    으로 설정해 놓았다면 서비스 수가 늘어날수록 PostgreSQL 입장에서는 잠재적인 연결 수가 계속 증가한다.

    그래서 DB 연결은 각 애플리케이션 개발자가 독립적으로 정하는 값이라기보다 공유 인프라의 제한된 자원을 나누는 문제에 가깝다.

    예를 들면 전체 Connection 예산을 대략 다음처럼 보는 방식이다.

    PostgreSQL max_connections
    
    - 관리자 및 운영용 여유
    - 검색 API Pool
    - 추천 API Pool
    - 배치 프로그램
    - 기타 서비스
    
    = 전체 연결 예산
    

    이렇게 생각하면 새로운 서비스를 추가할 때도 기존 서비스의 Pool 설정까지 같이 보게 된다.


    지금 같은 오류를 다시 본다면 확인할 순서

    이후에는 too many clients already가 보인다고 바로 PostgreSQL 설정 파일부터 수정하지 않는다.

    먼저 현재 상태를 확인한다.

    1. PostgreSQL 최대 연결 수 확인

    SHOW max_connections;
    

    이 값은 현재 서버가 허용하는 연결 상한을 알려준다.


    2. 현재 사용 중인 Connection 수 확인

    SELECT COUNT(*)
    FROM pg_stat_activity;
    

    실제 Connection 수가 상한에 얼마나 가까운지 본다.


    3. 상태별로 나눠 확인

    SELECT
        state,
        COUNT(*)
    FROM pg_stat_activity
    GROUP BY state
    ORDER BY COUNT(*) DESC;
    

    active, idle, idle in transaction 등이 어떤 비율인지 확인한다.


    4. 어느 클라이언트에서 들어왔는지 확인

    SELECT
        usename,
        application_name,
        client_addr,
        state,
        COUNT(*)
    FROM pg_stat_activity
    GROUP BY
        usename,
        application_name,
        client_addr,
        state
    ORDER BY COUNT(*) DESC;
    

    하나의 서비스에서 Connection이 집중되고 있는지 확인하는 데 도움이 된다.


    5. asyncpg Pool 설정 확인

    코드에서 다음 설정을 찾는다.

    min_size
    max_size
    

    숫자만 확인하는 게 아니라 그 Pool이 몇 개의 프로세스에서 만들어지는지까지 같이 본다.


    6. create_pool 호출 위치 검색

    프로젝트 전체에서:

    asyncpg.create_pool
    

    이 어디에서 실행되는지 확인한다.

    애플리케이션 시작 시 한 번만 실행되는지, 요청 처리 함수 안에서 반복적으로 실행되는지 보는 것이다.


    7. Connection 반환 구조 확인

    다음 패턴이 있는지 확인한다.

    await pool.acquire()
    

    직접 acquire한다면 대응되는 release가 모든 코드 흐름에서 실행되는지도 본다.

    가능하면:

    async with pool.acquire() as conn:
    

    형태로 관리하면 확인하기가 상대적으로 쉽다.


    8. worker와 컨테이너 수 확인

    코드의 Pool 크기만 보고 끝내지 않는다.

    max_size
    × worker
    × container
    

    를 계산해서 현재 PostgreSQL 설정과 비교한다.


    오류를 겪고 나서 가장 크게 바뀐 생각

    이 문제를 보기 전에는 PostgreSQL 연결 수를 주로 서버 설정이라고 생각했다.

    max_connections는 PostgreSQL 설정이고 max_size는 asyncpg 설정이라고 각각 따로 봤다.

    하지만 실제 서비스에서는 둘이 완전히 연결되어 있다.

    FastAPI 요청
            ↓
    FastAPI worker
            ↓
    asyncpg Pool
            ↓
    PostgreSQL Connection
            ↓
    max_connections
    

    Docker를 사용한다면 그 위에 컨테이너 개수까지 추가된다.

    결국 PostgreSQL의 too many clients already 오류는 DB 설정 하나만의 문제가 아니라 애플리케이션 배포 구조 전체에서 Connection이 어떻게 만들어지는지를 보여주는 결과일 수 있다.

    이후부터는 같은 문제가 발생하면 단순히:

    DB 연결이 부족하다.
    

    라고 생각하지 않는다.

    대신 다음 질문부터 한다.

    현재 애플리케이션 구조를 기준으로 PostgreSQL에 몇 개의 Connection이 만들어질 수 있는가?

    이 질문에 답할 수 있어야 max_connections를 늘리는 것이 필요한 조치인지, Pool을 수정해야 하는지, worker 수를 조정해야 하는지 판단할 수 있다.


    내가 사용하는 PostgreSQL 연결 문제 체크리스트

    마지막으로 비슷한 상황에서 다시 확인하기 위해 정리한 항목이다.

    PostgreSQL

    • SHOW max_connections로 최대 연결 수를 확인했는가
    • pg_stat_activity로 현재 Connection 수를 확인했는가
    • active, idle, idle in transaction 상태를 구분해서 봤는가
    • 특정 사용자나 클라이언트에서 Connection이 집중되는지 확인했는가
    • 장시간 유지되는 비정상적인 Connection이 있는지 확인했는가

    FastAPI / asyncpg

    • create_pool()이 애플리케이션 시작 시 한 번만 실행되는가
    • min_sizemax_size가 현재 트래픽에 적절한가
    • 직접 acquire()한 Connection을 정상적으로 release()하는가
    • 가능하면 async with pool.acquire()를 사용하고 있는가
    • Transaction이 정상적으로 commit 또는 rollback되는가

    배포 환경

    • FastAPI worker가 몇 개인가
    • Docker 컨테이너가 몇 개인가
    • 각 프로세스에서 독립적으로 Pool을 만들고 있는가
    • 같은 PostgreSQL을 사용하는 다른 서비스가 있는가
    • 모든 서비스의 Pool 크기를 합쳐도 DB가 감당할 수 있는 수준인가

    마무리

    too many clients already라는 메시지만 보면 해결 방법은 단순해 보인다.

    PostgreSQL Connection 부족
    → max_connections 증가
    

    나도 처음에는 그렇게 접근했다.

    하지만 FastAPI와 asyncpg Connection Pool을 함께 사용하는 환경에서는 PostgreSQL의 제한값만 봐서는 전체 구조를 이해하기 어렵다.

    Pool 하나의 max_size, FastAPI의 worker 수, Docker의 컨테이너 수, Connection 반환 방식, 같은 DB를 사용하는 다른 애플리케이션까지 모두 연결되어 있기 때문이다.

    그래서 지금은 too many clients already를 만나면 얼마까지 연결을 늘릴 수 있는가보다 왜 현재 연결 수가 여기까지 늘어났는가를 먼저 확인한다.

    max_connections를 늘려야 하는 상황도 분명히 있다. 하지만 그 결정을 하기 전에 현재 서비스가 몇 개의 Connection을 만들 수 있는 구조인지부터 설명할 수 있어야 한다.

    이 문제를 겪고 나서 데이터베이스 Connection Pool을 단순한 성능 옵션으로 보지 않게 됐다.

    Connection Pool은 PostgreSQL이라는 제한된 자원을 애플리케이션이 어떻게 나눠 사용할 것인지 결정하는 운영 설정에 더 가깝다.

    그리고 이 관점으로 바꾸고 나니 PostgreSQL 연결 문제를 볼 때 확인해야 할 위치도 훨씬 명확해졌다.

  • 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 정보를 기준으로 접근하면 상당히 체계적으로 원인을 찾을 수 있습니다.