← 염승준
14

ISR 결과가 메모리를 채우던 서버: 하루 6번의 OOM

코드 리뷰로는 보이지 않던 원인을, 운영 지표와 로컬 재현으로 찾았습니다. 원인은 버그 하나가 아니라 합리적인 기본값 여러 개였습니다.

문제
  • 운영 웹 서버(Cloud Run, 인스턴스당 2GiB)가 메모리 한도 초과로 하루 약 6번 강제 종료(7일 38건). 종료 순간의 요청은 실패하고 서버 캐시도 통째로 사라짐
  • "메모리 누수가 있는지 봐 달라"로 시작했지만, 정적 코드 감사로는 리스너·타이머 같은 전형적인 누수가 나오지 않음
해결
  • 코드에서 운영 지표로 넘어가 OOM 로그와 메모리 그래프로 문제를 확인. 이름이 딱 맞는 "인메모리 파일 시스템 사용량"이 0이었지만 그것으로 원인을 배제하지 않음
  • standalone 빌드를 로컬에 띄워 고유 경로 300개씩 요청: 300개당 +162MB 선형 증가, 같은 경로 재요청 +0, 유휴 후 RSS 회수. 원인은 JS 힙 누수가 아니라 ISR 파일 누적
  • ISR 결과의 디스크 기록을 끄고(isrFlushToDisk: false) 상한 50MB의 메모리 LRU만 남김
  • 없는 채널은 백엔드의 "채널 없음" 문구일 때만 404(notFound + revalidate). 검토에서 라우트 미스 404까지 잡는 위험을 찾아 판정을 문구까지 좁힘
  • 메인 페이지 SSR 캐시 키로 들어가던 언어 쿠키를 en/ko만 허용해 캐시 항목을 최대 8개로 고정
  • 수정마다 기존 구현에서 실패하는 테스트를 먼저 확인
결과
  • OOM 강제 종료 하루 약 6회 → 배포 후 62시간 0건
  • 메모리 증가(p95) 시간당 9~10% → 셋째 날 약 0.1%. 둘째 날 기울기로 한 "4일 뒤 한도" 추정은 틀렸음(상한에 다가가는 곡선). 완전히 평평한지는 하루 더 확인 중
  • 대가: 캐시 MISS 페이지 응답 약 0.13초 → 약 0.5초
OOM 강제 종료
하루 약 6회
0회 (배포 후 62시간)
메모리 증가 속도 (p95)
시간당 9~10%
시간당 약 0.1% (셋째 날, 거의 멈춤)
고유 경로 1개당 누적 (로컬 재현)
약 540KB
0
없는 채널 주소 응답
200
404

문제

운영 웹 서버(Next.js 14 Pages Router, Cloud Run 인스턴스당 메모리 2GiB)가 Memory limit of 2048 MiB exceeded로 하루 약 6번 강제 종료되고 있었습니다. 7일 동안 38건이었습니다. 종료되는 순간 처리 중이던 요청은 실패했고, 서버에 쌓여 있던 캐시도 통째로 사라졌습니다.

Cloud Run 컨테이너 메모리 사용률 7일 그래프. 11월 6일 저녁까지 p95가 50%에서 100%로 오르다 뚝 떨어지는 톱니 모양이 반복되고, 그 뒤로는 30% 안팎에서 평평하다.
Cloud Run 컨테이너 메모리 사용률, 최근 7일(2026-11-07 오전 캡처). 11/6 저녁 배포 전까지 100%에 닿으면 강제 종료되는 톱니가 반복되고, 배포 후에는 30% 안팎에서 멈춥니다. 11/6 낮의 낮은 봉우리들은 그날 여러 번 있었던 다른 배포로 인스턴스가 교체된 구간입니다.

코드에서 운영 지표로

