About HERREN
home
Media
home

Next.js SSR 메모리 누수 2년 추적기

요약
현상 — 웹뷰 캘린더 페이지에서 약 2년간 간헐적 OOM 재시작 발생
1차 시도 — CSR 전환으로 누수는 사라졌으나 LCP 720ms → 2,316ms로 UX 악화, 롤백
근본 원인 — Recoil atomFamily / selectorFamily의 SSR 환경 GC 미수거
해결 — Recoil 제거 후 서버 상태(React Query) / UI 상태(Context) 분리 재설계
결과 — 800명 동시 부하에서 44분 crash → 60분 완주, 메모리 peak 46% → 8%, 누수율 60배 감소

2년간 원인 불명의 OOM

특정 캘린더 페이지에서 약 2년간 동일한 장애 패턴이 반복되고 있었습니다.
재기동 → 메모리 우상향 → 힙 리밋 도달 → OOM kill → 재기동
Plain Text
복사
전형적인 메모리 누수 시그니처였지만, 누구도 원인을 특정하지 못한 채 운영이 계속되고 있었습니다.
▲ 장애 시점 서버 에러 로그. SSR 환경에서 heap이 1.2GB 가까이 적재된 상태로 502가 발생했다. (heapUsedMB 1194.59 / heapTotalMB 1270.58 / rssMB 1382.32)

왜 2년간 묻혀 있었나

문제가 표면화되지 않은 데에는 두 가지 환경적 완충재가 있었습니다.
1.
정기 배포가 사실상 메모리 리셋 역할을 수행 — 배포 주기가 누수가 임계점에 도달하기 전에 인스턴스를 갈아엎어 주는 효과를 냈습니다.
2.
충분한 인스턴스 수에 의한 부하 분산 — 인스턴스당 부하가 낮아 누수 속도 자체가 느렸습니다.
2026년 4월, ECS 인스턴스를 10대에서 3대로 축소하면서 두 완충재가 동시에 사라졌고, "가끔 죽던" 문제가 "자주 죽는" 장애로 전환되었습니다.

SSR 부담 가설과 CSR 전환 실험

가설

SSR 단계에서 페이지가 짊어지는 연산이 과도해 메모리를 점유하는 것 아닐까?
가장 단순하고 직관적인 가설이었습니다. 검증을 위해 해당 페이지의 렌더링 전략을 SSR → CSR로 전환했습니다.

결과 — 지표상으로는 "해결"

서버 지표는 모든 면에서 개선되었습니다.
지표
SSR (현행)
CSR (전환 후)
변화
CPU 평균 사용률
41.7%
15.5%
↓ 26.2%p
메모리 누수율
+0.089%p/분 (누수)
−0.031%p/분 (감소)
누수 해소
응답 시간 (p50)
236ms
97ms
↓ 139ms

그러나 사용자 체감은 더 나빠졌습니다

LCP가 3배 이상 악화되고, 인터랙션 지연이 체감 가능한 수준으로 늘어났습니다.
항목
SSR (현행)
CSR (전환 후)
변화
LCP
720ms
2,316ms
↑ 1,596ms (3.2배)
캘린더 콘텐츠 초기 노출
포함
미포함
사라짐
클릭 가능 시점
7,675ms
10,438ms
↑ 2,763ms
드래그 가능 시점
8,873ms
11,750ms
↑ 2,877ms
SSR vs CSR의 사용자 체감 차이를 드러내기 위해, 예약이 많은 환경(주 1,600건)에서 느린 네트워크·저사양 CPU를 가정해 측정했습니다.
CSR 전환은 누수의 원인을 제거한 게 아니라 원인을 실행하지 않는 환경으로 이동시킨 것에 불과했습니다. 사용자 경험 손실이 분명한 이상, 1차 안은 폐기되었습니다.
근본 원인은 SSR 컨텍스트 안에 존재한다는 사실만 명확해졌습니다.

근본 원인 추적

후보군을 뽑아내고 하나하나 제거하고 관점을 전환하며 근본 원인을 추적합니다.

여러 후보군 검증

SSR 컨텍스트에서 메모리를 점유할 가능성이 있는 일반적인 후보들을 차례로 검증했습니다.
Axios response buffer
HTTP keep-alive / TIME_WAIT 누적
Next.js render buffer
모두 부합하지 않았습니다. 누수 양상이 요청 단위로 누적되지 않고, 상태 공간 자체가 비대해지는 패턴에 가까웠기 때문입니다.

단순하게 다시 바라보기 - 상태 관리 레이어

핵심 질문을 다시 세웠습니다.
CSR에서는 정상이고, SSR에서만 누수가 발생한다. → SSR 단계에서만 실행되는 코드 경로 중, 인스턴스 수명을 넘어 참조가 살아남는 구조가 있는가?
해당 페이지는 Recoil 기반 상태 관리 구조였고, 페이지 진입마다 다음과 같이 구성되고 있었습니다.
<RecoilRoot initializeState={...}> {/* ... */} </RecoilRoot>
TypeScript
복사
SSR 요청마다 새로운 상태 트리가 생성되는 구조입니다. 정상이라면 응답 후 GC 대상이 되어야 합니다.

