본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 04. 22:23

단순한 Mock으로는 LLM 에이전트의 부하 테스트를 할 수 없습니다

요약

LLM 에이전트 부하 테스트 시 단순한 Mocking이 무한 루프를 유발하는 원인을 분석하고, 메시지 히스토리를 기반으로 한 동적 Mocking 해결책을 제시합니다. 이를 통해 LangGraph 에이전트의 인프라 성능과 안정성을 효과적으로 검증하는 방법을 설명합니다.

핵심 포인트

  • 단순 Mock은 에이전트의 도구 호출 루프를 방지하지 못함
  • 턴 카운팅 방식은 복잡한 에이전트 경로 대응에 한계가 있음
  • 메시지 히스토리를 분석하여 응답을 결정하는 동적 Mocking 필요
  • 성공적인 Mocking을 통해 인프라 부하 및 메모리 누수 검증 가능

제가 처음 시도한 것은 아주 당연한 것이었습니다. 하드코딩된 응답을 반환하는 가짜 OpenAI 엔드포인트를 구축하고, 에이전트가 이를 바라보게 한 뒤, 동시 사용자 수를 늘리는 것이었습니다. 비용이 많이 드는 의존성(dependency)을 Mock(모킹) 처리하여 변수를 격리하고 인프라를 측정하는 방식이었습니다.

하지만 에이전트는 모든 요청에서 무한 루프에 빠졌습니다.

그 이유는 LangGraph 에이전트가 작동하는 방식에 있습니다. 매 턴(turn)마다 에이전트는 OpenAI를 호출하여 도구 호출(tool invocation) 또는 텍스트를 반환받습니다. 만약 도구 호출이라면, 에이전트는 해당 도구를 실행하고 그 결과를 메시지 히스토리(message history)에 추가한 뒤, 이제 그 결과를 컨텍스트(context)에 포함하여 다시 OpenAI를 호출합니다. OpenAI는 도구 결과를 확인하고 텍스트로 응답합니다. 그리고 다음 턴으로 넘어갑니다.

단순한 Mock은 히스토리에 무엇이 있든 상관없이 매번 동일한 응답을 반환합니다. 따라서 에이전트가 도구를 호출하면, 도구 호출을 반환받고, 도구를 실행하고, 결과를 추가한 뒤, 다시 Mock을 호출합니다. 그러면 똑같은 응답, 똑같은 도구 호출이 돌아옵니다. 결과는 히스토리에 그대로 남아 있습니다. 하지만 Mock은 신경 쓰지 않습니다. 그래서 무한 루프가 발생합니다.

뻔한 해결책은 턴 카운팅(turn-counting)입니다. 첫 번째 호출에는 도구 호출을 반환하고, 두 번째 호출에는 텍스트를 반환하는 식입니다. (Databricks의 에이전트 부하 테스트 가이드도 동일한 접근 방식을 취합니다. 저는 직접 구축하고 실행해 본 후에야 이 사실을 알게 되었습니다.) 만약 모든 대화가 정확히 '도구 호출 한 번 후 응답'의 과정을 거친다면 이 방식은 작동합니다. 하지만 제 경우에는 그렇지 않았습니다. 어떤 요청은 도구를 전혀 사용하지 않았습니다. 어떤 요청은 첫 번째 결과에 따라 두세 개의 도구를 순차적으로 연결하여 사용했습니다. 실제 경로가 하드코딩된 내용과 일치하지 않는 순간, 턴 카운팅 방식은 깨져버립니다.

실제로 작동하는 방법은 더 간단합니다. 무엇을 반환할지 결정하기 전에 들어오는 메시지 히스토리를 확인하는 것입니다. 마지막 메시지가 도구 결과(tool result)라면 텍스트를 반환합니다. 그렇지 않다면 도구 호출을 반환합니다. 상태(state)도, 카운터도 필요 없습니다. Mock은 각 요청에서 에이전트가 보내는 내용을 읽고, 실제로 일어난 일에 대응하여 응답할 뿐입니다.

const messages = req.body.messages;
const lastMessage = messages[messages.length - 1];

...

그렇게 준비를 마친 후, 모든 의존성(dependencies)을 모킹(mocked)하고 운영 환경 부하의 3배까지 부하를 높인 첫 번째 실행은 별다른 문제 없이 지나갔습니다.

