수집이 끝날 때마다 죽던 컨테이너

대시보드는 수집된 AI 답변 멘션을 등록 브랜드에 귀속하고 집계해서 보여 줍니다. 웹은 메모리 한도 2.5GB인 컨테이너 한 대입니다. 9월 23일부터 이 컨테이너가 CPU 100%에 붙고 OOM으로 죽기 시작했습니다. 28일까지 하루 5~6회였고 전부 수집이 끝난 직후였습니다. 계산이 큰 상세 화면 요청은 502로 끝났습니다. 그 전 열흘 동안 배포 브랜치에 커밋이 없었으므로, 코드보다는 쌓인 데이터가 어느 선을 넘은 것으로 봅니다.

원인은 세 가지였다

첫째, CPU는 프로파일로 잡았습니다. 한 브랜드의 요약 계산 한 번이 176초였고, 그중 107초가 멘션을 등록 브랜드에 매칭하는 함수 하나에서 나왔습니다. 둘째, 겹치는 계산입니다. 대시보드는 AI 답변 화면 유형(이하 답변 표면) 세 곳을 따로 수집하는데, 세 수집이 거의 동시에 끝나면 각각 캐시 예열을 불러 무거운 상세 계산이 겹쳐 돌았습니다. OOM이 전부 수집 직후였다는 시각 일치로 좁힌 원인입니다. 셋째, 계산 한 건의 크기입니다. 성과·질문 화면은 ORM(Prisma)의 nested 로딩으로 한 브랜드 30일치 멘션 75만 건을 받아, 멘션마다 객체가 생기고 반복되는 값도 따로 들어갔습니다. 여기에 프로세스 안 결과 캐시가 실제 힙을 작게 잡고 있던 것이 OOM을 거들었습니다.

정규식은 브랜드 사전마다 한 번만

매칭 함수는 호출될 때마다 등록 표기마다 정규식을 새로 만들었습니다. 이미 찾은 것보다 짧은 표기와 완전 일치는 건너뛰었으므로 컴파일 횟수의 상한이 호출 수 × 표기 수이고, 제품 멘션은 회사명과 제품명으로 두 번 이상 호출되어 호출 수가 멘션 수보다 많습니다. 대시보드 한 번에 멘션 수십만 건이 이 함수를 지납니다.

// 전
값 = 정규화(원문)                       // 호출마다 한 번
for (name of 브랜드사전.표기)            // 표기마다 새로 컴파일
  if (더 긴 표기 && 값 !== name) new RegExp(경계 + name + 경계, "u").test(값)
// 후: 브랜드 사전 객체를 키로 두 단계 캐시
컴파일 = WeakMap<브랜드사전, 패턴[]>            // 사전마다 한 번
결과   = WeakMap<브랜드사전, Map<원문, 브랜드>>  // 최대 20,000건, 차면 저장만 멈춤
결과에 원문이 있으면 정규화도 건너뜀

8월부터 다른 매칭 함수에서 쓰던, 사전 객체를 키로 둔 WeakMap 캐시 패턴을 이 함수에도 적용했습니다. 브랜드 사전은 그것을 만든 함수 호출 동안만 살아서 컴파일은 함수 단위로 재사용되고, 요청 사이의 재사용은 상세 결과 캐시가 맡습니다. 테스트는 사전별로 결과가 갈리는지와 캐시 적중 때도 결과가 같은지를 봅니다.

무거운 계산은 한 번에 하나씩

겹치는 계산은 프로세스 안의 직렬 큐로 묶었습니다. 큐는 두 개입니다. 수집 완료가 부르는 예열은 예열 큐에서 한 번에 하나씩 돌고, 예열이 부르는 상세 계산은 사용자 요청과 같은 상세 계산 큐에 들어가 한 줄에 섭니다.

직렬 큐 (동시성 1, 두 큐가 같은 규칙)
  아직 시작 전인 같은 키  -> 결과를 공유
  이미 시작된 같은 키     -> 그사이 데이터가 바뀌었을 수 있으니 새로 줄을 섬
  앞 작업 실패           -> 다음 작업은 그대로 진행
예열 큐      키 = 예열 범위(한 브랜드 또는 전체), 화면 사이마다 이벤트 루프를 한 번 양보
상세 계산 큐  키 = 결과 캐시 키(데이터 버전 포함), 예열과 사용자 요청이 함께 섬

웹이 단일 인스턴스라 Promise 체인 하나로 충분했고, 인스턴스를 늘리면 Redis 락으로 바꿔야 한다는 상한은 주석에 남겼습니다.