Recoil Issue #1864

조사 중 Recoil 저장소의 다음 이슈를 확인했습니다.
facebookexperimental/Recoil#1864 — SSR memory leak
atomFamily / selectorFamily가 SSR 환경에서 정상적으로 GC되지 않음
RecoilRoot unmount 이후에도 내부 캐시가 메모리에 잔존
요청이 누적될수록 힙이 단조 증가
문제의 페이지는 atomFamilyselectorFamily를 적극적으로 사용하고 있었고, 증상이 정확히 일치했습니다.

부하 테스트로 패턴 확인

가설을 정량적으로 검증하기 위해 부하 테스트를 진행했습니다. 동일한 SSR 코드(누수 있던 버전)와 CSR 전환 버전을 동시접속자 수를 바꿔가며 비교했습니다.
▲ 부하 테스트 비교표. SSR(릭 있던 버전)만 분당 +0.089%p의 단조 증가를 보였고, CSR 전환 버전은 모두 정상 범위였다.

테스트 결과 요약

시나리오
동시접속
지속
분당 메모리 증가율
결론
SSR (Recoil 그대로)
200명
110분
+0.089%p/분
메모리 릭
CSR 전환
200명
60분
-0.031%p/분
릭 해소
CSR 전환
400명
60분
+0.044%p/분
정상
CSR 전환
800명
60분
+0.079%p/분
정상
여기서 주목할 점은 두 가지입니다.
1.
SSR 버전은 동시접속자 200명만으로도 분당 +0.089%p의 단조 증가를 보였습니다. 일반적인 트래픽 변동성과 무관하게 시간에 비례해 누적되는 패턴입니다.
2.
CSR 버전은 800명까지 부하를 올려도 누수가 발생하지 않았습니다. 트래픽 양이 아니라 SSR이라는 실행 컨텍스트가 변수임이 확인되었습니다.
요청당 잔존 참조가 누적되는 구조적 누수임이 확정되었습니다.

4가지 선택지

옵션
평가
채택
Recoil 업데이트
메인테이너 부재, 패치 기대 어려움
patch-package 핫픽스
내부 캐시 구조 수정 필요, 위험도 과다
Zustand/Jotai 등 대체 라이브러리
동일한 글로벌 상태 패러다임의 반복 위험
Recoil 제거 + 상태 구조 재설계
근본 해결, 장기적 유지보수성 확보
여기서 가장 중요했던 판단은 다음 한 줄이었습니다.
우리가 "전역 상태"라고 부르던 것 대부분은 사실 서버 상태였다.
서버에서 가져온 데이터를 클라이언트 전역에 보관하던 구조였기 때문에, Recoil이 떠맡고 있던 책임의 80% 이상은 데이터 패칭 / 캐싱 / 동기화 책임이었습니다. 이건 React Query의 영역입니다.

새 구조

책임
도구
서버 상태 (캐싱, refetch, 동기화)
React Query
UI 상태 (모달, 드래그, 토글 등)
Context + useState
파생 값
useMemo

Before / After 코드 스니펫

도구 매핑만으로는 책임 분리의 모양이 잘 안 그려질 수 있어서, 실제 어떻게 바꿨는지 단순화한 예시를 함께 남깁니다.
Before — Recoil 기반
서버에서 받아온 직원 데이터와 UI 상태(선택된 직원)가 같은 atom 시스템에 섞여 있는 구조였습니다.
// 서버 상태도 atom으로 관리 (atomFamily가 SSR에서 GC되지 않는 지점) const employeesAtom = atomFamily({ key: 'employees', default: (shopId: string) => fetchEmployees(shopId), }); // UI 상태도 같은 시스템에 const selectedEmployeeAtom = atom<string | null>({ key: 'selectedEmployee', default: null, }); function CalendarView({ shopId }: Props) { const employees = useRecoilValue(employeesAtom(shopId)); const [selected, setSelected] = useRecoilState(selectedEmployeeAtom); // ... }
TypeScript
복사
After — React Query + Context
서버 상태는 React Query가, UI 상태는 Context가 각자 책임지는 구조로 분리했습니다.
// 서버 상태: 캐싱/동기화 책임은 React Query에 function useEmployees(shopId: string) { return useQuery({ queryKey: ['employees', shopId], queryFn: () => fetchEmployees(shopId), }); } // UI 상태: 명시적인 Context로 격리 const CalendarUIContext = createContext<{ selectedEmployee: string | null; setSelectedEmployee: (id: string | null) => void; } | null>(null); function CalendarView({ shopId }: Props) { const { data: employees } = useEmployees(shopId); const { selectedEmployee, setSelectedEmployee } = useContext(CalendarUIContext)!; // ... }
TypeScript
복사
핵심은 두 가지였습니다. 첫째, 서버에서 가져온 데이터의 라이프사이클을 React Query에 일임해 전역 atom 캐시에 서버 데이터가 쌓이지 않게 했습니다. 둘째, 이 페이지에서 RecoilRoot 자체를 제거함으로써 SSR 단계에서 문제의 atomFamily / selectorFamily가 아예 호출되지 않게 했습니다.