처음 받은 질문은 "메인 페이지나 서비스 전반에 메모리 누수를 일으키는 코드가 있는지 봐 달라"였습니다. 그래서 정적 감사부터 했습니다. 클라이언트(메인 페이지, _app 전역)와 서버(getServerSideProps, API 라우트, middleware)를 나눠 리스너·타이머·Observer·Firebase 구독·Swiper·모듈 레벨 캐시를 전부 확인했습니다.

정리 함수가 빠진 클라이언트 누수는 없었습니다. 대신 서버 쪽에서 후보 둘이 나왔습니다. 메인 페이지 SSR 캐시의 키가 끝없이 늘어날 수 있다는 점, 그리고 ISR 결과가 디스크에 기록된다는 점이었습니다. 설치된 Next 14.2.26 소스에서 증분 캐시 동작을 확인해 보니, isrFlushToDisk는 기본값이 true이고 디스크에 쓴 파일을 지우는 로직은 없었습니다.

여기까지는 "그럴 수 있다"였습니다. 실제로 일어나고 있는지는 운영 지표를 봐야 했습니다. Cloud Run 메모리 그래프는 2주 내내 톱니 모양이었고, Cloud Logging에서 메모리 한도 초과 종료가 7일간 38건 나왔습니다. 문제는 실제로 있었습니다.

0이라고 원인이 아닌 건 아니다

원인을 확인하려고 Cloud Run의 "인메모리 파일 시스템 사용량" 지표를 열었습니다. 이름만 보면 바로 답을 보여 줄 지표인데, 2주 내내 거의 0이었습니다.

Cloud Run 인메모리 파일 시스템 사용량 7일 그래프. 11월 4일에 잠깐 3MiB 근처로 튄 것 말고는 내내 0이다.
같은 기간의 인메모리 파일 시스템 사용량. 7일 내내 0에 가깝습니다. Next가 쓰는 컨테이너 파일은 이 지표에 잡히지 않았습니다.

이 지표만 믿었다면 디스크 가설을 버렸을 겁니다. 하지만 이 지표가 무엇을 재는지는 확인하지 못한 상태였고, Cloud Run 문서는 컨테이너가 쓴 파일이 인스턴스 메모리 한도를 함께 쓴다고 적고 있었습니다. 지표와 문서가 어긋나면 어느 한쪽으로 결론을 내리지 않고 직접 재현해 보기로 했습니다.

재현으로 원인과 비원인을 가르기

운영 HTML과 /_next/data JSON을 받아 크기를 재 보니 경로 하나가 약 550KB였습니다. HTML이 약 300KB, JSON이 약 270KB였고, JSON의 약 90%(255KB)는 next-translate가 모든 페이지 props에 넣는 번역 namespace였습니다. HTML의 __NEXT_DATA__에도 같은 내용이 한 번 더 들어갑니다.

그다음 standalone 빌드를 로컬에 띄우고, 고유한 채널 주소 300개씩을 요청하면서 디스크 사용량과 프로세스 메모리(RSS)를 쟀습니다.

상황응답디스크파일 수비고
고유 주소 300개 (3회 반복)모두 200회당 +162MB회당 +900경로당 약 540KB, 선형 증가
이미 요청한 300개 재요청모두 200+0+0같은 경로는 덮어쓰기
요청 없이 둔 뒤 RSS약 870MB → 약 300MB로 회수JS 힙 누수는 아님

세 숫자가 원인과 비원인을 동시에 갈랐습니다. 새 경로마다 파일이 선형으로 쌓이고, 같은 경로는 늘지 않으며, 힙은 GC가 제대로 회수합니다. 처음 질문이었던 "코드의 메모리 누수"는 없었고, 원인은 파일 누적이었습니다.

합리적인 기본값 여러 개가 맞물린 결과

원인은 하나의 버그가 아니었습니다. 각각은 합리적인 동작 일곱 개가 이어져 있었습니다.

봇의 고유 채널 주소 요청
  ↓
fallback: 'blocking' 렌더
  ↓