같은 날 상세 결과 캐시의 기본 예산도 512MB에서 192MB로 낮췄습니다. 이 캐시는 결과 JSON의 길이로 크기를 추정하는데, JSON 길이는 실제 힙보다 작게 잡혀 512MB 예산이 훨씬 큰 힙을 허용했습니다. 192MB는 배율을 재서 고른 값이 아니라 보수적으로 낮춘 값입니다.

재시작 직후의 콜드 계산은 상세 결과를 Redis에 한 번 더 저장하고, 워커가 뜰 때 빈 화면만 채우는 예열로 줄였습니다. 예열은 계산을 없애지 않고 시점을 옮길 뿐이라 같은 큐에서 사용자 요청과 다툽니다.

멘션을 값 ID로 싣고 객체를 공유하기

동시 실행을 막아도 성과·질문 화면 계산 한 건이 2GB 가까이를 썼고, 프로세스 기본 메모리와 캐시가 더해져 한도에 닿은 것으로 봅니다. 질문 하나를 한 번 수집한 답변을 런이라고 부르는데, 멘션은 같은 값 조합이 여러 런에 반복됩니다. 8월 일부 화면에 넣은 압축 로딩이 이 성질로 운영 25만 멘션의 Node RSS를 약 420MB에서 181MB로 줄였고(8월 당시 코드에 남긴 측정), 이번에는 필드가 더 많은 전체 멘션에 같은 원리를 적용했습니다.

-- DB: 표면마다 SQL 한 번
값 목록   멘션을 이루는 값 조합(브랜드명·순위 등)을 중복 없이 -> 0..n-1
런별 ID   런마다 ID 배열 (저장 순서 그대로, 런 안의 중복도 그대로)
응답      값 목록 한 번 + [런, ID 배열] 목록
// Node: ID마다 객체를 하나만 만들고, 런은 그 객체를 가리키기만 한다

소비 코드를 건드리지 않으려고 ORM 쿼리 모양은 그대로 두고, 멘션은 아무 행도 걸리지 않는 조건(빈 ID 목록)으로 비워 받은 뒤 새로 만든 배열을 런 객체에 끼워 넣었습니다. 표면마다 예전에 select하던 필드만 객체 키로 남기고 순서도 저장 순서로 맞췄습니다. 공유해도 되는 근거는 소비 코드가 멘션을 읽기만 한다는 점입니다.

9월 28일에 잰 같은 범위(한 브랜드 30일치) 계산의 메모리는 1.97GB에서 0.59GB가 됐습니다. 측정 지표(힙인지 RSS인지)와 환경을 함께 기록하지 않아서, 이 값은 같은 조건의 전후 비교로만 읽어야 합니다.

운영 DB로 결과를 대조하다

메모리를 줄여도 숫자가 바뀌면 의미가 없습니다. 멘션 로딩을 바꾼 뒤 운영 DB로 기존 경로와 새 경로의 결과를 비교했고 동일했습니다. 다른 곳은 순위가 같은 상품 한 쌍의 순서뿐이었는데, 기존 경로도 정렬을 보장하지 않던 부분입니다. nested 로딩도 정렬 조건이 없으면 순서를 보장하지 않으므로, 저장 순서로 맞춘 것은 같은 결과를 내기 위한 근사입니다. 정규식과 큐 변경은 단위 테스트로만 확인했습니다.

대조는 일회성이고 스크립트로 남기지 않았습니다. 회귀 테스트는 기존 nested 픽스처를 값 ID 응답으로 바꾸는 어댑터를 두고 Node 쪽 변환 뒤에도 소비 결과가 같은지를 봅니다. SQL의 중복 제거와 순서, 런 사이의 객체 공유, 메모리는 이 테스트 밖에 있습니다.

남은 것과 배운 것

동시성 1이라 느린 계산 뒤에 요청이 줄을 서고, 이 제한은 집계 테이블로 옮긴 뒤 풀 계획입니다. 공유 객체는 읽기만 한다는 규칙에 기댈 뿐 강제 장치가 없고, 개선 후 처리 시간과 배포 후 메모리 피크는 다음에 잴 항목입니다. 배운 건 OOM이라는 증상 하나 밑에 동시 실행, 계산 한 건의 크기, 실제 힙을 작게 잡은 캐시 예산이 함께 걸려 있었다는 점입니다. 하나만 고쳤다면 남은 요인이 같은 증상을 냈을 수 있습니다. 그리고 로딩 방식만 바꿔도 같은 계산이 약 30% 크기에 들어갔습니다.