블로그

하나의 무거운 채팅이 우리 API를 멈출 수 있었던 이유

9월 28일, 릴리스 약 17분 후에 프로덕션 API 서버 두 대가 같은 순간에 메모리를 모두 써 버렸고, 재시작되었습니다. 그 뒤 약 5분 동안 로드 밸런서는 트래픽을 보낼 정상 서버가 없었습니다. 첫 대응으로 API의 메모리를 두 배로 늘렸습니다. 진짜 원인은 그날 오후에 드러났습니다. 특정 채팅 하나를 열면 API가 메시지 53MB를 단 한 번의 요청으로 불러오고 있었습니다. 이제 채팅 기록은 개수뿐 아니라 크기로도 제한된 페이지 단위로 불러오며, 그 채팅은 충돌 대신 5MB짜리 첫 페이지로 열립니다.

무엇이 멈췄나

우리 API는 두 대의 서버에서 각각 컨테이너로 돌아가며, 그 앞에 로드 밸런서가 있습니다. 각 컨테이너의 메모리 한도는 384MiB였습니다. 평소에는 130~140MiB를 쓰므로, 한도에는 여유가 충분해 보였습니다.

두 컨테이너가 같은 시점에 한도에 도달해 "JavaScript heap out of memory"와 함께 충돌했습니다. 힙은 Node.js 프로세스가 작업 중인 객체를 담는 메모리입니다. 두 서버가 동시에 실패했기 때문에 요청을 보낼 곳이 남아 있지 않았습니다. 서버가 두 대인 구성에서 가장 나쁜 장애입니다. 이중화는 실패가 동시에 일어나지 않을 때만 도움이 됩니다.

첫 대응: 여유 공간 늘리기

타이밍을 보면 배포가 원인처럼 보였습니다. 릴리스 후에는 배포로 중단된 에이전트 턴이 재개되고 기록을 다시 저장하는데, 그 대량의 작업이 메모리 급증의 원인으로 보였습니다.

그래서 첫 변경으로 API의 한도를 384MiB에서 768MiB로 올렸습니다. 같은 날 두 서버에 직접 적용했고, 다음 배포가 조용히 384로 되돌리지 않도록 배포 설정에도 커밋했습니다. 서버에서 손으로 한 수정은 다음 배포가 되돌려 버리면, 같은 장애가 다시 돌아오는 흔한 방식입니다.

메모리 증설은 합리적인 안전 여유였습니다. 하지만 설명은 아니었습니다.

진짜 원인: 한 번의 요청, 71MB

같은 날 나중에 한 채팅의 복사본으로 장애를 재현했습니다. 프로덕션의 예전 메모리 한도를 그대로 썼습니다. 배포와 무관하게, 그 복사본만으로 API가 충돌했습니다.

프로젝트를 열면 웹 앱이 API에 채팅 기록을 요청합니다. 해당 엔드포인트는 가장 최근 메시지 30개를 통째로 돌려주고 있었습니다. 대부분의 채팅에서 30개 메시지는 작은 양입니다. 이 채팅에서는 가장 최근 30개 메시지가 데이터베이스에서 53MB, 응답으로는 71MB였습니다. 에이전트 답변 몇 개는 각각 4~10MB였는데, 대부분 서브 에이전트의 기록이었습니다. 서브 에이전트는 메인 에이전트가 작업의 일부를 병렬로 처리하려고 시작하는 도우미이며, 그 전체 작업이 답변과 함께 저장됩니다.

그 응답 하나를 만드는 데 API가 가진 힙보다 많은 메모리가 필요했습니다. 게다가 그 프로젝트를 열거나 새로고침할 때마다 요청이 다시 전송되었습니다. 채팅 하나가 자신이 도달한 서버를 무너뜨릴 수 있었고, 페이지는 새로고침할 수 있으므로 두 서버 모두에 닿을 수 있었습니다.

"30개 메시지"는 크기에 대한 실제 제한이 한 번도 아니었습니다. 일반적인 채팅에서 우연히 작았던 개수 제한일 뿐이었습니다.

해결: 개수뿐 아니라 크기로도 제한한 페이지

기록의 한 페이지는 이제 30개 메시지 또는 약 4MB 중 먼저 도달하는 쪽까지입니다.

두 단계로 작동합니다.

  1. 먼저 크기를 요청합니다. API는 데이터베이스에 가장 최근 메시지들의 ID와 저장된 크기를 내용 없이 요청합니다. 이 쿼리는 메시지가 얼마나 크든 저렴합니다.
  2. 맞는 것만 불러옵니다. 가장 최신에서 가장 오래된 순으로 걸어가며, 다음 메시지를 더하면 4MB를 넘을 때까지 추가합니다. 그 행들만 전체를 불러옵니다.