AI를 네 번째 팀원으로

수작업으로 했다면 한 달이 걸렸을 작업이 일주일에 끝났습니다. 저희가 활용한 도구는 Claude Code(Anthropic의 CLI 기반 코딩 에이전트)와 부하 테스트 자동화용 Claude API였습니다.

AI에게 위임한 영역과 실제 작업 압축률

작업
수작업 예상
AI 활용 시
어떻게 시켰나
atom / selector 사용처 전수 조사
2~3일
약 30분
"이 페이지에서 사용하는 atomFamily / selectorFamily 호출처를 모두 찾고, 각 atom의 의존성 트리와 사용 위치(파일/라인)를 표로 정리해줘"
selector → useMemo 변환
약 1주
약 3시간
selector 단위로 의존성 분석 후, 동일 결과를 보장하는 useMemo 형태로 재작성 요청
컴포넌트별 상태 의존성 매핑
2~3일
약 2시간
"각 컴포넌트가 읽는 atom / setter / selector를 서버 상태 vs UI 상태로 분류해줘"
회귀 테스트 케이스 초안
2~3일
약 30분
변경 전 동작을 기준으로 검증해야 할 시나리오 자동 도출
부하 테스트 자동화
2일
약 30분
autocannon 스크립트 + 1분 단위 슬랙 리포팅 파이프라인 셋업

가장 인상 깊었던 점

사람이 지루해서 끝까지 못 하는 반복 작업을, AI는 균일한 품질로 끝까지 수행합니다.
특히 atom 사용처 전수 조사처럼 "분명히 해야 하지만 누구도 끝까지 하기 싫은" 작업의 비용이 0에 수렴하면서, 의사결정 자체가 더 과감해질 수 있었습니다.

결과 — 메모리 그래프의 모양이 바뀌었다

배포 직후 ECS 컨테이너 인사이트 지표입니다.
동일한 800명 부하 조건에서 재테스트한 결과는 다음과 같습니다.
지표
이전 (Recoil 유지)
이후 (Recoil 제거)
변화
안정 운영 시간
44분만에 다운
60분 완주
60분 안정
메모리 최대 사용률
46.58%
8.53%
↓ 약 38%p
분당 누수율
+1.05%p/분
+0.017%p/분
↓ 약 60배
가장 중요한 변화는 수치보다 그래프의 형태입니다.
이전: 우상향 톱니 패턴 (요청마다 적립되는 잔존 참조)
이후: 평탄한 안정 구간 (오르고 내려가는 정상 GC 사이클)
즉, GC가 다시 정상 동작하기 시작했습니다.

다시 비슷한 일을 만난다면?

이 이슈는 약 2년간 원인 불명으로 잔존해 있던 기술 부채였습니다. 인프라 축소라는 조건이 맞물리면서 드디어 제대로 드러났고, 그제서야 근본 원인까지 짚을 수 있었습니다. 그 과정에서 새로 갖게 된 시선 몇 가지를 정리하자면
서버 지표가 좋아졌다고 끝난 게 아니다.
: CSR 전환은 모든 서버 지표를 개선시켰지만 LCP는 세 배 가까이 늘었습니다. 한쪽 지표가 좋아질 때 다른 쪽이 어떻게 움직이는지를 함께 보지 않으면, 우회를 정답으로 착각하기 쉽다는 걸 배웠습니다.
메모리 누수는 종종 의외의 곳에 숨어 있다.
: Axios Buffer, HTTP 소켓, renderToString 같은 흔한 후보들을 다 검증해도 답이 안 나온다면, 너무 깊게 들어가서 진짜 문제가 무엇인지 못찾고 있는 것이 아닌지 되돌아 볼 필요가 있습니다.
전역 상태라고 부르던 값의 대부분은 사실 서버 상태.
: atomFamily로 관리하던 직원·예약·휴일 데이터를 React Query로 옮기는 것만으로 누수 지점이 사라졌고, 동시에 책임 경계가 명확해졌습니다. 도구를 바꾸는 게 아니라 책임을 다시 나누는 일이었던 셈입니다.
그리고 AI는 작업 속도를 점진적으로가 아니라 구조적으로.
: 호출처 전수 조사, 의존성 매핑, 부하 테스트 자동화처럼 "분명히 해야 하지만 끝까지 정확하게 하기 어려운" 작업들의 비용이 0에 수렴하면서, 의사결정 자체가 더 과감해질 수 있었습니다.