힙(Heap) 메모리는 내내 47MB를 유지했습니다. 이벤트 루프(Event loop)는 운영 환경 부하에서 4.7ms였으며, 3배의 부하에서는 51ms까지 서서히 올라갔습니다. 응답 경로(response path)가 밀리초(ms) 단위가 아닌 초(seconds) 단위로 측정된다는 점을 상기한다면 나쁜 수치는 아닙니다. 누수(leak)도 없었고, 포화(saturated) 상태도 되지 않았습니다. 저는 이 결과를 기록해 두고 실제 테스트로 넘어갔습니다.

세 번째 실행에서 흥미로운 결과가 나타났습니다. 호스팅 계층(hosting layer)이 격리된 상태에서 이미 깨끗하다는 것을 확인했으므로, 운영 환경 부하에서 실제 의존성(real dependencies)을 모두 투입했습니다. 52회의 반복(iterations) 동안 오류는 0건이었고, 끊긴 SSE 연결도 0건이었습니다. p95 응답 시간은 102초였습니다.

단일 사용자일 때와 동일했습니다. 저는 동시성(concurrency)이 무언가 변화를 보여줄 것이라 예상했습니다. 성능 저하나 부하가 가해지고 있다는 어떤 징후라도 말이죠. 하지만 전혀 움직이지 않았습니다.

102초라는 시간은 경합(contention) 효과가 아니었습니다. OpenAI의 합성(synthesis) 과정이 동시성과 관계없이 매 턴(turn) 소요 시간의 54%에서 78%를 소비하고 있었습니다. 이는 부하가 아니라 고유한 생성 시간(inherent generation time)이었습니다.

부하의 병목 지점(finding)은 속도 제한(rate-limit) 상한선이었습니다. 웹 리서치 턴은 쿼리 분석, 최대 3개의 병렬 합성 패스(synthesis passes), 후속 분류기(classifier)까지 총 5개의 상위 API 호출(upstream API calls)을 생성합니다. 턴당 호출 수와 일반적인 세션 주기(약 2~3분마다 한 번의 무거운 합성)를 계산해 보면, 우리 배포 환경의 RPM(Requests Per Minute) 할당량에 도달하기 전의 상한선은 약 60에서 90개의 동시 세션 사이입니다. 서버는 이 부하를 거의 감지조차 못 하고 있습니다.

이 중 어떤 것도 대시보드에는 나타나지 않습니다. 이벤트 루프는 녹색(green)입니다. 메모리는 평탄합니다. 제가 관리하는 모든 지표(metric)는 건강해 보입니다. 병목을 일으키는 요소는 제가 계측(instrument)하지 않는 시스템 안에 있습니다.

이것이 바로 모킹(mocked) 실행이 설정한 아이러니입니다. 저는 OpenAI를 제외한 모든 것을 측정하기 위해 특별히 OpenAI를 프로토콜 인식형 가짜(protocol-aware fake)로 만들었습니다. 런타임(runtime)은 괜찮았습니다. 호스팅도 괜찮았습니다. 그러다 OpenAI를 다시 추가하자, 그것만이 유일하게 중요한 요소가 되었습니다.

Databricks의 가이드와 제 방식은 동일한 전제를 공유합니다. 인프라 처리량 (throughput)을 모델 지연 시간 (latency)과 분리하기 위해 LLM을 모킹 (mock)하는 것입니다. 그들은 플랫폼이 어디에서 무너지는지 찾기 위해 이 방식을 사용합니다. 저는 플랫폼이 유의미한 수준의 어떤 부하에서도 무너지지 않는다는 것을 발견했습니다. 한계점은 모킹 (mock)이 제거해 버린 상류 (upstream) 단계에 있었습니다.

멍청한 모킹 (mock)은 이 모든 과정을 건너뜁니다. 아예 실패하거나 (무한 루프), 잘못된 이유로 통과해 버립니다 (즉각적으로 응답하여, 실제 모델이 뒤에 있을 때보다 인프라가 훨씬 더 빠른 것처럼 보이게 만듭니다). 어느 쪽이든 당신은 실제로 그 무엇도 격리할 수 없으며, 얻을 가치가 있는 유일한 발견을 놓치게 됩니다.

저는 테스트 스크립트보다 모킹 (mock)을 만드는 데 더 많은 시간을 보냈습니다. 작성하기 어려워서가 아니라 (어렵지 않습니다), 에이전트 (agent)가 루프를 돌기 시작할 때까지 그것이 필요하다는 사실을 몰랐기 때문입니다.

다른 에이전트 부하 테스트 (load-testing)를 수행하는 분들도 동일한 벽에 부딪혔는지, 아니면 다른 곳에서 한계점을 발견했는지 궁금합니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0