Angular SSR 메모리 누수 삽질기 — 범인은 `interval()`이었다
배포 2주 만에 서버 메모리가 45%를 넘겼다. 의심스러운 곳을 두 군데나 고쳤는데 아무 소용이 없었고, 진짜 원인은 전혀 다른 곳에 있었다.
1. 증상: 우상향하는 메모리 그래프
회사에서 Angular로 만든 홈페이지를 배포했다. SEO와 GEO(생성형 엔진 노출)를 신경 써야 하는 사이트라 CSR 대신 SSR(Angular Universal) 로 구성했다.
배포하고 1~2주쯤 지났을 때, 웹서버 메모리 점유율이 45%를 넘고 있는 것을 발견했다. 트래픽이 폭증한 것도 아닌데 이상했다.
시간 단위로 다시 확인해봤다.
- 그래프가 계단식으로 꾸준히 우상향
- 내려오는 구간이 단 한 번도 없음
- 웹서버를 재시작하니 5%대로 뚝 떨어짐
재시작으로 회수된다는 건, 누군가 메모리를 잡고 안 놓아주고 있다는 뜻이다. 전형적인 메모리 누수 패턴이었다.
여기서 문제를 하나 짚고 가자. 이건 서버(Node.js)의 메모리다. 브라우저 메모리가 아니다.
2. 첫 번째 오진: "구독 해제를 안 했나 보다"
가장 먼저 의심한 건 header.component.ts였다.
Angular 개발자라면 반사적으로 떠올리는 그 패턴이다. subscribe() 해놓고 ngOnDestroy에서 정리를 안 해서 컴포넌트가 좀비처럼 남는다는 것. 헤더는 로그인 상태 스트림을 구독하고 있었고, 실제로 해제 코드가 없었다.
하지만 이건 서버 누수의 원인이 될 수 없었다.
Angular Universal은 HTTP 요청마다 injector를 새로 만든다. providedIn: 'root'로 선언한 서비스조차 서버에서는 "요청당 인스턴스"다. 브라우저에서처럼 앱이 살아있는 내내 하나만 존재하는 싱글턴이 아니다.
그래서 이런 구조가 된다.
[요청 A] → injector A → AuthService(A) ↔ HeaderComponent(A)
[요청 B] → injector B → AuthService(B) ↔ HeaderComponent(B)
서비스가 헤더를 붙잡고 있어도, 그 서비스 자신이 요청과 함께 버려진다. 서로만 참조하는 고립된 섬(island)이 되므로 GC가 통째로 수거해간다.
메모리 누수가 성립하려면 요청보다 오래 사는 무언가가 참조를 붙잡고 있어야 한다. 요청 스코프 안에서 서로를 참조하는 건 아무리 얽혀 있어도 누수가 아니다.
물론 이 코드가 브라우저에서는 누수를 일으키는 게 맞다. SPA로 페이지를 옮겨 다닐 때마다 헤더 구독이 쌓인다. 그래서 고치긴 했다. 다만 지금 잡으려는 서버 메모리와는 무관했다.
3. 두 번째 오진: "3D 배너가 무거운가 보다"
다음 용의자는 main-visual.component.ts. Three.js로 3D 배너를 렌더링하는 컴포넌트였다.
WebGL 컨텍스트와 geometry는 명시적으로 반납하지 않으면 GPU 메모리를 잡아먹는 걸로 유명하다. 그래서 정리 코드를 추가했다.
this._disposables = [];
this._scene.clear();
this._renderer.forceContextLoss();
역시 소용없었다. 이유는 두 가지였다.
(1) 추가한 정리 코드가 전부 isPlatformBrowser 가드 안쪽에 있었다.
서버에서 애초에 실행되지 않는 코드다. 서버에서 안 도는 코드는 서버 누수를 고칠 수 없다.
(2) 더 결정적인 근거 — SSR이 정상 동작하고 있다는 사실 자체.
new THREE.WebGLRenderer()는 Node.js에서 실행되면 즉시 예외가 난다. WebGL 컨텍스트가 없기 때문이다. 그런데 SSR은 아무 에러 없이 HTML을 뱉고 있었다. 즉, 이 코드는 서버에서 단 한 줄도 실행되지 않고 있었다는 증거다.
여기서 얻은 교훈이 하나 있다.
브라우저 메모리 누수와 서버 메모리 누수는 완전히 다른 문제다.
Three.js dispose, 구독 해제, 이벤트 리스너 제거 — 전부 사용자 PC를 가볍게 만드는 작업이지 서버와는 상관이 없다.
4. 질문을 바꿨다
두 번 헛발질하고 나서야 접근을 바꿨다.
그동안 나는 "누수를 일으킬 만한 코드"를 찾고 있었다. 익숙한 패턴(구독 해제 누락, dispose 누락)에 갇혀 있었던 것이다. 그런데 그 패턴들은 전부 "오래 사는 컨텍스트에 참조가 쌓인다"를 전제로 한다. 요청마다 앱을 새로 만들고 버리는 SSR 서버에는 그런 컨텍스트가 애초에 없다.
그래서 질문을 이렇게 바꿨다.
"서버에서 실제로 실행되는 코드 중에, 스스로 끝나지 않는 게 있는가?"
SSR 서버 입장에서 위험한 건 참조가 쌓이는 코드가 아니라 요청이 끝나지 못하게 만드는 코드다. 이 기준으로 컴포넌트들을 다시 훑었더니 걸리는 게 있었다.
ngOnInit() {
interval(5000).pipe(
takeUntil(this.destroy$)
).subscribe(() => {
this.rollNews();
});
}
뉴스 롤링용 코드였다. takeUntil까지 붙어 있는, 딱히 흠잡을 데 없어 보이는 코드다. 가드는 없었다.
의심이 가자 확인은 간단했다. 해당 페이지를 서버에 직접 요청해봤다.
curl https://도메인/문제의페이지
# → 응답이 안 온다. 무한 대기.
# → --max-time으로 강제 종료해야 끝남
서버가 HTML을 영원히 안 보내고 있었다. 다른 페이지는 3초 안에 멀쩡히 응답했다.
5. 왜 이런 일이 벌어지나
5-1. Angular SSR은 isStable을 보고 응답한다
브라우저는 화면을 계속 그려나가면 되지만, SSR 서버는 "이제 다 그렸다"고 판단하는 시점이 있어야 한다. HTML을 완성해서 응답으로 내보내야 하니까.
Angular는 그 기준으로 ApplicationRef.isStable을 쓴다. 그리고 isStable이 true가 되는 조건은 이렇다.
NgZone 내부에 대기 중인 비동기 작업(Task)이 0개가 될 것
API 요청, setTimeout, Promise가 전부 끝나야 "화면에 그릴 데이터를 다 가져왔구나" 하고 HTML을 직렬화해서 내보낸다.
5-2. interval은 절대 0이 되지 않는다
RxJS의 interval()은 내부적으로 setInterval을 쓴다. setInterval은 명시적으로 clearInterval 하기 전까지 영원히 살아있는 매크로태스크다.
그래서 서버에서 이런 일이 벌어진다.
| 단계 | 서버에서 일어나는 일 |
|---|---|
| 1. 요청 도착 | Node가 요청을 받는다 |
| 2. 앱 생성 | 요청 처리를 위해 Angular 앱 인스턴스를 메모리에 통째로 생성 |
| 3. 타이머 등록 | 컴포넌트가 렌더링되며 interval(5000)이 Zone에 등록됨 |
| 4. 무한 대기 | Zone의 작업 수가 0이 되기를 기다림 → interval은 영구 작업이라 절대 0이 안 됨 |
| 5. 응답 불가 | isStable이 영원히 false → HTTP 응답을 못 보냄 |
curl이 무한 대기했던 이유가 이거였다.
5-3. 그래서 메모리가 쌓인다
Node 입장에서는 "이 요청은 아직 처리 중" 이다. 요청이 안 끝났으니 그 요청을 위해 만든 것들이 전부 힙에 남는다.
- Angular 컴포넌트 트리 전체
- DI 컨테이너와 그 안의 모든 서비스 인스턴스
- 렌더링 중이던 DOM 추상 구조
요청 1건당 Angular 앱 하나가 통째로 메모리에 박제된다. 그리고 이건 GC 대상이 될 수 없다. Node가 "아직 쓰는 중"이라고 보고 있으니까.
5-4. takeUntil(this.destroy$)는 왜 못 막았나
이 부분이 개인적으로 제일 흥미로웠다. RxJS 누수 방지의 정석인 takeUntil 패턴이 완벽하게 무력화되어 있었다.
destroy$가 발행되려면 → ngOnDestroy가 불려야 함
ngOnDestroy가 불리려면 → 렌더링이 끝나고 앱이 파괴되어야 함
렌더링이 끝나려면 → interval이 취소되어야 함
interval이 취소되려면 → destroy$가 발행되어야 함
↑ 처음으로 돌아감
완벽한 교착 상태(deadlock)다.
브라우저에서는 사용자가 페이지를 떠나면 ngOnDestroy가 불리니까 이 패턴이 잘 작동한다. 하지만 SSR에서 ngOnDestroy는 렌더링이 끝난 뒤에야 불린다. 그 렌더링을 interval이 막고 있으니 사이클이 시작조차 못 한다.
takeUntil은 "정상적으로 파괴될 수 있는 컴포넌트"를 전제로 한 패턴이다. 파괴 자체가 막혀 있으면 아무 의미가 없다.
6. 남은 의문: 그런데 왜 하필 배포 2주 뒤였나
원인은 찾았지만 이상한 점이 있었다. 이 페이지는 배포 첫날부터 있었는데, 왜 문제가 2주쯤 지나서야 드러났을까. 그리고 개발하는 내내, QA 하는 내내 아무도 눈치채지 못한 이유는 뭘까.
원인을 알고 나니 역으로 설명이 됐다. 이 누수는 "SSR을 타는 요청"에서만 발생한다.
Angular는 첫 진입 때만 서버 렌더링을 하고, 이후 내부 링크 이동은 브라우저에서 SPA 라우팅으로 처리한다. 즉 메뉴 클릭으로 그 페이지에 들어가는 건 아무 문제가 없다. 서버를 안 거치니까.
서버에 SSR을 직접 요청하는 건 이런 경우들이다.
- URL 직접 접근 — 주소창에 URL을 입력해서 들어오는 경우
- 크롤러 봇 접근 — 구글봇, 네이버 예티, SNS 공유 미리보기를 만드는 오픈그래프 스크래퍼
- 강력 새로고침(Ctrl+F5) — 캐시·서비스워커를 무시하고 서버에 직접 요청
개발자와 QA는 대부분 메인에서 시작해 메뉴를 클릭한다. 1번 경로로 그 페이지를 여는 일이 드물다. 그래서 개발 기간 내내 아무도 못 봤다.
그리고 누적의 주범은 2번이다.
봇은 캐시를 안 탄다. 사이트맵에 있는 모든 URL을 하나하나, 반복적으로, 직접 요청한다. 사람 사용자 100명보다 크롤러 한 마리가 이 페이지를 더 많이 때린다. 요청 한 건당 Angular 앱 하나가 메모리에 박제되니, 크롤러가 이 URL을 100번 긁으면 거대한 앱 100개가 영구 적재된다.
배포 후 1~2주라는 타이밍도 맞아떨어진다. 검색엔진이 새로 올라온 사이트를 발견하고 본격적으로 인덱싱을 시작하는 시점이 딱 그쯤이다. SEO를 위해 SSR을 도입했는데, 그 SEO 크롤러가 서버를 무너뜨리고 있었던 셈이다.
7. 해결
수정은 놀랄 만큼 단순했다. isPlatformBrowser 가드 하나를 씌우면 끝이다.
Before
ngOnInit() {
interval(5000).subscribe(() => {
// 뉴스 롤링
});
}
After
import { isPlatformBrowser } from '@angular/common';
import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// 브라우저에서만 실행되므로 SSR 렌더링을 방해하지 않음
interval(5000).subscribe(() => {
// 뉴스 롤링
});
}
}
이걸로 뭐가 달라졌나
서버(Node.js)에서는
조건문이 false가 되어 interval이 아예 등록되지 않는다. Zone에 걸린 작업이 없으니 컴포넌트를 그리자마자 isStable === true가 된다. HTML이 즉시 응답으로 나가고, 요청이 종료되고, 그 요청이 만든 앱 인스턴스는 GC가 깨끗하게 수거해간다.
브라우저에서는
사용자가 HTML을 받고 하이드레이션이 끝난 뒤에 interval이 시작된다. 5초마다 뉴스가 롤링되는 원래 기획된 동작은 그대로다.
결과curl 응답 시간이 무한대에서 3초 이내로 돌아왔고, 메모리 그래프의 우상향이 멈췄다.
8. SSR에서 조심해야 할 것들
이번 일로 정리한 원칙이다.
⚠️ 가드 없이 쓰면 안 되는 것들
Angular Zone이 "아직 작업 중"이라고 인식하게 만들어 isStable을 막는 것들이다.
| 분류 | 예시 |
|---|---|
| 반복 타이머 | interval(), setInterval() |
| 긴 지연 타이머 | 렌더링에 당장 필요 없는 긴 setTimeout() |
| 지속 연결 | WebSocket, SSE(Server-Sent Events), Long Polling |
| 무한 스트림 | complete 되지 않는 RxJS 스트림 구독 |
핵심 판별 기준은 하나다. "이 작업은 스스로 끝나는가?"
HttpClient요청 → 응답이 오면 끝남 → 안전 (오히려 SSR이 기다려줘야 하는 대상)interval→ 스스로 안 끝남 → 위험
✅ 해결책 1: isPlatformBrowser 가드 (권장)
UI 애니메이션, 화면 갱신 타이머 등 어차피 서버에서 필요 없는 로직에 쓴다. 가장 안전하고 의도가 명확하다.
if (isPlatformBrowser(this.platformId)) {
// 브라우저 전용 로직
}
💡 해결책 2: ngZone.runOutsideAngular
서버에서도 주기적 로직이 반드시 필요한 예외 상황이라면, Angular의 감시망(Zone) 바깥으로 작업을 내보낸다. isStable 판단에서 제외되므로 렌더링을 막지 않는다.
constructor(private ngZone: NgZone) {}
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
setInterval(() => {
// Zone 밖 — SSR 무한 대기를 유발하지 않음
}, 5000);
});
}
다만 이 방법은 타이머를 직접 정리할 책임이 생긴다. 반환된 ID를 보관했다가 ngOnDestroy에서 clearInterval 해야 한다. 특별한 이유가 없다면 1번을 쓰는 게 낫다.
🔍 다시 한다면 이 순서로 하겠다
이번에 나는 순서를 반대로 갔다. 돌아보니 이렇게 접근했으면 훨씬 빨랐다.
- 재시작하면 회수되는가? → Yes면 누수 확정
curl로 주요 페이지를 하나씩 때려본다 → 응답이 안 오거나 비정상적으로 느린 페이지를 찾는다- 그 페이지 컴포넌트에서 "스스로 안 끝나는" 비동기를 찾는다 —
interval,setInterval, WebSocket,complete없는 스트림 - 가드를 씌운다
2번이 특히 강력하다. 서버 누수라면 대개 "요청이 안 끝나는 페이지"가 하나쯤 있고, 그건 curl 한 방이면 드러난다. 코드를 감으로 뒤지는 것보다 훨씬 빠르다.
브라우저 누수 잡듯 힙 스냅샷을 비교하려 들지 않은 것만도 다행이었다. SSR 서버의 누수는 "참조가 쌓여서"가 아니라 "요청이 안 끝나서" 생기므로, 진단 도구부터 다르다.
마치며
이번 일에서 얻은 건 두 가지다.
첫째, 브라우저 누수와 서버 누수는 문법이 다르다.
Angular 개발자로서 익숙한 누수 지식(구독 해제, WebGL dispose, 리스너 정리)은 전부 브라우저 이야기다. SSR 서버는 요청마다 앱을 새로 만들고 버리는 구조라 "참조가 쌓인다"는 개념 자체가 잘 안 맞는다. 서버에서 물어야 할 질문은 하나다. "요청이 제때 끝나는가?"
두 번의 헛발질은 이 구분을 못 해서 나온 것이었다. 익숙한 패턴을 먼저 떠올린 게 문제가 아니라, 그 패턴이 이 환경에서 성립하는지를 먼저 따지지 않은 게 문제였다.
둘째, 원인을 찾아야 증상이 설명된다.
"왜 개발 중엔 몰랐나", "왜 하필 2주 뒤인가" 같은 의문은 원인을 알기 전엔 풀리지 않는 수수께끼였다. interval을 찾고 나서야 크롤러와 SSR 진입 경로가 한 줄로 꿰였다. 증상만 보고 원인을 역추적하려던 게 오히려 시간을 잡아먹었다.
그리고 마지막으로, SSR 환경에서 interval은 그냥 폭탄이다. 브라우저에서 아무 문제 없이 잘 돌던 코드가 서버에서는 요청 하나를 통째로 인질로 잡는다. Angular Universal을 쓴다면 지금 프로젝트에서 interval, setInterval, WebSocket을 전부 한 번씩 검색해보길 권한다.