채널 API 응답 (200이든 404든)
  ↓
ISR 엔트리 저장
  ├─ 메모리 LRU: 50MB 상한
  └─ .next/server/pages 파일 기록: 상한·삭제 없음
       ↓
     Cloud Run 인메모리 파일시스템
       ↓
     컨테이너 메모리 2GiB 도달
       ↓
     OOM 강제 종료: 요청 실패 + 캐시 초기화
  1. 봇이 서로 다른 채널 주소를 계속 요청합니다. 로드밸런서 로그 50초 구간에 여러 크롤러가 서로 다른 /channel/@User-… 주소를 20개 넘게 요청했습니다(초당 약 0.4건).
  2. 채널 페이지는 처음 오는 주소를 그 자리에서 렌더해 저장합니다. getStaticPaths가 paths: [], fallback: 'blocking'이라 빌드 때는 아무것도 만들지 않고, 요청이 올 때마다 새 ISR 엔트리를 만듭니다.
  3. 없는 채널도 저장 대상이 됩니다. 채널 API가 404를 줘도 에러 props를 반환했기 때문에, 가짜 핸들마다 200 페이지가 만들어져 캐시에 들어갔습니다.
  4. Next는 ISR 결과를 메모리 LRU와 디스크 두 곳에 씁니다. 메모리 LRU는 50MB 상한이 있지만, 디스크 기록에는 상한도 삭제도 없습니다. 경로마다 .html, .json, .meta 세 파일이 생깁니다.
  5. 페이지 하나가 큽니다. 위에서 잰 것처럼 경로당 약 540KB입니다.
  6. Cloud Run의 파일시스템은 메모리입니다. Next 입장에서는 디스크에 썼지만 실제로는 인스턴스의 RAM이 줄어듭니다.
  7. 2GiB에 닿으면 인스턴스가 강제 종료됩니다. 여유분 약 1.2GB를 540KB로 나누면 약 2,200개 경로입니다. 메모리 그래프가 톱니 모양이 된 이유입니다.

수정

// next.config.mjs
experimental: {
  isrFlushToDisk: false, // ISR 결과는 상한 50MB의 메모리 LRU에만 둔다
},

수정한 뒤에는 별도로 회귀·안전 검토를 받아 코드와 운영 API를 다시 대조했습니다. 여기서 처음 구현의 문제가 하나 나왔습니다. 처음에는 "채널 API가 404면 notFound"로만 판정했는데, 백엔드 배포 중에 생기는 라우트 미스도 404라서, 멀쩡한 채널이 5분간 404로 굳을 수 있었습니다. 운영·개발 API가 없는 채널에는 {"status":404,"message":"Not found channel"}을, 라우트 미스에는 "Cannot GET …"을 준다는 걸 직접 확인하고 판정을 문구까지 좁혔습니다. 백엔드 문구가 바뀌면 404 처리만 꺼지는, 안전한 방향으로 실패합니다.

하지 않은 것

결과: 같은 조건끼리 비교하기

배포 후 62시간 동안 OOM 강제 종료는 0건이었습니다. 첫날 밤 컨테이너 메모리 p95는 4시간 동안 31% 근처에서 멈췄고, 16시간 뒤 33.8%였습니다. 배포 전 기울기(시간당 약 9%)였다면 그 사이 한도에 닿았을 시간입니다.

배포 후 관측이 밤 시간대에 걸쳐 있어서, 트래픽이 적어서 느리게 오른 것일 수도 있었습니다. 그래서 두 가지로 이 가능성을 배제했습니다. 배포 전에는 트래픽이 더 적은 새벽에도 시간당 약 8%씩 올랐고, 같은 7일 동안 인스턴스 수와 CPU는 배포 전후가 비슷하고 메모리 모양만 바뀌었습니다. OOM이 더 잦았던 다른 리전에서도 배포 전 평일과 배포 당일의 같은 24시간 창을 나란히 놓았습니다.

