블로그

  • RAG에서 검색 문서를 많이 넣는다고 답변이 항상 좋아지지는 않았던 이유

    RAG를 처음 구성할 때 검색 결과를 몇 개까지 LLM에 넘겨야 하는지 고민하게 된다.

    내가 처음 이 문제를 생각했을 때는 답이 비교적 단순해 보였다.

    관련 문서가 부족해서 LLM이 제대로 답하지 못하는 것이 문제라면 검색 결과를 조금 더 많이 넘기면 되는 것 아닐까.

    예를 들어 검색 결과를 한 개만 전달하는 것보다 세 개를 전달하는 것이 낫고, 세 개보다 다섯 개가 더 안전하며, 아예 열 개 정도를 넘겨주면 필요한 정보가 빠질 가능성도 줄어들 것처럼 느껴졌다.

    생각 자체는 자연스럽다.

    RAG에서는 먼저 Elasticsearch에서 관련 문서를 검색하고, 그 결과를 LLM이 답변을 생성할 때 사용할 Context로 전달한다. Elastic도 RAG를 Elasticsearch에서 관련 Context를 가져와 언어 모델에 전달하는 구조로 설명하고 있다.

    그렇다면 검색 단계에서 후보를 많이 확보할수록 LLM에게 더 많은 정보를 줄 수 있고, 정보가 많으면 더 좋은 답변을 만들 가능성도 높아질 것처럼 보였다.

    나도 처음에는 Top K를 거의 이런 의미로 생각했다.

    Top K가 작다
    → 정보가 부족할 수 있다
    
    Top K를 늘린다
    → 더 많은 관련 문서를 얻는다
    
    → LLM이 참고할 정보가 많아진다
    
    → 답변이 좋아진다
    

    그런데 RAG 구조를 조금 더 길게 따라가다 보니 이 생각에는 빠진 부분이 있었다.

    검색 결과를 더 많이 가져온다는 것은 관련 정보만 더 많이 가져온다는 뜻이 아니었다.

    관련성이 높은 문서뿐 아니라 애매하게 관련된 문서, 비슷한 내용을 반복하는 문서, 사용자의 질문과는 조금 다른 방향의 문서까지 Context 안으로 같이 들어올 가능성이 생긴다.

    그때부터 Top K를 단순히 “몇 개를 검색할까”라는 숫자로만 보기 어렵게 됐다.


    처음에는 좋은 문서를 놓치는 것이 가장 무서웠다

    예를 들어 사용자가 다음처럼 질문한다고 생각해보자.

    파이썬을 처음 공부하는 사람이 어렵지 않게 읽을 책을 추천해줘.

    내 검색 시스템에는 이 질문과 잘 맞는 책 A, B, C가 있다고 하자.

    그런데 KNN 검색에서 Top 1만 가져왔다.

    검색 결과
    
    1. A
    

    A가 정말 좋은 책이라면 별 문제가 없어 보인다.

    하지만 검색 결과의 1위가 항상 완벽하다는 보장은 없다.

    벡터 검색 자체가 approximate search를 사용할 수도 있고, 문서 embedding 구성이나 검색 문장 표현에 따라 상위 결과의 순위가 조금씩 달라질 수도 있다.

    앞에서 knum_candidates를 보면서도 같은 문제를 생각했다.

    검색 단계에서 좋은 후보가 빠지면 이후 단계에서 그 책을 정상적으로 활용하기 어렵다.

    그래서 Top 1은 조금 불안하게 느껴졌다.

    한 문서의 검색 순위가 조금만 빗나가도 LLM에게 제공되는 정보 전체가 달라지기 때문이다.

    그러면 자연스럽게 Top 3을 생각하게 된다.

    검색 결과
    
    1. A
    2. B
    3. C
    

    이제 하나의 결과가 조금 빗나가더라도 다른 후보가 존재한다.

    이때까지만 보면 Top K를 늘리는 논리가 상당히 설득력 있다.

    좋은 문서를 놓칠 가능성을 줄이기 위해 Retrieval 단계에서 어느 정도 후보를 넉넉하게 가져오는 것은 분명 필요한 접근이 될 수 있다.

    Elasticsearch의 현재 검색 기능에서도 첫 단계 Retrieval에서 후보를 만들고, 필요하면 그 후보를 별도의 semantic reranker 등으로 다시 정렬하는 파이프라인을 구성할 수 있다. 즉 검색 단계에서 후보 집합을 확보하고 이후 더 비싼 방식으로 순서를 개선하는 구조 자체가 지원된다.

    그런데 문제는 여기서 멈추지 않고 Top 5, Top 10, Top 20으로 계속 늘릴 때부터 생긴다.


    다섯 개가 좋다면 열 개는 더 좋지 않을까

    이 질문이 나에게는 꽤 중요했다.

    다섯 개의 문서를 넣었더니 좋은 후보를 놓칠 가능성이 줄었다.

    그렇다면 열 개를 넣으면 더 안전할 것 같다.

    스무 개면 더 안전할 수도 있다.

    그런데 이 논리를 끝까지 밀어붙이면 결국 이런 결론이 나온다.

    가능하면 검색된 문서를 전부 넣는 것이 가장 좋은가?

    여기에서 뭔가 이상하다는 생각이 들었다.

    만약 정보가 많을수록 무조건 좋다면 RAG에서 검색 순위를 만들 필요도 별로 없다.

    관련 가능성이 조금이라도 있는 문서를 전부 Context에 넣으면 되기 때문이다.

    하지만 실제로는 검색 시스템이 관련성이 높은 문서를 위로 올리고, 그중 일부를 선택하기 위해 존재한다.

    Elasticsearch도 검색 결과를 ranking하고, 여러 retrieval 결과를 RRF로 합치거나 상위 후보를 semantic reranker에 보내 더 정교하게 재정렬할 수 있는 기능을 제공한다.

    그렇다면 Top K를 크게 만드는 것만으로 검색 품질 문제를 해결하려는 접근은 RAG 앞단의 ranking을 제대로 활용하지 않고 LLM 쪽으로 문제를 떠넘기는 방식이 될 수도 있다는 생각이 들었다.


    검색 결과가 많아질수록 “관련 문서 수”와 “전체 문서 수”가 같은 비율로 늘어나지는 않는다

    예를 들어 실제로 매우 관련 있는 문서가 세 개 있다고 생각해보자.

    검색 결과가 다음과 같을 수 있다.

    1. 매우 관련 있음
    2. 매우 관련 있음
    3. 관련 있음
    4. 어느 정도 관련 있음
    5. 어느 정도 관련 있음
    6. 조금 관련 있음
    7. 다른 주제도 많이 포함
    8. 키워드 일부만 비슷함
    9. 거의 관계 없음
    10. 거의 관계 없음
    

    Top 3을 사용할 때는 Context 대부분이 사용자 질문과 강하게 연결되어 있다.

    그런데 Top 10으로 늘리면 정보량은 세 배 이상 증가했지만 강하게 관련된 정보의 양이 세 배로 늘어난 것은 아니다.

    오히려 Context 안에서 관련 정보의 비율이 낮아질 수 있다.

    내가 이 문제를 생각하면서 중요하게 느낀 것이 정보의 양과 정보의 밀도는 다른 문제라는 점이었다.

    LLM에 긴 Context를 준다는 것은 단순히 모델에게 많은 지식을 선물하는 것이 아니다.

    그 안에서 어떤 부분을 실제 답변 근거로 사용해야 할지 판단해야 할 정보도 같이 늘어난다.

    Elastic에서도 RAG Playground에서 모델의 답변에 추가되는 Context가 Elasticsearch에서 가져온 정보로 구성된다는 점을 설명하고 있다. 결국 검색 결과로 선택한 정보가 LLM이 실제로 보게 되는 재료가 된다.

    이 관점으로 보면 좋은 RAG Context는 단순히 많은 Context가 아니라 질문과 관련 있는 정보의 밀도가 높은 Context에 더 가깝다는 생각이 들었다.


    같은 내용을 반복하는 문서도 문제가 될 수 있었다

    도서 검색에서는 같은 책이 여러 도서관에 존재할 수도 있고, 비슷한 설명이나 키워드를 가진 도서들이 상위 결과에 여러 개 들어올 수도 있다.

    일반 문서 검색에서도 비슷하다.

    어떤 질문에 대한 Top 10을 가져왔는데 실제 내용을 보면 거의 같은 내용을 말하는 문서가 반복되어 있을 수 있다.

    문서 1
    → A라는 설명
    
    문서 2
    → A를 조금 다르게 설명
    
    문서 3
    → 거의 같은 A
    
    문서 4
    → 또 비슷한 A
    

    문서 수만 보면 네 개다.

    하지만 LLM이 얻는 새로운 정보는 거의 하나일 수 있다.

    그런데 입력 Context 크기는 네 문서만큼 사용한다.

    이런 문제 때문에 단순한 relevance만큼 검색 결과의 다양성도 생각할 필요가 있다고 느꼈다.

    흥미롭게도 현재 Elasticsearch에는 검색 결과의 다양성을 높이는 diversify retriever도 존재하며, 공식 문서에서는 RAG workflow에서 LLM에 더 다양한 Context를 제공하고 prompt의 중복을 줄이는 용도로 사용할 수 있다고 설명한다.

    이 기능 자체를 반드시 사용해야 한다는 뜻은 아니다.

    오히려 내가 중요하게 느낀 것은 Elasticsearch 쪽에서도 상위 문서를 많이 가져오는 것과 유용한 Context를 만드는 것을 같은 문제로 보지 않는다는 점이었다.


    여기에서 Top K와 Context 문서 수를 꼭 같게 만들 필요도 없다는 생각이 들었다

    처음에는 검색에서 k=10이면 LLM에도 10개를 그대로 넣는 것이 자연스럽다고 생각했다.

    KNN Top 10
        ↓
    10개 전부 Context
        ↓
    LLM
    

    하지만 다시 생각해보면 검색 엔진이 후보를 찾기 위해 필요한 문서 수와 LLM이 실제로 읽어야 하는 문서 수는 목적이 다르다.

    검색 단계에서는 좋은 후보를 놓치지 않는 것이 중요하다.

    반면 LLM Context 단계에서는 가장 관련 있는 정보를 밀도 높게 전달하는 것이 중요할 수 있다.

    그렇다면 다음과 같은 구조도 가능하다.

    1차 검색
    Top 20 후보 확보
    
         ↓
    
    ranking / reranking
    
         ↓
    
    가장 관련 있는 5개 선택
    
         ↓
    
    LLM Context
    

    Elasticsearch의 semantic reranking도 이런 생각과 비슷하다. 첫 단계 retrieval에서 후보를 확보한 뒤 보다 계산 비용이 높은 reranker로 후보 순서를 다시 정렬할 수 있다.

    이 구조를 보고 나니 Top K라는 숫자를 하나로만 관리하는 것보다:

    Retrieval Candidate K
    
    와
    
    LLM Context K
    

    를 분리해서 생각하는 것이 더 자연스럽게 느껴졌다.


    RRF를 쓰는 경우에는 이 문제가 더 재미있어졌다

    내 검색에서는 텍스트 검색과 벡터 검색을 함께 보는 구조도 생각했다.

    예를 들어:

    Keyword Search
    +
    KNN Vector Search
    

    를 조합하면 두 방식에서 서로 다른 문서가 상위로 올라올 수 있다.

    Elasticsearch의 RRF retriever는 여러 child retriever에서 나온 결과를 Reciprocal Rank Fusion으로 하나의 ranking으로 결합한다. 각 retrieval 방식이 반환한 상위 결과를 이용해 최종 순위를 만든다.

    여기에서 후보 수를 너무 작게 잡으면 한 retrieval 방식에서만 발견할 수 있었던 좋은 문서를 초반에 놓칠 가능성을 생각할 수 있다.

    반대로 각 retrieval에서 아주 많은 후보를 가져와 전부 LLM에 전달하는 것도 비효율적이다.

    결국 이 경우에도:

    충분한 후보를 확보한다.

    LLM에게 전부 보여준다.

    는 같은 이야기가 아니었다.

    Retrieval 단계와 Context 단계 사이에 선택 과정이 하나 더 필요할 수 있다는 생각으로 이어졌다.


    그리고 결국 비용 문제가 따라왔다

    이전 글에서 LLM API 비용을 생각하면서 RAG Context가 입력 토큰과 직접 연결될 수 있다는 점을 정리했다.

    Top K 문제도 여기와 연결된다.

    예를 들어 책 한 권마다:

    title
    author
    keywords
    description
    

    을 LLM에 전달한다고 생각해보자.

    책 세 권이면 description도 세 개다.

    열 권이면 열 개다.

    검색 결과를 더 많이 가져오는 것 자체는 Elasticsearch의 문제지만, 그 문서 내용을 모두 Prompt에 넣기 시작하는 순간 LLM 입력량의 문제가 된다.

    Top K 증가
        ↓
    Context 문서 증가
        ↓
    Prompt 증가
        ↓
    입력 토큰 증가
    

    따라서 Top K를 늘렸을 때 답변 품질은 아주 조금 좋아지는데 입력량만 크게 증가한다면 운영 측면에서는 좋은 설정이라고 보기 어려울 수 있다.

    이전에는 이걸 단순히 비용 문제라고 생각했다.

    그런데 조금 더 생각해보니 비용뿐 아니라 좋은 검색 결과를 얼마나 효율적으로 전달하는가에 관한 문제였다.


    Context Window가 크다고 해서 그만큼 채우는 것이 목표는 아니었다

    모델이 긴 Context를 받을 수 있다는 사실도 처음에는 헷갈리기 쉬웠다.

    Context Window가 충분히 크다면 검색 문서 열 개나 스무 개를 넣어도 기술적으로 입력할 수 있을 수 있다.

    그러면:

    들어갈 공간이 있는데 왜 줄여야 하지?

    라는 생각이 든다.

    하지만 Context Window는 사용 가능한 최대 공간이지 반드시 채워야 하는 목표량은 아니다.

    예를 들어 방이 넓다고 해서 회의에 필요 없는 자료까지 책상 위에 전부 펼쳐놓는 것이 회의를 더 잘하게 만드는 것은 아니다.

    오히려 중요한 자료를 찾기 어려워질 수 있다.

    나는 RAG Context도 비슷한 관점으로 보는 편이 이해하기 쉬웠다.

    모델이 읽을 수 있다는 것과 모델에게 반드시 읽혀야 한다는 것은 다르다.


    실제로 테스트하려면 “답변이 좋아 보인다”만 봐서는 부족했다

    그렇다면 Top K를 얼마나 사용해야 할까.

    결국 직접 비교해야 한다.

    예전 같으면 검색 결과를 몇 번 눈으로 보고:

    Top 5 정도가 괜찮아 보인다.

    고 끝냈을 수도 있다.

    하지만 이런 판단은 검색어가 바뀌면 쉽게 달라질 수 있다.

    그래서 지금 다시 실험한다면 먼저 실제 질문을 고정할 것 같다.

    예를 들어 도서 추천 질문 30개를 만든다.

    Python 초보자 책
    
    직장 스트레스에 도움이 될 책
    
    투자를 처음 공부하는 책
    
    초등학생이 우주에 관심을 갖게 할 책
    ...
    

    그리고 각 질문에서 관련성이 있다고 판단하는 도서를 미리 정한다.

    그다음 Retrieval 조건은 동일하게 두고 Context 문서 수만 바꾼다.

    조건 A
    Context Top 1
    
    조건 B
    Context Top 3
    
    조건 C
    Context Top 5
    
    조건 D
    Context Top 10
    

    그 상태에서 같은 Prompt와 같은 모델로 답변을 만든다.

    이렇게 해야 최소한 Top K 외의 다른 변수를 줄일 수 있다.


    여기서 내가 보고 싶은 것은 단순 정확도 하나가 아니다

    처음에는 답변 정확도만 비교하면 된다고 생각했다.

    하지만 운영까지 생각하면 보고 싶은 값이 더 많다.

    첫 번째는 필요한 근거가 Context에 들어왔는가다.

    좋은 답을 만들기 위해 필요한 책이 아예 Retrieval에서 빠졌다면 Generation을 평가하기 어렵다.

    두 번째는 답변이 실제 Context에 근거하고 있는가다.

    관련 문서를 많이 넣었더니 오히려 엉뚱한 문서 내용을 답변에 섞기 시작한다면 Context 확대가 도움이 됐다고 말하기 어렵다.

    세 번째는 입력 토큰 증가량이다.

    Top 3에서 Top 10으로 늘렸는데 품질 개선은 거의 없고 입력량만 크게 늘었다면 의미 있는 trade-off인지 생각해야 한다.

    네 번째는 응답시간이다.

    Context 크기를 변경했을 때 실제 서비스의 첫 응답시간과 전체 생성시간이 어떻게 바뀌는지도 함께 측정하고 싶다.

    결국 실험 결과는 이런 형태가 더 도움이 된다.

    Top 1
    - Retrieval 근거
    - 답변 품질
    - Input Token
    - 응답시간
    
    Top 3
    - Retrieval 근거
    - 답변 품질
    - Input Token
    - 응답시간
    
    Top 5
    ...
    
    Top 10
    ...
    

    그렇게 봐야 “몇 개가 최고다”가 아니라 어디부터 추가 문서의 이득이 줄어드는지 볼 수 있다.


    만약 Top 3과 Top 10의 답변이 거의 같다면 무엇을 의미할까

    이 결과가 실제로 나온다면 상당히 흥미롭다.

    Top 3만으로도 답변에 필요한 핵심 근거가 이미 충분했을 가능성이 있다.

    이 경우 Top 4부터 10까지는 새로운 가치보다 중복 정보를 추가하고 있었을 수도 있다.

    그렇다면 Context를 줄이는 방향을 생각할 수 있다.

    반대로 Top 3에서는 중요한 근거가 자주 빠지고 Top 5부터 안정적으로 들어온다면 Top 5가 더 적절할 가능성을 볼 수 있다.

    여기서 중요한 것은:

    Top 5가 정답이다
    

    라는 결론이 아니다.

    내 데이터에서 Top 5부터 필요한 정보가 안정적으로 확보된다는 근거가 생겼다는 것이다.

    이 차이가 중요하다.

    다른 서비스에서는 Top 3일 수도 있고 Top 10일 수도 있다.

    문서 하나의 길이도 다르고, 질문의 복잡성도 다르고, retrieval 품질도 다르기 때문이다.


    Top K가 크다고 답변이 나빠졌다면 검색이 나쁜 것인지 LLM이 나쁜 것인지도 나눠봐야 했다

    예를 들어 Top 3보다 Top 10에서 답변이 더 이상해졌다고 하자.

    그러면:

    문서를 많이 주면 LLM이 혼란스러워진다.

    라고 바로 결론내리고 싶어진다.

    하지만 그것도 조금 빠른 판단이다.

    Top 4~10 문서를 실제로 확인해야 한다.

    만약 그 문서들이 사용자 질문과 거의 관계없는 자료라면 문제의 시작은 Context 크기 자체보다 retrieval ranking이 뒤쪽에서 빠르게 나빠지는 것일 수 있다.

    반대로 Top 10까지 모두 상당히 관련 있는데 LLM이 근거를 잘 활용하지 못한다면 Prompt 구성이나 Generation 쪽을 봐야 할 수 있다.

    즉:

    Top K 증가
    → 답변 품질 하락
    

    이라는 현상도 다시 나눠야 한다.

    후반 검색 문서가 나쁨?
    

    인지,

    Context는 좋은데 모델 활용이 나쁨?
    

    인지 봐야 한다.

    앞에서 RAG를 Retrieval → Context → Generation으로 나눴던 이유가 여기에서 다시 나타난다.


    그래서 RAG 디버깅에서 “Context에 실제로 무엇을 넣었는가”를 저장하고 싶었다

    최종 답변만 저장하면 Top K를 바꿨을 때 왜 결과가 달라졌는지 알기 어렵다.

    사용자가 같은 질문을 했더라도 당시 검색 결과가 무엇이었는지 모르면 재현하기 어려울 수 있다.

    그래서 요청마다 최소한:

    사용자 질문
    
    검색 mode
    
    Retrieval Top K
    
    검색된 문서 ID
    
    검색 순위
    
    검색 score
    
    실제 Context에 넣은 문서
    
    최종 답변
    

    정도는 연결해두고 싶다.

    이 데이터가 있으면 몇 달 뒤 다음과 같은 분석도 가능하다.

    답변 품질이 낮았던 요청들은 Top K가 작아서 필요한 자료가 빠진 경우가 많았는가?

    아니면 Top K는 충분한데 검색 순위 자체가 좋지 않았는가?

    Context에 너무 많은 중복 문서가 들어갔는가?

    이런 질문에 답할 수 있다.


    그리고 여기에서 reranking이 왜 나오는지도 이해됐다

    처음에는 reranker를 검색을 더 복잡하게 만드는 기능 정도로 봤다.

    이미 KNN으로 순위가 나왔는데 왜 또 순위를 매겨야 하는지 이해하기 어려울 수 있다.

    그런데 RAG Context를 생각하면 이유가 보였다.

    첫 단계에서는 빠르게 후보를 넓게 확보한다.

    그리고 비싼 모델을 전체 문서에 사용할 수 없으니 상위 후보에만 더 정교한 평가를 적용한다.

    Elastic의 semantic reranking도 첫 단계 retrieval이 후보를 만들고 그 후보들을 semantic similarity 모델로 다시 정렬하는 구조를 제공한다.

    개념적으로:

    빠른 Retrieval
    Top 30
    
         ↓
    
    더 정교한 Reranking
    
         ↓
    
    Top 5
    
         ↓
    
    LLM Context
    

    가 가능해진다.

    이 구조의 핵심은 모든 단계에서 같은 수의 문서를 사용하지 않는다는 것이다.

    후보를 찾을 때는 넉넉하게.

    비싼 처리는 좁게.

    LLM에는 정말 필요한 만큼만.

    이 흐름이 RAG를 운영할 때 훨씬 합리적으로 느껴졌다.


    Hybrid Search에서도 같은 생각을 적용할 수 있었다

    내 검색에서는 keyword 신호와 vector 신호를 함께 보는 방식을 사용했다.

    정확한 도서명을 입력하는 사용자와 자연어로 상황을 설명하는 사용자의 의도가 다르기 때문에 하나의 검색 방식만으로는 부족하다고 생각했기 때문이다.

    현재 Elasticsearch도 lexical retrieval과 vector retrieval 결과를 RRF로 결합해 hybrid ranking을 만들 수 있다.

    이 구조에서도 중요한 것은:

    Keyword Top 5 + KNN Top 5니까 10개를 전부 LLM에 넣는다.

    가 아니다.

    두 결과 사이에 같은 문서가 있을 수도 있고 관련도가 다른 문서도 섞일 수 있다.

    먼저 최종 ranking을 만들고 필요한 후보를 고르는 것이 더 자연스럽다.

    이렇게 생각하고 나니 검색 시스템과 RAG Context 구성은 하나의 이어진 pipeline으로 보이기 시작했다.


    결국 Top K는 “많이 찾기”를 결정하는 숫자가 아니었다

    처음의 질문은 단순했다.

    검색 결과를 몇 개 넣어야 하지?

    조금 지나서는:

    좋은 문서를 놓치지 않으려면 많이 넣는 것이 안전하지 않을까?

    라고 생각했다.

    하지만 지금은 질문을 조금 다르게 하고 싶다.

    답변에 필요한 근거를 놓치지 않을 정도로 충분히 넓게 찾되, LLM에는 실제로 도움이 되는 정보만 얼마나 밀도 있게 제공할 수 있는가?

    이 질문에서는 숫자 하나로 끝나지 않는다.

    Retrieval 후보 수가 있고,

    reranking 후보 수가 있고,

    실제 Context 문서 수가 있을 수 있다.

    그리고 문서 수뿐 아니라 문서 길이도 중요하다.

    어떤 필드를 Context로 사용하는지도 중요하다.

    중복 문서도 고려해야 한다.


    실제 도서 서비스라면 다음 실험을 먼저 하고 싶다

    지금 다시 테스트할 수 있다면 20~30개의 자연어 검색 질문을 고정할 것이다.

    그리고 같은 질문을 다음 조건에서 실행한다.

    Context Top 1
    Context Top 3
    Context Top 5
    Context Top 10
    

    검색 엔진과 모델, Prompt는 그대로 둔다.

    그리고 각 조건마다 다음을 기록한다.

    관련 도서가 Context에 포함됐는가.

    답변에서 실제 그 근거를 사용했는가.

    검색되지 않은 책을 만들어내지는 않았는가.

    입력 토큰은 얼마나 사용됐는가.

    응답시간은 얼마나 걸렸는가.

    답변이 불필요하게 길어지지는 않았는가.

    이 데이터를 쌓으면 지금까지 감으로 설정했던 Top K를 실제 서비스의 근거 있는 설정으로 바꿀 수 있을 것 같다.


    마무리

    RAG를 처음 만들 때는 정보를 많이 넣는 것이 안전하다고 생각했다.

    검색 결과 한 개보다는 세 개가 좋고, 세 개보다는 다섯 개가 좋다면 열 개는 더 좋을 것처럼 보였다.

    그런데 검색과 생성 사이의 전체 흐름을 따라가면서 생각이 바뀌었다.

    Top K를 늘리면 좋은 문서를 놓칠 가능성을 줄일 수 있다.

    하지만 동시에 관련성이 낮은 문서와 중복 정보가 Context에 들어올 가능성도 커진다.

    Context가 길어지면 LLM이 처리해야 하는 입력도 증가한다.

    그리고 Retrieval 단계에서 충분히 많은 후보가 필요하다고 해서 그 후보를 모두 LLM에 보여줘야 하는 것은 아니다.

    Elasticsearch도 현재 첫 단계 후보 검색, RRF를 이용한 여러 retrieval 결과의 결합, semantic reranking처럼 여러 단계로 검색 결과를 좁히고 재정렬할 수 있는 구조를 제공한다.

    그래서 지금은 RAG의 Top K를 하나의 숫자로만 생각하지 않는다.

    Retrieval에서 얼마나 넓게 찾을까?
    
    ↓
    
    그중 무엇이 정말 관련 있는가?
    
    ↓
    
    어떤 문서를 Context로 사용할까?
    
    ↓
    
    LLM에게 실제로 필요한 정보는 얼마인가?
    

    로 나눠서 본다.

    결국 목표는 가장 많은 문서를 LLM에 전달하는 것이 아니다.

    사용자가 질문한 내용에 답하기 위해 필요한 근거는 놓치지 않으면서, 불필요한 내용은 최대한 줄인 Context를 만드는 것이다.

    그리고 그것이 제대로 됐는지는 느낌으로 판단하기보다 동일한 질문 세트를 가지고 Top 1·3·5·10을 실제로 비교해봐야 한다.

    그 결과가 있어야 비로소:

    우리 서비스에서는 왜 이 정도의 Context가 필요한가?

    라는 질문에 답할 수 있다.

    나는 이제 그 설명이 가능한 숫자를 찾는 것이 단순히 큰 Top K를 사용하는 것보다 훨씬 중요한 RAG 튜닝이라고 생각한다.

  • Nginx 502 Bad Gateway를 보고 Nginx 설정부터 고치지 않게 된 이유

    Docker로 FastAPI 서비스를 운영하면서 Nginx를 앞단에 두면 사용자의 요청은 애플리케이션으로 바로 들어오지 않는다. 그래서 많은 사람들이 Nginx를 안정적인 서비스를 하기 위해서 서비스 앞단에 설치하고 호출을 받는다.

    내가 사용하던 구조도 크게 보면 이런 형태였다.

    사용자
       ↓
    도메인
       ↓
    Nginx
       ↓
    Docker 내부의 FastAPI
       ↓
    Uvicorn
       ↓
    애플리케이션
    

    처음 이 구조를 만들 때는 Nginx를 단순히 외부 요청을 내부 API로 전달해주는 중간 서버 정도로 생각했다.

    사용자는 80 또는 443 포트로 Nginx에 요청하고, Nginx가 내부 FastAPI가 실행 중인 포트로 요청을 넘겨주면 된다.

    설정도 겉으로 보면 복잡하지 않다.

    대략 이런 형태다.

    location / {
        proxy_pass http://backend:8000;
    }
    

    그래서 어느 날 브라우저에서 다음과 같은 응답을 만나면 자연스럽게 Nginx부터 의심하게 된다.

    502 Bad Gateway
    

    나 역시 처음에는 그랬다.

    화면에 표시되는 서버가 Nginx이고 오류 페이지에도 Nginx가 보이니, nginx.confproxy_pass 설정이 잘못된 것이라고 생각하기 쉬웠다.

    그런데 502라는 상태를 조금 다르게 생각해보니 오히려 중요한 단서가 하나 있었다.

    사용자의 요청이 Nginx까지는 도착했다는 사실이다.

    Nginx가 502를 만들어서 사용자에게 반환했다는 것은 최소한 브라우저에서 Nginx까지의 요청 경로 자체는 동작하고 있었다.

    그렇다면 문제를 처음부터 끝까지 다시 볼 필요는 없었다.

    문제의 범위는 조금 더 좁혀졌다.

    사용자
       ↓
    Nginx        ← 여기까지는 도착
       ↓
    ?
       ↓
    FastAPI
    

    이때부터 내가 먼저 확인해야 할 것은 Nginx 자체가 살아 있는가가 아니라,

    Nginx가 다음 서버, 즉 upstream에 실제로 연결할 수 있는가?

    라는 질문이었다.

    Nginx 공식 문서에서도 upstream은 proxy_pass 등을 통해 요청을 전달할 서버를 의미하고, upstream 서버를 선택하거나 도달할 수 없는 상황에서 Bad Gateway 문제가 발생할 수 있다.

    이걸 이해하고 나니 502 Bad Gateway라는 문구가 이전보다 조금 다르게 보이기 시작했다.

    Nginx가 고장났다는 메시지라기보다,

    “나는 요청을 받았는데 그 다음 서버와 정상적인 응답을 주고받지 못했다”

    라는 신호에 가까웠다.


    처음에는 브라우저에서 FastAPI가 열리는지만 확인했다

    502가 발생했을 때 가장 먼저 확인하기 쉬운 것은 FastAPI가 실제로 살아 있는지다.

    예를 들어 호스트에서 다음 주소가 정상적으로 열린다고 하자.

    http://localhost:8000
    

    또는:

    curl http://localhost:8000/health
    

    를 실행했는데 정상적으로 응답한다.

    그러면 이렇게 생각하기 쉽다.

    FastAPI는 정상인데 왜 Nginx에서는 502가 나오지?

    나도 처음에는 이 테스트가 꽤 강한 증거라고 생각했다.

    호스트에서 FastAPI에 접속할 수 있으니 Nginx에서도 같은 주소로 접근할 수 있을 것이라고 생각하기 때문이다.

    그런데 Docker를 사용하면서 localhost라는 단어를 다시 생각할 필요가 있었다.

    localhost는 항상 같은 컴퓨터를 의미하지 않았다.

    정확히는 현재 그 명령을 실행하는 네트워크 공간의 자기 자신을 가리킨다.

    예를 들어 내 PC나 서버 호스트에서:

    localhost:8000
    

    을 호출하면 호스트 자신을 바라본다.

    하지만 Nginx가 별도의 Docker 컨테이너 안에서 실행 중이라면 그 Nginx 컨테이너에서:

    localhost:8000
    

    은 호스트를 의미하지 않는다.

    Nginx 컨테이너 자신을 의미한다.

    이 차이를 처음 제대로 이해했을 때 Docker 네트워크 오류가 왜 그렇게 헷갈리는지도 조금 이해됐다.


    localhost라는 단어는 같지만 바라보는 곳이 달랐다

    예를 들어 다음과 같은 Docker 구성이 있다고 하자.

    Host Server
    
    ├─ nginx container
    │    └─ localhost
    │
    └─ fastapi container
         └─ localhost:8000
    

    FastAPI 컨테이너 안에서는 Uvicorn이 8000 포트에서 실행되고 있다.

    그런데 Nginx 설정을 이렇게 작성했다고 생각해보자.

    location / {
        proxy_pass http://localhost:8000;
    }
    

    처음에는 그럴듯하다.

    FastAPI가 8000 포트이니 Nginx도 8000으로 보내면 될 것 같다.

    하지만 이 설정에서 Nginx가 바라보는 localhostFastAPI 컨테이너가 아니라 Nginx 컨테이너 자신이다.

    결국 요청 흐름은 내가 생각했던:

    Nginx
       ↓
    FastAPI:8000
    

    이 아니라:

    Nginx
       ↓
    Nginx 자기 자신의 8000번 포트
    

    가 된다.

    그 포트에 아무 서비스도 없다면 당연히 upstream 연결은 실패한다.

    브라우저에서는 FastAPI가 정상적으로 열렸는데 Nginx에서는 계속 502가 발생하는 상황이 충분히 가능해진다.

    Docker Compose에서는 같은 네트워크에 연결된 서비스들이 서비스 이름으로 서로를 찾을 수 있다. Docker 공식 문서에서도 Compose의 기본 네트워크에서 각 서비스가 서비스 이름을 통해 다른 컨테이너에 접근할 수 있다고 설명한다. 예를 들어 db 서비스가 있다면 같은 Compose 네트워크의 다른 서비스가 db:5432처럼 접근할 수 있다.

    그래서 FastAPI 서비스 이름이 backend라면 Nginx에서는 다음과 같은 형태가 더 자연스럽다.

    location / {
        proxy_pass http://backend:8000;
    }
    

    여기서 backend는 컨테이너의 현재 IP를 직접 적은 것이 아니다.

    Compose 서비스 이름이다.

    이 방식이 중요한 이유는 Docker 컨테이너 IP가 영구적인 값이 아니기 때문이다.

    컨테이너가 다시 생성되면 IP가 달라질 수 있지만 Compose 네트워크에서는 같은 서비스 이름으로 다시 찾을 수 있다. Docker도 컨테이너를 IP 대신 서비스 이름으로 참조하도록 권장한다.

    이 사실을 알고 나니 Nginx upstream에 컨테이너 IP를 직접 적는 방식도 다시 생각하게 됐다.


    컨테이너 IP를 직접 적으면 처음에는 잘 되는 것처럼 보일 수 있다

    예를 들어 docker inspect를 해보니 FastAPI 컨테이너 IP가 다음과 같았다고 하자.

    172.18.0.5
    

    그러면 Nginx에:

    proxy_pass http://172.18.0.5:8000;
    

    을 넣으면 당장은 동작할 수 있다.

    그리고 502가 사라지면 문제가 해결됐다고 생각하기 쉽다.

    하지만 컨테이너를 다시 만들었더니 IP가:

    172.18.0.7
    

    로 달라질 수 있다.

    그러면 Nginx는 여전히 이전 IP로 요청을 보내고 있다.

    Nginx
     ↓
    172.18.0.5
     ↓
    이제 FastAPI가 없음
    

    다시 502가 발생한다.

    이 상황을 몇 번 겪으면:

    어제까지 잘 됐는데 Docker를 다시 올리니까 왜 안 되지?

    라는 현상이 만들어진다.

    문제는 애플리케이션 코드가 바뀐 것이 아니라 주소를 고정하는 방법 자체가 Docker의 동작 방식과 맞지 않았던 것이다.

    Docker Compose가 서비스 이름 기반 discovery를 제공하는 이유도 이런 구조에서 이해하기 쉬웠다. 컨테이너가 교체되어 IP가 바뀌어도 서비스 이름은 유지된다.

    이때부터 나는 Docker 환경에서 서버 주소를 볼 때 숫자로 된 IP보다 먼저:

    같은 Docker network인가?

    서비스 이름으로 접근할 수 있는가?

    를 보게 됐다.


    그런데 서비스 이름을 제대로 적어도 502가 날 수 있었다

    여기까지 확인해서 Nginx 설정이 다음처럼 되어 있다고 하자.

    proxy_pass http://backend:8000;
    

    FastAPI 서비스 이름도 실제로 backend다.

    그런데 여전히 502가 발생한다.

    그러면:

    서비스 이름도 맞는데 대체 왜 안 되지?

    라는 생각이 든다.

    여기서 다음으로 확인해야 했던 것이 두 컨테이너가 실제로 같은 Docker network에 있는가였다.

    서비스 이름으로 다른 컨테이너를 찾을 수 있다는 것은 아무 컨테이너끼리나 가능한 것이 아니다.

    같은 네트워크 안에서 서로 발견할 수 있어야 한다.

    예를 들어:

    network A
    
    - nginx
    - backend
    

    라면 서로 통신할 수 있는 구조를 만들 수 있다.

    반면:

    network A
    - nginx
    
    network B
    - backend
    

    처럼 완전히 나뉘어 있다면 단순히 서비스 이름이 맞다는 이유만으로 통신되는 것은 아니다.

    Docker에서는 docker network inspect로 네트워크에 어떤 컨테이너들이 연결되어 있는지 확인할 수 있고, 공식 CLI에서도 이 명령을 네트워크 상세 정보를 확인하는 방법으로 제공한다.

    그래서 지금 같은 문제가 다시 발생하면 단순히 docker ps만 보는 것이 아니라 네트워크부터 확인할 것 같다.

    docker network ls
    

    그리고 사용하는 네트워크를 확인한다.

    docker network inspect <network_name>
    

    여기에서 Nginx와 FastAPI 컨테이너가 둘 다 보이는지 확인한다.

    이 테스트의 의미는 단순하다.

    Nginx가 upstream 주소를 알고 있다는 것과 실제로 그 upstream까지 네트워크 경로가 존재한다는 것은 서로 다른 문제이기 때문이다.


    이쯤에서 나는 502를 네트워크 문제라고 확신하고 싶어졌다

    Nginx.

    Docker network.

    서비스 이름.

    여기까지 확인하다 보면 502는 결국 네트워크 문제라고 생각하기 쉽다.

    그런데 다시 한 가지가 남는다.

    Nginx 컨테이너가 FastAPI 컨테이너까지 갈 수 있다고 해도 FastAPI가 외부 연결을 받을 수 있는 주소에 실제로 bind되어 있어야 한다.

    이 부분 때문에 Uvicorn 실행 옵션도 봐야 했다.

    Uvicorn의 기본 host는 현재 공식 문서 기준 127.0.0.1이고, 다른 네트워크 인터페이스에서도 접근 가능하게 하려면 --host 0.0.0.0을 사용할 수 있다.

    예를 들어 FastAPI 컨테이너에서 이렇게 실행했다고 생각해보자.

    uvicorn main:app --host 127.0.0.1 --port 8000
    

    FastAPI 컨테이너 안에서:

    curl http://127.0.0.1:8000
    

    을 하면 잘 된다.

    그래서 애플리케이션은 정상이라고 생각한다.

    하지만 127.0.0.1에만 bind되어 있다면 다른 컨테이너에서 접근하려는 연결과는 상황이 달라진다.

    Nginx는 다른 컨테이너다.

    그래서 Docker에서 Uvicorn을 실행할 때 흔히 다음처럼 구성한다.

    uvicorn main:app --host 0.0.0.0 --port 8000
    

    Uvicorn의 Docker 배포 문서 예제 역시 --host 0.0.0.0 --port 8000 형태를 사용한다.

    이 지점에서 나는 같은 “FastAPI가 살아 있다”는 말을 두 종류로 나눠야 한다고 느꼈다.

    컨테이너 자기 자신에게는 응답할 수 있다
    

    다른 컨테이너의 연결도 받을 수 있다
    

    는 같은 상태가 아니다.


    결국 브라우저에서 확인한 결과만으로는 부족했다

    처음에는 브라우저에서 FastAPI 주소가 열리면 백엔드는 정상이라고 판단했다.

    하지만 Docker + Nginx 구조에서는 브라우저가 접근하는 경로와 Nginx가 접근하는 경로가 다를 수 있다.

    예를 들어 호스트에서는:

    Host
     ↓
    localhost:8000
     ↓
    Docker published port
     ↓
    FastAPI container
    

    경로로 접근한다.

    Docker에서 포트를 publish하면 호스트의 특정 포트를 컨테이너 포트에 연결할 수 있다. Docker 문서에서도 -p HOST_PORT:CONTAINER_PORT 방식으로 호스트에서 컨테이너 서비스에 접근하도록 하는 구조를 설명한다.

    하지만 Nginx도 Docker 컨테이너라면 굳이 호스트의 published port를 돌아서 갈 필요가 없다.

    같은 network 안에서:

    Nginx container
           ↓
    backend:8000
           ↓
    FastAPI container
    

    로 직접 통신할 수 있다. Compose 네트워크에서 컨테이너 간 통신은 서비스 이름과 컨테이너 포트를 기준으로 하고, 호스트에서 접근할 때는 published host port를 사용한다는 차이가 있다.

    이 차이를 모르면 포트 번호가 굉장히 헷갈린다.


    특히 8080:8000 같은 설정을 처음 볼 때 착각하기 쉬웠다

    Compose에 이런 설정이 있다고 하자.

    ports:
      - "8080:8000"
    

    처음에는 8080이 FastAPI의 포트라고 생각하기 쉽다.

    하지만 두 숫자의 역할이 다르다.

    8080 = Host에서 접근하는 port
    
    8000 = Container 내부에서 애플리케이션이 듣는 port
    

    그래서 호스트에서는:

    localhost:8080
    

    으로 접근할 수 있다.

    그런데 같은 Docker network 안의 Nginx가 FastAPI로 직접 요청한다면 보통:

    backend:8000
    

    처럼 컨테이너가 실제로 듣고 있는 포트를 사용한다.

    Docker Compose 공식 문서에서도 호스트에서는 published host port를 이용하지만 같은 Compose 네트워크의 다른 컨테이너에서는 service name과 container port를 이용하는 예를 보여준다.

    이걸 모르고 Nginx에서:

    proxy_pass http://backend:8080;
    

    처럼 설정하면, 호스트에서는 8080으로 잘 열리는데 컨테이너끼리는 통신하지 못하는 상황을 만들 수 있다.

    그리고 다시 502가 나온다.

    이렇게 보면 같은 502라도 원인이 꽤 다양하다.

    localhost 문제
    
    서비스 이름 문제
    
    Docker network 문제
    
    container port 문제
    
    Uvicorn bind 문제
    

    전부 결과는 Nginx에서 비슷하게 보일 수 있다.

    그래서 502라는 결과만 보고 설정 파일을 계속 고치는 방식이 비효율적이라는 생각이 들었다.


    어느 순간부터는 Nginx 설정을 읽는 대신 Nginx 위치에서 직접 요청해보게 됐다

    이런 문제를 몇 번 생각하다 보니 가장 단순한 테스트가 보였다.

    Nginx가 FastAPI에 연결해야 한다면, Nginx와 같은 위치에서 FastAPI에 직접 요청해보면 된다.

    Nginx도 Docker 안에서 실행 중이라고 하자.

    그렇다면 Nginx 컨테이너에 들어간다.

    docker exec -it nginx_container sh
    

    그리고 FastAPI 서비스로 직접 요청한다.

    curl http://backend:8000/health
    

    여기에서 정상적으로 응답한다면 중요한 사실을 하나 알 수 있다.

    Nginx container
          ↓
    Docker network
          ↓
    backend:8000
    

    경로 자체는 동작한다.

    그렇다면 이제 Nginx proxy_pass나 URI 처리 같은 설정을 좀 더 집중해서 볼 이유가 생긴다.

    반대로 여기에서:

    Could not resolve host
    

    가 나온다면 서비스 이름이나 network부터 볼 수 있다.

    Connection refused
    

    라면 이름 해석은 됐지만 해당 주소/포트에서 서비스를 받을 수 없는 상태를 의심할 수 있다.

    200 OK
    

    가 나온다면 네트워크보다는 Nginx configuration을 더 볼 수 있다.

    나는 이 방식이 특히 좋다고 생각한다.

    왜냐하면 한 번의 테스트로 문제 범위를 확 줄일 수 있기 때문이다.


    Nginx error log를 먼저 보는 이유도 여기 있었다

    502가 발생했을 때 브라우저에는 보통 많은 정보가 나오지 않는다.

    502 Bad Gateway
    

    정도가 전부다.

    이 정보만 가지고는 Nginx가 hostname을 찾지 못했는지, Connection이 거부됐는지, timeout이 발생했는지 알 수 없다.

    그런데 Nginx는 error_log를 통해 오류를 기록할 수 있고, 공식 core module 문서에서도 파일과 로그 레벨을 지정하는 error_log directive를 제공한다.

    그래서 브라우저에서 502를 본 뒤 Nginx 설정을 무작정 바꾸기보다 error log에 어떤 메시지가 남았는지부터 확인하는 것이 훨씬 직접적이다.

    예를 들어 로그가 말해주는 방향은 서로 다를 수 있다.

    connection refused
    

    라면 upstream 주소는 찾았지만 Connection을 만들지 못했을 가능성을 본다.

    host not found
    

    계열이라면 이름 해석이나 network 문제를 본다.

    timeout 관련 메시지가 있다면 upstream이 응답하지 않는 이유를 본다.

    즉 Nginx error log는 단순히 오류 기록이 아니라 요청이 어느 단계까지 갔는지를 알려주는 단서가 된다.

    Access log는 요청 자체가 어떻게 처리되었는지를 기록할 수 있고, Nginx 공식 HTTP log module은 요청 로그를 access_log로 작성하는 기능을 제공한다.


    그런데 Connection이 된다고 502 문제가 모두 끝나는 것도 아니었다

    Nginx 컨테이너 안에서:

    curl http://backend:8000/health
    

    가 잘 된다.

    서비스 이름도 맞다.

    Docker network도 정상이다.

    Uvicorn도 0.0.0.0:8000에서 듣고 있다.

    그런데 특정 API만 Nginx를 통하면 문제가 생긴다면 이제 조금 다른 부분을 봐야 한다.

    이때부터는 연결 자체의 문제에서 HTTP proxy 동작 문제로 범위가 이동한다.

    예를 들어 Nginx의 locationproxy_pass에서 URI가 어떻게 전달되는지 생각해야 할 수 있다.

    다음 두 설정은 얼핏 매우 비슷하게 보인다.

    location /api/ {
        proxy_pass http://backend:8000;
    }
    

    그리고:

    location /api/ {
        proxy_pass http://backend:8000/;
    }
    

    하지만 Nginx proxy_pass는 URI를 포함하는지 여부와 location 구조에 따라 upstream으로 전달되는 요청 URI 처리 방식이 달라질 수 있다. Nginx 공식 proxy module 문서에서 proxy_pass에 URI를 지정한 경우와 지정하지 않은 경우 upstream에 전달되는 request URI가 어떻게 달라지는지를 별도로 설명한다.

    이 차이 때문에:

    사용자 요청
    /api/search
    

    을 보냈는데 FastAPI에서는 내가 예상한:

    /search
    

    가 아니라 다른 path를 받고 있을 수도 있다.

    이 경우에는 FastAPI까지 Connection 자체는 성공한다.

    따라서 network 문제와는 성격이 다르다.

    그래서 나는 지금 502나 proxy 문제를 볼 때 “연결할 수 있는가”와 “올바른 HTTP 요청을 전달하고 있는가”를 별도 단계로 생각한다.


    root_path를 사용하는 서비스라면 경로 문제를 더 조심해야 했다

    내 FastAPI 서비스에서는 애플리케이션이 항상 루트 /에서만 동작하는 형태가 아니라 특정 prefix 아래에서 서비스하는 구조도 사용했다.

    예를 들어 애플리케이션이 /bookq 같은 경로 구조를 가진다면 Nginx가 어떤 URI를 upstream으로 전달하는지가 훨씬 중요해진다.

    사용자가:

    https://example.com/bookq/search
    

    로 요청했는데 Nginx가 FastAPI에는:

    /search
    

    를 보내야 하는 구조인지,

    아니면:

    /bookq/search
    

    를 그대로 보내야 하는지는 FastAPI의 routing과 proxy 구성에 따라 달라진다.

    이걸 제대로 맞추지 않으면 네트워크는 정상이고 Uvicorn도 정상인데 결과가 원하는 API로 가지 않을 수 있다.

    그래서 reverse proxy를 구성할 때는 단순히:

    Nginx가 backend:8000에 연결되는가?
    

    만 보는 것이 아니라:

    사용자의 원래 URI가
    FastAPI에서는 어떤 URI로 보여야 하는가?
    

    까지 생각해야 한다.

    이 지점부터 Nginx는 단순한 포트 전달기가 아니라 요청의 경로와 헤더를 어떻게 upstream에 전달할지를 결정하는 HTTP proxy라는 점이 더 명확해졌다.


    결국 내가 502를 볼 때 가장 먼저 묻게 된 질문은 “어디까지 갔나?”였다

    처음에는 502가 뜨면 Nginx 설정 파일부터 열었다.

    proxy_pass를 바꿔보고,

    포트를 바꿔보고,

    컨테이너를 재시작하고,

    그래도 안 되면 Nginx를 다시 설치해볼 생각까지 할 수 있다.

    그런데 이런 방식에는 문제가 있다.

    현재 어느 단계까지 정상인지 확인하지 않고 여러 곳을 동시에 바꾸고 있다는 것이다.

    지금은 오히려 요청 하나를 머릿속으로 따라간다.

    브라우저
       ↓
    Nginx
       ↓
    Docker DNS
       ↓
    Docker Network
       ↓
    FastAPI Container
       ↓
    Uvicorn Socket
       ↓
    FastAPI Route
    

    그리고 앞에서부터 하나씩 확인한다.

    브라우저에서 502가 나왔다는 것은 Nginx까지는 왔다.

    그다음 Nginx error log를 본다.

    Nginx 컨테이너에서 backend:8000으로 직접 요청한다.

    이름이 해석되는지 본다.

    Connection이 되는지 본다.

    같은 Docker network인지 본다.

    Uvicorn이 0.0.0.0에 bind되어 있는지 본다.

    그리고 여기까지 정상이라면 Nginx의 location과 proxy_pass URI를 본다.

    이 방식으로 바꾸고 나니 502가 더 이상 하나의 큰 문제처럼 느껴지지 않았다.

    하나의 요청이 여러 단계를 지나가다가 어느 한 단계에서 다음으로 넘어가지 못한 결과로 보이기 시작했다.


    Docker에서 Nginx를 사용할 때 호스트 포트를 굳이 거쳐야 하는지도 다시 생각했다

    예전에는 호스트에서 FastAPI가:

    localhost:8000
    

    으로 열리니 Nginx도 그 주소를 사용하는 것이 자연스럽다고 생각하기 쉽다.

    하지만 둘 다 Docker 안에 있다면 네트워크 관점에서는 오히려 돌아가는 경로가 된다.

    Nginx container
       ↓
    Host published port
       ↓
    FastAPI container
    

    보다:

    Nginx container
       ↓
    Docker network
       ↓
    FastAPI service
    

    가 더 직접적이다.

    Compose는 같은 기본 네트워크의 서비스가 서비스 이름으로 서로 접근할 수 있도록 구성한다.

    이걸 이해하고 나니 Docker Compose에서 ports를 언제 사용하는지도 조금 달리 보게 됐다.

    FastAPI를 외부 호스트에서 직접 접근할 필요가 없다면 반드시 모든 backend port를 host에 publish해야 하는 것은 아니다.

    Nginx만 외부에 열고 FastAPI는 내부 Docker network에서만 접근하도록 구성하는 방식도 생각할 수 있다.

    이런 구조에서는 서비스 간 경로가 더 명확해지고 backend port를 외부에 직접 노출하지 않아도 된다.

    물론 실제 운영 요구사항에 따라 구성이 달라질 수 있지만, 적어도:

    Docker 컨테이너끼리 통신하려면 무조건 host port가 필요하다.

    고 생각했던 것은 틀린 접근이었다.


    지금 다시 502를 만난다면 가장 먼저 할 일

    지금 같은 상황을 다시 만나면 nginx.conf를 처음부터 다시 작성하지 않을 것 같다.

    먼저 Nginx error log를 본다.

    그다음 Nginx가 Docker 안에 있다면 컨테이너에 들어간다.

    docker exec -it nginx_container sh
    

    그리고 upstream에 직접 요청한다.

    curl http://backend:8000/health
    

    여기서 실패하면 Nginx configuration보다 앞의 문제다.

    서비스 이름을 확인한다.

    docker compose ps
    

    network를 확인한다.

    docker network inspect <network_name>
    

    FastAPI가 어떤 주소에 bind되어 있는지 확인한다.

    0.0.0.0:8000
    

    인지 본다. Uvicorn은 --host 0.0.0.0을 이용해 로컬 인터페이스 밖에서도 접근 가능한 형태로 bind할 수 있다.

    그리고 upstream 직접 요청이 성공한다면 그때 Nginx의 locationproxy_pass를 본다.

    이 순서가 좋은 이유는 단순하다.

    매 단계에서 문제의 절반 이상을 지울 수 있기 때문이다.


    502와 504도 같은 문제라고 생각하기 쉬웠다

    reverse proxy 오류를 보다 보면 502 Bad Gateway504 Gateway Timeout도 자주 함께 등장한다.

    둘 다 Nginx가 앞에 있고 backend에서 문제가 발생한 것처럼 보이기 때문에 같은 오류처럼 느껴질 수 있다.

    하지만 조사할 때는 의미를 구분하는 것이 도움이 된다.

    upstream에 정상적으로 연결할 수 없거나 유효한 upstream 처리가 되지 않는 경우에는 502 계열 문제를 볼 수 있다. Nginx upstream 문서도 upstream 서버를 선택하거나 도달하지 못하는 상황에서 502를 반환할 수 있음을 설명한다.

    반면 upstream에 요청은 갔지만 응답을 기다리는 과정에서 timeout이 문제가 된다면 조사 방향은 달라진다.

    예를 들어 FastAPI가 내부에서 LLM API 응답을 오래 기다리고 있거나 PostgreSQL Query가 오래 실행되고 있다면 Nginx와 FastAPI의 Connection 자체는 정상인데 backend 처리시간이 너무 긴 문제일 수 있다.

    이때 proxy_read_timeout을 무작정 크게 만드는 방법도 생각할 수 있지만, 이전에 max_connections를 보면서 느꼈던 것과 같은 질문을 다시 하게 된다.

    timeout을 늘리는 것이 원인을 해결하는 것인가, 아니면 느린 backend를 더 오래 기다리게 만드는 것인가?

    설정값을 늘리는 것이 필요한 경우도 있지만 먼저 왜 backend 응답이 그만큼 오래 걸렸는지 보는 것이 순서라고 생각한다.


    이전에는 설정값을 먼저 바꿨지만 이제는 요청의 이동 경로를 먼저 그린다

    Docker와 Nginx를 처음 다루면 설정 파일이 많다.

    nginx.conf.

    Compose 파일.

    Dockerfile.

    Uvicorn 실행 명령.

    FastAPI 설정.

    어디에서 문제가 났는지 모르면 모든 파일이 의심스럽다.

    그래서 하나씩 값을 바꾸게 된다.

    그런데 지금은 문제를 만나면 설정 파일보다 먼저 작은 그림을 그리는 편이 더 낫다고 생각한다.

    예를 들어:

    Internet
       │
       ▼
    Nginx :80
       │
       │ Docker Network
       ▼
    backend:8000
       │
       ▼
    Uvicorn 0.0.0.0:8000
       │
       ▼
    FastAPI
    

    그리고 각 선마다 질문 하나를 붙인다.

    Internet → Nginx
    정상인가?
    
    Nginx → backend
    이름을 찾을 수 있는가?
    
    backend:8000
    Connection이 되는가?
    
    Uvicorn
    외부 인터페이스에 bind되어 있는가?
    
    FastAPI
    요청한 route가 존재하는가?
    

    이렇게 만들어 놓으면 502라는 큰 오류가 여러 개의 작은 확인 작업으로 바뀐다.

    개인적으로 Docker와 reverse proxy 문제에서는 이 방식이 설정 예제를 외우는 것보다 훨씬 도움이 됐다.


    마무리

    처음 Nginx에서 502 Bad Gateway를 봤을 때는 Nginx 자체가 문제라고 생각하기 쉬웠다.

    화면에 Nginx가 표시되고 Nginx가 오류를 반환했기 때문이다.

    하지만 reverse proxy 구조를 다시 생각해보면 502는 오히려 한 가지 중요한 정보를 준다.

    사용자의 요청은 Nginx까지 도착했다.

    그렇다면 그 다음 경로를 보면 된다.

    Nginx가 upstream 주소를 찾을 수 있는가.

    Docker network가 연결되어 있는가.

    localhost를 잘못 사용하고 있지는 않은가.

    호스트 포트와 컨테이너 포트를 혼동하고 있지는 않은가.

    FastAPI가 실제로 0.0.0.0에 bind되어 있는가.

    그리고 이 모든 것이 정상이라면 Nginx가 요청 URI를 원하는 형태로 전달하고 있는지 확인한다.

    처음에는:

    502
    ↓
    Nginx 설정 오류
    

    라고 생각했다.

    지금은:

    502
    ↓
    Nginx는 요청을 받았다
    ↓
    upstream까지 어느 단계에서 막혔을까?
    

    라고 생각한다.

    이 작은 차이 때문에 확인 순서가 완전히 달라졌다.

    그리고 Docker 환경에서는 특히 localhost라는 익숙한 주소가 문제를 더 어렵게 만들 수 있었다.

    호스트의 localhost와 Nginx 컨테이너의 localhost와 FastAPI 컨테이너의 localhost는 서로 같은 공간이 아니다.

    그래서 컨테이너끼리 통신할 때는 Compose가 제공하는 network와 service name을 이용하는 구조를 먼저 생각하게 됐다. Docker Compose는 같은 네트워크의 서비스를 이름으로 발견할 수 있도록 하고, 컨테이너가 교체되어 IP가 변경되더라도 서비스 이름을 기준으로 연결하도록 설계되어 있다.

    결국 이 문제를 겪으면서 얻은 가장 큰 교훈은 Nginx 설정 문법 자체가 아니었다.

    502를 보면 설정값을 고치기 전에 요청이 실제로 어디까지 이동했는지를 먼저 확인하는 것.

    Nginx에서 FastAPI까지의 경로를 단계별로 확인할 수 있으면 502는 더 이상 막연한 서버 오류가 아니다.

    어느 선 하나가 끊어진 문제로 바뀐다.

    그리고 그 선이 어디인지 찾은 뒤에 설정을 수정하는 편이, 여러 설정을 동시에 바꾸면서 우연히 문제가 사라지기를 기다리는 것보다 훨씬 설명 가능한 해결 방법이라고 생각한다.

  • 도서 추천 서비스에서 일반 LLM만 쓰지 않고 RAG를 붙인 이유

    LLM을 이용해 도서 추천 서비스를 처음 생각하면 구현 방법은 의외로 단순해 보인다.

    사용자가 원하는 책을 자연어로 입력하고, 그 문장을 LLM에 전달한 뒤 추천 도서를 받아오면 된다.

    예를 들어 사용자가 다음과 같이 질문한다고 해보자.

    요즘 회사 생활이 너무 지치는데 가볍게 읽으면서 위로받을 수 있는 책을 추천해줘.

    LLM은 이런 질문에 꽤 자연스럽게 답할 수 있다. 책 몇 권을 추천하고 각각의 이유도 그럴듯하게 설명할 수 있다.

    문제는 내가 만들고 있던 서비스가 단순한 AI와의 도서 대화 서비스가 아니었다는 점이다.

    실제 서비스에서는 사용자가 이용하는 도서관을 기준으로 검색해야 했고, 검색된 도서의 제목과 저자, ISBN, 출판 정보 같은 실제 데이터를 기반으로 결과를 만들어야 했다.

    즉 내가 필요했던 것은:

    그럴듯한 책을 알고 있는 AI

    가 아니라,

    현재 내가 가지고 있는 도서 데이터 안에서 조건에 맞는 책을 찾고, 그 결과를 사람이 이해하기 좋은 문장으로 설명해주는 시스템

    이었다.

    이 차이 때문에 일반 LLM 호출만으로는 부족했고 검색 시스템과 LLM을 연결하는 RAG 구조를 사용하게 됐다.


    일반 LLM이 책을 잘 추천하는데도 부족했던 이유

    LLM에게 책을 추천해 달라고 하면 답변 자체는 상당히 자연스럽다.

    특히 유명한 책을 대상으로 하면 제목, 저자, 책의 특징까지 함께 설명하는 경우도 많다.

    하지만 실제 검색 서비스를 만든다는 관점에서는 답변이 자연스러운 것과 서비스가 신뢰할 수 있는 데이터를 반환하는 것은 완전히 다른 문제였다.

    내 서비스에서 필요한 결과에는 단순한 추천 문장만 있는 것이 아니었다.

    검색 결과를 구성하려면 다음과 같은 실제 도서 정보가 필요했다.

    책 제목
    저자
    ISBN
    표지
    출판연도
    키워드
    도서 설명
    소장 도서관
    

    그리고 그 정보를 이용해 다시 다음과 같은 문장을 만들어야 했다.

    왜 이 책을 추천하는가
    
    실제로 어떻게 활용할 수 있는가
    
    이 책을 읽었을 때 어떤 점을 기대할 수 있는가
    

    여기서 중요한 차이가 생긴다.

    LLM은 추천 이유를 만들어내는 데는 강하지만, 우리 데이터베이스에 지금 어떤 책이 존재하는지 알지 못한다.

    특정 도서관에 그 책이 있는지도 알 수 없다.

    내부 DB에서 사용하는 정확한 ISBN이나 현재 저장되어 있는 메타데이터 역시 프롬프트만으로는 확인할 방법이 없다.

    따라서 일반 LLM에게 처음부터 끝까지 맡기는 방식은 내가 만들고 싶은 서비스 구조와 맞지 않았다.


    그래서 역할을 검색과 생성으로 분리했다

    도서 추천을 하나의 AI 기능으로 생각하면 모든 일을 LLM에게 맡기기 쉽다.

    하지만 실제 구현에서는 역할을 둘로 나누는 편이 훨씬 명확했다.

    첫 번째는 어떤 책을 보여줄지 결정하는 검색 단계다.

    두 번째는 검색된 책을 사용자에게 어떻게 설명할지 결정하는 생성 단계다.

    내가 만든 구조를 단순하게 표현하면 다음과 같다.

    사용자 질문
        ↓
    검색어 분석
        ↓
    Elasticsearch에서 실제 도서 검색
        ↓
    관련 도서 후보
        ↓
    검색된 도서 정보 + 사용자 질문
        ↓
    LLM
        ↓
    추천 이유와 설명 생성
    

    여기서 LLM은 존재하지 않는 책을 자유롭게 고르는 역할을 하지 않는다.

    먼저 검색 시스템에서 실제 도서 후보를 확보하고, LLM이 판단할 수 있는 범위를 그 후보 안으로 좁힌다.

    이 구조가 내가 도서 서비스에서 RAG를 사용하는 가장 중요한 이유였다.


    RAG가 필요한 이유는 LLM에게 더 많은 지식을 넣기 위해서만은 아니었다

    RAG를 설명하는 글을 보면 흔히 다음과 같이 이야기한다.

    LLM이 모르는 최신 정보를 검색해서 제공한다.

    물론 중요한 기능이다.

    하지만 실제 서비스를 개발하면서 느낀 RAG의 장점은 조금 달랐다.

    내 입장에서는 LLM이 답변할 수 있는 범위를 통제할 수 있다는 점이 더 중요했다.

    예를 들어 현재 검색된 책이 다음 세 권이라고 하자.

    도서 A
    도서 B
    도서 C
    

    LLM에게 이 데이터와 사용자 질문을 같이 전달하면 내가 원하는 것은 다음과 같다.

    A, B, C 중에서 사용자의 질문에 가장 적합한 책을 설명해줘.

    반면 검색 과정 없이 질문만 전달하면 의미가 달라진다.

    네가 알고 있는 모든 책 중에서 적합한 책을 찾아줘.

    둘 다 자연스러운 답변을 만들 수 있지만 서비스 개발자 입장에서는 첫 번째 방식이 훨씬 관리하기 쉽다.

    어떤 데이터가 모델에게 전달됐는지 확인할 수 있고, 왜 특정 책이 답변에 등장했는지도 어느 정도 역추적할 수 있기 때문이다.


    특정 도서관의 책만 추천해야 하는 문제가 있었다

    내가 만든 검색 서비스에서는 도서관을 구분하는 값이 중요했다.

    사용자가 특정 도서관에서 책을 찾고 있다면 검색 결과 역시 그 도서관이 보유한 도서를 대상으로 해야 한다.

    그래서 검색 데이터에는 도서관을 구분하기 위한 libCode가 존재했고 검색 단계에서 이 조건을 이용했다.

    이 문제는 일반 LLM만으로 해결하기 어렵다.

    예를 들어 사용자가:

    내가 이용하는 도서관에서 빌릴 수 있는 파이썬 입문 책을 추천해줘.

    라고 입력했다고 하자.

    여기에서 중요한 것은 ‘파이썬 입문 책’을 아는 것이 아니다.

    실제 서비스는 먼저:

    1. 사용자가 어느 도서관을 이용하는지 확인하고
    2. 해당 도서관의 데이터만 대상으로 검색하고
    3. 그중 파이썬 입문에 적합한 책을 찾고
    4. 검색된 책을 근거로 추천해야 한다.

    내 검색 구조에서는 이 역할을 Elasticsearch가 담당한다.

    LLM은 이 결과를 받은 뒤 추천 이유나 사용자가 이해하기 쉬운 설명을 만드는 쪽에 집중한다.


    자연어 질문 때문에 벡터 검색도 필요했다

    도서 검색은 ISBN이나 제목만 입력받는다면 비교적 단순하다.

    하지만 내가 만들고 싶었던 기능은 사용자가 자연어로 질문할 수 있는 검색이었다.

    예를 들어:

    최근에 아무것도 하기 싫고 의욕이 없는데 너무 무겁지 않은 책을 읽고 싶어.

    이런 질문에는 정확한 도서명이 없다.

    저자도 없다.

    사용자는 자기 상태와 원하는 느낌만 이야기하고 있다.

    기존 키워드 검색으로도 의욕, 무기력, 위로 같은 단어를 이용해 후보를 찾을 수 있지만 책 메타데이터에서 사용하는 표현과 사용자의 표현이 정확히 일치한다는 보장은 없다.

    그래서 검색 단계에서 텍스트 검색과 함께 embedding을 이용한 벡터 검색도 사용했다.

    전체 흐름은 다음에 가깝다.

    사용자 자연어 질문
            ↓
    텍스트 검색 + 의미 기반 검색
            ↓
    관련성이 높은 실제 도서 후보
            ↓
    도서 메타데이터 확보
            ↓
    LLM에 컨텍스트로 전달
            ↓
    사용자 질문에 맞춘 설명
    

    이 구조에서 검색과 생성은 서로 역할이 다르다.

    Elasticsearch가 무슨 책을 근거로 사용할 것인지를 결정하고,

    LLM은 그 책이 사용자에게 왜 적합한지를 설명한다.


    실제로는 검색 결과가 나쁘면 LLM도 살릴 수 없었다

    RAG를 붙였다고 해서 답변 품질이 자동으로 좋아지는 것은 아니었다.

    이 구조를 개발하면서 가장 중요하게 느낀 부분 중 하나다.

    LLM에 아무리 좋은 프롬프트를 사용해도 앞 단계에서 엉뚱한 문서가 검색되면 최종 답변 역시 제한을 받는다.

    예를 들어 사용자가:

    파이썬을 처음 공부하는 사람이 읽을 책

    을 검색했는데 검색 단계에서 고급 머신러닝 서적이나 Python 내부 구현에 관한 책이 상위 후보로 들어왔다고 하자.

    LLM에게 그 책들만 전달한 상태에서:

    초보자에게 가장 적합한 책을 골라 설명해줘.

    라고 요구한다고 갑자기 좋은 입문서가 생겨나지는 않는다.

    LLM은 제공된 후보 안에서 답을 만들기 때문이다.

    그래서 RAG 답변이 이상하게 나왔을 때 처음부터 프롬프트만 수정하는 방식은 효율적이지 않았다.

    먼저 확인해야 할 것은:

    LLM에게 어떤 검색 결과를 전달했는가?

    였다.


    RAG 문제를 세 단계로 나눠서 보기 시작했다

    이후에는 답변이 마음에 들지 않을 때 문제를 크게 세 단계로 나눠봤다.

    1. Retrieval 문제

    먼저 검색이 제대로 됐는지 본다.

    사용자 질문
    
    ↓
    
    검색 결과 TOP N
    

    여기에서 이미 관련 없는 책이 들어왔다면 검색 단계부터 조정해야 한다.

    예를 들어 확인할 수 있는 것은:

    • 텍스트 검색 필드
    • title 가중치
    • keywords
    • description
    • embedding
    • KNN 설정
    • 검색 필터
    • 검색 후보 수

    등이다.


    2. Context 문제

    검색 결과는 적절하지만 LLM에 전달하는 정보가 부족할 수도 있다.

    책 제목만 넘기는 것과:

    title
    

    다음 정보를 같이 넘기는 것은 차이가 크다.

    title
    author
    keywords
    description
    publication year
    

    하지만 반대로 필요 이상으로 많은 데이터를 한 번에 넣는다고 항상 좋은 것도 아니다.

    관련 없는 정보를 많이 전달하면 모델이 어떤 정보에 집중해야 할지 어려워질 수 있고 입력 토큰도 증가한다.

    그래서 검색 결과에서 생성에 필요한 정보가 무엇인지를 따로 결정해야 했다.


    3. Generation 문제

    검색 결과도 좋고 전달한 정보도 충분한데 설명이 이상하다면 그때 생성 단계를 본다.

    예를 들어 프롬프트에서:

    • 추천 이유를 어떻게 작성할지
    • 검색 결과 밖의 책을 언급해도 되는지
    • 어떤 말투를 사용할지
    • 사실과 추천 표현을 어떻게 구분할지

    같은 조건을 조정한다.

    이렇게 나눠보니 RAG 문제를 전부 프롬프트 문제라고 생각하는 것보다 원인을 찾기가 훨씬 쉬웠다.


    일반 LLM 방식과 RAG 방식에서 가장 크게 다른 부분

    내가 실제 서비스 관점에서 두 방식을 비교할 때 가장 중요한 차이는 다음이었다.

    일반 LLM

    질문:

    초등학생이 과학에 흥미를 가질 만한 책 추천해줘.

    LLM은 자신의 학습 과정에서 알고 있는 정보를 바탕으로 책을 추천할 수 있다.

    답변이 자연스러울 수 있고 실제 존재하는 유명 도서를 추천할 수도 있다.

    하지만 서비스 입장에서는 다음을 바로 보장할 수 없다.

    우리 DB에 있는 책인가?
    
    해당 도서관이 가지고 있는가?
    
    현재 저장된 ISBN과 일치하는가?
    
    우리 검색 조건에 맞는가?
    

    RAG

    같은 질문을 먼저 검색 시스템으로 보낸다.

    질문
    ↓
    도서 검색
    ↓
    실제 데이터에 있는 후보
    ↓
    후보 정보를 LLM에 전달
    ↓
    추천 설명 생성
    

    이 경우 적어도 답변의 출발점은 내가 관리하고 있는 데이터가 된다.

    나에게는 이 차이가 RAG를 사용하는 가장 현실적인 이유였다.


    RAG를 붙인다고 환각이 사라지는 것은 아니다

    RAG를 사용하면 환각 문제가 완전히 해결된다고 생각하기 쉽다.

    하지만 그렇게 보지는 않는다.

    검색된 데이터를 제공해도 LLM은 여전히 생성 모델이다.

    전달한 정보를 잘못 해석하거나, 입력에 없는 내용을 추가하거나, 지나치게 확신하는 문장을 만들 가능성은 남아 있다.

    그래서 서비스에서는 RAG를:

    LLM에게 정답을 알려주는 기능

    이라기보다,

    LLM이 참고해야 할 근거와 답변 범위를 최대한 명확하게 만들어주는 구조

    로 보는 편이 더 맞다고 생각한다.

    이 관점으로 보면 검색 결과 자체를 로그로 남기는 것도 중요해진다.

    최종 답변만 저장하면 나중에 문제가 생겼을 때:

    왜 이런 답변이 나왔지?

    를 확인하기 어렵다.

    반대로:

    사용자 검색어
    검색된 책
    검색 방식
    최종 답변
    

    을 함께 확인할 수 있다면 문제를 검색과 생성 단계로 나눠서 분석할 수 있다.


    실제 서비스에서는 답변보다 추적 가능성이 중요했다

    개발 초반에는 사용자가 보는 최종 문장이 가장 중요해 보였다.

    추천 문장이 자연스러운지, 질문에 잘 답했는지에 집중했다.

    하지만 운영을 생각하면 왜 그 답변이 나왔는지를 다시 확인할 수 있는 구조도 중요했다.

    예를 들어 사용자가:

    왜 이런 책을 추천했지?

    라고 느낄 만한 결과가 나왔다고 하자.

    일반 LLM 호출만 저장했다면 확인할 수 있는 정보가 많지 않다.

    하지만 RAG 구조라면 최소한 다음 순서로 추적할 수 있다.

    당시 사용자가 입력한 검색어
            ↓
    Elasticsearch가 반환한 도서
            ↓
    LLM에 전달한 컨텍스트
            ↓
    최종 추천 문장
    

    검색된 책 자체가 이상했다면 검색 품질 문제다.

    책은 적절한데 설명만 이상했다면 생성 문제에 가깝다.

    이 차이는 기능을 처음 만들 때보다 운영하면서 더 중요하게 느껴졌다.


    그래서 검색 로그를 따로 남기게 됐다

    검색·추천 서비스를 만들면서 검색과 생성 관련 데이터를 별도로 기록하는 이유도 여기에 있다.

    사용자가 어떤 검색어를 입력했는지, 어떤 검색 결과가 나왔는지, 어떤 추천 문장이 생성됐는지를 나중에 다시 확인해야 검색 품질을 개선할 수 있기 때문이다.

    단순히:

    요청 성공
    HTTP 200
    

    만 남겨서는 AI 검색 품질을 분석하기 어렵다.

    오히려 다음 정보가 더 유용할 수 있다.

    사용자가 입력한 검색어
    
    사용한 검색 방식
    
    검색된 도서
    
    추천 문장
    
    응답시간
    
    오류 여부
    

    서비스를 운영하면서 이런 데이터를 쌓으면 특정 검색어에서 반복적으로 좋지 않은 결과가 나오는지 확인할 수 있다.

    그리고 그때 비로소 검색 알고리즘이나 프롬프트를 수정할 근거가 생긴다.


    일반 LLM이 필요 없는 것은 아니다

    그렇다고 일반 LLM 호출 자체가 의미 없다는 뜻은 아니다.

    모든 질문이 반드시 검색 데이터를 필요로 하는 것은 아니다.

    예를 들어:

    독서 습관을 만들려면 어떻게 시작하는 게 좋을까?

    같은 질문이라면 특정 도서 데이터 없이도 일반적인 답변을 제공할 수 있다.

    반면:

    우리 도서관에서 빌릴 수 있는 책 중에서 경제를 처음 공부하기 좋은 책을 추천해줘.

    는 검색 데이터가 핵심이다.

    그래서 앞으로 AI 서비스를 설계한다면 모든 요청을 무조건 RAG로 보내기보다 **이 질문에 외부 데이터가 필요한가?**를 먼저 생각하는 편이 좋다고 본다.

    검색이 필요하지 않은 질문까지 매번 Elasticsearch를 거치게 하면 시스템 구조와 비용만 복잡해질 수도 있다.


    내가 RAG를 사용하는 기준

    현재는 다음과 같은 질문에 RAG가 특히 필요하다고 본다.

    서비스가 보유한 데이터에서 답해야 할 때

    우리 도서관의 책
    우리 서비스에 등록된 책
    특정 조건에 해당하는 도서
    

    데이터의 정확한 값을 사용해야 할 때

    ISBN
    저자
    출판연도
    도서관 코드
    

    자연어 질문을 실제 검색 결과와 연결해야 할 때

    취업 준비하면서 자신감이 떨어졌을 때 읽을 책
    

    나중에 결과의 근거를 추적해야 할 때

    왜 이 책이 추천됐는가?
    

    반대로 외부 데이터가 필요하지 않은 일반적인 대화라면 반드시 RAG가 필요한 것은 아니다.


    이번 서비스를 만들면서 RAG를 바라보는 방식이 달라졌다

    처음에는 RAG를 주로:

    LLM에 검색 기능을 붙이는 기술

    정도로 생각했다.

    실제 서비스를 만들면서는 조금 다르게 보게 됐다.

    RAG의 핵심은 단순히 검색 결과를 프롬프트에 넣는 것이 아니라 서비스가 신뢰하는 데이터와 LLM의 생성 능력 사이에 경계를 만드는 것에 더 가까웠다.

    내 도서 검색 서비스에서는:

    어떤 책이 존재하는가
    어떤 도서관에 있는가
    책의 실제 정보는 무엇인가
    

    같은 부분은 검색과 데이터가 담당한다.

    반대로:

    왜 이 책이 사용자에게 맞는가
    어떻게 설명하면 이해하기 쉬운가
    어떤 방식으로 추천 이유를 표현할 것인가
    

    같은 부분은 LLM이 담당한다.

    이렇게 역할을 나누고 나니 LLM에게 모든 것을 맡기는 것보다 시스템 전체를 이해하고 수정하기가 훨씬 쉬워졌다.


    마무리

    도서 추천 기능만 놓고 보면 일반 LLM도 상당히 좋은 답변을 만든다.

    하지만 실제 서비스를 만들 때 내가 필요했던 것은 좋은 문장을 만드는 AI 하나가 아니었다.

    특정 도서관의 실제 데이터에서 책을 찾고, 그 책의 정확한 정보를 유지하면서, 사용자의 자연어 질문에 맞는 추천 설명까지 생성해야 했다.

    그래서 검색과 생성을 분리했다.

    Elasticsearch는 실제 도서를 찾는다.

    RAG는 검색된 정보를 LLM에 전달한다.

    LLM은 그 범위 안에서 사용자가 이해하기 쉬운 설명을 만든다.

    그리고 문제가 생겼을 때:

    검색이 잘못됐는가?
    컨텍스트가 잘못됐는가?
    생성이 잘못됐는가?
    

    를 따로 확인한다.

    지금은 이것이 일반 LLM과 RAG의 가장 큰 차이라고 생각한다.

    RAG를 사용했다고 해서 LLM이 갑자기 더 똑똑해지는 것은 아니다.

    대신 실제 서비스가 가지고 있는 데이터를 근거로 답하게 만들고, 답변이 만들어진 과정을 추적할 수 있게 해준다.

    도서 검색·추천 서비스를 운영하는 입장에서는 바로 그 점이 RAG를 붙일 가장 큰 이유였다.

  • 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라는 결과에서 실제 원인까지 훨씬 자연스럽게 따라갈 수 있다고 생각한다.