모든 디버깅 본능을 거스르는 버그 리포트가 접수되었습니다. 저사양 안드로이드 폰을 사용하는 사용자들은 앱이 그냥 사라져 버린다고 말했습니다. 실행 시점도 아니었고, 특정 탭이나 스와이프 도중도 아니었습니다. 세션을 시작한 지 약 20분 정도 지나면 화면이 멈추고 프로세스가 종료되었습니다. 로그는 깨끗했습니다. QA 팀은 고사양 하드웨어에서 이를 재현할 수 없었습니다. 따라 할 수 있는 재현 단계도 없었습니다. 3시간 동안의 메모리 프로파일링 끝에 마침내 실체가 드러났습니다. React hook 내부에 단 하나의 이벤트 리스너가 있었습니다. 그 리스너가 대규모 데이터셋을 클로저(closure)로 캡처하고 있었습니다. 컴포넌트는 언마운트되었습니다. 하지만 리스너는 남아 있었고, 데이터셋도 메모리에 그대로 머물렀습니다. 2GB RAM을 가진 기기에서는 이러한 누적이 힙(heap)을 고갈시켰고, 운영체제가 앱을 강제 종료했습니다. 이것은 구문 오류나 로직 결함이 아니었습니다. 스코프(scope) 버그였으며, 치명적이었습니다.
클로저가 어떻게 메모리 누수가 되는가
대부분의 튜토리얼은 스코프를 변수가 어디서 보이는지에 대한 학술적인 퍼즐로 가르칩니다. 하지만 프로덕션 환경에서 스코프는 메모리 수명에 관한 계약입니다. JavaScript 함수가 변수를 클로저로 캡처하면, 엔진은 클로저 자체에 접근할 수 있는 한 해당 변수를 계속 유지합니다. React 컴포넌트에서 이는 사용자가 페이지를 벗어나고 UI 노드가 사라진 후에도 데이터가 오랫동안 살아남는다는 것을 의미합니다.
window 객체에 리스너를 등록하는 훅을 생각해 봅시다. 컴포넌트가 렌더링되어 리스너를 부착하고, 나중에 언마운트됩니다. 만약 정리(cleanup) 단계가 누락되었거나 잘못되었다면, 리스너는 그대로 남습니다. 컴포넌트가 새로 마운트될 때마다 갇혀 있는 데이터의 유령 복사본이 RAM에 추가됩니다. 메모리가 풍부한 개발자 워크스테이션에서는 이러한 비대해짐을 전혀 눈치채지 못할 수도 있습니다. Android Go를 실행하는 저가형 폰에서는 평범하게 20분만 사용해도 가용 힙을 모두 소진하기에 충분합니다. OS가 개입하여 프로세스를 종료합니다. 로그에 남을 예외도 없습니다. 시스템이 그냥 전원을 뽑아버리는 것과 같습니다.
이것이 바로 스코프가 메모리 관리인 이유입니다. 렉시컬 환경(lexical environment)은 철학적인 경계가 아닙니다. 그것은 유지 그래프(retention graph)입니다. 수집되지 않은 클로저 내부에 남겨둔 모든 변수는 결국 당신의 앱을 가두는 벽의 벽돌이 됩니다.
스코프가 프로덕션 앱을 망가뜨리는 세 가지 방식
스코프 문제는 모두 같은 모습이 아닙니다. 어떤 것은 메모리를 천천히 소모하고, 어떤 것은 즉각적으로 폭발합니다. 앱을 확실하게 다운시키는 패턴들은 다음과 같습니다.
전역 스코프 오염 (Global Scope Pollution)
마이크로 프론트엔드(Micro-frontend) 아키텍처는 팀들이 독립적으로 배포할 수 있게 해주지만, 모두 동일한 window 객체를 공유합니다. 한 애플리케이션이 window.config와 같은 전역 변수를 설정하거나 window에 공유 유틸리티를 패치하면, 그것은 고립되어 존재하지 않습니다. 다른 팀의 앱이 동일한 전역 객체에 대해 다른 형태를 기대하거나, 자체 부트스트랩 과정에서 이를 덮어쓸 수 있습니다. 그 결과는 조직의 규모에 비례하여 커지는 기능 충돌로 이어집니다. 한 저장소의 개발자는 자신의 지름길(shortcut)이 다른 팀에게는 장애를 일으키는 변경 사항(breaking change)이 될 수 있다는 사실을 전혀 모릅니다. 접점이 넓어질수록, 이러한 전역 변수들은 공유된 토양에 매설된 지뢰가 됩니다.
클로저 메모리 누수 (Closure Memory Leaks)
싱글 페이지 애플리케이션(SPA)은 몇 시간 동안 실행되도록 설계되었습니다. 바로 그 내구성 때문에 누수되는 클로저가 치명적인 독이 됩니다. 패턴은 기만적일 정도로 흔합니다. useEffect가 전역 이벤트 버스, WebSocket 핸들러 또는 DOM 자체에 콜백을 등록하는 경우입니다. 의존성 배열(dependency array)이 불안정하거나 누락되면, 정리(cleanup) 단계가 원래의 구독(subscription)과 일치하지 않게 됩니다. 클로저는 렉시컬 스코프에 있는 무엇이든 캡처하며, 여기에는 거대한 파싱된 배열, 가져온 JSON 블롭(blob), 또는 DOM 트리에 대한 참조가 포함될 수 있습니다. 페이지를 이동할 때마다 무게가 더해집니다. 사용자는 왜 브라우저 탭이 800MB를 차지하는지 모릅니다. 그저 앱이 느려지다가 결국 죽는다는 것만 알 뿐입니다.
특히 렌더링마다 의존성 배열이 변경될 때 매우 위험합니다. 매 사이클마다 새로운 함수 참조가 생성되어 리스너에 등록되지만, 이전 참조는 절대 해제되지 않습니다. 그 결과, 생성될 당시의 데이터를 각각 움켜쥐고 있는 '죽은 클로저들의 박물관'이 만들어집니다.
동적 모듈에서의 TDZ 오류 (TDZ Errors in Dynamic Modules)
The Temporal Dead Zone은 이론적인 엣지 케이스가 아닙니다. let이나 const를 선언이 실행되기 전에 접근하면 엔진은 ReferenceError를 던집니다. 순환 의존성과 동적 임포트가 있는 대규모 모노레포에서는 정확한 실행 순서가 종종 암시적입니다. 모듈 A가 모듈 B를 임포트하고, 모듈 B가 다시 모듈 A에 의존하는 청크를 동적으로 임포트하는 상황을 가정해 봅시다. 만약 한 브랜치가 초기화가 완료되지 않은 변수에 접근하면, 앱은 로드 중에 충돌합니다. 이러한 실패는 타이밍에 따라 달라지기 때문에 매우 까다롭습니다. 번들러 분할 지점의 작은 변화, 코드 로딩 시의 네트워크 지연, 또는 청크 캐싱의 변화만으로도 순서가 바뀌어 TDZ를 유발할 수 있습니다. 충돌은 예측 불가능하며, 스택 트레이스는 대개 아무런 잘못이 없는 평범한 코드 라인을 가리킵니다.
방어적 전략
스코프 버그로부터 자신을 구하기 위해 스택 트레이스에만 의존할 수는 없습니다. 예방과 탐지가 필요합니다.
정적 분석부터 시작하십시오. ESLint를 설정하여 엄격한 경계를 강제하십시오. no-implicit-globals 및 no-shadow와 같은 규칙은 명백한 실수를 잡아냅니다. 섀도잉(Shadowing)은 특히 위험한데, 외부 변수에 대한 클로저를 생성하거나 실수로 중복 변수를 만들고 있음에도 불구하고 로컬 변수를 수정하고 있다고 착각하게 만들기 때문입니다. 이러한 규칙은 명시적인 의도를 강제하고 조용한 충돌을 제거합니다.
단위 테스트에 적용하는 것과 동일한 규율로 메모리를 프로파일링하십시오. Chrome DevTools를 열고 시작 경로에서 힙 스냅샷(heap snapshot)을 찍은 뒤, 애플리케이션을 5분 동안 탐색하고 다시 하나를 찍으십시오. 두 개를 비교하십시오. "Closure"로 필터링하여 제한 없이 계속 증가하는 카운트를 찾으십시오. 이벤트 리스너를 여전히 보유하고 있는 분리된(detached) DOM 노드를 찾으십시오. 사용자 수가 일정하게 유지되는 동안 두 번째 스냅샷에서 수천 개의 새로운 Closure 항목이 나타난다면, 데이터를 붙잡고 있는 함수가 갇혀 있는 것입니다. 그것이 바로 메모리 누수입니다.
아키텍처 측면에서는 설정을 위해 전역 window 객체에 접근하는 것을 중단하십시오. 설정을 props로 전달하거나 타입이 지정된 context를 통해 전달하십시오. 여기서 의존성 주입(Dependency injection)은 기업용 유행어가 아닙니다. 함수가 전역 스코프를 냄새 맡게 두는 대신, 인자를 통해 필요한 모든 것을 제공하는 관행을 의미합니다. 그 결과 브라우저 심(shim) 없이도 테스트할 수 있는 코드와, 여러 앱이 동일한 셸 내에 마운트될 때 서로 충돌하지 않는 모듈을 얻을 수 있습니다.
마지막으로, 정리(cleanup) 단계를 가차 없이 준수하십시오. 모든 addEventListener에는 effect cleanup 내부의 대응하는 removeEventListener가 필요합니다. 비동기 작업의 경우 AbortController를 사용하고 그 시그널을 fetch에 전달하여, 컴포넌트가 소멸할 때 진행 중인 요청이 취소되도록 하십시오. 이러한 습관은 스코프가 얼마나 오래 유지될지를 직접적으로 제어합니다. 이것은 단순한 상용구(boilerplate)가 아닙니다. 이것은 메모리 관리입니다.
이것이 팀에 의미하는 바
스코프는 면접 중에 후보자를 테스트하기 위한 눈속임이 아닙니다. 프로덕션 환경에서 스코프는 메모리 관리입니다. 여러분이 선언하는 모든 변수는 잠재적인 인질입니다. 모든 클로저는 엔진이 지켜야 할 약속입니다. 리스너를 해제하는 것을 잊는다면, 단순히 불을 켜두고 나가는 것이 아닙니다. 앱에 무게추를 매달아 바다에 던지는 것과 같습니다. 고성능 하드웨어에서는 앱이 어떻게든 헤엄쳐 나갑니다. 하지만 저사양 기기를 사용하는 사용자들에게 앱은 가라앉습니다. 스코프를 유한한 자원으로 취급하기 시작하십시오. 그러면 여러분의 사용자들과, 3시간 동안 이어질 디버깅 세션이 여러분에게 감사해할 것입니다.
