블로그

  • Docker에서 Node.js가 메모리 문제로 종료됐을 때 Exit Code만 보지 않게 된 이유

    Docker 환경에서 Node.js 프로젝트를 빌드하다가 프로세스가 갑자기 종료된 적이 있었다.

    당시에는 Nest 기반 프로젝트를 빌드하는 과정에서 문제가 나타났고, 로그를 따라가다 보면 결국 눈에 들어오는 문구는 메모리와 관련된 것이었다.

    (아래와 같은 오류.. 수도 없이 봤을 것이다…)

    JavaScript heap out of memory
    

    그리고 프로세스는 정상적으로 끝나지 않았고 Exit Code 134가 남았다.

    처음에는 이 상황을 아주 단순하게 받아들였다.

    Docker 안에서 실행 중이던 Node.js가 메모리 문제로 죽었으니 컨테이너에 메모리가 부족했던 것이라고 생각하면 되지 않을까 싶었다.

    당시 서버에는 다른 컨테이너도 실행되고 있었고, Docker라는 환경 자체가 프로세스의 자원을 제한할 수 있다는 것도 알고 있었기 때문에 더 그렇게 생각했다.

    그래서 처음 머릿속에 떠오른 해결 방법도 자연스러웠다.

    Docker에 메모리를 더 주거나 서버 메모리를 확보하면 해결되지 않을까?

    그런데 로그를 다시 보면서 조금 이상한 점이 생겼다.

    에러 메시지가 단순히 운영체제에서 프로세스를 강제로 죽였다는 내용이 아니라 JavaScript heap 자체를 명확하게 이야기하고 있었기 때문이다.

    Node.js 공식 문서에도 V8이 heap 한계에 접근하면 garbage collection에 더 많은 시간을 사용하게 되고, 끝내 메모리를 확보하지 못하면 JavaScript heap out of memory 형태의 치명적인 오류로 프로세스가 종료될 수 있는 사례가 나온다. 또한 --max-old-space-size는 V8 old memory 영역의 최대 크기를 조절하는 옵션으로 제공된다.

    이 내용을 보고 나니 내가 처음부터 서로 다른 두 종류의 메모리 문제를 하나로 보고 있었던 것은 아닐까라는 생각이 들었다.


    처음에는 “메모리 부족”이면 전부 같은 문제라고 생각했다

    애플리케이션을 운영하면서 메모리가 부족하다는 표현을 자주 사용한다.

    그래서 다음과 같은 상황도 모두 비슷하게 느껴진다.

    메모리가 부족하다
    → 프로세스가 죽는다
    

    하지만 Node.js를 Docker에서 실행하고 있다면 실제로는 적어도 두 개의 서로 다른 한계를 생각해야 했다.

    하나는 Node.js/V8이 JavaScript 객체를 관리하는 heap의 한계이고, 다른 하나는 컨테이너 또는 호스트 운영체제가 사용할 수 있도록 허용한 실제 메모리의 한계다.

    둘 다 최종적으로는 “프로세스가 죽었다”라는 결과를 만들 수 있기 때문에 로그를 자세히 보지 않으면 같은 문제처럼 느껴진다.

    그런데 원인이 다르면 해결 방법도 달라진다.

    Node.js의 JavaScript heap 한계에 도달한 것이라면 V8의 heap 설정이나 애플리케이션의 메모리 사용 패턴을 확인해야 한다.

    반대로 컨테이너가 허용된 실제 메모리 한계를 넘어서 운영체제의 OOM 처리 대상이 된 것이라면 Docker의 memory limit과 컨테이너의 실제 사용량을 확인해야 한다.

    Docker 공식 문서에서도 컨테이너가 사용 가능한 메모리보다 많이 사용해 OOM 상황이 발생하면 기본적으로 커널이 컨테이너 내부 프로세스를 종료할 수 있다고 설명한다. Docker는 컨테이너에 --memory를 이용한 hard limit도 설정할 수 있다.

    이 차이를 알고 나니 “메모리 때문에 죽었다”라는 설명만으로는 원인을 거의 설명하지 못한다는 생각이 들었다.

    정말 알아야 하는 것은 누가 어떤 한계를 넘겼다고 판단해서 프로세스를 종료했는가였다.

    그리고 Docker 컨테이너 일부가 좀비로 남는 경우도.. 간혹 있었다.


    내가 본 JavaScript heap out of memory는 무엇을 말하고 있었을까

    Node.js는 JavaScript 객체를 저장하고 관리하기 위해 V8 heap을 사용한다.

    애플리케이션이 객체를 계속 만들면 heap 사용량이 증가하고, V8은 더 이상 사용하지 않는 객체를 정리하기 위해 garbage collection을 수행한다.

    여기까지는 일반적인 동작이다.

    문제가 되는 것은 garbage collection을 반복해도 필요한 공간을 확보하지 못하고 heap이 허용된 한계에 가까워질 때다.

    Node.js 공식 진단 문서에서도 메모리 부족 상황에서는 memory usage가 증가하다 프로세스가 crash하고, garbage collection 활동이 증가하면서 CPU 사용량과 응답시간에도 영향을 줄 수 있다고 설명한다.

    Node 공식 예제를 보면 heap limit에 도달했을 때 다음과 유사한 로그가 나타난다.

    FATAL ERROR:
    Reached heap limit
    Allocation failed
    JavaScript heap out of memory
    

    그리고 그 전에 여러 번의 GC 기록이 남을 수도 있다.

    이 부분을 보고 나서 내가 당시 봤던 오류를 조금 다르게 생각할 수 있었다.

    Docker가 먼저:

    너는 컨테이너 메모리를 너무 많이 썼으니 강제로 종료한다.

    라고 판단한 상황과,

    Node.js/V8이:

    JavaScript heap에서 더 이상 필요한 메모리를 확보하지 못한다.

    고 판단하면서 자체적으로 치명적인 오류를 발생시키는 상황은 관찰되는 원인이 다를 수 있다.

    그래서 JavaScript heap out of memory가 로그에 명확하게 보인다면 적어도 V8 heap 상태를 확인할 이유가 생긴다.

    Docker 자체만 바라보고 끝내면 안 되는 이유였다.


    반대로 Docker OOM Kill이라면 무엇을 봐야 할까

    그렇다고 로그에 메모리라는 말이 나왔다는 이유만으로 Docker 쪽을 무시해서도 안 된다.

    Node.js heap을 크게 늘려놓았는데 컨테이너 자체의 memory limit보다 더 많은 실제 메모리를 필요로 한다면 결국 다른 한계에 부딪힐 수 있기 때문이다.

    Docker에서는 컨테이너의 상태 정보 안에 OOMKilled 여부와 ExitCode가 따로 존재한다. Docker Engine의 공식 Container Inspect 응답에도 State.OOMKilledState.ExitCode가 별도의 값으로 정의되어 있다.

    그래서 지금 같은 문제가 다시 발생한다면 로그만 보고 추측하기보다 바로 상태를 확인하게 되었다.

    예를 들면:

    docker inspect \
      --format '{{.State.ExitCode}} {{.State.OOMKilled}}' \
      container_name
    

    여기에서 중요한 것은 Exit Code 하나만 보는 것이 아니다.

    Docker가 실제로 이 컨테이너를 OOM Kill로 기록하고 있는지를 같이 보는 것이다.

    Docker 공식 문서에서는 상태 코드 137SIGKILL(9)로 종료된 컨테이너를 찾을 때 사용할 수 있다고 명시하고 있고, OOM 상황에서는 커널이 컨테이너 프로세스를 종료할 수 있다고 설명한다.

    이 내용을 보고 나니 내가 겪었던 Exit 134에 대해서도 “메모리라는 단어가 있었으니 Docker OOM Kill이었다”라고 단정하면 안 된다는 생각이 들었다.

    당시 함께 나타난 Node.js의 JavaScript heap out of memory 로그가 오히려 더 중요한 단서였다.

    Exit Code는 결과를 좁혀가는 하나의 정보이고, 실제 종료 원인을 판단하려면 애플리케이션 로그와 Docker 상태를 함께 봐야 했다.


    처음 해결책으로 생각한 것은 --max-old-space-size였다

    Node.js heap 문제를 검색하다 보면 상당히 빠르게 만나게 되는 옵션이 있다.

    node --max-old-space-size=4096 app.js
    

    Node.js 공식 문서에서 --max-old-space-size는 V8 old memory 영역의 최대 크기를 MiB 단위로 설정한다. heap 사용량이 이 한계에 접근하면 V8은 사용하지 않는 메모리를 확보하기 위해 garbage collection에 더 많은 시간을 사용한다.

    그래서 JavaScript heap out of memory를 보면 이 값을 높이는 것이 아주 자연스러운 해결 방법처럼 보인다.

    나도 처음에는 그렇게 봤다.

    현재 JavaScript heap이 부족하다.

    그렇다면 heap을 더 크게 허용하면 된다.

    논리 자체는 틀리지 않는다.

    실제로 정상적인 작업이 현재 V8 heap 한계보다 많은 메모리를 필요로 하는 경우라면 heap limit을 늘리는 것이 필요한 조치일 수 있다.

    그런데 여기서 또 하나의 질문이 생겼다.

    왜 이 빌드가 그렇게 많은 JavaScript heap을 필요로 하고 있는가?

    이 질문 없이 숫자만 늘리면 PostgreSQL에서 max_connections를 계속 올리던 문제와 비슷해질 수 있다는 생각이 들었다.

    1GB가 부족하면 2GB.

    2GB가 부족하면 4GB.

    4GB가 부족하면 8GB.

    이런 식으로 계속 늘렸는데 실제 원인이 메모리 누수나 비정상적으로 큰 빌드 작업에 있다면 한계선을 뒤로 미루는 것에 불과할 수 있다.

    그래서 --max-old-space-size를 사용하더라도 이 값이 해결인지, 단순한 증상 완화인지 구분할 필요가 있었다.


    특히 “서버 RAM이 32GB니까 Node에 16GB 줘도 되겠지”라고 생각하면 부족했다

    처음에는 물리적인 서버 메모리만 보면 여유가 충분해 보일 수 있다.

    예를 들어 서버에 RAM이 많이 남아 있다면 Node.js heap limit 역시 크게 줘도 괜찮을 것 같다.

    그런데 Docker 안에서 실행한다면 다시 한 단계가 추가된다.

    물리 서버 RAM
          ↓
    Docker가 컨테이너에 허용한 메모리
          ↓
    Node.js 프로세스 전체 메모리
          ↓
    그 안의 V8 JavaScript Heap
    

    이 숫자들은 전부 같은 것이 아니다.

    Node.js의 --max-old-space-sizeNode 프로세스 전체 메모리 상한을 설정하는 옵션이 아니라 V8 old space의 최대 크기를 조정하는 것이다. Node.js 공식 문서도 이 옵션을 V8 old memory section의 최대 크기로 정의한다.

    Node 프로세스에는 heap 이외에도 다른 메모리가 필요할 수 있다.

    여기에 컨테이너 자체에는 Node 프로세스 외의 자원도 존재할 수 있다.

    그런데 container memory limit을 4GB로 설정해 놓고 Node의 old-space 한계 역시 거의 4GB 가까이 사용하도록 만든다면 다른 메모리가 사용할 여유를 생각하지 못한 설정이 될 수 있다.

    Node.js 문서에서도 2GiB 메모리 환경을 예로 들면서 old-space를 1536MiB 정도로 설정해 다른 용도를 위한 메모리를 남기는 예를 제시한다.

    이 예시를 보고 나니 숫자를 정할 때 “얼마까지 줄 수 있는가”보다 “다른 메모리가 사용할 공간을 얼마나 남겨야 하는가”도 같이 생각해야 한다는 점이 더 명확해졌다.


    빌드 중에만 죽는다면 또 다르게 봐야 했다

    내가 겪었던 문제에서 특히 신경 쓰였던 부분은 애플리케이션을 단순 실행할 때와 빌드할 때 메모리 사용 패턴이 다를 수 있다는 점이었다.

    Nest나 Node 기반 프로젝트를 빌드하면 TypeScript 변환, 모듈 분석, 번들링이나 기타 빌드 과정이 한꺼번에 진행될 수 있다.

    평소 API 서버는 잘 실행되고 있는데:

    npm run build
    

    또는 Nest 빌드 과정에서만 JavaScript heap out of memory가 나타난다면,

    애플리케이션 운영 자체가 메모리를 계속 누수하고 있다.

    라는 결론과는 구분해서 생각해야 한다.

    문제가 발생하는 시점이 단서가 될 수 있기 때문이다.

    항상 일정 시간 운영한 뒤 죽는다면 장기적으로 메모리 사용량이 증가하는 문제를 의심해볼 수 있다.

    반대로 특정 빌드 명령을 실행하는 순간에만 메모리가 급격히 증가한다면 빌드 작업이 순간적으로 필요로 하는 heap 크기나 빌드 구조를 먼저 확인할 수 있다.

    Node.js 공식 메모리 진단 문서에서도 메모리 문제를 분석할 때 heap profiler, heap snapshot, GC trace 등을 이용해 객체의 크기와 allocation pattern을 확인하는 방법을 안내하고 있다.

    이걸 보고 나서 “메모리 부족”이라는 한 문장보다 언제부터, 어떤 작업을 할 때, 어떤 형태로 증가했는가가 더 중요한 정보라고 생각하게 됐다.


    그래서 지금이라면 먼저 메모리가 증가하는 모습을 보려고 할 것 같다

    오류가 이미 발생한 뒤의 로그만 가지고 보면 그 순간의 결과만 알 수 있다.

    나는 지금 다시 같은 문제를 만난다면 프로세스가 죽기 전에 메모리가 어떻게 변하는지부터 보고 싶다.

    Docker에서는 docker stats를 이용해 실행 중인 컨테이너의 CPU와 memory usage, memory limit 등의 runtime metrics를 볼 수 있다.

    예를 들어:

    docker stats
    

    를 켜놓고 빌드를 실행한다.

    그리고 생각한다.

    빌드를 시작하기 전에는 어느 정도였는가.

    빌드가 시작되면 서서히 증가하는가.

    특정 시점에 급격하게 증가하는가.

    컨테이너 한계에 거의 도달하는가.

    아니면 Docker가 허용한 메모리에는 여유가 있는데 Node.js가 먼저 heap out of memory로 종료되는가.

    두 번째 상황이라면 상당히 중요한 단서가 된다.

    Docker Memory Limit
    4GB
    
    실제 컨테이너 사용량
    2GB 부근
    
    그런데
    
    Node.js
    JavaScript heap out of memory
    

    와 같은 패턴이 실제로 관찰된다면 컨테이너 전체 memory limit이 첫 번째 의심 대상이 아닐 수 있다.

    반대로:

    Docker Memory Limit
    2GB
    
    Memory Usage
    2GB에 매우 근접
    
    그 직후 종료
    

    되고 OOMKilled도 true로 기록된다면 Docker/커널 쪽 메모리 한계를 먼저 볼 근거가 강해진다.

    위 숫자는 설명을 위한 예시지만, 이런 방식으로 실제 측정값을 남겨야 두 상황을 구분할 수 있다는 점이 중요하다.


    --max-old-space-size를 올린 뒤 잘 됐다고 해서 원인이 완전히 해결됐다고 보기도 어려웠다

    가령 old-space 값을 높였더니 빌드가 성공했다고 생각해보자.

    그러면 다음과 같이 결론을 내리기 쉽다.

    원인: Node heap이 작았음
    해결: max-old-space-size 증가
    끝
    

    하지만 나는 지금이라면 한 번 더 질문할 것 같다.

    왜 기존 heap으로는 빌드가 불가능했을까?

    프로젝트 규모가 실제로 커져서 정상적인 빌드에 더 많은 메모리가 필요한 것이라면 큰 문제가 아닐 수 있다.

    그렇지만 이전에는 같은 설정으로 빌드됐는데 어느 시점부터 갑자기 실패하기 시작했다면 그 사이 무엇이 변했는지를 확인할 이유가 생긴다.

    Dependency가 크게 늘었는지.

    빌드 대상에 불필요한 파일이 들어갔는지.

    코드 생성이나 변환 작업이 늘어났는지.

    특정 버전 변경 이후 메모리 사용 패턴이 달라졌는지.

    CI나 Docker 빌드 환경의 memory limit이 달라졌는지.

    이런 질문이 필요하다.

    --max-old-space-size로 성공했다는 것은 더 많은 heap을 주면 현재 작업을 끝낼 수 있다는 사실은 보여준다.

    하지만 메모리를 많이 사용하게 된 이유까지 설명해주는 것은 아니다.

    이 차이를 구분하지 않으면 몇 달 뒤 프로젝트가 더 커졌을 때 같은 문제를 다시 만날 수 있다.


    메모리 누수와 단순히 큰 작업도 구분해야 했다

    heap out of memory라는 말을 들으면 메모리 누수가 바로 떠오를 수 있다.

    하지만 heap이 부족했다고 해서 항상 leak이라고 말할 수는 없다.

    예를 들어 한 번의 작업에서 실제로 3GB의 객체가 동시에 필요하고 heap 한계가 2GB라면 메모리가 정상적으로 해제되고 있어도 해당 작업은 끝낼 수 없다.

    반대로 처리한 작업이 끝났는데도 사용했던 객체가 계속 참조되어 garbage collection 대상이 되지 않고 시간이 갈수록 heap이 증가한다면 leak을 의심할 수 있다.

    두 상황 모두 마지막에는:

    JavaScript heap out of memory
    

    로 끝날 수 있다.

    그래서 Node.js 공식 메모리 진단 문서에서도 단순히 heap 크기를 조절하는 방법뿐 아니라 heap snapshot, profiler, GC trace 등을 이용해 어떤 객체가 공간을 차지하고 있고 무엇이 garbage collection을 막는지 분석하도록 안내한다.

    이 차이가 중요한 이유는 해결 방법이 완전히 다르기 때문이다.

    정상적인 대형 빌드라면 더 큰 heap을 제공하는 것이 합리적일 수 있다.

    메모리 누수라면 heap을 늘리는 것은 프로세스가 죽는 시간을 늦출 뿐이다.


    그렇다고 운영 서버에서 무턱대고 Heap Snapshot을 찍는 것도 조심해야 했다

    메모리 문제가 있으니 Heap Snapshot을 찍으면 되지 않을까 생각할 수 있다.

    하지만 이 작업 자체도 메모리를 사용한다.

    Node.js 공식 문서는 heap snapshot을 생성할 때 main thread의 다른 작업이 멈출 수 있고, snapshot 생성 과정에서 현재 heap 크기의 약 두 배 수준 메모리가 필요할 수 있어 애플리케이션이 crash할 위험도 있다고 경고한다.

    이 내용을 보면 운영 서버에서 메모리가 거의 바닥난 순간에:

    지금 바로 heap snapshot부터 찍어보자.

    라고 하는 것도 위험할 수 있다는 것을 알 수 있다.

    이미 메모리 여유가 거의 없는 상태라면 진단 작업이 마지막 남은 메모리를 사용하는 상황도 생각해야 한다.

    그래서 실제 운영 환경에서는 재현 가능한 테스트 환경을 만들거나, 장애 영향이 없는 인스턴스에서 진단하는 방법까지 같이 고려해야 한다.

    이런 부분은 단순히 heapdump 사용법만 알고 있을 때는 잘 보이지 않았다.


    내가 지금 다시 이 오류를 만나면 보는 순서가 완전히 달라질 것 같다

    예전에는 로그에서 heap out of memory를 보고 바로 메모리를 늘리는 방향으로 생각했다.

    지금은 먼저 로그 자체를 자세히 볼 것 같다.

    Node.js가 JavaScript heap out of memory를 출력했는지 확인한다.

    그다음 Docker 상태를 본다.

    docker inspect \
      --format '{{.State.ExitCode}} {{.State.OOMKilled}}' \
      container_name
    

    그리고 실행 중 재현이 가능하다면:

    docker stats
    

    로 컨테이너의 실제 메모리 증가를 본다.

    Node.js heap limit도 별도로 확인할 수 있다. Node.js 공식 메모리 문서는 v8.getHeapStatistics()heap_size_limit을 이용해 현재 heap limit을 확인하는 방법을 제공한다.

    예를 들면:

    const v8 = require('node:v8');
    
    const { heap_size_limit } = v8.getHeapStatistics();
    
    console.log(
      heap_size_limit / 1024 / 1024,
      'MB'
    );
    

    이렇게 보면 최소한 세 개의 숫자를 서로 비교할 수 있다.

    Node.js V8 Heap Limit
    
    Docker Memory Limit
    
    실제 Container Memory Usage
    

    예전에는 이 셋을 거의 같은 “메모리 크기”로 생각했다.

    지금은 각각 다른 계층의 한계라고 본다.


    이 세 숫자를 같이 보면 해결 방향도 달라진다

    예를 들어 실제 측정 결과가 다음과 같다고 생각해보자.

    Node.js heap limit 근처까지 계속 올라가는데 Docker 컨테이너의 memory limit에는 충분한 여유가 있다.

    그리고 로그에는 명확하게 JavaScript heap out of memory가 있다.

    그렇다면 V8 heap 설정과 Node 애플리케이션의 heap 사용을 먼저 볼 근거가 생긴다.

    반대로 Node heap limit보다 앞서 컨테이너가 Docker memory limit에 도달하고 OOMKilled=true로 종료된다면 컨테이너 자원 제한을 먼저 확인해야 한다.

    그리고 heap과 Docker limit 모두 여유가 있는데 프로세스가 계속 비정상 종료된다면 메모리 문제라고 고집하지 말고 다른 원인으로 범위를 넓혀야 한다.

    이것이 내가 Exit Code 하나에 너무 큰 의미를 부여하지 않게 된 이유다.

    Exit Code는 조사 시작점이지 항상 원인 자체는 아니었다.


    특히 Docker에서는 빌드 단계와 실행 단계를 구분해서 보고 싶다

    Node 프로젝트를 Docker로 배포하면 빌드와 런타임을 같은 컨테이너 과정에서 처리할 수도 있고 multi-stage build처럼 분리할 수도 있다.

    내가 다시 구조를 만든다면 빌드 중에만 큰 메모리가 필요한지, 실제 서비스 실행 중에도 계속 큰 메모리가 필요한지를 구분하고 싶다.

    예를 들어 빌드 시점에만 메모리 요구량이 크다면:

    Build Stage
    → 큰 메모리 필요
    
    Runtime Stage
    → 상대적으로 작은 메모리
    

    일 수 있다.

    이 상황에서 런타임 컨테이너의 Node heap까지 무조건 크게 설정할 이유는 없을 수 있다.

    반대로 서비스가 실행된 뒤 요청을 받을수록 메모리가 계속 증가한다면 빌드 문제가 아니라 런타임 메모리 사용 패턴을 분석해야 한다.

    같은 heap out of memory라도 언제 발생했는지에 따라 조사할 코드와 설정이 달라진다.

    이 부분을 기록해두는 것이 중요하다고 느꼈다.


    당시에는 해결하는 것이 우선이었지만 지금이라면 실패 당시의 수치를 남길 것 같다

    이런 문제를 실제로 만나면 블로그 글을 쓰기 위해 차분하게 데이터를 모으고 있을 여유가 없는 경우가 많다.

    나 역시 당시에는 우선 빌드가 정상적으로 되도록 만드는 것이 중요했다.

    그래서 지금 이 글에서:

    당시 컨테이너 메모리가 정확히 몇 GB였고 Node heap이 정확히 몇 MB에서 실패했다.

    같은 값을 만들어 적고 싶지는 않다.

    그 숫자를 별도로 기록해두지 않았다면 모른다고 하는 것이 맞다.

    다만 그 경험 때문에 다음에 같은 문제가 생기면 반드시 기록하고 싶은 항목은 분명해졌다.

    Node 버전.

    빌드 명령.

    Docker memory limit.

    오류 직전 container memory usage.

    Node heap limit.

    docker inspect의 ExitCode와 OOMKilled.

    JavaScript heap out of memory 로그 직전 GC 내용.

    --max-old-space-size 변경 전후 결과.

    이 값들이 있다면 다음에는 단순히:

    메모리를 올리니까 해결됐다.

    가 아니라,

    어느 한계에 먼저 도달했고, 어떤 설정을 바꾸자 그 한계가 어떻게 변했는지

    를 설명할 수 있다.

    나는 지금은 이 차이가 훨씬 중요하다고 생각한다.


    결국 내가 잘못 묻고 있었던 질문은 “메모리를 얼마나 더 줘야 하지?”였다

    처음 오류를 만났을 때 내가 가장 궁금했던 것은 이것이었다.

    몇 GB를 더 주면 빌드가 될까?

    지금은 이 질문부터 시작하지 않을 것 같다.

    먼저 이렇게 묻는다.

    지금 부족하다고 말하는 메모리는 정확히 어느 메모리인가?

    V8의 JavaScript heap인가.

    Docker가 컨테이너에 허용한 memory limit인가.

    호스트 전체의 실제 RAM인가.

    또는 메모리가 아니라 다른 이유로 프로세스가 비정상 종료된 것인가.

    이 구분을 먼저 해야 해결 방법도 결정할 수 있다.

    V8 heap 문제라면 --max-old-space-size를 검토하면서 왜 heap 사용량이 그렇게 큰지 본다.

    Docker OOM Kill이라면 container memory limit과 실제 사용량을 확인한다.

    장기적으로 memory usage가 계속 증가한다면 heap profiling이나 snapshot을 통해 무엇이 남아 있는지 분석한다.

    빌드 시점에만 발생한다면 빌드 과정과 런타임을 분리해서 본다.

    결국 모두 “메모리 문제”이지만 같은 문제는 아니다.


    마무리

    Docker에서 Node.js 프로세스가 갑자기 종료되고 로그에 메모리 관련 오류가 나타나면 처음에는 모든 원인이 한곳에 있는 것처럼 느껴진다.

    나도 Exit Code 134JavaScript heap out of memory를 보면서 가장 먼저 Docker에 메모리를 더 주는 방향을 생각했다.

    하지만 다시 구조를 살펴보면서 Node.js의 V8 heap과 Docker의 container memory limit은 서로 다른 계층이라는 것을 구분해서 보기 시작했다.

    Node.js는 V8 old-space의 최대 크기를 --max-old-space-size로 조절할 수 있고, heap limit에 접근하면 GC 활동이 증가하며 결국 메모리를 확보하지 못하면 JavaScript heap out of memory로 종료될 수 있다.

    Docker에서는 별도로 컨테이너의 memory limit을 설정할 수 있고, OOM 상황에서 커널이 프로세스를 종료할 수 있으며, Container Inspect 상태에는 OOMKilledExitCode가 별도로 기록된다.

    이 둘을 구분하고 나니 Exit Code를 보는 방식도 달라졌다.

    예전에는:

    Exit Code
    +
    메모리 오류
    
    → Docker 메모리 부족
    

    처럼 바로 연결하려고 했다.

    지금은:

    애플리케이션 로그 확인
    
    ↓
    
    Node/V8 Heap 오류인가?
    
    ↓
    
    Docker OOMKilled인가?
    
    ↓
    
    실제 Container Memory는 어디까지 올라갔는가?
    
    ↓
    
    Node Heap Limit은 얼마인가?
    
    ↓
    
    문제가 Build에서만 발생하는가,
    Runtime에서도 발생하는가?
    

    순서로 생각한다.

    그리고 이 과정을 거친 뒤에야 메모리를 늘리는 것이 정말 해결책인지 판단한다.

    결국 이 문제를 겪으면서 가장 크게 바뀐 생각은 메모리가 부족하다는 메시지를 해결책으로 연결하기 전에, 누가 어떤 메모리의 한계를 이야기하고 있는지부터 확인해야 한다는 것이었다.

    그 한계를 구분할 수 있어야 --max-old-space-size를 올릴지, Docker memory limit을 수정할지, 애플리케이션의 메모리 사용 자체를 분석할지 결정할 수 있다.

    단순히 숫자를 크게 만드는 것보다 먼저 해야 할 일은 프로세스가 어디에서 한계에 부딪혔는지를 설명할 수 있는 상태를 만드는 것이었다.

  • Elasticsearch Hybrid Search와 KNN을 도서 검색에 함께 적용한 이유

    Elasticsearch를 이용해 도서 검색 기능을 만들면서 처음부터 벡터 검색만 사용한 것은 아니다.

    실제로 서비스를 만들다 보니 사용자가 입력하는 검색어에는 성격이 전혀 다른 것들이 섞여 있었다.

    예를 들어 어떤 사용자는 책 제목을 거의 정확하게 알고 검색한다.

    채식주의자

    처럼 제목을 그대로 입력할 수도 있고,

    한강 채식주의자

    처럼 제목과 저자를 섞어서 입력할 수도 있다.

    반면 어떤 사용자는 책 제목을 전혀 모른다.

    직장 생활이 너무 지쳤을 때 읽을 책

    초등학생이 우주에 관심을 가질 만한 책

    주식 처음 시작하는 사람이 읽기 어렵지 않은 책

    처럼 자신이 원하는 상황을 문장으로 입력한다.

    도서 검색 서비스를 만들면서 어려웠던 부분이 바로 이것이었다.

    정확한 단어를 찾는 검색과 의미가 비슷한 책을 찾는 검색을 하나의 방식으로 처리하기가 생각보다 쉽지 않았다.

    그래서 내가 만든 도서 검색 API에서는 Elasticsearch의 텍스트 검색과 벡터 기반 KNN 검색을 비교해서 사용할 수 있도록 검색 방식을 나눴다.

    이 글에서는 Elasticsearch의 기능을 일반적으로 설명하기보다는 실제 도서 검색 기능을 만들면서 왜 두 방식을 모두 사용하게 되었고, 검색 쿼리를 어떤 기준으로 구성했는지를 기록해보려고 한다.


    검색 대상은 단순히 책 제목 하나가 아니었다

    검색할 데이터에는 제목만 있는 것이 아니었다.

    실제 검색에 활용할 수 있는 정보는 대략 다음과 같이 나뉘었다.

    title
    author
    keywords
    description
    isbn13
    libCode
    embedding
    

    여기에서 특히 검색에 중요하게 사용한 것은 title, keywords, description이었다.

    그리고 의미 기반 검색을 위해 별도로 생성한 벡터를 embedding 필드에 저장했다.

    문제는 이 필드들의 역할이 서로 다르다는 것이다.

    책 제목을 정확하게 입력한 사용자에게는 title이 굉장히 강한 신호다.

    반대로,

    사람 관계 때문에 스트레스 받을 때 읽을 책

    처럼 제목에는 존재하지 않을 가능성이 높은 문장을 입력한다면 제목만 비교해서는 원하는 결과를 찾기 어렵다.

    이 경우에는 책의 설명이나 키워드, 더 나아가 문장의 의미 자체를 이용하는 검색이 필요해진다.

    Elasticsearch도 현재 검색 방식을 full-text, vector, hybrid 등으로 구분하고 있으며, hybrid search는 텍스트 기반 검색과 의미 기반 검색을 함께 활용할 필요가 있을 때 사용하는 접근이다.


    텍스트 검색에서는 title을 다른 필드보다 강하게 봤다

    텍스트 검색을 구성할 때 모든 필드를 같은 비중으로 처리하지 않았다.

    실제 검색 설정에서는 다음과 같은 형태로 필드 중요도를 줬다.

    title^2
    keywords
    description
    

    title^2로 설정한 이유는 단순했다.

    사용자가 실제 책 제목을 입력했을 경우에는 책 설명에 같은 단어가 여러 번 등장하는 문서보다 제목 자체가 맞는 책을 위에 보여주는 것이 자연스럽다고 판단했기 때문이다.

    예를 들어 검색어가:

    아몬드
    

    라고 들어왔다고 생각해보자.

    어떤 책의 description 안에 우연히 ‘아몬드’라는 단어가 포함되어 있는 것보다 제목이 실제로 ‘아몬드’인 책을 우선하는 것이 일반적인 도서 검색에서는 더 자연스럽다.

    그래서 제목에는 상대적으로 높은 가중치를 주고, keywordsdescription은 검색 범위를 보완하는 역할로 사용했다.

    내가 사용한 텍스트 검색 설정의 핵심을 단순화하면 다음과 같다.

    query: 사용자가 입력한 검색어
    
    fields:
    - title^2
    - keywords
    - description
    
    operator:
    - or
    
    prefix_length:
    - 2
    

    operator=or를 사용한 것도 검색어에 포함된 모든 단어가 반드시 문서에 존재해야만 검색되는 상황을 피하고 싶었기 때문이다.

    사용자가 검색창에 입력하는 문장은 데이터베이스에 저장된 문장과 정확하게 같지 않은 경우가 많다.

    특히 자연어 검색을 받으려면 입력 문장을 지나치게 엄격하게 제한하는 것보다 어느 정도 후보를 확보한 뒤 관련도를 판단하는 방식이 필요했다.


    그런데 텍스트 검색만으로 해결되지 않는 검색어가 있었다

    제목이나 키워드를 알고 있는 검색에서는 텍스트 검색이 상당히 직관적이다.

    문제는 사용자가 책의 정보를 모르는 상태에서 자신의 요구를 설명할 때다.

    예를 들어:

    회사에서 사람 때문에 스트레스 받을 때 읽을 책
    

    이라는 검색어를 생각해볼 수 있다.

    좋은 책의 제목이나 설명에 반드시 다음 단어가 그대로 들어간다는 보장은 없다.

    회사
    사람
    스트레스
    

    실제로 책의 내용은 인간관계, 감정 조절, 번아웃, 직장생활 등에 관한 것인데 사용자가 입력한 표현과 책 메타데이터의 표현이 다를 수 있다.

    이런 검색에서는 단어가 동일한지뿐 아니라 사용자 문장과 책 정보의 의미가 얼마나 가까운지를 이용하고 싶었다.

    그래서 별도로 KNN 검색을 적용했다.


    KNN에서는 검색어도 embedding으로 바꿨다

    도서 데이터에는 의미 검색을 위해 embedding 값을 저장해두었다.

    검색 과정에서는 사용자의 검색어도 동일한 임베딩 방식으로 벡터화한 뒤 Elasticsearch의 도서 벡터와 비교한다.

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

    사용자 검색어
    
    "직장 생활이 너무 힘들 때 읽을 책"
    
            ↓
    
    Embedding 생성
    
            ↓
    
    Query Vector
    
            ↓
    
    Elasticsearch KNN Search
    
            ↓
    
    의미적으로 가까운 도서 후보
    

    이 방식의 장점은 검색어와 책 정보가 정확하게 같은 단어를 사용하지 않아도 벡터 공간에서 의미가 가까운 문서를 찾을 수 있다는 것이다.

    Elasticsearch의 kNN 검색도 query vector와 저장된 document vector의 유사성을 기준으로 가까운 문서를 검색하는 구조다.

    하지만 실제 서비스에 적용하고 보니 벡터 검색도 이것 하나만 사용하면 모든 문제가 해결되는 것은 아니었다.


    의미가 비슷하다는 것과 사용자가 찾는 책이라는 것은 같지 않았다

    벡터 검색을 처음 접하면 의미가 비슷한 문서를 찾아주는 기능이 텍스트 검색보다 훨씬 발전된 검색처럼 느껴질 수 있다.

    하지만 실제 검색 서비스에서는 그렇게 단순하지 않았다.

    예를 들어 사용자가 책 제목을 상당히 정확하게 입력했다면 굳이 의미적으로 비슷한 다른 책을 먼저 보여줄 이유가 없다.

    검색어:
    데미안
    

    이라면 사용자가 원하는 것이 헤르만 헤세의 『데미안』일 가능성이 높다.

    이때 검색 시스템이 ‘자아 탐색’, ‘성장’, ‘내면세계’라는 의미가 비슷하다는 이유로 다른 소설을 먼저 보여준다면 검색 기술 자체는 의미를 잘 이해했을지 몰라도 사용자가 기대한 검색 결과는 아니다.

    반대 상황도 있다.

    검색어:
    자존감이 너무 낮아졌을 때 읽을 책
    

    이라면 단순 제목 일치보다 의미 검색의 가치가 커진다.

    결국 내가 필요했던 것은:

    Keyword Search가 좋다
    

    또는

    Vector Search가 좋다
    

    라는 하나의 결론이 아니었다.

    사용자가 어떤 종류의 검색어를 입력했는지에 따라 두 검색 방식이 가지고 있는 장점이 달랐다.

    Elastic에서도 full-text search는 정확한 단어나 구문이 중요한 경우에 적합하고, vector/semantic search는 일상적인 표현이나 동의어처럼 정확한 단어 일치만으로 찾기 어려운 경우에 활용할 수 있다고 설명한다.


    도서관 검색에서는 libCode 필터가 특히 중요했다

    내가 만들던 검색 기능에는 일반적인 인터넷 서점 검색과 다른 조건이 하나 있었다.

    단순히 관련성 높은 책을 찾는 것에서 끝나는 것이 아니라 특정 도서관에 존재하는 책을 대상으로 검색해야 했다.

    도서 데이터에는 도서관을 구분하기 위한 libCode가 있었다.

    따라서 검색 흐름은 단순히:

    의미적으로 가장 가까운 책 찾기
    

    로 끝나면 안 됐다.

    실제로 필요한 것은:

    사용자가 선택한 도서관의 책 중에서
    +
    검색어와 관련성이 높은 책 찾기
    

    였다.

    KNN 검색에서도 이 조건을 적용하기 위해 libCode를 필터에 사용했다.

    구조를 단순화하면 다음과 같다.

    {
      "knn": {
        "field": "embedding",
        "query_vector": "...",
        "k": "...",
        "num_candidates": "...",
        "filter": {
          "terms": {
            "libCode": ["..."]
          }
        }
      }
    }
    

    실제 서비스 관점에서는 이 필터가 검색 알고리즘만큼 중요했다.

    사용자에게 의미적으로 완벽하게 맞는 책을 추천했더라도 사용자가 검색하고 있는 도서관에는 없는 책이라면 검색 결과로서의 가치가 떨어지기 때문이다.

    Elasticsearch의 kNN 검색에서는 filter를 이용해 조건에 맞는 문서를 대상으로 벡터 검색을 제한할 수 있다.


    k와 num_candidates를 같은 값으로 두지 않았다

    KNN을 적용하면서 신경 쓴 설정이 knum_candidates였다.

    내 검색 코드에서는 사용자가 요청한 k를 그대로 내부 후보 크기로 사용하지 않고 약간의 여유를 뒀다.

    사용한 방식은 다음과 같은 형태였다.

    k = 요청한 k + 10
    
    num_candidates = 요청한 k + 20
    

    예를 들어 외부 API에서 필요한 결과 수를 기준으로 내부 검색 후보를 조금 더 확보하는 식이었다.

    여기에서 중요한 점은 +10, +20이 Elasticsearch KNN 검색의 정답이라는 의미가 아니라는 것이다.

    이 값은 당시 내가 사용하던 검색 구조에서 잡아둔 운영 파라미터다.

    데이터 규모와 shard 구성, 필터 조건, 응답속도 요구사항이 달라지면 적절한 값도 달라질 수 있다.

    Elasticsearch에서 num_candidates는 각 shard에서 근접 이웃을 탐색할 때 확보하는 후보 집합과 관련되고, 그 후보들을 이용해 최종 k 결과를 결정한다. 일반적으로 후보 범위를 어떻게 잡느냐는 검색 품질과 검색 비용 사이의 조정 요소가 된다.

    이 부분은 앞으로 별도의 글에서 실제 검색어 세트를 만들어 knum_candidates를 변경해가며 비교할 생각이다.

    지금 단계에서 테스트하지 않은 값을 가지고 “이 값이 가장 빠르다”거나 “정확도가 몇 퍼센트 증가했다”고 적지는 않으려고 한다.


    검색 API에 Hybrid와 KNN 모드를 따로 둔 이유

    실제 API에서는 검색 방식을 바꿔볼 수 있도록 mode를 두었다.

    형태는 다음과 같았다.

    mode=hybrid
    
    mode=knn
    

    이 구조를 유지한 이유는 개발 과정에서 꽤 유용했다.

    검색 결과가 이상하게 느껴졌을 때 결과만 보고 있으면 왜 이런 책이 올라왔는지 판단하기 어렵다.

    하지만 같은 검색어를 서로 다른 방식으로 실행해보면 문제의 위치를 훨씬 좁히기 쉬웠다.

    예를 들어 특정 검색어에서:

    Hybrid 결과는 괜찮은데
    KNN 결과가 이상하다
    

    면 embedding이나 vector search 쪽을 먼저 볼 수 있다.

    반대로:

    KNN 결과에서는 원하는 책이 나오는데
    텍스트 신호를 포함한 결과에서는 뒤로 밀린다
    

    면 필드 가중치나 검색 쿼리 구성을 다시 볼 수 있다.

    즉 두 모드를 둔 것은 단순히 사용자에게 검색 옵션 두 개를 제공하기 위한 목적만이 아니라 검색 품질을 분석하는 과정에서도 유용한 비교 기준이 됐다.


    내가 검색 품질을 볼 때 가장 먼저 확인하는 것

    검색 서비스를 만들기 전에는 검색 결과가 이상하면 Elasticsearch Query부터 바꿔야 한다고 생각하기 쉬웠다.

    그런데 실제로 검색 기능을 만들면서는 다음 순서로 보는 편이 더 낫다고 느꼈다.

    먼저 사용자가 무엇을 입력했는지 본다.

    그다음 내가 기대하는 정상적인 결과가 무엇인지 정한다.

    그 후에 실제 Hybrid 결과와 KNN 결과를 비교한다.

    마지막으로 왜 순위가 달라졌는지 필드와 벡터를 살펴본다.

    예를 들어:

    검색어
    ↓
    내가 기대하는 책
    ↓
    Hybrid 결과
    ↓
    KNN 결과
    ↓
    title / keywords / description 확인
    ↓
    embedding 검색 결과 확인
    

    순서로 보면 단순히 _score 숫자 하나만 보는 것보다 문제를 이해하기 쉬웠다.

    결국 검색 품질은 Elasticsearch 설정을 복잡하게 만든다고 자동으로 좋아지는 것이 아니라 어떤 검색어에서 어떤 책이 나와야 하는지를 먼저 정해야 조정할 수 있는 문제였다.


    특히 테스트해야 하는 검색어를 세 종류로 나눴다

    도서 검색에서는 검색어를 한 종류로만 테스트하면 결과를 판단하기 어렵다.

    내가 중요하게 보는 검색 유형은 크게 세 가지다.

    제목이나 저자를 알고 검색하는 경우

    채식주의자
    
    한강 채식주의자
    
    데미안 헤르만 헤세
    

    이 경우에는 정확한 텍스트 신호가 중요하다.

    주제만 알고 검색하는 경우

    인공지능 입문 책
    
    주식 초보자 책
    
    아이와 함께 읽을 우주 책
    

    제목 일치뿐 아니라 keywords와 description이 중요해진다.

    상황을 문장으로 설명하는 경우

    요즘 아무것도 하기 싫을 때 읽을 책
    
    사람 관계 때문에 너무 지쳤을 때 읽을 책
    
    처음 코딩을 공부하는 사람이 어렵지 않게 읽을 책
    

    이런 검색은 벡터 검색의 의미를 확인하기 좋은 유형이다.

    앞으로 검색 품질을 더 체계적으로 측정한다면 이 세 종류를 섞어서 테스트 세트를 만들 생각이다.

    그래야 특정 검색 방식에 유리한 검색어만 골라놓고 결과가 좋아졌다고 판단하는 일을 줄일 수 있다.


    지금은 Hybrid와 KNN 중 하나를 버리지 않고 있다

    Elasticsearch 검색을 구성하면서 처음 기대했던 것은 결국 하나의 가장 좋은 검색 방법을 찾는 것이었다.

    하지만 실제 도서 검색을 만들면서 생각이 달라졌다.

    정확한 책 정보를 알고 검색하는 사용자와 자신이 원하는 상황을 자연어로 설명하는 사용자는 애초에 검색 의도가 다르다.

    그래서 지금은:

    텍스트 검색 = 오래된 방식
    
    벡터 검색 = 새로운 방식
    

    처럼 생각하지 않는다.

    두 방식이 해결하려는 문제가 조금 다르다고 보는 편이 더 맞았다.

    Elasticsearch의 현재 공식 문서에서도 hybrid search는 full-text와 vector search를 함께 사용해 정확한 단어 일치와 의미 기반 검색의 장점을 하나의 검색 흐름에서 활용하는 방식으로 설명하고 있다.

    내 서비스에서도 같은 이유로 텍스트 필드와 embedding을 모두 유지하고 있다.


    앞으로 직접 비교해보고 싶은 부분

    현재 설정값을 사용하고 있다고 해서 이것을 최적값이라고 생각하지는 않는다.

    오히려 다음 단계에서는 검색어를 고정한 상태에서 파라미터를 변경해 결과가 어떻게 달라지는지 직접 기록하려고 한다.

    특히 확인하고 싶은 것은 다음과 같다.

    k 변경
    
    num_candidates 변경
    
    title boost 변경
    
    검색어 유형별 결과 차이
    
    필터 적용 전후
    
    상위 검색 결과 순위 변화
    

    이때는 “검색 품질이 좋아진 것 같다”는 식으로 평가하지 않고, 미리 정한 검색어와 기대 도서를 기준으로 결과 순위를 기록할 생각이다.

    예를 들어 검색어 20개를 정하고 각 검색어에서 기대하는 도서를 직접 표시한 뒤 Hybrid와 KNN의 TOP 5 결과를 비교하면 지금보다 훨씬 객관적으로 판단할 수 있다.

    이 결과는 별도의 글에서 실제 데이터와 함께 정리할 예정이다.


    정리하며

    도서 검색에 Elasticsearch를 적용하면서 가장 크게 느낀 것은 검색 방식에는 항상 사용자의 검색 의도가 먼저 와야 한다는 점이었다.

    제목을 알고 있는 사용자는 정확한 결과를 기대한다.

    반대로 어떤 책을 읽어야 할지조차 모르는 사용자는 자신의 상황을 설명하고 그 의미에 맞는 결과를 기대한다.

    이 둘을 같은 방식으로만 처리하려고 하면 어느 한쪽에서 불편함이 생길 가능성이 높다.

    그래서 현재 도서 검색 구조에서는 title, keywords, description을 이용한 텍스트 검색 신호와 embedding을 이용한 KNN 검색을 모두 활용하고 있다.

    텍스트 검색에서는 제목을 상대적으로 중요하게 보고, KNN에서는 특정 도서관의 소장 도서만 검색할 수 있도록 libCode 조건을 함께 적용했다.

    KNN의 내부 후보값도 실제 API에서 필요한 결과 수보다 조금 여유 있게 가져가도록 구성했다.

    이 설정이 모든 검색 서비스에 맞는 정답이라고 생각하지 않는다.

    오히려 이 시스템을 운영하면서 알게 된 것은 검색 파라미터보다 먼저 테스트할 검색어와 기대 결과를 정의하는 것이 중요하다는 것이었다.

    앞으로는 실제 검색어 세트를 고정한 뒤 Hybrid, KNN, k, num_candidates를 하나씩 변경하면서 결과가 얼마나 달라지는지 직접 비교해볼 예정이다.

    그때는 추측이 아니라 실제 검색 결과 순위를 가지고 다시 기록해보려고 한다.


    참고

    Elasticsearch의 Hybrid Search는 full-text와 vector 기반 검색을 결합해 하나의 검색 경험을 만드는 방식이며, 최신 Elastic 문서에서는 RRF 등의 방법을 활용한 결합 방식도 안내하고 있다. KNN 검색의 num_candidates는 shard별 근접 후보를 확보하는 과정에 사용된다.

  • FastAPI의 DB Connection Pool을 크게 잡으면 정말 빨라질까

    FastAPI에서 PostgreSQL을 연결할 때 나는 asyncpg의 Connection Pool을 사용했다.

    설정은 다음과 같은 형태였다.

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

    처음 이 설정을 볼 때 max_size는 꽤 단순한 숫자로 느껴졌다.

    최대 50개의 Connection을 사용할 수 있다는 의미이고, 동시에 DB를 사용하는 요청이 많아진다면 Connection을 더 많이 열 수 있는 쪽이 유리할 것 같았다.

    생각의 흐름도 자연스러웠다.

    FastAPI는 여러 요청을 비동기로 처리한다. 그런데 요청마다 PostgreSQL을 사용해야 한다면 결국 Connection이 필요하다. Pool에 사용할 수 있는 Connection이 부족하면 요청이 Connection을 기다리게 된다.

    그렇다면 Pool을 크게 잡으면 기다리는 요청이 줄어들 것이고, 결국 API도 빨라지지 않을까?

    처음에는 나도 그렇게 생각했다.

    그런데 too many clients already 같은 PostgreSQL 연결 문제를 겪고 Connection 구조를 다시 보면서 이 생각에는 빠진 부분이 있다는 것을 알게 됐다.

    Connection을 빨리 얻는 것과 Query가 빨리 끝나는 것은 같은 문제가 아니었다.

    이 차이를 이해하면서 Connection Pool의 크기를 전과는 조금 다르게 보게 됐다.


    Pool에서 기다리지 않으면 어디에서도 기다리지 않는 걸까

    Connection Pool의 역할을 아주 단순하게 보면 다음과 같다.

    FastAPI 요청이 DB 작업을 하려고 한다.

    그러면 Pool에서 Connection 하나를 빌린다.

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

    작업이 끝나면 Connection은 다시 Pool로 돌아간다.

    asyncpg의 공식 문서에서도 create_pool()로 Pool을 생성하고 Pool에서 Connection을 빌려 사용하는 구조를 제공한다. max_size는 Pool이 가질 수 있는 최대 Connection 수다.

    여기까지 생각하면 Pool 크기를 늘리는 것이 좋아 보인다.

    예를 들어 동시에 30개의 요청이 PostgreSQL을 사용하려고 하는데 Pool이 5개라고 생각해보자.

    개념적으로는 일부 요청만 즉시 Connection을 얻고 나머지는 기다려야 한다.

    요청 1  ─┐
    요청 2   │
    요청 3   ├─ Connection 사용
    요청 4   │
    요청 5  ─┘
    
    요청 6  ─┐
    요청 7   │
    요청 8   ├─ Connection 대기
    ...      │
    요청 30 ─┘
    

    이 모습을 보면 답은 간단해 보인다.

    Pool을 5에서 30으로 늘리면 30개의 요청이 모두 Connection을 받을 수 있다.

    그렇다면 대기가 사라진다.

    여기까지만 보면 분명히 좋아졌다.

    그런데 다시 생각해보면 한 가지 질문이 남는다.

    Connection을 받은 30개의 요청은 그다음 어디로 가는가?

    결국 PostgreSQL이다.

    Pool에서 기다리던 요청 25개가 사라진 것이 아니라, 30개의 Query가 PostgreSQL 쪽으로 동시에 들어갈 수 있게 된 것이다.

    이 차이를 생각하고 나니 Pool 크기를 단순히 “대기시간을 줄이는 숫자”로 보면 부족하다는 생각이 들었다.


    Connection을 많이 열어주는 것이 DB의 처리 능력을 늘려주는 것은 아니었다

    PostgreSQL 서버가 어떤 순간에 안정적으로 처리할 수 있는 작업량에는 한계가 있다.

    CPU도 유한하고 메모리도 유한하며 디스크 I/O 역시 무한하지 않다.

    어떤 Query는 매우 빠르게 끝나지만 어떤 Query는 많은 데이터를 읽거나 정렬해야 할 수도 있다. Lock을 기다리는 Query도 있을 수 있다.

    따라서 Connection이 10개 있다고 해서 PostgreSQL의 능력이 10이고, Connection을 100개로 늘리면 능력이 100이 되는 것은 아니다.

    이 부분을 생각하면서 나는 Connection Pool을 식당의 좌석과 비슷하게 보게 됐다.

    주방에서 동시에 제대로 조리할 수 있는 주문이 10개 정도라고 가정해보자.

    손님을 밖에서 기다리게 하지 않으려고 테이블을 10개에서 100개로 늘렸다고 해서 주방에서 음식을 만드는 속도가 10배 빨라지지는 않는다.

    오히려 주문 100개가 한꺼번에 주방으로 들어갈 수 있다.

    테이블 부족
    → 입구에서 기다림
    
    테이블 대폭 증가
    → 모두 착석
    → 주문이 주방으로 한꺼번에 들어감
    

    손님이 기다리는 위치만 달라졌을 수도 있다.

    Connection Pool도 비슷한 관점에서 볼 수 있었다.

    Pool이 작다면 요청은 애플리케이션에서 Connection을 기다린다.

    Pool을 크게 만들면 더 많은 요청이 PostgreSQL까지 내려간다.

    그런데 PostgreSQL이 그 작업들을 충분히 빠르게 처리하지 못한다면 이번에는 DB 내부에서 CPU, I/O, Lock 같은 다른 자원을 놓고 경쟁하게 될 수 있다.

    즉,

    Pool 대기 감소
    

    가 반드시

    전체 API 처리시간 감소
    

    를 의미하지는 않는다.

    병목이 Pool에 있었을 때만 Pool을 늘리는 것이 직접적인 해결이 될 수 있기 때문이다.


    이 부분에서 내가 처음 했던 질문이 조금 잘못됐다고 느꼈다

    처음에는 이렇게 물었다.

    Pool을 몇 개로 하면 빠를까?

    그런데 이 질문에는 현재 어디가 느린지가 빠져 있다.

    예를 들어 요청이 느려지는 이유가 정말 Connection을 기다리기 때문이라면 Pool 크기를 검토하는 것이 맞다.

    반대로 Connection은 바로 얻고 있는데 실행하는 SQL 자체가 느리다면 Pool을 늘려도 Query 하나의 실행시간은 그대로다.

    또 SQL 하나는 빠른데 동시에 너무 많은 Query가 실행되면서 PostgreSQL의 CPU가 포화되는 상황이라면 Pool을 더 크게 만드는 것이 오히려 도움이 되지 않을 수도 있다.

    그래서 질문을 다음처럼 바꿔야 했다.

    현재 요청은 Connection을 기다리고 있는가, 아니면 Connection을 얻은 뒤 PostgreSQL 작업이 끝나기를 기다리고 있는가?

    비슷해 보이지만 완전히 다른 문제다.

    첫 번째라면 애플리케이션 Pool의 문제일 가능성을 볼 수 있다.

    두 번째라면 SQL이나 PostgreSQL 서버 상태를 먼저 봐야 한다.


    그래서 max_size=50이라는 숫자만 보고는 아무것도 판단하기 어려웠다

    내 설정에서는 max_size=50을 사용했다.

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

    이 숫자만 떼어놓고 보면 크다고 할 수도 있고 작다고 할 수도 있다.

    그런데 사실 둘 다 근거가 부족하다.

    API 요청이 하루에 몇 번 들어오지 않는 서비스라면 50개의 최대 Connection이 필요하지 않을 수도 있다.

    반대로 많은 요청이 동시에 들어오는 서비스라면 50개가 부족하게 느껴질 수도 있다.

    그런데 동시 요청이 많다고 해서 다시 바로 100이나 200으로 올리는 것도 이상하다.

    결국 PostgreSQL이 실제로 감당해야 하는 Query도 함께 늘어나기 때문이다.

    그래서 Pool 크기는 요청량 하나만 가지고 결정할 수 있는 값이 아니었다.

    적어도 다음 세 가지가 서로 연결된다.

    FastAPI 동시 요청량
    
            ↓
    
    Connection Pool 크기
    
            ↓
    
    PostgreSQL이 동시에 처리하게 되는 DB 작업
    

    FastAPI가 비동기로 1,000개의 요청을 받을 수 있다고 해서 PostgreSQL에도 동시에 1,000개의 Query를 보내야 한다는 의미는 아니다.

    오히려 Pool이 그 사이에서 얼마나 많은 DB 작업을 동시에 허용할지를 제어하는 장치로 볼 수도 있다.

    이렇게 보고 나니 Pool의 의미가 조금 달라졌다.

    Connection을 많이 확보해주는 도구이기도 하지만, 반대로 PostgreSQL로 들어가는 동시 작업량을 제한하는 역할도 할 수 있는 것이다.


    처음에는 Pool에서 기다리는 것이 무조건 나쁜 것이라고 생각했다

    Connection을 기다리는 요청이 있다는 말을 들으면 자연스럽게 문제가 있다고 생각하기 쉽다.

    사용자가 요청을 보냈는데 Pool에 Connection이 없어서 기다린다.

    당연히 좋지 않아 보인다.

    나도 처음에는 이 기다림을 없애는 것이 성능 개선이라고 생각했다.

    그런데 DB 전체를 놓고 보면 일정한 대기는 오히려 시스템을 보호하는 역할을 할 수도 있다는 생각이 들었다.

    예를 들어 PostgreSQL이 안정적으로 처리할 수 있는 동시 Query 수보다 훨씬 많은 작업을 애플리케이션이 한꺼번에 밀어 넣는다고 해보자.

    Pool이 그것을 제한하지 않는다면 DB는 동시에 훨씬 많은 일을 처리하려고 한다.

    그 결과 각 Query의 처리시간이 길어질 수도 있고, 서버 자원 사용량도 올라갈 수 있다.

    이때는 모든 요청이 Connection을 바로 얻었다는 사실 자체가 좋은 성능을 의미하지 않는다.

    요청들이 Pool 앞에서 조금 기다리는 대신 PostgreSQL에는 일정한 수의 Query만 보내는 구조가 전체적으로 더 안정적일 수도 있다.

    이것은 Pool의 대기열이 무조건 좋다는 뜻은 아니다.

    대기가 너무 길다면 당연히 응답시간 문제가 된다.

    내가 중요하게 느낀 것은:

    대기가 있다 = Pool이 무조건 작다.

    라고 바로 결론 내리면 안 된다는 점이다.

    그 대기를 없앴을 때 실제 병목이 사라지는지, 아니면 PostgreSQL 쪽으로 이동하는지를 확인해야 한다.


    PostgreSQL의 max_connections와 Pool의 max_size도 같은 숫자가 아니었다

    Connection 문제를 볼 때 자주 같이 등장하는 것이 PostgreSQL의 max_connections다.

    PostgreSQL의 현재 공식 문서에서 max_connections는 서버가 허용하는 최대 동시 연결 수를 결정하며, 이 값을 높이면 PostgreSQL이 일부 자원을 더 크게 할당하게 된다고 설명한다.

    그래서 처음에는 다음 두 숫자를 비슷하게 보기 쉽다.

    asyncpg max_size
    PostgreSQL max_connections
    

    하지만 역할은 다르다.

    asyncpg max_size는 특정 애플리케이션 Pool이 얼마나 많은 Connection을 보유할 수 있는지 정한다.

    max_connections는 PostgreSQL 서버 전체가 받아들일 수 있는 Connection 수의 상한이다.

    그리고 PostgreSQL은 내 FastAPI 하나만 사용하는 것이 아닐 수도 있다.

    예를 들어 같은 DB에:

    검색 API
    
    관리 API
    
    배치 프로그램
    
    운영자가 사용하는 DB 클라이언트
    
    모니터링 프로그램
    

    이 함께 접속할 수도 있다.

    그러면 FastAPI Pool 하나가 PostgreSQL 전체 Connection을 독점하면 안 된다.

    이런 구조까지 생각하고 나면 max_size=50이라는 숫자는 단독으로 존재하는 설정이 아니었다.

    PostgreSQL 전체 Connection 예산 중 이 애플리케이션에 얼마를 허용할 것인지에 가까워진다.


    이 생각 때문에 max_connections를 무작정 크게 올리는 것도 다시 보게 됐다

    too many clients already 같은 오류를 보면 가장 직접적인 해결책은 PostgreSQL의 max_connections를 올리는 것이다.

    실제로 더 많은 정상 연결이 필요한 시스템이라면 조정이 필요할 수 있다.

    하지만 연결 문제가 생겼다는 이유만으로 상한을 크게 올리는 것은 조금 다른 문제다.

    PostgreSQL 공식 문서에서도 max_connections를 높이면 관련 서버 자원 할당이 증가한다고 설명하고 있고, PostgreSQL 자체가 메모리 부족의 원인인 상황에서는 너무 많은 DB 연결을 허용하기보다 Connection Pooling을 사용하는 것이 나을 수 있다고 안내한다.

    이 문서를 다시 보면 Connection Pool의 목적을 이해하기가 더 쉬워진다.

    Pool은 Connection 생성 비용을 줄이는 용도도 있지만 DB 서버로 들어갈 수 있는 Connection 수를 애플리케이션 쪽에서 관리하는 역할도 한다.

    그런데 애플리케이션 Pool을 무작정 크게 만들고 PostgreSQL의 max_connections도 계속 크게 만들면 두 제한 장치를 모두 느슨하게 만드는 셈이다.

    그러면 당장의 Connection 부족은 사라질 수 있어도:

    왜 이렇게 많은 Connection이 필요해졌는가?

    라는 질문은 해결되지 않는다.

    그래서 지금 다시 같은 상황을 본다면 숫자를 올리기 전에 실제 Connection 상태부터 확인하려고 할 것 같다.


    여기서 pg_stat_activity가 필요한 이유가 생겼다

    Pool 설정만 보고 있으면 애플리케이션이 최대 몇 개까지 사용할 수 있는지는 알 수 있다.

    하지만 실제로 지금 몇 개를 사용하고 있는지는 알 수 없다.

    예를 들어:

    max_size=50
    

    이라고 되어 있어도 실제 서비스가 평소 3개만 사용하고 있을 수 있다.

    반대로 트래픽이 몰릴 때 대부분의 Connection을 계속 사용하고 있을 수도 있다.

    그래서 PostgreSQL에서 현재 연결 상태를 확인해야 한다.

    PostgreSQL의 pg_stat_activity는 서버 프로세스별 현재 활동을 보여주며 state, 현재 Query 등의 정보를 확인할 수 있다.

    예를 들어 상태별로 Connection을 확인해볼 수 있다.

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

    처음에는 이 결과에서 idle이 많으면 나쁜 것이라고 생각하기 쉽다.

    그런데 Connection Pool을 사용하고 있다면 사용이 끝난 Connection을 바로 끊지 않고 다시 사용하기 위해 유지할 수 있다.

    따라서 idle이 존재한다는 사실만 가지고 Connection 누수라고 판단하면 안 된다.

    내가 정말 알고 싶은 것은 조금 다르다.

    현재 보이는 Connection 수가 내가 구성한 Pool과 서비스 구조로 설명 가능한가?

    예를 들어 Pool은 하나라고 생각했는데 예상보다 훨씬 많은 동일 애플리케이션 Connection이 보인다면 그때는 Pool 생성 위치나 worker, 컨테이너 수를 다시 확인할 이유가 생긴다.

    pg_stat_activity는 단순히 숫자를 세는 도구라기보다 내가 생각한 시스템 구조와 실제 DB 상태가 일치하는지를 비교하는 도구가 된다.


    Pool이 부족한지 확인하려면 DB Connection 수만 봐서도 부족하다

    여기서 다시 한 단계 더 생각해야 했다.

    PostgreSQL에서 Connection을 10개 사용하고 있다는 사실만으로 Pool이 충분한지 부족한지를 결정할 수도 없다.

    예를 들어 Pool 최대값이 10이고 항상 10개가 사용 중이라고 하자.

    그럼 Pool을 20으로 늘려야 할까?

    직감적으로는 그렇다.

    그런데 10개의 Connection이 왜 모두 사용 중인지 알아야 한다.

    각 Query가 10ms 만에 끝나는데 요청량이 너무 많아서 계속 10개가 사용 중인 것인지,

    아니면 Query 하나가 5초씩 걸려서 Connection이 오랫동안 반환되지 않는 것인지에 따라 대응이 완전히 다르다.

    두 번째 상황에서 Pool을 20으로 늘리면 느린 Query를 동시에 20개 실행할 수 있게 될 뿐이다.

    근본 원인은 여전히 Query가 5초 걸린다는 것이다.

    이 지점에서 나는 Pool 포화가 원인일 수도 있지만 결과일 수도 있다고 생각하게 됐다.

    느린 Query 때문에 Connection이 오랫동안 반환되지 않는다.

    그 결과 Pool이 가득 찬다.

    새 요청은 Pool을 기다린다.

    겉으로 보면:

    Pool 대기가 길다
    

    이지만 시작점은:

    느린 Query
    

    였던 것이다.

    따라서 Connection Pool만 측정해서도 충분하지 않다.


    그래서 DB 문제를 보면 Query 시간도 같이 봐야 했다

    PostgreSQL에서는 pg_stat_activity로 현재 실행 중인 활동을 볼 수 있고, pg_stat_statements를 사용하면 SQL 문의 planning과 execution 통계를 추적할 수 있다. 현재 PostgreSQL 문서에서도 pg_stat_statements를 SQL 실행 통계를 추적하는 모듈로 제공하고 있다.

    이 기능들이 의미 있는 이유가 여기 있다.

    내가 알고 싶은 것은 단순히:

    Connection이 몇 개인가?

    가 아니다.

    가능하면:

    그 Connection들이 무엇을 하고 있는가?

    까지 알아야 한다.

    예를 들어 Pool이 계속 가득 차는데 특정 Query 실행시간이 매우 길다면 Pool 크기를 늘리기 전에 그 Query를 보는 것이 맞을 수 있다.

    반대로 Query는 충분히 빠르고 PostgreSQL 자원도 여유가 있는데 요청이 Connection을 기다리는 시간이 크다면 그때는 Pool 크기를 조정할 근거가 조금 더 생긴다.

    즉 Pool 크기를 결정하려면 Connection 대기와 Query 실행시간을 분리해서 봐야 한다.


    여기서부터 성능 측정을 어떻게 해야 할지도 조금 명확해졌다

    처음에는 Pool 크기를 테스트한다면 다음처럼 하면 될 것 같았다.

    max_size=5
    → 응답시간 측정
    
    max_size=10
    → 응답시간 측정
    
    max_size=20
    → 응답시간 측정
    
    max_size=50
    → 응답시간 측정
    

    그리고 가장 빠른 숫자를 선택한다.

    이 방식도 의미는 있다.

    하지만 결과만 가지고는 왜 빨라졌거나 느려졌는지 설명할 수 없다.

    예를 들어 max_size=20에서 가장 빠르게 나왔다고 하자.

    왜 20일까?

    10에서는 Connection 대기가 있었기 때문인가?

    50에서는 PostgreSQL 동시 Query가 늘어 서버가 더 바빠졌기 때문인가?

    우연히 테스트 당시 트래픽이 달랐던 것인가?

    원인을 알 수 없다.

    그래서 지금 다시 테스트한다면 Pool 크기와 함께 최소한 세 종류의 시간을 나눠 보고 싶다.

    1. Pool에서 Connection을 기다린 시간
    
    2. Connection을 얻은 뒤 Query가 끝나는 시간
    
    3. 전체 API 응답시간
    

    이렇게 하면 훨씬 많은 것을 설명할 수 있다.

    예를 들어 실제 측정에서:

    Pool 5
    → Connection 대기 큼
    → Query 시간 안정적
    
    Pool 20
    → Connection 대기 감소
    → Query 시간 안정적
    
    Pool 50
    → Connection 대기 거의 없음
    → Query 시간이 크게 증가
    

    라는 패턴이 나온다면 어떤 일이 일어나고 있는지 추론할 수 있다.

    위 숫자와 결과는 설명을 위한 예시일 뿐 실제 측정 결과는 아니다.

    하지만 이런 식으로 측정해야 “20이 빨랐다”가 아니라 “왜 20에서 빨랐는가”를 설명할 수 있다.

    바로 이런 데이터가 내가 앞으로 글에 남기고 싶은 부분이다.


    평균 응답시간만 봐도 또 놓치는 것이 생긴다

    Pool 설정을 바꾼 뒤 평균 API 응답시간만 비교하는 것도 부족할 수 있다.

    예를 들어 대부분 요청은 빠른데 일부 요청만 Connection을 오래 기다린다고 하자.

    평균값에서는 그 현상이 약하게 보일 수 있다.

    그래서 실제 부하 테스트를 한다면 평균뿐 아니라 p50, p95 같은 percentile도 같이 보고 싶다.

    특히 p95가 크게 증가한다면 일부 사용자가 꽤 긴 대기를 경험하고 있다는 의미로 해석할 수 있다.

    여기에 동시 요청량도 고정해야 한다.

    동시 요청 10
    동시 요청 30
    동시 요청 50
    

    을 섞어서 실행하면 Pool 크기 때문인지 요청량 차이 때문인지 알기 어렵기 때문이다.

    결국 실험의 핵심은 가능한 한 다른 조건을 고정하고 Pool 크기 하나만 바꾸는 것이다.


    그리고 FastAPI worker가 여러 개라면 다시 계산이 달라진다

    이전에 Connection 문제를 보면서 가장 헷갈리기 쉬웠던 부분이 이것이었다.

    코드에는 하나의 설정만 보인다.

    max_size=50
    

    그래서 애플리케이션이 최대 50개의 Connection을 사용하는 것처럼 느껴진다.

    하지만 여러 프로세스에서 각각 Pool을 생성하는 배포 구조라면 각 프로세스에 별도의 Pool이 존재할 수 있다.

    asyncpg Pool은 Python 애플리케이션에서 생성하는 객체이므로 배포 프로세스가 여러 개라면 Pool의 총 규모를 프로세스 구조와 함께 봐야 한다. asyncpg 공식 문서에서 Pool 자체가 애플리케이션이 생성하고 Connection을 빌리는 객체라는 점을 확인할 수 있다.

    따라서 Pool 크기를 테스트할 때도:

    max_size
    

    만 기록하면 부족하다.

    프로세스 수
    컨테이너 수
    동일 DB를 사용하는 서비스 수
    

    까지 당시 조건을 기록해야 나중에 그 결과를 다시 해석할 수 있다.

    그렇지 않으면 몇 달 뒤:

    Pool 20이 가장 좋았다.

    라는 기록만 남고, 당시 worker가 몇 개였는지 몰라 같은 설정을 재현하기 어려워질 수 있다.


    지금 생각하면 Pool 크기는 “성능 설정”보다 “동시성 예산”에 가까웠다

    처음에는 max_size를 성능 설정으로 생각했다.

    숫자를 높이면 더 많은 요청을 처리하고, 숫자를 낮추면 느려질 것이라고 생각하기 쉬웠다.

    그런데 FastAPI와 PostgreSQL 사이의 전체 흐름을 보면서 조금 다른 표현이 더 적절하다고 느꼈다.

    Pool 크기는 애플리케이션이 PostgreSQL에 동시에 몇 개의 작업을 맡길 수 있도록 허용할 것인지 정하는 예산에 가깝다.

    예를 들어 Pool이 20이라면 동시에 100개의 API 요청이 들어오더라도 DB Connection이 필요한 모든 요청이 동시에 PostgreSQL로 내려가는 것을 어느 정도 제한할 수 있다.

    FastAPI 동시 요청 100
              ↓
       Connection Pool 20
              ↓
    PostgreSQL 동시 DB 작업 제한
    

    물론 Query 사용 방식과 Connection을 잡고 있는 시간에 따라 실제 모습은 훨씬 복잡하다.

    하지만 이 관점으로 바꾸고 나니 왜 Pool을 무조건 크게 만들면 안 되는지가 이해됐다.

    Pool은 요청을 빨리 보내기 위한 통로이면서 동시에 DB를 보호하는 경계가 될 수도 있다.


    그렇다면 적절한 Pool 크기는 얼마일까

    여기까지 읽으면 결국 이 질문이 남는다.

    그래서 5가 좋은가, 10이 좋은가, 20이 좋은가, 50이 좋은가?

    이 글에서 특정 숫자를 정답처럼 말하고 싶지는 않다.

    내가 실제로 사용한 값은 min_size=1, max_size=50이었지만, 이 값이 모든 서비스에서 최적이라는 의미는 전혀 없다.

    오히려 지금 다시 결정한다면 처음부터 50을 정하고 시작하기보다 작은 값에서 부하를 점차 늘리면서 어디에서 병목이 발생하는지를 볼 것 같다.

    Pool 크기를 늘릴 때마다 다음 질문을 한다.

    Connection 대기가 실제로 줄었는가?

    그다음:

    그 결과 전체 응답시간도 줄었는가?

    다시:

    PostgreSQL Query 시간이나 CPU/I/O는 악화되지 않았는가?

    그리고:

    Pool을 더 늘렸을 때 얻는 개선이 여전히 있는가?

    이런 식으로 보면 어느 순간부터 Pool을 늘려도 개선 폭이 거의 없어지는 지점이 생길 수 있다.

    그 이후는 더 많은 Connection이 실질적인 성능 향상을 만들지 않는 구간일 가능성을 생각할 수 있다.

    결국 찾고 싶은 숫자는 가장 큰 Pool이 아니다.

    현재 트래픽에서 PostgreSQL을 과도하게 밀어붙이지 않으면서 Connection 대기도 받아들일 수 있는 수준으로 유지하는 Pool 크기다.


    그래서 실제 실험을 한다면 이런 글을 다시 쓰고 싶다

    현재 글에서는 과거의 max_size=50이라는 실제 설정과, 그 설정을 다시 보면서 알게 된 구조를 중심으로 정리했다.

    하지만 여기에서 한 단계 더 가려면 실제 측정이 필요하다.

    같은 API를 대상으로:

    max_size=5
    max_size=10
    max_size=20
    max_size=50
    

    을 순서대로 적용한다.

    동시 요청 수도 고정한다.

    그리고 각 조건에서:

    Pool acquire 대기시간
    
    SQL 실행시간
    
    전체 API 응답시간
    
    p50
    
    p95
    
    PostgreSQL Connection 수
    
    PostgreSQL CPU 사용량
    

    을 함께 남긴다.

    그 결과가 나온다면 그때는 정말 다음과 같은 제목의 글을 쓸 수 있다.

    FastAPI asyncpg Pool을 5·10·20·50으로 바꿔 직접 측정해봤다.

    그리고 그 글에서는:

    20이 좋았다.

    라고만 쓰지 않을 것이다.

    왜 20까지는 좋아졌고, 왜 그 이후에는 개선되지 않았는지를 데이터로 설명할 수 있어야 한다.

    그때부터 비로소 Connection Pool 튜닝이라는 말을 쓸 수 있다고 생각한다.


    마무리

    FastAPI에서 DB Connection Pool을 처음 설정할 때 나는 max_size를 비교적 단순하게 봤다.

    동시 요청이 많다면 Connection도 많을수록 유리할 것이라고 생각하기 쉬웠다.

    실제로 Pool이 너무 작아서 요청이 Connection을 기다리고 있다면 크기를 늘리는 것이 성능 개선으로 이어질 수도 있다.

    하지만 그 다음 단계를 생각하지 않으면 설명이 부족하다.

    Pool에서 Connection을 얻은 요청은 결국 PostgreSQL로 간다.

    PostgreSQL의 CPU와 메모리, I/O 처리 능력이 Pool 크기를 늘린다고 같이 커지는 것은 아니다.

    따라서 Pool에서의 대기를 없애면서 DB 쪽 경쟁을 늘리는 상황도 생각해야 한다.

    이 관점에서 보면:

    Connection을 빨리 얻었다
    

    요청이 빨리 끝났다
    

    는 서로 다른 결과다.

    그리고:

    Pool이 꽉 찼다
    

    라는 현상도 반드시:

    Pool이 너무 작다
    

    를 의미하지 않는다.

    느린 Query가 Connection을 오래 점유한 결과일 수도 있다.

    그래서 지금은 Pool 문제가 의심되면 숫자를 바로 올리는 것보다 먼저 묻는다.

    요청이 실제로 어디에서 기다리고 있는가?

    Pool에서 기다리고 있는가.

    PostgreSQL Query가 끝나기를 기다리고 있는가.

    Lock을 기다리고 있는가.

    외부 시스템을 기다리고 있는가.

    이 위치를 알지 못하면 Pool 크기를 변경해도 무엇을 개선한 것인지 설명하기 어렵다.

    결국 max_size는 많이 열어두기 위한 숫자가 아니라, 애플리케이션과 PostgreSQL 사이에서 동시에 허용할 DB 작업량을 조절하는 값으로 보는 편이 내게는 더 이해하기 쉬웠다.

    그리고 이 관점으로 바꾸고 나서야 max_size=50이라는 숫자를 보고 단순히 크다거나 작다고 말하는 대신,

    왜 50이어야 하는가?

    라는 질문을 먼저 하게 됐다.

    그 질문에 실제 측정값으로 답할 수 있어야 그 설정이 비로소 내 서비스에 맞는 설정이라고 말할 수 있다고 생각한다.

  • PostgreSQL max_connections를 크게 올리기 전에 연결 상태부터 보게 된 이유

    FastAPI 서비스를 운영하면서 PostgreSQL의 too many clients already 오류를 처음 봤을 때는 해결 방향이 꽤 명확해 보였다.

    오류 메시지를 그대로 읽으면 PostgreSQL이 받아들일 수 있는 Connection 수를 모두 사용했다는 뜻으로 이해할 수 있었고, 그렇다면 허용 가능한 Connection 수를 늘리면 되는 것처럼 보였다.

    당시에는 PostgreSQL의 max_connections를 상당히 크게 잡는 방법을 찾아보면서 3,000 정도까지 올리는 방안도 생각해봤다.

    서버 메모리가 충분하고 PostgreSQL에서 연결을 더 많이 허용하면 적어도 too many clients already 때문에 새로운 요청이 실패하는 문제는 사라지지 않을까 하는 생각이었다.

    처음에는 꽤 합리적인 해결 방법처럼 느껴졌다.

    그런데 값을 실제로 크게 올리는 쪽으로 생각하다 보니 오히려 한 가지 질문이 생겼다.

    지금 PostgreSQL이 연결을 적게 허용하고 있는 것이 정말 문제라면, 내 애플리케이션은 왜 그렇게 많은 Connection을 필요로 하고 있는 걸까?

    이 질문에 답하지 못하고 있었다.

    그때 FastAPI에서는 이미 asyncpg Connection Pool을 사용하고 있었고, 내가 사용하던 설정은 대략 다음과 같은 형태였다.

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

    애플리케이션이 Connection을 무제한으로 생성하지 않도록 최대 크기까지 정해놓은 상태였다.

    그렇다면 단순히 PostgreSQL의 상한만 올리기 전에 내가 생각하고 있는 Connection 구조와 실제 PostgreSQL 상태가 같은지부터 확인하는 것이 순서가 아닐까라는 생각이 들었다.

    이 지점부터 max_connections를 바라보는 방식이 조금 달라졌다.


    처음에는 “최대 몇 개까지 받을 수 있느냐”만 보고 있었다

    PostgreSQL의 현재 설정값은 간단하게 확인할 수 있다.

    SHOW max_connections;
    

    PostgreSQL 공식 문서에서 max_connections는 서버가 동시에 허용할 수 있는 최대 Connection 수를 결정하는 설정이다. 그런데 문서를 조금 더 읽어보면 PostgreSQL은 이 값을 기준으로 일부 자원의 크기를 결정하고, 값을 높이면 shared memory를 포함한 관련 자원 할당도 증가할 수 있다고 설명하고 있다.

    처음에는 앞부분만 보고 있었다.

    Connection이 부족하다
    → max_connections가 작다
    → 값을 높인다
    

    그런데 뒤의 이야기를 같이 생각하면 조금 달라진다.

    max_connections는 단순히 출입문 앞에 붙어 있는 숫자표가 아니다.

    값을 크게 만든다는 것은 PostgreSQL이 더 많은 동시 Connection을 받아들일 수 있는 구조로 운영하겠다는 결정에 가깝다.

    그렇다면 100이 부족해서 300으로 늘리고, 300이 부족하면 1,000으로 늘리고, 또 부족하면 3,000으로 늘리는 방식이 정말 올바른 해결인지 먼저 확인해야 했다.

    왜냐하면 애플리케이션 구조에서 Connection이 비정상적으로 계속 증가하고 있다면 상한을 올리는 것은 그 증가를 멈추는 것이 아니라 문제가 다시 나타나는 지점을 뒤로 옮기는 것일 수도 있기 때문이다.

    예를 들어 물이 계속 새고 있는데 양동이 크기만 계속 키우는 것과 비슷하다.

    양동이가 커졌기 때문에 넘치는 시점은 늦어진다.

    하지만 물이 새는 원인은 그대로다.

    나는 max_connections를 크게 올리는 문제도 이런 상황일 수 있다고 생각하기 시작했다.


    그래서 총 Connection 수를 먼저 보기 시작했다

    그다음 생각은 자연스럽게 이어졌다.

    PostgreSQL이 최대 몇 개를 허용하는지는 알았다.

    그렇다면 지금 실제로 몇 개가 연결되어 있는지를 봐야 했다.

    PostgreSQL에서는 pg_stat_activity를 통해 서버 프로세스별 현재 활동을 확인할 수 있다. 현재 공식 문서에서도 이 View는 서버 프로세스마다 한 행을 제공하고 현재 상태와 Query 등의 정보를 보여준다고 설명한다.

    가장 단순하게는 현재 Connection 수부터 확인할 수 있다.

    SELECT COUNT(*)
    FROM pg_stat_activity;
    

    그런데 여기서 숫자 하나만 확인하고 끝내면 다시 처음과 비슷한 문제가 생긴다.

    예를 들어 결과가 90이라고 하자.

    max_connections가 100이라면:

    거의 다 썼네. 역시 100이 부족했구나.

    라고 생각하기 쉽다.

    하지만 90개의 Connection이 왜 존재하고 있는지는 아직 모른다.

    90개가 모두 실제 사용자 요청을 처리하고 있을 수도 있다.

    Connection Pool이 다음 요청을 위해 유지하고 있는 연결이 많이 포함되어 있을 수도 있다.

    오랫동안 끝나지 않는 Query 때문에 Connection이 반환되지 않고 있을 수도 있고, Transaction이 정상적으로 종료되지 않은 상태가 있을 수도 있다.

    다른 애플리케이션이 같은 PostgreSQL에 연결하고 있을 수도 있다.

    COUNT(*) = 90은 현상을 보여주기는 하지만 원인을 설명하지는 못한다.

    이 부분 때문에 다음에는 Connection의 상태를 같이 보게 됐다.


    activeidle을 보고 나서야 숫자의 의미가 조금 달라졌다

    다음처럼 상태별로 나누면 현재 Connection이 어떤 상태인지 조금 더 구체적으로 볼 수 있다.

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

    pg_stat_activitystate에는 active, idle, idle in transaction 등 현재 backend의 상태가 표시된다. PostgreSQL 공식 문서에서도 active는 Query를 실행하고 있는 상태, idle은 새로운 client command를 기다리는 상태, idle in transaction은 Transaction 안에 있지만 현재 Query를 실행하지 않는 상태로 구분한다.

    처음 이 값을 보면 idle이 많다는 사실 자체가 문제처럼 보일 수 있다.

    사용하지 않는 Connection이 왜 이렇게 많이 남아 있느냐는 생각이 들기 때문이다.

    하지만 내가 asyncpg Connection Pool을 사용하고 있다는 사실을 다시 떠올리면 그렇게 단순하게 볼 수 없었다.

    Connection Pool은 사용이 끝난 Connection을 매번 PostgreSQL에서 완전히 끊어버리는 대신 다음 요청에서 재사용할 수 있도록 유지한다.

    따라서 Pool 안에서 당장 사용하지 않는 Connection이 idle로 존재하는 것은 자연스러운 상황일 수도 있다.

    여기에서 질문을 다시 바꿔야 했다.

    idle Connection이 있는가?

    가 아니라,

    현재 보이는 idle Connection의 개수가 내가 설정한 Pool 구조로 설명 가능한가?

    가 더 중요한 질문이었다.

    예를 들어 Pool이 하나이고 최대값이 50이라고 생각했는데 동일한 애플리케이션에서 만들어진 것으로 보이는 Connection이 그보다 훨씬 많다면 그때부터 이야기가 달라진다.

    Pool 자체가 여러 번 만들어지고 있는지, FastAPI worker마다 별도의 Pool이 생성되는지, 동일한 컨테이너가 여러 개 실행 중인지 확인할 이유가 생긴다.

    이렇게 보니 pg_stat_activity의 숫자는 단순히 많고 적음을 판단하기 위한 값이 아니라 내가 알고 있는 애플리케이션 구조가 실제 DB에서도 그렇게 보이는지를 검증하는 자료가 됐다.


    idle in transaction은 같은 idle이라고 보기 어려웠다

    상태를 보다 보면 idle in transaction이라는 값도 만날 수 있다.

    처음에는 이름에 idle이 들어 있으니 일반적인 idle Connection과 비슷하게 생각하기 쉽다.

    하지만 의미를 보면 다르다.

    Transaction이 시작된 상태인데 현재 Query는 실행하지 않고 client의 다음 명령을 기다리고 있다는 의미다. PostgreSQL 문서에서도 idle in transaction을 Transaction 안에 있으면서 현재 Query를 실행하지 않는 상태로 구분한다.

    왜 이 차이가 중요할까 생각해봤다.

    일반적인 idle Connection은 Connection Pool에서 다음 사용을 기다리고 있을 수 있다.

    반면 Transaction이 열린 채로 오래 남아 있다면 단순히 Connection 하나를 유지하고 있다는 것에서 끝나지 않을 수 있다.

    Transaction이 언제 시작됐고 얼마나 오래 유지되고 있는지를 확인할 필요가 생긴다.

    그래서 상태만 보는 것보다 조금 더 정보를 붙여서 확인하는 편이 의미가 있었다.

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

    이 쿼리를 보면 질문이 더 구체적으로 바뀐다.

    이 Connection은 언제 만들어졌는가.

    Transaction은 언제 시작됐는가.

    마지막 Query는 언제 시작됐는가.

    어떤 애플리케이션에서 연결했는가.

    어느 client 주소에서 들어왔는가.

    현재 어떤 Query와 연결되어 있는가.

    단순히 90개 연결되어 있다에서 끝났을 때보다 조사할 수 있는 방향이 훨씬 많아진다.


    그러고 나니 “연결이 많다”와 “연결 누수다”도 구분해야 했다

    Connection 숫자가 계속 많아 보이면 곧바로 누수를 의심하기 쉽다.

    나도 처음에는 연결 수가 예상보다 많으면:

    Connection이 반환되지 않고 있는 것 아닐까?

    라는 생각부터 했다.

    충분히 확인할 가치가 있는 가설이다.

    하지만 그것 역시 바로 결론을 내릴 수 있는 문제는 아니었다.

    Connection Pool을 사용하면 일정한 수의 Connection을 의도적으로 유지할 수 있다.

    FastAPI worker가 여러 개라면 프로세스마다 별도의 Pool이 만들어질 수 있다.

    같은 PostgreSQL을 여러 서비스가 사용한다면 다른 서비스 Connection도 pg_stat_activity에 같이 보인다.

    따라서 단순히:

    Connection 수가 많음
    =
    Connection leak
    

    라고 연결할 수 없다.

    누수라고 판단하려면 적어도 내가 예상하는 정상적인 Connection 수와 실제 Connection 수 사이에 설명할 수 없는 차이가 있는지를 봐야 했다.

    이 지점에서 이전 글에서 생각했던 Pool의 크기 문제와 다시 연결됐다.

    코드에는:

    max_size=50
    

    이라고 적혀 있어도 프로세스가 여러 개라면 PostgreSQL 서버 전체에서 보게 되는 Connection 수는 50보다 클 수 있다.

    결국 PostgreSQL 문제라고 생각했던 것이 실제로는 배포 구조까지 포함해서 봐야 하는 문제가 된다.


    여기서 application_name 같은 값도 왜 필요한지 이해하게 됐다

    정상적으로 동작할 때는 DB Connection이 어느 프로그램에서 왔는지를 자세히 구분할 필요가 없어 보인다.

    연결만 잘 되고 Query만 정상적으로 실행되면 충분하기 때문이다.

    그런데 여러 서비스가 같은 PostgreSQL을 사용하기 시작하면 상황이 달라진다.

    예를 들어 검색 API와 추천 API, 관리용 프로그램, 배치 프로그램이 모두 같은 DB를 이용한다고 생각해보자.

    Connection이 많아졌을 때:

    누가 많이 사용하고 있는가?

    를 알아야 한다.

    그런데 모든 Connection이 비슷하게 보이면 조사하기가 어렵다.

    pg_stat_activity에는 application_name, usename, client_addr 같은 정보가 있기 때문에 서비스별 Connection을 구분할 수 있도록 애플리케이션을 구성해두면 장애 상황에서 도움이 된다. 해당 컬럼들은 현재 pg_stat_activity 공식 정의에도 포함되어 있다.

    평소에는 이런 메타데이터가 기능 구현과 직접 관계없는 정보처럼 보였다.

    하지만 문제가 생긴 뒤에는 오히려 이런 값이 원인을 좁히는 데 중요하다는 것을 알게 됐다.

    운영 로그와 모니터링 정보는 문제가 없을 때보다 문제가 생겼을 때 가치가 드러나는 경우가 많았다.


    그런데 Connection 상태만 보고 끝내도 부족했다

    여기까지 확인하면 PostgreSQL Connection 문제를 꽤 많이 이해한 것처럼 느껴진다.

    하지만 한 가지가 다시 걸렸다.

    예를 들어 Pool에 Connection이 50개 있고 대부분 active라고 하자.

    그렇다면 Connection이 부족한 것이고 max_connections를 늘려야 할까?

    꼭 그렇지는 않다.

    왜 50개의 Connection이 모두 오랫동안 active인지 봐야 하기 때문이다.

    Query 하나가 매우 빠르게 끝난다면 Connection은 곧 Pool로 돌아간다.

    반대로 Query가 오래 걸리면 Connection 하나가 오랫동안 점유된다.

    이 상황에서 요청이 계속 들어오면 Pool은 자연스럽게 가득 찰 수 있다.

    즉 겉으로 보이는 현상은:

    Connection 부족
    

    이지만 실제 시작점은:

    느린 Query
    

    일 수도 있다.

    이 사실을 이해하고 나니 max_connections를 높이는 것보다 SQL 실행시간을 줄이는 것이 더 직접적인 해결인 상황도 있을 수 있다는 생각이 들었다.

    Connection을 두 배로 늘리는 것은 느린 Query를 두 배 더 많이 동시에 실행하게 만드는 결과로 이어질 수도 있기 때문이다.


    그래서 Query 실행 통계까지 연결해서 봐야 했다

    현재 실행 중인 Query는 pg_stat_activity로 확인할 수 있다.

    하지만 어떤 SQL이 반복적으로 많이 실행되고 있고 평균적으로 얼마나 오래 걸리는지를 보려면 더 장기적인 통계도 필요하다.

    PostgreSQL에서는 pg_stat_statements 모듈을 이용해 서버에서 실행되는 SQL의 planning 및 execution 통계를 추적할 수 있다. 공식 문서에서도 이 모듈을 서버에서 실행한 SQL 문의 계획 및 실행 통계를 추적하는 기능으로 설명하고 있다.

    이 부분에서 Connection 문제와 Query 성능 문제가 연결된다.

    처음에는 둘을 별개의 문제처럼 봤다.

    Connection 문제
    → Pool / max_connections
    
    Query 문제
    → SQL 튜닝
    

    그런데 실제로는 느린 Query가 Connection을 오래 점유하고, 그 때문에 Pool 대기가 생기고, 결국 새로운 Connection을 더 만들려고 하면서 PostgreSQL 전체 Connection 수까지 증가하는 흐름도 생각할 수 있다.

    느린 Query
        ↓
    Connection 오래 점유
        ↓
    Pool 사용량 증가
        ↓
    새 요청이 Connection 대기
        ↓
    Pool이 추가 Connection 사용
        ↓
    전체 DB Connection 증가
    

    그렇다면 마지막에 나타난 too many clients already만 보고 문제의 시작점을 판단하면 잘못된 곳을 수정할 수도 있다.

    이런 이유로 이제는 DB 장애 메시지를 볼 때 가장 마지막에 터진 오류가 항상 최초 원인이라고 생각하지 않게 됐다.


    한때 3,000까지 생각했던 숫자를 다시 보게 된 이유도 여기에 있다

    max_connections=3000이라는 숫자를 처음 생각했을 때는 단순했다.

    연결 상한이 매우 크면 Connection 부족 때문에 장애가 발생할 가능성도 작아질 것이라고 생각했다.

    그런데 지금처럼 전체 흐름을 다시 보면 질문이 달라진다.

    정말 3,000개의 Connection이 필요한 서비스인가.

    동시에 3,000개의 Connection이 필요할 정도의 요청이 들어오는가.

    그 Connection들이 실제로 동시에 Query를 실행한다면 현재 PostgreSQL 서버가 그 부하를 어떻게 처리할 것인가.

    아니면 대부분의 Connection은 실제 작업 없이 유지되고 있는가.

    왜 Application Pool에서 그렇게 많은 Connection을 허용해야 하는가.

    이 질문에 답하지 않고 3,000이라는 숫자만 설정하는 것은 너무 큰 결정을 근거 없이 하는 셈이라는 생각이 들었다.

    PostgreSQL 공식 문서 역시 max_connections를 높이면 이 값에 직접 비례해 일부 서버 자원 할당이 늘어날 수 있다고 밝히고 있다.

    그래서 지금 다시 같은 문제를 만난다면 3,000이면 충분할까?부터 묻지 않을 것 같다.

    오히려:

    현재 서비스가 정상적으로 동작할 때 필요한 Connection은 몇 개인가?

    부터 묻고 싶다.

    이 값이 있어야 현재 Connection 수가 정상적인 성장인지 비정상적인 증가인지 판단할 기준도 생긴다.


    지금이라면 장애가 발생하기 전 평상시 값을 먼저 기록할 것 같다

    이 문제를 지나고 나서 한 가지 아쉬운 점도 있었다.

    장애가 발생했을 때의 상태만 보면 그 숫자가 평소보다 얼마나 이상한 것인지 판단하기 어렵다는 것이다.

    예를 들어 장애 순간 Connection이 80개라고 하자.

    평소에도 70~80개를 사용하는 서비스라면 80이라는 숫자가 특별하지 않을 수 있다.

    반대로 평소 5개를 사용하던 서비스가 갑자기 80개가 됐다면 매우 중요한 변화다.

    결국 장애 분석에는 정상 상태의 기준값이 필요하다.

    그래서 지금 다시 운영한다면 일정 간격으로 다음 정도는 기록해두고 싶다.

    현재 총 Connection 수.

    상태별 Connection 수.

    서비스별 Connection 수.

    오래 실행되는 Query 수.

    Transaction이 오래 열린 세션.

    이 데이터를 시간에 따라 보면 한 순간의 스냅샷보다 훨씬 많은 것을 알 수 있다.

    예를 들어:

    평소 10~15
    ↓
    어느 배포 이후 30
    ↓
    며칠 뒤 50
    ↓
    장애 직전 90
    

    같은 패턴이 보인다면 단순히 장애 순간의 숫자만 보는 것보다 원인을 추적하기 쉬워진다.

    위 숫자는 설명을 위한 예시이지만, 이런 변화 자체를 기록하는 구조가 필요하다는 점이 중요하다.


    결국 Connection 수는 하나의 결과값이었다

    처음에는 too many clients already라는 오류 때문에 Connection 숫자 자체에만 집중했다.

    몇 개까지 받을 수 있느냐.

    몇 개가 연결되어 있느냐.

    Pool이 몇 개인가.

    그런데 계속 생각해보니 Connection 수는 여러 동작의 결과로 만들어지는 값이었다.

    사용자가 얼마나 들어오는지에 따라 달라진다.

    Query가 얼마나 빨리 끝나는지에 따라 달라진다.

    Pool의 크기에 따라 달라진다.

    FastAPI worker 수에도 영향을 받을 수 있다.

    컨테이너 개수에 따라서도 달라질 수 있다.

    동일 DB를 사용하는 다른 서비스가 있으면 또 달라진다.

    Transaction을 잘못 관리하면 Connection이 오래 점유될 수도 있다.

    그래서 Connection 문제를 단순히 PostgreSQL의 숫자 하나로 해결하려고 하면 원인을 설명하기 어려워진다.

    이제는 전체 흐름을 다음처럼 보는 편이 훨씬 자연스럽다.

    사용자 요청
        ↓
    FastAPI
        ↓
    DB Connection 필요
        ↓
    asyncpg Pool
        ↓
    PostgreSQL Connection
        ↓
    SQL 실행
        ↓
    Connection 반환
    

    이 흐름 중 어느 부분에서 작업이 오래 머무르는지를 찾아야 한다.


    지금 다시 too many clients already를 본다면

    지금 같은 오류를 다시 만난다면 예전과 순서가 많이 다를 것 같다.

    가장 먼저 max_connections를 높이지는 않을 것이다.

    우선 현재 상한을 확인한다.

    SHOW max_connections;
    

    그다음 실제 Connection 수와 상태를 본다.

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

    그런데 여기에서도 결론을 내리지 않는다.

    예상보다 많다면 어떤 서비스에서 만든 Connection인지 본다.

    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이 있다면 생성 시점과 Transaction, Query 시작 시각을 같이 본다.

    그 후 애플리케이션으로 돌아가 Pool 설정과 create_pool() 위치를 확인한다.

    worker와 container 수도 다시 본다.

    그리고 Query가 Connection을 오래 잡고 있는 상황이라면 SQL 실행시간과 통계를 확인한다.

    이 과정을 거치고도 실제 서비스 규모 때문에 정상적인 Connection이 부족하다는 결론이 나오면 그때 max_connections 조정을 검토할 수 있다.

    이 순서가 예전과 가장 크게 달라진 점이다.


    마무리

    처음 too many clients already 오류를 봤을 때 내 질문은 단순했다.

    max_connections를 얼마까지 높이면 될까?

    지금은 그 질문부터 하지 않는다.

    먼저 묻는다.

    왜 지금 이렇게 많은 Connection이 필요한가?

    이 질문에 답하기 위해서는 PostgreSQL 설정만 봐서는 부족하다.

    pg_stat_activity에서 실제 Connection 상태를 보고, Pool이 어떻게 만들어지고 있는지 확인하고, 느린 Query가 Connection을 오래 점유하고 있지는 않은지까지 연결해서 봐야 한다.

    max_connections는 분명 필요한 PostgreSQL 설정이다.

    서비스 규모가 커지면 실제로 값을 조정해야 하는 상황도 있을 수 있다.

    하지만 상한을 크게 만드는 것과 Connection을 올바르게 관리하는 것은 서로 다른 문제다.

    내가 이 문제를 겪으면서 가장 크게 바뀐 부분은 바로 이것이었다.

    Connection 한계에 도달했다는 사실을 해결해야 할 원인으로 보지 않고, 그 앞에서 어떤 일이 일어났는지를 알려주는 결과로 보기 시작한 것.

    그러고 나니 too many clients already라는 메시지를 보는 방식도 달라졌다.

    예전에는:

    Connection이 부족하다.
    → 더 많이 허용하자.
    

    였다면,

    지금은:

    Connection이 부족하다.
    → 왜 이렇게 많이 사용하고 있지?
    → 정상적인 사용량인가?
    → 어떤 상태의 Connection이 많은가?
    → 어느 서비스에서 만들었는가?
    → Query가 오래 잡고 있는 것은 아닌가?
    → Pool과 배포 구조로 설명 가능한가?
    

    로 질문이 이어진다.

    그리고 그 질문에 답한 다음에야 max_connections를 늘리는 것이 정말 필요한 조치인지 판단할 수 있다고 생각한다.

    결국 PostgreSQL 설정값 하나를 바꾸는 것보다 현재 시스템에서 Connection이 만들어지고 사용되고 반환되는 흐름을 설명할 수 있는 상태를 만드는 것이 먼저였다.

  • Elasticsearch KNN에서 k와 num_candidates를 따로 봐야 했던 이유

    Elasticsearch에 벡터 검색을 붙이면서 처음에는 knum_candidates를 거의 같은 종류의 설정으로 생각했다.

    둘 다 결국 몇 개의 문서를 검색할 것인지 결정하는 숫자처럼 보였기 때문이다.

    내 도서 검색 코드에서는 외부에서 받은 k를 그대로 Elasticsearch에 넣지 않고 다음처럼 조금 더 많은 후보를 확보하도록 설정했다.

    search_k = k + 10
    num_candidates = k + 20
    

    예를 들어 API에서 k=10을 요청했다면 내부에서는 그보다 많은 결과와 후보를 검색하는 방식이다.

    처음에는 이것을 단순히 “후보를 조금 넉넉하게 가져온다” 정도로 생각했다.

    그런데 KNN 검색을 계속 다루다 보니 중요한 질문은 숫자를 몇 개 더 가져오느냐가 아니었다.

    왜 후보가 부족하면 원하는 결과를 놓칠 수 있는가?

    그리고 반대로,

    후보를 많이 가져오면 정확도가 좋아질 가능성이 있는데 왜 무조건 크게 설정하지 않는가?

    이 두 질문을 이해해야 knum_candidates를 제대로 설정할 수 있었다.


    먼저 KNN 검색이 모든 벡터를 정확하게 비교하는 것은 아니다

    벡터 검색을 처음 접했을 때 나는 검색 과정을 다음과 비슷하게 상상했다.

    사용자 검색 벡터
            ↓
    모든 도서 벡터와 비교
            ↓
    유사도 계산
            ↓
    가장 가까운 10개 선택
    

    이 방식이라면 이해하기 쉽다.

    도서가 100만 권 있으면 100만 개의 벡터와 모두 비교하고 그중 가장 가까운 10개를 선택하면 된다.

    이런 방식은 정확한 nearest-neighbor search에 가깝다.

    문제는 데이터가 커질수록 매 검색마다 모든 벡터와 비교하는 비용이 커진다는 것이다.

    Elasticsearch의 일반적인 approximate kNN 검색에서는 HNSW 같은 그래프 기반 인덱스를 이용해서 전체 벡터를 모두 탐색하지 않고 가까울 가능성이 높은 영역부터 탐색한다.

    여기서 approximate, 즉 근사 검색이라는 말이 중요하다.

    빠른 대신 처음부터 모든 문서를 조사하는 것이 아니다.

    따라서 실제로 가장 가까운 문서가 존재하더라도 검색 과정에서 그 문서가 후보군 안으로 들어오지 못하면 최종 결과에서도 볼 수 없다.

    이 지점에서 num_candidates가 중요해진다.


    num_candidates가 부족하면 왜 좋은 결과를 놓칠 수 있을까

    Elasticsearch의 approximate kNN 검색에서는 shard마다 먼저 num_candidates만큼의 후보를 수집하고, 그 후보를 이용해 최종적으로 가까운 k개의 결과를 선택한다.

    개념적으로 단순화하면 다음과 같다.

    전체 벡터
       ↓
    HNSW 그래프 탐색
       ↓
    num_candidates 만큼 후보 확보
       ↓
    후보들의 유사도 비교
       ↓
    상위 k개 선택
    

    여기서 굉장히 중요한 점이 있다.

    최종 선택은 처음 확보한 후보 안에서만 할 수 있다.

    예를 들어 정말 좋은 도서가 전체 데이터에서는 사용자 검색어와 매우 가까운 벡터를 가지고 있다고 하자.

    그런데 approximate search의 초기 탐색 과정에서 그 책이 후보군에 포함되지 않았다면 어떻게 될까?

    최종 단계에서 아무리 정확하게 유사도를 계산해도 그 책은 선택할 수 없다.

    애초에 후보 목록에 없기 때문이다.

    즉 문제를 다음처럼 볼 수 있다.

    좋은 문서 존재
          ↓
    초기 후보 탐색에서 발견하지 못함
          ↓
    num_candidates 후보에 포함되지 않음
          ↓
    최종 k 선정 대상에도 없음
          ↓
    검색 결과에서 누락
    

    이것이 num_candidates를 지나치게 작게 잡았을 때 검색 품질이 떨어질 수 있는 가장 중요한 이유다.

    단순히:

    후보가 적으니까 정확도가 떨어진다.

    가 아니다.

    좀 더 정확하게 말하면:

    approximate search가 실제 nearest neighbor를 후보군 안으로 가져올 기회가 줄어들 수 있기 때문에 최종 결과의 recall이 떨어질 수 있다.

    Elastic 공식 문서에서도 HNSW 검색에서 num_candidates를 늘리는 것이 일반적으로 recall과 정확도를 개선할 가능성이 있으며, 그 대신 latency가 증가하는 trade-off가 있다고 설명한다.


    예를 들어 왜 k=10, num_candidates=10이 답답할 수 있을까

    설명을 위해 단순한 상황을 생각해볼 수 있다.

    내가 최종적으로 10개의 책을 가져오고 싶다고 하자.

    k = 10
    

    그리고 후보도 정확히 10개만 확보한다면:

    num_candidates = 10
    

    검색 엔진 입장에서는 사실상 처음 탐색한 후보 10개에서 최종 10개를 결정해야 하는 여유 없는 구조가 된다.

    후보 탐색 단계에서 조금이라도 더 좋은 문서를 놓쳤다면 최종 단계에서 이를 보완할 여지가 작다.

    반면 후보를 더 넓게 확보한다면:

    k = 10
    num_candidates = 50
    

    최종적으로 보여줄 책은 여전히 10개지만 검색 엔진은 더 넓은 후보를 비교할 수 있다.

    후보 50개
        ↓
    유사도 비교
        ↓
    그중 상위 10개
    

    따라서 최종 10개에 포함될 만한 좋은 문서가 후보군 안으로 들어올 가능성을 높일 수 있다.

    다만 여기서 50이라는 숫자가 좋다는 의미는 아니다.

    이것은 원리를 설명하기 위한 예일 뿐이며, 적절한 후보 수는 데이터 규모와 인덱스 구성, shard 수, 필터 조건 등에 따라 달라진다.


    그렇다면 num_candidates를 아주 크게 하면 되지 않을까

    여기까지 이해하면 자연스럽게 다음 생각이 든다.

    후보를 많이 찾을수록 놓칠 가능성이 줄어든다면 그냥 num_candidates를 매우 크게 하면 되는 것 아닌가?

    실제로 검색 품질 하나만 놓고 보면 후보를 더 많이 탐색하는 것이 도움이 될 가능성이 있다.

    하지만 검색 서비스에는 정확도만 있는 것이 아니다.

    후보를 많이 탐색하려면 검색 엔진이 그만큼 더 많은 그래프 노드를 살펴보고 더 많은 후보를 평가해야 한다.

    즉 다음 두 목표가 충돌한다.

    더 많은 후보 탐색
          ↓
    좋은 문서를 찾을 가능성 ↑
    
    하지만
    
    더 많은 후보 탐색
          ↓
    검색 작업량 ↑
          ↓
    응답 지연 가능성 ↑
    

    Elastic에서도 num_candidates를 HNSW approximate kNN의 주요한 검색 시점 speed/accuracy 조절값으로 설명한다. 값을 증가시키면 recall과 accuracy 개선 가능성이 있는 대신 latency가 높아질 수 있다.

    따라서 목표는:

    최대한 많은 후보를 찾는다.

    가 아니다.

    오히려:

    검색 결과가 충분히 안정되는 범위까지 후보를 늘리고, 그 이후에는 불필요하게 검색 비용을 증가시키지 않는다.

    에 가깝다.


    여기서 knum_candidates의 역할이 갈린다

    이제 두 숫자를 따로 볼 수 있다.

    내가 이해하는 기준은 다음과 같다.

    k

    최종적으로 얼마나 많은 가까운 결과가 필요한가?

    num_candidates

    그 결과를 찾기 위해 검색 과정에서 얼마나 넓은 후보를 살펴볼 것인가?

    예를 들어 최종 서비스에서 10개의 후보가 필요한데 검색 엔진 내부에서는 30개나 50개의 후보를 탐색할 수도 있다.

    서비스가 필요한 결과 수
             ↓
             k
    
    검색 엔진이 살펴볼 후보 폭
             ↓
     num_candidates
    

    두 숫자가 관련되어 있지만 같은 목적의 숫자는 아니다.

    현재 Elastic의 kNN retriever 문서에서도 k는 반환할 nearest neighbors 수이고, num_candidates는 shard별 초기 후보군의 크기로 정의되어 있다. 또한 knum_candidates보다 클 수 없다.


    내 서비스에서는 libCode 필터 때문에 한 가지를 더 생각해야 했다

    내가 구현한 도서 검색에서는 전체 도서를 대상으로 검색하지 않는다.

    사용자가 특정 도서관을 선택했다면 해당 도서관이 보유한 데이터 안에서 검색해야 한다.

    그래서 libCode를 필터 조건으로 사용했다.

    구조를 단순화하면 다음과 같다.

    {
      "knn": {
        "field": "embedding",
        "query_vector": [ ... ],
        "k": 20,
        "num_candidates": 30,
        "filter": {
          "terms": {
            "libCode": ["128057"]
          }
        }
      }
    }
    

    처음에는 필터가 붙으면 오히려 검색할 데이터가 줄어드니 항상 더 빨라질 것이라고 생각하기 쉽다.

    일반적인 데이터베이스 검색에서는 상당히 자연스러운 생각이다.

    100만 건에서 찾는 것보다 조건을 걸어 1만 건에서 찾는 것이 일반적으로 쉬울 것처럼 보인다.

    그런데 HNSW 기반 approximate kNN에서는 필터가 반드시 그런 식으로 동작하지 않는다.

    Elastic 공식 문서에서도 필터가 있는 approximate kNN에서는 조건에 맞는 num_candidates를 확보하기 위해 그래프를 더 탐색해야 할 수 있기 때문에, 필터가 오히려 검색 비용을 증가시킬 수 있다고 설명한다.

    왜 그런지 생각해보면 이해하기 쉽다.


    필터가 있으면 왜 더 많은 탐색이 필요할 수 있을까

    예를 들어 벡터상 가까운 책을 탐색했다고 하자.

    검색 엔진이 가까운 후보를 하나 발견했다.

    그런데 그 책의 libCode가 사용자가 선택한 도서관과 다르다.

    벡터상 매우 가까운 책
            ↓
    하지만 libCode 불일치
            ↓
    결과 후보로 사용할 수 없음
    

    또 다른 가까운 책을 찾았다.

    그것도 다른 도서관의 책이다.

    가까운 책 발견
      → 필터 탈락
    
    가까운 책 발견
      → 필터 탈락
    
    가까운 책 발견
      → 필터 통과
    

    검색 엔진 입장에서는 num_candidates=30이라고 해도 단순히 벡터상 가까운 문서 30개만 발견하면 끝나는 문제가 아니다.

    필터까지 통과하는 후보를 충분히 확보해야 한다.

    Elastic의 현재 kNN filter는 approximate kNN 과정에서 적용되는 pre-filter이며, 조건을 만족하는 후보들을 확보하도록 검색 과정에 반영된다.

    그래서 필터 조건이 강하면 그래프를 더 깊게 탐색해야 하는 상황이 생길 수 있다.

    이 부분은 내가 libCode처럼 서비스 자체의 필터를 KNN에 적용할 때 특히 중요하게 생각해야 하는 지점이다.


    반대로 필터가 너무 강하면 brute force로 바뀔 수도 있다

    더 흥미로운 부분도 있다.

    현재 Elasticsearch/Lucene 구현에서는 필터를 적용한 결과 문서 수가 num_candidates 이하라면 HNSW 그래프 검색 대신 필터를 통과한 문서에 대해 brute-force 방식으로 검색할 수 있다.

    또 HNSW 탐색 도중 검색해야 할 그래프 노드 수가 필터 조건에 맞는 문서 수를 넘어서는 상황에서도 brute-force 방식으로 전환할 수 있다.

    이 의미는 꽤 중요하다.

    같은 KNN Query를 사용하고 있어도 항상 내부 실행 방식이 똑같다고 단정할 수 없다는 것이다.

    예를 들어:

    전체 도서 대상 검색
    

    과:

    아주 작은 도서관의 libCode 하나만 대상으로 검색
    

    은 데이터 범위가 상당히 다를 수 있다.

    필터 결과가 매우 작다면 HNSW approximate search의 장점보다 필터된 문서 전체를 직접 비교하는 것이 더 효율적이라고 Elasticsearch가 판단할 수 있다.

    따라서 내 서비스에서 KNN 성능을 측정할 때도 단순히:

    num_candidates=30일 때 몇 ms

    만 기록하면 부족하다.

    어떤 libCode 조건에서 테스트했는지도 같이 기록해야 한다.


    shard가 여러 개면 num_candidates를 이해할 때 더 조심해야 한다

    또 하나 처음에는 놓치기 쉬운 부분이 shard다.

    num_candidates는 전체 Elasticsearch 인덱스에서 딱 그 숫자만큼의 후보를 본다는 의미가 아니다.

    Elastic 문서에서는 num_candidatesshard별 후보 수라고 설명한다. 각 shard에서 후보를 확보한 뒤 그 결과를 병합해서 최종 top k를 찾는다.

    예를 들어 개념적으로 shard가 여러 개라면:

    Shard 1
    → 후보 확보
    
    Shard 2
    → 후보 확보
    
    Shard 3
    → 후보 확보
    
          ↓
    
    후보 결과 병합
    
          ↓
    
    Global Top K
    

    와 같은 과정이 들어간다.

    따라서 인덱스가 shard 하나일 때와 shard 여러 개일 때 같은 num_candidates를 사용한다고 해서 검색 비용까지 완전히 동일하다고 생각해서는 안 된다.

    이것도 단순히 다른 블로그에서:

    num_candidates=100 추천
    

    같은 숫자를 가져오면 안 되는 이유 중 하나다.


    k가 너무 작을 때는 또 다른 종류의 문제가 생긴다

    num_candidates는 검색 품질과 탐색 폭의 문제지만 k 자체도 너무 작으면 문제가 될 수 있다.

    특히 내 서비스처럼 검색 결과를 RAG나 추천 단계에서 다시 사용하는 경우 그렇다.

    예를 들어 최종 생성 단계에서 충분히 좋은 책을 고르려면 검색 단계에서 여러 후보가 필요하다고 하자.

    그런데 KNN 단계에서 처음부터:

    k=1
    

    로 한 권만 가져오면 LLM이나 후속 추천 로직은 사실상 선택할 수 있는 것이 없다.

    KNN 결과 1권
            ↓
    RAG Context 1권
            ↓
    LLM
    

    첫 번째 검색 결과가 조금만 빗나가도 뒤 단계에서 복구하기 어렵다.

    검색 엔진이 제공하지 않은 책을 후속 로직이 선택할 수 없기 때문이다.

    이것은 RAG에서도 상당히 중요한 문제다.

    Retrieval 단계에서 빠진 정보는 Generation 단계에서 정상적으로 복원하기 어렵다.

    따라서 k는:

    사용자 화면에서 몇 개 보여줄 것인가

    만 가지고 결정할 숫자는 아니다.

    그 검색 결과가 이후 어떤 과정에 사용되는지도 봐야 한다.


    그렇다고 k를 크게 만드는 것이 항상 좋은 것도 아니다

    반대 방향의 문제도 있다.

    RAG를 사용하는 서비스에서:

    k=50
    

    으로 책을 50권 가져온다고 생각해보자.

    후보가 많으니 좋은 책이 포함될 가능성은 높아질 수 있다.

    하지만 그 50권의 설명을 모두 LLM Context에 넣는다면 다른 문제가 생긴다.

    검색 결과 증가
         ↓
    Context 증가
         ↓
    입력 Token 증가
         ↓
    비용 증가
    
    그리고
    
    관련성이 낮은 자료까지 포함
         ↓
    LLM이 참고할 정보가 복잡해짐
    

    따라서 RAG에서는 검색 recall만 극단적으로 높이는 것이 목표가 아니다.

    필요한 정보는 놓치지 않으면서 관련성이 낮은 정보는 최대한 줄이는 것이 더 중요하다.

    그래서 내 서비스에서 k를 판단할 때는 Elasticsearch만 보지 않고 그 뒤의 생성 과정까지 같이 봐야 한다.

    사용자 질문
       ↓
    KNN
       ↓
    k개의 도서
       ↓
    RAG Context
       ↓
    LLM
       ↓
    최종 추천
    

    앞 단계의 숫자 하나가 뒤의 토큰 사용량과 추천 품질에도 영향을 준다.


    그래서 k+10, k+20이 부족할 수도 있다

    내가 사용한 설정은:

    search_k = k + 10
    num_candidates = k + 20
    

    이었다.

    이제 이 값을 조금 더 비판적으로 볼 수 있다.

    예를 들어 외부 k가 작으면:

    k = 5
    
    search_k = 15
    num_candidates = 25
    

    가 된다.

    반대로:

    k = 100
    
    search_k = 110
    num_candidates = 120
    

    이 된다.

    문제는 데이터 규모가 커졌다고 해서 +20이라는 고정된 차이가 항상 같은 수준의 탐색 여유를 제공하지는 않는다는 것이다.

    k=5에서 25개의 후보와 k=100에서 120개의 후보는 k 대비 후보 비율이 크게 다르다.

    k=5
    num_candidates=25
    → k의 5배
    
    k=100
    num_candidates=120
    → k의 1.2배
    

    따라서 +20이라는 단순한 규칙은 구현하기는 쉽지만 데이터가 커지거나 요청 k가 크게 달라지는 환경에서는 적절한지 다시 검증해야 한다.

    이 부분이 지금 내 설정에서 가장 먼저 실험해보고 싶은 지점이다.

    현재 Elastic 문서의 최신 kNN API에서는 별도로 지정하지 않았을 때 num_candidates 기본값을 대체로 1.5 × k 범위로 계산하는 동작도 설명되어 있지만, 실제 기본값과 지원 방식은 Elasticsearch 버전/API 형태에 따라 확인해야 한다.

    따라서 내가 사용한 k+20 역시 정답으로 보기보다 튜닝이 필요한 초기 운영값이라고 보는 편이 맞다.


    “부족하다”를 느낌으로 판단하면 안 된다

    검색 결과를 보면서:

    결과가 좀 별로네.

    라고 느끼는 것만으로 num_candidates가 부족하다고 결론내릴 수도 없다.

    검색 품질이 나쁜 원인은 훨씬 많기 때문이다.

    예를 들어 기대한 책이 나오지 않았다고 하자.

    원인은 다음 중 하나일 수도 있다.

    1. 애초에 그 책에 embedding이 없다

    아무리 num_candidates를 늘려도 검색할 벡터가 없다면 나올 수 없다.

    2. libCode 필터에서 제외됐다

    사용자가 선택한 도서관에 해당 책이 없으면 KNN 후보가 되는 것 자체가 맞지 않을 수 있다.

    3. embedding을 만들 때 넣은 텍스트가 부족하다

    예를 들어 도서 제목만 embedding했다면:

    직장 생활이 지쳤을 때 위로받을 책

    같은 자연어 질문과 의미적으로 연결할 정보 자체가 부족할 수 있다.

    이 경우 num_candidates=1000으로 올려도 embedding 품질 문제는 해결되지 않는다.

    4. Query embedding 자체의 문제일 수 있다

    문서와 검색어가 같은 임베딩 공간에서 비교 가능한 형태로 생성되지 않았다면 검색 파라미터를 튜닝해도 결과는 계속 좋지 않을 수 있다.

    5. 실제로 num_candidates가 부족할 수 있다

    앞의 조건들이 정상인데 후보 폭을 늘렸을 때 기대 결과가 반복적으로 상위권에 들어온다면 그때 num_candidates 영향을 의심할 근거가 생긴다.

    그래서 나는 검색 결과가 좋지 않을 때 다음 순서로 보는 편이 더 적절하다고 생각한다.

    원하는 도서가 인덱스에 있는가?
            ↓
    embedding이 존재하는가?
            ↓
    libCode 조건을 만족하는가?
            ↓
    query vector가 정상인가?
            ↓
    현재 KNN 결과 확인
            ↓
    k / num_candidates 조정
    

    파라미터 튜닝은 생각보다 뒤쪽에 있다.


    실제로 num_candidates가 부족한지 확인하려면 비교 실험이 필요하다

    이제는 +10, +20을 그대로 사용하면서 결과가 괜찮아 보인다고 끝내고 싶지는 않다.

    실제로 부족한지를 확인하려면 정답에 가까운 기준 데이터를 먼저 만들어야 한다.

    예를 들어 검색 질문 30개를 준비한다고 하자.

    질문의 종류도 나눈다.

    정확한 도서 검색

    채식주의자
    데미안 헤르만 헤세
    

    주제 검색

    파이썬 입문 책
    투자를 처음 공부하는 책
    

    자연어 검색

    회사 생활 때문에 지쳤을 때 부담 없이 읽을 책
    

    그리고 각 질문에 대해:

    최소한 이 책들은 상위권에 들어왔으면 좋겠다.

    라는 기대 결과를 직접 정한다.

    그 후 같은 query vector를 사용하면서 num_candidates만 변경한다.

    20
    50
    100
    200
    

    이때 봐야 할 것은 단순한 검색시간 하나가 아니다.

    확인 항목이유
    기대 도서가 Top 5에 들어오는가실제 검색 품질 확인
    기대 도서의 순위가 바뀌는가후보 증가의 효과 확인
    Top 10 결과가 얼마나 변하는가검색 안정성 확인
    응답시간은 얼마나 달라지는가품질 대비 비용 확인
    libCode에 따라 차이가 있는가필터 영향 확인

    이 실험에서:

    num_candidates 50
    → 기대 결과 충분
    
    num_candidates 100
    → 거의 동일
    
    num_candidates 200
    → 거의 동일하지만 느려짐
    

    같은 패턴이 실제로 나온다면 200까지 늘릴 이유가 작다.

    반대로 후보 수를 늘릴수록 놓치던 결과가 반복적으로 들어온다면 현재 설정이 부족할 가능성을 의심할 수 있다.

    이런 비교가 있어야 “부족하다”는 말을 근거 있게 할 수 있다.


    검색 품질에서는 결국 Recall을 봐야 한다

    이 문제를 좀 더 정확하게 표현하면 num_candidates 튜닝은 Recall과 비용의 균형에 가깝다.

    내가 원하는 관련 도서가 실제로 10권 있다고 하자.

    검색 결과가 그중 4권만 찾아낸다면 많은 관련 결과를 놓치고 있는 것이다.

    후보 폭을 늘렸더니 8권을 찾아낸다면 recall이 좋아졌다고 볼 수 있다.

    하지만 후보를 더 늘렸는데도 8권에서 변화가 없다면 그 이후부터는 후보 탐색보다 embedding이나 데이터 품질을 확인하는 것이 더 의미 있을 수도 있다.

    그래서 앞으로는 단순히:

    검색 결과가 좋아 보인다.

    보다:

    미리 정한 관련 도서를 몇 개나 다시 찾아왔는가?

    를 측정하는 방향으로 검색 품질을 보고 싶다.


    결국 숫자보다 중요한 것은 검색 파이프라인 전체였다

    KNN을 처음 만들 때는 관심이 숫자에 있었다.

    k를 몇으로 하지?
    
    num_candidates를 몇으로 하지?
    

    하지만 실제 서비스를 만들면서는 숫자 하나보다 그 숫자가 들어가는 전체 구조가 더 중요하다는 생각이 들었다.

    내 서비스에서는 대략 다음 흐름을 가진다.

    사용자 자연어 검색
            ↓
    Embedding 생성
            ↓
    libCode 필터 적용
            ↓
    HNSW approximate KNN
            ↓
    num_candidates 후보 탐색
            ↓
    상위 k 결과
            ↓
    도서 메타데이터
            ↓
    RAG Context
            ↓
    LLM 추천 문장
    

    여기서 검색 결과가 이상하다고 num_candidates 하나만 바꾸면 앞뒤의 문제를 놓칠 수 있다.

    Embedding이 잘못됐을 수도 있다.

    필터가 문제일 수도 있다.

    후보 수가 부족할 수도 있다.

    RAG에서 너무 많은 문서를 사용하고 있을 수도 있다.

    그래서 knum_candidates검색 품질을 결정하는 중요한 설정이지만 검색 품질 전체를 결정하는 설정은 아니다.


    지금 내 설정을 다시 본다면

    현재 사용했던:

    search_k = k + 10
    num_candidates = k + 20
    

    이라는 방식은 구현 초기에는 이해하기 쉽고 관리하기 편했다.

    외부 API가 원하는 결과 수보다 조금 더 넉넉한 후보를 확보한다는 의도도 명확했다.

    하지만 지금 다시 보면 검증해야 할 부분이 있다.

    첫째, +20이라는 고정값이 k 크기가 달라져도 적절한가.

    둘째, libCode별 데이터 규모 차이가 큰데 같은 값을 적용해도 되는가.

    셋째, shard 구성이 바뀌었을 때 같은 검색 특성을 유지하는가.

    넷째, 후보 수를 더 늘렸을 때 실제 검색 결과가 얼마나 개선되는가.

    다섯째, 개선되는 결과에 비해 응답시간 증가가 받아들일 만한 수준인가.

    이 다섯 가지를 실제 데이터로 확인하지 않은 이상 현재 설정을 최적값이라고 표현하면 안 된다.

    지금 단계에서는 가장 정확하게 다음과 같이 말할 수 있다.

    k+10, num_candidates=k+20은 실제 서비스에서 사용한 운영값이다. 하지만 이것이 최적의 값이라는 근거는 아직 부족하고, 검색어 테스트 세트를 이용한 별도의 평가가 필요하다.


    마무리

    Elasticsearch KNN에서 knum_candidates를 처음 보면 둘 다 단순히 검색 개수를 결정하는 옵션처럼 보인다.

    하지만 내부 동작을 이해하고 나면 역할이 상당히 다르다.

    k최종적으로 확보하려는 nearest neighbor 수와 관련된다.

    num_candidates그 결과를 찾기 위해 approximate search 과정에서 얼마나 넓게 후보를 탐색할 것인지와 관련된다. Elastic 역시 num_candidates를 HNSW 검색에서 speed와 accuracy 사이를 조절하는 주요 검색 파라미터로 설명한다.

    후보가 너무 적으면 실제로 관련성이 높은 문서가 초기 후보에 들어오지 못해 최종 결과에서도 사라질 수 있다.

    반대로 후보를 무작정 크게 만들면 검색 품질 개선은 거의 없는데 탐색 비용만 증가할 수도 있다.

    여기에 내 서비스처럼 libCode 필터가 들어가면 조건을 만족하는 후보를 찾기 위해 그래프를 더 탐색해야 하는 상황도 생길 수 있다. 필터 결과 규모에 따라 Elasticsearch 내부에서 brute-force 방식으로 전환되는 경우도 있다.

    그래서 지금은:

    num_candidates는 몇으로 하면 좋을까?

    라는 질문 자체가 조금 부족하다고 생각한다.

    더 정확한 질문은 다음과 같다.

    내 데이터와 검색어, 필터 조건에서 원하는 결과를 놓치지 않으면서도 불필요한 탐색을 하지 않는 후보 수는 얼마인가?

    이 질문에 답하려면 결국 실제 검색어 세트와 기대 결과를 만들어 측정해야 한다.

    그리고 그 실험까지 하고 나면 지금 사용하고 있는 k+10, num_candidates=k+20을 유지할지, 비율 기반으로 바꿀지, 검색 조건에 따라 동적으로 조절할지 판단할 수 있을 것이다.

    그 결과는 별도의 테스트를 진행한 뒤 실제 검색 순위와 응답시간을 기준으로 다시 기록하려고 한다.

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