이를 안전하게 하는 규칙이 두 가지 있습니다.

  • 가장 최근 메시지는 크기와 관계없이 항상 페이지에 포함됩니다. 아무것도 돌려주지 않는 페이지는 채팅을 멈추게 하고, 위로 스크롤해서 과거를 볼 방법도 없애 버립니다.
  • 아무것도 자르지 않습니다. 메시지를 잘라내거나 버리지 않습니다. 나머지 채팅은 이미 있던 "이전 메시지 보기 위로 스크롤" 컨트롤을 통해, 한 번에 한 페이지씩 불러옵니다.

같은 형태의 문제가 한 곳 더 있었습니다. 턴을 중지하면 API는 마지막 몇 개의 답변을 검사해 비어 있어서 정리해야 할 것이 있는지 확인합니다. 이를 위해 메시지 여덟 개를 통째로 불러오고 있었습니다. 수 MB짜리 답변이 많은 채팅에서는 같은 문제의 작은 형태입니다. 이제는 내용을 불러오지 않고 데이터베이스에 예/아니오 질문을 합니다. 이 메시지에 내용이 하나라도 있는가?

검증 방법

그 채팅의 복사본을 예전 384MiB 한도로 실행해서 확인했습니다.

  • 첫 페이지는 5MB입니다. 수정 전에는 같은 요청이 API를 충돌시켰습니다.
  • 같은 채팅을 여는 브라우저 탭 다섯 개를 동시에 열었을 때 메모리 최고점은 154MB입니다.
  • 채팅 전체를 위로 스크롤해서 가져오면, 총 190개 메시지 모두를 순서대로 한 번씩 돌려줍니다.

페이지를 고르는 로직에는 단위 테스트도 있습니다. 그 채팅의 가장 최근 메시지들의 실제 크기로 만든 테스트도 포함됩니다. 첫 페이지는 메시지 한 개를 돌려주고, 더 있다고 알리며, 다음 페이지는 그 다음 메시지부터 시작합니다.

얻은 교훈

  • 개수는 크기 제한이 아닙니다. 무한정 커질 수 있는 것에 대해 "가장 최근 N개"를 돌려주는 엔드포인트에는 바이트 예산도 필요합니다. 데이터는 결국 그 틈을 찾아냅니다.
  • 가장 그럴듯한 용의자가 항상 원인은 아닙니다. 배포는 추가 작업을 일으킨 실제 계기였고, 메모리 증설도 가치가 있었습니다. 하지만 충돌은 배포 없이도 재현되었고, 재현하지 않았다면 메모리 증설에서 멈췄을 것입니다.
  • 같은 요청을 받으면 두 서버는 함께 실패합니다. 이중화는 서버 한 대가 죽는 것을 막아 줍니다. 닿는 서버마다 죽이는 요청은 막지 못합니다. 특히 사용자가 새로고침으로 자연스럽게 다시 시도하는 요청이라면 더욱 그렇습니다.
  • 설정에서 고치세요. 서버 한 대에서만 고치지 마세요. 메모리 증설은 같은 날 배포 설정에 들어갔으므로, 다음 배포도 그 값을 유지했습니다.

이번 일은 에이전트 출력과 관련된 최근 두 번째 메모리 이야기입니다. 첫 번째는 문자열 690MB를 큐에 쌓은 상태 라벨이었습니다. Mythex에서 서브 에이전트를 쓰고 있다면, 작동 방식은 문서에서 확인할 수 있습니다.

Mythex · Pricing · AI로 무엇이든 만들기 · 웹사이트 및 앱 템플릿 · Mythex 비교 · AI 앱 개발 용어집 · 무료 AI 앱 개발 도구 · English

English አማርኛ العربية Български বাংলা Bosanski Català Čeština Dansk Deutsch Ελληνικά Español Español (España) Eesti فارسی Suomi Français Français (Canada) ગુજરાતી हिन्दी Hrvatski Magyar Հայերեն Bahasa Indonesia Íslenska Italiano 日本語 ქართული Қазақ тілі ಕನ್ನಡ Lietuvių Latviešu Македонски മലയാളം Монгол मराठी Bahasa Melayu မြန်မာ Norsk bokmål Nederlands ਪੰਜਾਬੀ Polski Português (Brasil) Português (Portugal) Română Русский Slovenčina Slovenščina Soomaali Shqip Srpski Svenska Kiswahili தமிழ் తెలుగు ไทย Tagalog Türkçe Українська اردو Tiếng Việt 简体中文 繁體中文(香港) 繁體中文(台灣)