하나의 무거운 채팅이 우리 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 중 먼저 도달하는 쪽까지입니다.
두 단계로 작동합니다.
- 먼저 크기를 요청합니다. API는 데이터베이스에 가장 최근 메시지들의 ID와 저장된 크기를 내용 없이 요청합니다. 이 쿼리는 메시지가 얼마나 크든 저렴합니다.
- 맞는 것만 불러옵니다. 가장 최신에서 가장 오래된 순으로 걸어가며, 다음 메시지를 더하면 4MB를 넘을 때까지 추가합니다. 그 행들만 전체를 불러옵니다.
이를 안전하게 하는 규칙이 두 가지 있습니다.
- 가장 최근 메시지는 크기와 관계없이 항상 페이지에 포함됩니다. 아무것도 돌려주지 않는 페이지는 채팅을 멈추게 하고, 위로 스크롤해서 과거를 볼 방법도 없애 버립니다.
- 아무것도 자르지 않습니다. 메시지를 잘라내거나 버리지 않습니다. 나머지 채팅은 이미 있던 "이전 메시지 보기 위로 스크롤" 컨트롤을 통해, 한 번에 한 페이지씩 불러옵니다.
같은 형태의 문제가 한 곳 더 있었습니다. 턴을 중지하면 API는 마지막 몇 개의 답변을 검사해 비어 있어서 정리해야 할 것이 있는지 확인합니다. 이를 위해 메시지 여덟 개를 통째로 불러오고 있었습니다. 수 MB짜리 답변이 많은 채팅에서는 같은 문제의 작은 형태입니다. 이제는 내용을 불러오지 않고 데이터베이스에 예/아니오 질문을 합니다. 이 메시지에 내용이 하나라도 있는가?
검증 방법
그 채팅의 복사본을 예전 384MiB 한도로 실행해서 확인했습니다.
- 첫 페이지는 5MB입니다. 수정 전에는 같은 요청이 API를 충돌시켰습니다.
- 같은 채팅을 여는 브라우저 탭 다섯 개를 동시에 열었을 때 메모리 최고점은 154MB입니다.
- 채팅 전체를 위로 스크롤해서 가져오면, 총 190개 메시지 모두를 순서대로 한 번씩 돌려줍니다.
페이지를 고르는 로직에는 단위 테스트도 있습니다. 그 채팅의 가장 최근 메시지들의 실제 크기로 만든 테스트도 포함됩니다. 첫 페이지는 메시지 한 개를 돌려주고, 더 있다고 알리며, 다음 페이지는 그 다음 메시지부터 시작합니다.
얻은 교훈
- 개수는 크기 제한이 아닙니다. 무한정 커질 수 있는 것에 대해 "가장 최근 N개"를 돌려주는 엔드포인트에는 바이트 예산도 필요합니다. 데이터는 결국 그 틈을 찾아냅니다.
- 가장 그럴듯한 용의자가 항상 원인은 아닙니다. 배포는 추가 작업을 일으킨 실제 계기였고, 메모리 증설도 가치가 있었습니다. 하지만 충돌은 배포 없이도 재현되었고, 재현하지 않았다면 메모리 증설에서 멈췄을 것입니다.
- 같은 요청을 받으면 두 서버는 함께 실패합니다. 이중화는 서버 한 대가 죽는 것을 막아 줍니다. 닿는 서버마다 죽이는 요청은 막지 못합니다. 특히 사용자가 새로고침으로 자연스럽게 다시 시도하는 요청이라면 더욱 그렇습니다.
- 설정에서 고치세요. 서버 한 대에서만 고치지 마세요. 메모리 증설은 같은 날 배포 설정에 들어갔으므로, 다음 배포도 그 값을 유지했습니다.
이번 일은 에이전트 출력과 관련된 최근 두 번째 메모리 이야기입니다. 첫 번째는 문자열 690MB를 큐에 쌓은 상태 라벨이었습니다. Mythex에서 서브 에이전트를 쓰고 있다면, 작동 방식은 문서에서 확인할 수 있습니다.