09. 메모리 — Heap → Reference → Garbage Collection → Closure → Memory Leak
앞에서는 React의 성능을 살펴봤습니다.
Render
→ Re-render
→ memo
→ useMemo
→ useCallback그리고 초기 로딩에서는
Code Splitting
→ Dynamic Import
→ React.lazy
→ Suspense를 살펴봤습니다.
이번에는 조금 다른 관점에서 성능을 바라보겠습니다.
바로 메모리입니다.
특히 브라우저에서 대용량 데이터를 다루다 보면 이런 문제가 발생할 수 있습니다.
파일 다운로드
↓
ArrayBuffer 생성
↓
데이터 처리
↓
객체 생성
↓
메모리 증가
↓
처리가 끝났는데도 메모리가 줄지 않음이런 상황에서 자연스럽게 다음과 같은 질문이 생깁니다.
"사용하지 않는 데이터라면 브라우저가 알아서 메모리를 정리해주는 것 아닌가?"
맞습니다.
JavaScript에는 Garbage Collection(GC)이 있습니다.
그런데 중요한 조건이 하나 있습니다.
더 이상 접근할 수 없는 데이터여야 합니다.
이것을 이해하려면 먼저 JavaScript가 메모리를 어떻게 사용하는지부터 알아야 합니다.
1. JavaScript에서 메모리는 어떻게 사용될까?
JavaScript 코드가 실행될 때 변수와 객체 등의 데이터를 저장하기 위해 메모리를 사용합니다.
개념적으로 단순화하면 다음과 같이 생각할 수 있습니다.
JavaScript Engine
│
├── Call Stack
│
└── Heap앞에서 Execution Context와 Call Stack을 배웠습니다.
함수 호출
↓
Execution Context 생성
↓
Call Stack에 추가이번에는 Heap을 살펴보겠습니다.
2. Heap이란?
Heap은 JavaScript에서 객체처럼 동적으로 생성되는 데이터를 관리하는 메모리 영역을 설명할 때 사용하는 개념입니다.
예를 들어
const user = {
name: "Yonghee",
age: 30
};라는 코드가 있다면 개념적으로
user
│
│ 참조
↓
┌──────────────┐
│ Object │
│ │
│ name │
│ age │
└──────────────┘처럼 생각할 수 있습니다.
user라는 변수와 실제 객체를 구분해서 보는 것이 중요합니다.
user
↓
객체여기서 화살표는 참조(reference)를 의미합니다.
3. Reference란 무엇인가?
JavaScript에서 객체를 이해하려면 Reference를 이해해야 합니다.
예를 들어
const user = {
name: "Yonghee"
};
const anotherUser = user;라고 해봅시다.
이때 객체가 하나 더 만들어지는 것이 아닙니다.
개념적으로
user ─────────┐
↓
┌─────────┐
│ Object │
│ name │
│ Yonghee │
└─────────┘
↑
│
anotherUser ──┘처럼 두 변수가 같은 객체를 가리킬 수 있습니다.
따라서
anotherUser.name = "Kim";이라고 하면
console.log(user.name);의 결과도
Kim이 됩니다.
왜냐하면 두 변수가 같은 객체를 참조하고 있기 때문입니다.
4. Primitive와 Object의 차이
여기서 Primitive와 Object를 구분하면 좋습니다.
대표적인 Primitive 값은
string
number
boolean
null
undefined
bigint
symbol입니다.
예를 들어
let a = 10;
let b = a;
b = 20;이라고 하면
a → 10
b → 20이 됩니다.
b를 변경했다고 a가 변경되지 않습니다.
반면 객체는
const a = {
value: 10
};
const b = a;
b.value = 20;이면
a ──┐
↓
Object
value: 20
↑
│
b ──┘처럼 같은 객체를 참조합니다.
따라서
console.log(a.value);도
20이 됩니다.
5. 그렇다면 객체는 언제 메모리에서 사라질까?
이제 중요한 질문으로 넘어갑니다.
let user = {
name: "Yonghee"
};객체가 만들어졌습니다.
그런데
user = null;이라고 하면 어떻게 될까요?
이제 기존 객체를 가리키는 참조가 사라집니다.
개념적으로
user
↓
null
┌──────────────┐
│ Object │
│ name │
│ Yonghee │
└──────────────┘
더 이상 접근할 수 있는 경로 없음이런 객체는 Garbage Collection의 대상이 될 수 있습니다.
여기서 중요한 표현은
null이 되자마자 즉시 메모리에서 삭제된다.가 아닙니다.
정확하게는
더 이상 접근할 수 없는 객체는 GC가 회수할 수 있는 대상이 된다.
입니다.
6. Garbage Collection
Garbage Collection은 JavaScript에서 더 이상 사용할 수 없는 메모리를 자동으로 회수하는 과정입니다.
개념적으로는
사용 중인 객체
↓
계속 참조됨
↓
메모리에 유지
사용하지 않는 객체
↓
어떤 경로에서도 접근 불가능
↓
GC 대상
↓
메모리 회수가 됩니다.
JavaScript 개발자가 직접
free()
delete memory같은 방식으로 객체 메모리를 해제하는 것이 아닙니다.
JavaScript 엔진이 자동으로 관리합니다.
7. GC는 어떻게 사용하지 않는 객체를 알까?
가장 대표적으로 이해하기 좋은 방식이 Reachability입니다.
쉽게 말하면
GC가 접근할 수 있는 객체인가?
를 보는 것입니다.
JavaScript에는 프로그램에서 계속 접근할 수 있는 시작점들이 있습니다.
이를 개념적으로 GC Root라고 합니다.
예를 들어
GC Root
↓
전역 변수
↓
Object A
↓
Object B라면
Root → A → B로 접근할 수 있습니다.
따라서 A와 B는 살아 있는 객체로 볼 수 있습니다.
반면
GC Root
↓
Object A
Object B
↓
Object C에서 B와 C로 접근할 수 있는 경로가 없다면 GC가 회수할 수 있습니다.
즉 핵심은
"변수가 있는가?"보다는
"GC Root에서 해당 객체까지 접근할 수 있는가?"입니다.
8. 객체를 null로 만드는 것이 GC의 핵심은 아니다
다음 코드를 보겠습니다.
let user = {
name: "Yonghee"
};
user = null;이 경우 객체에 대한 참조가 사라졌기 때문에 GC 대상이 될 수 있습니다.
하지만
const user = {
name: "Yonghee"
};라고 해서 반드시 GC되지 않는 것도 아닙니다.
함수가 종료되고 해당 객체를 더 이상 참조하는 곳이 없다면 GC 대상이 될 수 있습니다.
즉
참조가 살아 있음
→ GC 대상이 아님
참조가 모두 사라짐
→ GC 대상이 될 수 있음으로 이해하는 것이 중요합니다.
9. 순환 참조는 어떨까?
예전에는 순환 참조가 메모리 누수의 대표적인 예로 많이 설명되었습니다.
const a = {};
const b = {};
a.b = b;
b.a = a;그러면
a → b
↑ ↓
└───┘처럼 서로 참조합니다.
그렇다면 서로 참조하고 있으니 GC가 영원히 회수하지 못할까요?
그렇지는 않습니다.
중요한 것은 순환하고 있느냐가 아니라 외부에서 접근할 수 있느냐입니다.
GC Root
↓
a ↔ b라면 살아 있습니다.
하지만
GC Root
a ↔ b
접근 경로 없음이라면 순환 참조를 하고 있어도 GC가 회수할 수 있습니다.
따라서
순환 참조 자체가 곧 메모리 누수는 아닙니다.
이것은 중요한 포인트입니다.
10. 그렇다면 Memory Leak은 무엇일까?
Memory Leak, 즉 메모리 누수는 간단하게 말하면
더 이상 필요하지 않은 데이터가 계속 메모리에 남아 있는 상황
이라고 이해할 수 있습니다.
예를 들어 애플리케이션이 계속 실행되면서
화면 진입
↓
데이터 생성
화면 이탈
↓
데이터는 필요 없음
다시 진입
↓
새로운 데이터 생성
화면 이탈
↓
기존 데이터가 계속 남아 있음이런 일이 반복된다면 메모리가 계속 증가할 수 있습니다.
11. 가장 대표적인 예: 전역 변수
예를 들어
const cache = [];
function loadData(data) {
cache.push(data);
}가 있다고 해봅시다.
사용자가 페이지를 나가더라도
cache
↓
data
↓
data
↓
data
↓
data
...처럼 전역에서 계속 참조하고 있다면 GC가 데이터를 회수할 수 없습니다.
왜냐하면
GC Root
↓
cache
↓
data라는 접근 경로가 계속 존재하기 때문입니다.
따라서
메모리 누수는 GC가 일을 못하는 문제가 아니라, 개발자가 아직 참조를 유지하고 있기 때문에 GC가 회수할 수 없는 경우가 많습니다.
12. Closure와 메모리
이제 앞에서 배웠던 Closure가 등장합니다.
Closure를 다시 간단하게 정의하면
함수가 자신이 만들어진 렉시컬 환경의 변수에 접근할 수 있는 현상
입니다.
예를 들어
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();createCounter가 실행되면
createCounter 실행
↓
count 생성
↓
내부 함수 반환
↓
createCounter 실행 종료가 됩니다.
그런데 count는 사라지지 않습니다.
왜냐하면 반환된 함수가 count를 사용하고 있기 때문입니다.
counter
↓
function
↓
Lexical Environment
↓
count이런 식으로 접근 가능한 경로가 유지됩니다.
13. Closure가 변수를 "복사"하는 것은 아니다
이 부분은 이전에 배운 내용과 연결됩니다.
Closure를
"함수가 외부 변수의 값을 복사해서 가지고 있는 것"
이라고 이해하면 정확하지 않습니다.
좀 더 정확하게는
함수가 자신이 선언된 렉시컬 환경에 접근할 수 있는 상태
입니다.
그래서
function createCounter() {
let count = 0;
return () => {
count++;
return count;
};
}에서 내부 함수가 계속 살아 있다면 count에 접근할 수 있는 환경도 함께 유지될 수 있습니다.
14. Closure가 항상 메모리 누수인 것은 아니다
여기서 중요한 오해가 하나 있습니다.
Closure
→ 메모리 누수가 아닙니다.
Closure는 JavaScript에서 정상적이고 매우 중요한 기능입니다.
function createCounter() {
let count = 0;
return () => ++count;
}처럼 상태를 유지하는 데 유용합니다.
문제는 필요하지 않은 데이터까지 Closure를 통해 계속 잡고 있는 경우입니다.
15. Closure로 큰 데이터를 계속 잡고 있다면?
예를 들어
function createProcessor(largeData) {
return function process() {
return largeData.length;
};
}
const processor = createProcessor(largeData);여기서 largeData가 매우 큰 데이터라고 생각해봅시다.
processor
↓
function
↓
Closure
↓
largeData
↓
수백 MB 데이터processor가 계속 살아 있다면 largeData도 접근 가능한 상태로 유지될 수 있습니다.
이것은 Closure 자체가 잘못된 것이 아닙니다.
필요하지 않은 시점에도 Closure가 큰 데이터를 계속 참조하고 있는 구조가 문제입니다.
16. 이벤트 리스너도 메모리 누수의 원인이 될 수 있다
브라우저 개발에서 자주 만나는 또 다른 예가 이벤트 리스너입니다.
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);컴포넌트가 사라졌는데도 이벤트 리스너를 계속 등록해두면 문제가 생길 수 있습니다.
React에서는 보통 Effect cleanup을 사용합니다.
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);흐름은
컴포넌트 Mount
↓
이벤트 리스너 등록
컴포넌트 Unmount
↓
Cleanup
↓
이벤트 리스너 제거입니다.
이런 cleanup이 중요한 이유는 더 이상 필요하지 않은 참조를 끊기 위해서입니다.
17. Timer도 마찬가지다
예를 들어
const id = setInterval(() => {
console.log("running");
}, 1000);를 만들었다고 해봅시다.
더 이상 필요하지 않다면
clearInterval(id);를 호출해야 합니다.
React에서는
useEffect(() => {
const id = setInterval(() => {
console.log("running");
}, 1000);
return () => {
clearInterval(id);
};
}, []);처럼 cleanup을 구성할 수 있습니다.
이 역시 핵심은 같습니다.
더 이상 필요 없음
↓
참조 / 작업 제거
↓
GC가 회수할 수 있는 상태18. 대용량 데이터에서는 메모리 관리가 더 중요하다
프론트엔드에서 작은 객체 몇 개가 남는 것과 수백 MB의 데이터가 남는 것은 완전히 다릅니다.
예를 들어 의료 영상 데이터를 처리한다고 해봅시다.
10GB 데이터
↓
ArrayBuffer
↓
압축 해제
↓
파싱
↓
객체 생성
↓
렌더링여기서 여러 단계의 데이터가 동시에 살아 있다면 메모리 사용량이 크게 증가할 수 있습니다.
예를 들어
원본 데이터
+
압축 해제 데이터
+
파싱 결과
+
렌더링용 데이터가 동시에 메모리에 존재할 수 있습니다.
이 경우 단순히 GC가 있다고 해서 메모리 문제가 자동으로 해결되는 것은 아닙니다.
GC는 접근할 수 없는 데이터만 회수할 수 있기 때문입니다.
19. Streaming이 메모리와 연결되는 이유
앞에서 배운 Streaming도 여기서 연결됩니다.
한 번에 전체 데이터를 메모리에 올리는 방식은
10GB
↓
한 번에 메모리 적재
↓
처리가 될 수 있습니다.
반면 Streaming을 사용하면
Chunk 1
↓
처리
↓
해제 가능
Chunk 2
↓
처리
↓
해제 가능
Chunk 3
↓
처리처럼 데이터를 나눠 처리할 수 있습니다.
물론 실제 메모리 사용량은 구현에 따라 달라지지만, 핵심적인 사고방식은
한 번에 모든 데이터를 살아 있게 만들지 않는다.
입니다.
20. Web Worker와 메모리
앞에서 Web Worker도 배웠습니다.
Worker는 별도의 JavaScript 실행 환경을 사용합니다.
따라서
Main Thread
↕
Worker사이에서 데이터를 전달해야 합니다.
이때 데이터 전달 방식에 따라 메모리 사용에도 차이가 생길 수 있습니다.
예를 들어 큰 ArrayBuffer를 복사하는 방식과 Transferable을 이용해 소유권을 넘기는 방식은 메모리 관점에서 다르게 동작할 수 있습니다.
Main
ArrayBuffer
↓
Worker대용량 데이터를 처리하는 경우에는
복사 비용
메모리 사용량
소유권 이전까지 함께 고려해야 합니다.
즉 Web Worker는 단순히
"CPU를 다른 곳에서 처리한다."
에서 끝나는 것이 아니라
"큰 데이터를 어떻게 Worker와 주고받을 것인가?"
까지 생각해야 실제 성능을 개선할 수 있습니다.
21. Memory Leak을 찾을 때는 무엇을 봐야 할까?
실무에서 메모리 문제가 발생했다고 가정해봅시다.
가장 먼저
메모리가 계속 증가하는가?를 확인합니다.
그리고 특정 동작을 반복합니다.
페이지 진입
→ 이탈
→ 진입
→ 이탈
→ 진입
→ 이탈정상적인 구조라면 사용하지 않는 객체가 GC를 거치면서 메모리가 어느 정도 회수되어야 합니다.
그런데
100MB
↓
200MB
↓
300MB
↓
400MB처럼 계속 증가한다면 누수 가능성을 의심할 수 있습니다.
이때 브라우저의 Memory 관련 개발자 도구를 사용해 Heap Snapshot 등을 비교하면서 어떤 객체가 계속 살아 있는지 추적할 수 있습니다.
22. 중요한 것은 "누가 참조하고 있는가?"
Memory Leak을 분석할 때 가장 중요한 질문은 이것입니다.
"이 객체를 누가 계속 참조하고 있는가?"
예를 들어
큰 Object
↑
│
Closure
↑
Event Listener
↑
Window라면 큰 Object가 왜 살아 있는지 추적할 수 있습니다.
또는
큰 Array
↑
전역 cache
↑
GC Root라면 전역 cache가 참조를 유지하고 있기 때문에 GC가 회수하지 못하는 것입니다.
결국 Memory Leak 분석은
메모리가 증가한다
↓
어떤 객체가 남아 있는가?
↓
그 객체를 누가 참조하는가?
↓
왜 그 참조가 아직 필요한가?
↓
필요 없다면 참조를 끊을 수 있는가?라는 과정으로 접근하게 됩니다.
23. 지금까지의 개념을 하나로 연결하면
지금까지 배운 JavaScript의 실행과 메모리를 연결해봅시다.
JavaScript 실행
↓
Execution Context
↓
Call Stack
↓
변수 / 객체 생성
↓
Heap
↓
Reference
↓
객체 접근그리고 객체가 더 이상 필요하지 않으면
Reference 제거
↓
GC Root에서 접근 불가능
↓
Garbage Collection
↓
메모리 회수가 됩니다.
하지만 어떤 이유로 계속 참조하고 있다면
필요하지 않은 객체
↓
Reference 유지
↓
GC가 회수하지 못함
↓
Memory Leak이 될 수 있습니다.
24. Closure까지 연결하면
Closure는 다음과 같이 연결됩니다.
함수 생성
↓
Lexical Environment
↓
외부 변수 접근
↓
내부 함수가 계속 살아 있음
↓
외부 변수 환경도 유지될 수 있음이것은 정상적인 JavaScript 동작입니다.
하지만
Closure
↓
대용량 데이터
↓
더 이상 필요 없음
↓
그런데 Closure가 계속 살아 있음이라면 메모리 사용량이 커질 수 있습니다.
따라서 Closure를 이해하면 단순히 JavaScript 문법을 이해하는 것을 넘어 메모리 생명주기까지 생각할 수 있게 됩니다.
마무리
이번 글에서 가장 중요한 것은 하나입니다.
GC는 "안 쓰는 것처럼 보이는 데이터"를 삭제하는 것이 아니라, 더 이상 접근할 수 없는 객체를 회수한다.
이 관점을 가지고 보면 JavaScript 메모리 문제가 훨씬 쉽게 보입니다.
전체 흐름은 다음과 같습니다.
객체 생성
↓
Heap에 데이터 존재
↓
Reference를 통해 접근
↓
필요한 동안 유지
↓
Reference가 모두 끊김
↓
GC Root에서 접근 불가능
↓
Garbage Collection
↓
메모리 회수반대로
필요하지 않은 객체
↓
전역 변수 / Cache / Event Listener / Timer / Closure 등
↓
계속 참조
↓
GC가 회수하지 못함
↓
Memory Leak이 됩니다.
그리고 대용량 데이터를 다루는 프론트엔드에서는 여기서 한 단계 더 나아가야 합니다.
메모리를 적게 사용하는 것만이 아니라
애초에 한 번에 얼마나 많은 데이터를 메모리에 올릴 것인가?를 설계해야 합니다.
그래서 다음 글에서는 지금까지 배운 내용을 실제 성능 문제를 찾는 과정으로 연결해보겠습니다.
Network
→ Performance
→ Memory
→ React
→ 실제 병목 찾기즉,
"성능을 개선해야 한다"가 아니라 "어디가 느린지 어떻게 찾아낼 것인가?"
를 다루게 됩니다.
다음은 10. 성능 분석 — Network → Performance → Memory → 실제 문제 찾기로 이어집니다.