서론

이번에 회사에 디자인 팀이 추가되며 기존의 직접 개발을 하는 개발자 외에 QA를 하는 과정이 새로 더해졌고, 도움을 받아 추가로 유저들에게 베타 테스트를 진행할 수 있었습니다. 덕분에 개발자들끼리는 확인할 수 없었던 여러 서버의 문제점들을 발견할 수 있었고 이에 대해 진행한 대응들에 대해 정리 하려 합니다.

발견 가능했던 여러 문제점들에는 메모리 누수에 따른 서버 응답 실패 및 과도한 인스턴스 추가 발생, 로깅 시스템의 구조적 설계 및 로그 레벨 구분 설정 미비, 기획 변경에 따라가지 못했던 알고리즘 조정 등이 있습니다.


다발적인 서버 응답 실패 관측 - 메모리 이슈

인프라 측면에서의 해소

이번에 내부에서 소규모로 개발팀이 llm에 쿼리를 보내고 응답을 보냈을 때와 달리, 실제 환경에서 응답이 계속해서 실패하고 빈 결과 값만을 사용자에게 내보내고 있는 결과가 관측됐습니다. 처음에는 gemini api 자체의 응답 실패 혹은 빈 응답이 발생하는 것이 문제가 아닐까하고 생각했습니다. 저희 쪽 서버에서는 빈 응답이 오고 있다고 로그를 찍어 내보내고 있었고 gemini api가 실패한 응답을 제대로 처리해주지 않는다는 커뮤니티의 의견도 발견할 수 있었거든요. 하지만 그와 별개로 빈 응답이 발생하는 케이스가 너무 다발적이었고 개발 환경에서보다 비교적 높은 트래픽을 처리하는 과정에서 해당 이슈가 발생할 가장 높은 원인은 메모리라 판단했고 이와 관련한 대응을 하기 위해 일종의 스트레스 테스트를 진행하기로 결정했습니다. 서버의 메모리 용량 차지하는 것이 약 50%에서 머물고 있는데 cloudrun에서 인스턴스 갯수가 늘어난다는 것도 고려사항 중 하나였습니다.

스트레스 테스트는 단순하게 파이썬 스크립트를 이용해 저희 내부 api 중 gemini api server에 요청을 보내는 것을 골라 동시에 10 x (1, 2, 3, 4, 5, 10)의 쿼리를 날려보는 식으로 이 때 다양한 형태의 질문 및 컨텍스트를 변경해가는 것을 통해 진행했습니다. 그리고 50개의 요청을 보낼때부터 5% 이내의 요청이 실패하기 시작하며, 100개의 요청을 보낼 때는 사실상 대부분에 해당하는 약 50%의 요청이 실패하는 것을 관측할 수 있었습니다. 그리고 해당 시점에 메모리 사용량이 거의 100%에 도달하는 것을 확인할 수 있었기 때문에 메모리 이슈가 맞다는 판단 하에 인프라에서 기존의 500mb였던 메모리 크기를 cloudrun의 인스턴스 당 메모리 상한인 2GB로 대증적인 해결을 해뒀습니다.

메모리 누수 개선

그와 별개로, 50개 혹은 100개에 해당하는 응답을 동시에 받는다고 하더라도 llm이 응답값으로 보내는 결과 값은 그래봐야 텍스트에 불과하기 때문에 커봤자 수십 kb를 넘기지 못했습니다. 일반적으로는 10kb 내외면 큰 응답에 속했습니다. 그렇다면 1mb 밖에 안되는 응답 값을 추가로 저장하는 데에 서버의 메모리 용량이 부하를 견디지 못하고 있다는 이야기이기 때문에 말이 되지 않아 서버에서 메모리가 새고 있다는 의심을 하게 됐습니다. 그래서 이와 관련해서 조사를 시작했고 그 주 범인으로 추측되는 것은 단연 llm과의 응답을 생성하는 모듈이었습니다.

간단하게 저희 api 서버의 llm 응답 생성 모듈의 스펙에 대해 설명하자면 llm이 내보내는 응답의 결과를 모두 기다렸다가 클라이언트에 내려보내는 것은 너무 느리기에 streaming response를 사용하고 있었습니다. 문제는 해당 streaming response를 다 내려보낸 뒤에 로그의 측면에서 해당 정보를 저장하기 위해 처리하던 기능에 있었습니다.

full_text = ""
async for chunk in await gemini_client.generate_content_stream():
    if hasattr(chunk, "text") and chunk.text:
        full_text += chunk.text
        yield chunk.text

파이썬에 익숙하신 분들이라면 위의 코드가 가져올 끔찍한 사태에 대해 예상하실 수 있을 것이라 생각합니다. 파이썬의 str 객체는 immutable이기 때문에 full_text라는 객체의 뒤에 스트링 객체에 해당하는 캐릭터를 덧붙이는 형태로 변형이 이루어지는 것이 아니라 full_text라는 호출하는 변수명만 동일한 새로운 객체가 내부에서 하나 재생성되는 메모리 누수가 발생하게 됩니다. 그리고 10kb 정도의 텍스트를 streaming 형태로 gemini api에서 처리받을 경우에 이는 최악의 경우 GB 정도에 해당하는 공간을 차지하는 재앙이 발생합니다. 자세한 내용은 다음과 같은 real python post 등을 통해 확인하실 수 있습니다.

이후에 해당 코드를 예제외 비슷하게 list and join의 형태로 변경하고 응답이 빈 형태로 나오지 않는 것을 확인할 수 있었습니다.

response_list = []
async for chunk in await gemini_client.generate_content_stream():
    if hasattr(chunk, "text") and chunk.text:
        response_list.append(chunk.text)
        yield chunk.text

로깅 시스템의 전반적인 구조 개편

실제 유저들의 데이터를 확인하는 과정에서 지금 서버의 로깅 시스템은 개발자가 단일의 유저가 서버에 요청한 내용들에 대한 확인만을 고려했을 뿐, 실질적으로 유저가 생성하는 동시다발적인 로그들에 대해서는 대응하기에 불편한 구조로 작성됐다는 사실을 알 수 있었습니다. 이는 로그들이 모든 메소드마다 개별적으로 다 발생하고 있기 때문에 하나의 요청 처리에도 십수개씩의 로그들을 확인해야하는 경우가 잦은데 이를 묶어주는 하나의 tracdID와 같은 값이 없어 일일히 찍어가며 연속되는 로그들을 구분해야했기 때문입니다. 코드로 놓고보면 이와 같다 말할 수 있습니다.

class HelloWorldController:
    def Endpoint():
        logger.info("Hello World Endpoint called!")
        self.service.execute_hello_world()
        logger.info("Hello World Endpoint execution finished")

class HelloWorldService:
    def execute_hello_world():
        logger.info("execution of hello world started")

        logger.info("querying hello started")
        self.repository.query_hello()
        logger.info("posting hello started")
        self.repository.post_hello()

        logger.info("hello world execution finished")

class HelloRepository:
    def query_hello():
        logger.info("get hello data from repository")
        try:
            result = self.db_engine.query("hello")
            logger.info("querying hello resulted in the following: ", result)
            return result
        except as e:
            logger.except(f"querying hello from {result} failed due following", e)

2025-09-28
카테고리로 돌아가기 ↩