배포 전 24시간 컨테이너 메모리 사용률. 100%에 닿아 떨어지는 톱니가 일곱 번 반복된다.
배포 전 (11/1 11:46 ~ 11/2 11:46)
배포 당일 24시간 컨테이너 메모리 사용률. 오후 7시 무렵까지는 톱니가 이어지다가, 그 뒤로는 30~40%에서 평평하다.
배포 후 (11/6 11:46 ~ 11/7 11:46)
같은 시각대 24시간 비교. 배포 전에는 하루 동안 100%에 닿아 강제 종료되는 톱니가 일곱 번 반복되고, 배포 당일은 19시 무렵 배포 전까지 같은 톱니였다가 그 뒤로 30~40%에서 평평합니다.

다만 둘째 날에는 증가가 완전히 멈추지 않았습니다. 강제 종료는 한 번도 없었지만, 두 리전의 p95가 하루 사이 각각 약 12%p와 5%p 올랐습니다. 시간당 0.2~0.5%로, 배포 전보다 20분의 1 이하로 느려졌지만 0은 아니었습니다.

배포 후 둘째 날 24시간 컨테이너 메모리 사용률. 톱니 없이 p95가 약 41%에서 53%로 완만하게 오른다.
배포 후 둘째 날, OOM이 더 잦았던 리전의 24시간(11/7 12시 무렵 ~ 11/8 12시 무렵). 강제 종료는 한 번도 없지만 p95가 약 41%에서 53%로 완만하게 오릅니다. 이때는 남은 원인이 있다는 신호로 봤습니다.

셋째 날에는 증가가 거의 멈췄습니다. 아래는 콘솔 그래프에서 같은 시각의 p95를 리전별로 읽은 값입니다.

시각 (KST)리전 A (OOM이 더 잦았던 곳)리전 B
11/6 21시 (배포 직후)28.5%28.8%
11/7 16시44.8%36.3%
11/8 10시51.9%40.7%
11/8 23시55.8%44.8%
11/9 09시57.0%44.6%

오르는 폭이 구간마다 줄어듭니다. 직선이 아니라 어떤 상한에 다가가는 곡선이고, 셋째 날 아침에는 시간당 약 0.1%였습니다.

대가도 있습니다. 메모리 캐시는 인스턴스당 페이지 약 90개(550KB 기준)를 담는 크기라, 밀려난 롱테일 페이지는 다시 렌더합니다. 캐시 적중 시 약 0.13초이던 응답이 MISS일 때는 약 0.5초입니다. 이전에는 디스크의 옛 페이지가 백엔드 장애를 가려 줬지만, 이제 캐시에 없는 페이지는 장애 중에 에러 화면이 보일 수 있습니다. 채널·이벤트·게시글·오픈플레이·정적·메인 페이지 응답은 모두 200으로 이상이 없었습니다.

에러 트래커로는 보이지 않던 것

Sentry 이벤트는 배포 전후로 눈에 띄게 줄지 않았고, 줄 것으로 기대할 근거도 약했습니다. OOM으로 프로세스가 강제 종료되면 서버의 Sentry SDK는 보고할 틈이 없습니다. 브라우저의 API 호출은 Next 서버가 아니라 백엔드로 직접 가므로, Next 인스턴스가 죽어도 그 흔적이 Sentry에 남지 않습니다. 실제로 OOM이 세 번 난 밤에도 Sentry의 5xx 이벤트는 1건이었습니다.

그래서 이번 효과를 판단하는 지표는 Sentry가 아니라 Cloud Logging의 OOM 로그와 Cloud Run 메모리 그래프였습니다. 반대로 배포 후 늘어난 5xx 57건은 Sentry 덕분에 배포 다음 날 오전의 백엔드 DB 장애로 바로 분리할 수 있었습니다. 프로세스가 통째로 죽는 장애와 애플리케이션 예외는 보이는 곳이 달랐습니다.

남은 과제