목차
1. 들어가며: 왜 메모리 관리를 알아야 하는가
메모리 관리는 모든 프로그래밍 언어의 핵심 기반이다. GC(가비지 컬렉션)가 자동으로 메모리를 관리해 준다고 해도, 내부 동작을 이해해야 성능 문제를 진단하고 메모리 누수를 해결할 수 있다.
이 가이드에서 다루는 핵심 주제:
- 메모리 기초: 가상 메모리, 페이지, TLB
- Stack과 Heap의 구조와 차이점
- GC 알고리즘: Reference Counting, Mark-and-Sweep, Generational
- V8(JavaScript), JVM(Java), Python, Go의 GC 전략
- Rust 소유권 시스템 (GC 없는 메모리 안전)
- 메모리 누수 탐지와 디버깅
메모리 관리 전체 구조
======================
[프로세스 가상 메모리 공간]
+---------------------------+ High Address
| Kernel Space |
+---------------------------+
| Stack (grows downward) | <-- 함수 호출, 지역 변수
| ... |
| ...free... |
| ... |
| Heap (grows upward) | <-- 동적 할당 (new, malloc)
+---------------------------+
| BSS (uninitialized data) |
| Data (initialized data) |
| Text (code) |
+---------------------------+ Low Address
2. 메모리 기초
2.1 가상 메모리 (Virtual Memory)
가상 메모리 구조
================
프로세스 A의 가상 주소 공간 물리 메모리 (RAM)
+-------------------+ +-------------------+
| Page 0: 0x0000 | -------> | Frame 5 |
| Page 1: 0x1000 | -------> | Frame 12 |
| Page 2: 0x2000 | --+ | Frame 3 |
| Page 3: 0x3000 | | | ... |
+-------------------+ | +-------------------+
|
+-----> [Disk (Swap)]
Page Table (프로세스별):
+-------+-------+-------+-------+
| VPN 0 | VPN 1 | VPN 2 | VPN 3 |
| PFN:5 | PFN:12| Swap | PFN:8 |
| V=1 | V=1 | V=0 | V=1 |
+-------+-------+-------+-------+
V=1: 물리 메모리에 존재
V=0: 디스크(swap)에 존재 -> Page Fault 발생
2.2 TLB (Translation Lookaside Buffer)
TLB 동작
========
CPU가 가상 주소 접근:
1. TLB 확인 (캐시)
- Hit: 바로 물리 주소 획득 (1-2 cycle)
- Miss: Page Table 조회 (수십~수백 cycle)
2. Page Table 조회
- 해당 페이지가 메모리에 있음: PFN 반환
- 해당 페이지가 없음: Page Fault -> OS가 디스크에서 로드
TLB 구조:
+------+------+-------+
| VPN | PFN | Flags |
+------+------+-------+
| 0x5 | 0x12 | R/W |
| 0x8 | 0x3 | R |
| 0xA | 0x7 | R/W |
+------+------+-------+
TLB 적중률이 99% 이상이어야 정상적인 성능
2.3 Memory-Mapped I/O
mmap 동작 원리
==============
일반 파일 I/O:
[프로세스] -> read() -> [커널 버퍼] -> [사용자 버퍼]
데이터 복사가 2번 발생!
Memory-Mapped I/O:
[프로세스 가상 메모리] <--직접 매핑--> [파일]
장점:
- 커널/사용자 간 데이터 복사 제거
- OS 페이지 캐시 활용
- 대용량 파일 처리에 효율적
사용 예: 데이터베이스 (LMDB), 로그 파일 처리
3. Stack (스택)
3.1 Call Stack 구조
Call Stack 동작
===============
void main() {
int x = 10;
foo(x); // <-- 현재 실행 지점
}
void foo(int a) {
int y = 20;
bar(a, y);
}
void bar(int p, int q) {
int z = p + q;
}
Stack (아래에서 위로 성장):
+------------------------+
| bar의 Stack Frame | <-- Stack Pointer (SP)
| z = 30 |
| q = 20 |
| p = 10 |
| Return Address (foo) |
+------------------------+
| foo의 Stack Frame |
| y = 20 |
| a = 10 |
| Return Address (main)|
+------------------------+
| main의 Stack Frame |
| x = 10 |
| Return Address (OS) |
+------------------------+ <-- Stack Base
3.2 Stack Frame 상세
Stack Frame 구조 (x86-64)
==========================
High Address
+----------------------------+
| Argument N | (레지스터에 안 들어가는 인자)
| ... |
| Argument 7 |
+----------------------------+
| Return Address | (CALL 명령어가 push)
+----------------------------+
| Saved Base Pointer (RBP) | <-- Frame Pointer
+----------------------------+
| Local Variable 1 |
| Local Variable 2 |
| ... |
| Saved Registers |
+----------------------------+ <-- Stack Pointer (RSP)
Low Address
접근 방식:
- 지역 변수: [RBP - offset]
- 함수 인자: [RBP + offset]
3.3 Stack Overflow
Stack Overflow 발생 원인
========================
1. 무한 재귀 (가장 흔한 원인)
void infinite() {
infinite(); // Stack Frame이 계속 쌓임
}
2. 너무 큰 지역 변수
void largeLocal() {
int arr[10000000]; // 40MB on stack!
}
3. 너무 깊은 재귀
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n-1) + fibonacci(n-2);
// fib(50)은 수십억 번의 재귀 호출
}
Stack 크기 제한:
- Linux 기본: 8MB (ulimit -s)
- Windows 기본: 1MB
- Thread별 별도 Stack 할당
해결책:
- 재귀를 반복문으로 변환
- Tail Call Optimization (TCO) 활용
- 스택 크기 증가 (임시 방편)
4. Heap (힙)
4.1 동적 메모리 할당
Heap 할당 과정
==============
[프로세스 Heap 영역]
+------------------------------------------+
| [Used: 32B] [Free: 64B] [Used: 128B] |
| [Free: 256B] [Used: 16B] [Free: 512B] |
+------------------------------------------+
malloc(100) 호출:
1. Free List에서 100B 이상인 블록 검색
2. [Free: 256B] 블록 발견
3. 100B 할당 + 156B 남은 부분 Free List에 유지
결과:
+------------------------------------------+
| [Used: 32B] [Free: 64B] [Used: 128B] |
| [Used: 100B] [Free: 156B] [Used: 16B] ...|
+------------------------------------------+
4.2 메모리 단편화 (Fragmentation)
메모리 단편화 유형
==================
External Fragmentation (외부 단편화):
+---+----+---+--------+---+------+---+
|Use|Free|Use| Free |Use| Free |Use|
|32B| 8B |64B| 16B |32B| 12B |16B|
+---+----+---+--------+---+------+---+
Free 합계: 36B이지만 연속 최대 16B!
-> 20B 할당 불가 (충분한 연속 공간 없음)
Internal Fragmentation (내부 단편화):
요청: 100B, 할당: 128B (16B 단위 정렬)
-> 28B 낭비
해결 방법:
- Compaction: 사용 중인 블록을 한쪽으로 모음 (이동 비용 높음)
- Buddy System: 2의 거듭제곱 크기로 분할
- Slab Allocator: 고정 크기 객체 풀
4.3 메모리 할당기 비교
메모리 할당기 비교
==================
1. glibc malloc (ptmalloc2)
- 기본 Linux 할당기
- Thread별 arena로 lock 경합 감소
- 작은 할당: fastbin (LIFO, lock-free)
- 큰 할당: mmap 직접 사용
2. jemalloc (Facebook/Meta)
- FreeBSD 기본, Redis 사용
- Thread Cache -> Bin -> Run -> Chunk
- 크기 클래스별 미세 분류로 단편화 최소화
- 우수한 프로파일링/통계 기능
3. tcmalloc (Google)
- Thread-Caching Malloc
- Thread Local Cache: 작은 객체 lock-free 할당
- Central Free List: Thread 간 공유
- Page Heap: OS에서 큰 블록 할당
- Go 런타임의 메모리 할당기 기반
성능 비교 (상대적):
+----------+---------+---------+-----------+
| | 속도 | 메모리 | 멀티스레드 |
+----------+---------+---------+-----------+
| ptmalloc | 보통 | 보통 | 보통 |
| jemalloc | 빠름 | 우수 | 우수 |
| tcmalloc | 매우빠름| 우수 | 매우우수 |
+----------+---------+---------+-----------+
5. GC 알고리즘 기초
5.1 Reference Counting
Reference Counting 동작
=======================
let a = new Object(); // Object의 ref_count = 1
let b = a; // Object의 ref_count = 2
a = null; // Object의 ref_count = 1
b = null; // Object의 ref_count = 0 -> 즉시 해제!
장점:
- 즉시 해제: 참조 카운트가 0이 되면 바로 메모리 반환
- 예측 가능한 일시 정지 없음
- 구현이 비교적 단순
단점 (치명적):
- 순환 참조를 해결할 수 없음!
순환 참조 예시:
let a = {}; // a.ref_count = 1
let b = {}; // b.ref_count = 1
a.ref = b; // b.ref_count = 2
b.ref = a; // a.ref_count = 2
a = null; // a_obj.ref_count = 1 (b.ref가 아직 참조)
b = null; // b_obj.ref_count = 1 (a_obj.ref가 아직 참조)
// 둘 다 ref_count > 0이지만 접근 불가 -> 메모리 누수!
5.2 Mark-and-Sweep
Mark-and-Sweep 동작
====================
Phase 1: Mark (마킹)
- GC Root(스택, 전역 변수, 레지스터)에서 시작
- 도달 가능한 모든 객체에 mark 비트 설정
Phase 2: Sweep (쓸기)
- 전체 힙을 순회하며 mark 안 된 객체 해제
GC Root --> [A] --> [B] --> [C]
|
+--> [D]
[E] --> [F] (GC Root에서 도달 불가)
^ |
+-------+ (순환 참조이지만 도달 불가 -> 수거됨!)
Mark 결과: A(marked), B(marked), C(marked), D(marked)
Sweep 결과: E(해제), F(해제)
장점: 순환 참조 처리 가능
단점: Stop-the-World 일시 정지, 힙 단편화
5.3 Mark-Compact
Mark-Compact 동작
==================
Mark-and-Sweep의 단편화 문제 해결
Before:
[A][Free][B][Free][Free][C][Free][D]
After Mark-Compact:
[A][B][C][D][ Free ]
과정:
1. Mark: 도달 가능 객체 마킹 (Mark-and-Sweep과 동일)
2. Compact: 살아있는 객체를 한쪽으로 모음
3. Update References: 이동된 객체의 참조 업데이트
장점: 단편화 제거, 연속 free space 확보
단점: 객체 이동 비용, 참조 업데이트 비용
5.4 Copying GC
Copying GC 동작
================
메모리를 두 반공간(semi-space)으로 분할
From-Space (현재 사용):
[A][garbage][B][garbage][C][garbage]
To-Space (비어있음):
[ ]
Copy 과정:
1. GC Root에서 도달 가능 객체만 To-Space로 복사
2. From-Space와 To-Space 역할 교환
After:
From-Space (이전 To-Space):
[A][B][C][ Free ]
To-Space (이전 From-Space, 비워짐):
[ ]
장점: 단편화 없음, 할당이 매우 빠름 (bump pointer)
단점: 메모리 사용량 2배, 긴 수명 객체 반복 복사
5.5 Tri-color Marking
Tri-color Marking (삼색 표시)
==============================
색상 정의:
- White(흰색): 아직 방문하지 않음 (GC 대상 후보)
- Gray(회색): 방문했지만 자식을 아직 다 처리하지 않음
- Black(검정): 방문 완료, 모든 자식도 처리됨
초기 상태: Root만 Gray, 나머지 모두 White
Step 1: [Root:Gray] -> [A:White] -> [B:White]
[C:White]
Step 2: Root를 처리, 자식 A를 Gray로
[Root:Black] -> [A:Gray] -> [B:White]
[C:White]
Step 3: A를 처리, 자식 B를 Gray로
[Root:Black] -> [A:Black] -> [B:Gray]
[C:White]
Step 4: B를 처리 (자식 없음)
[Root:Black] -> [A:Black] -> [B:Black]
[C:White]
결과: C는 White -> 수거 대상
장점: 점진적(incremental) GC 가능
Concurrent GC의 기반 알고리즘
6. Generational GC (세대별 GC)
6.1 Weak Generational Hypothesis
약한 세대 가설
==============
"대부분의 객체는 생성된 직후 곧 사용되지 않게 된다"
객체 수명 분포:
|
|*
|**
|****
|********
|*****************
|*******************************************
+------------------------------------------------->
짧음 긴 수명
90% 이상의 객체가 첫 번째 GC 이전에 죽음!
-> 젊은 객체를 자주, 늙은 객체를 드물게 수거하면 효율적
6.2 JVM Generational GC 구조
JVM 힙 메모리 구조
==================
+---------------------------------------------------+
| Young Generation (전체의 1/3) |
| +--------+----------+----------+ |
| | Eden | Survivor | Survivor | |
| | | S0 | S1 | |
| | (80%) | (10%) | (10%) | |
| +--------+----------+----------+ |
+---------------------------------------------------+
| Old Generation (전체의 2/3) |
| +---------------------------------------------+ |
| | Tenured Space | |
| +---------------------------------------------+ |
+---------------------------------------------------+
| Metaspace (클래스 메타데이터, 네이티브 메모리) |
+---------------------------------------------------+
객체 생명주기:
1. Eden에서 생성
2. Minor GC: Eden 살아남은 객체 -> S0 (age=1)
3. 다음 Minor GC: Eden+S0 살아남은 객체 -> S1 (age++)
4. S0와 S1을 번갈아 사용 (Copying GC)
5. age가 임계값(기본 15) 도달 -> Old Generation으로 승격
6. Old Generation이 가득 차면 -> Major GC (Full GC)
6.3 Minor GC vs Major GC
GC 유형 비교
=============
Minor GC (Young GC):
- 대상: Young Generation (Eden + Survivor)
- 빈도: 매우 자주 (초~분 단위)
- 시간: 수 ms ~ 수십 ms
- 알고리즘: Copying GC
- STW: 짧은 일시 정지
Major GC (Old GC):
- 대상: Old Generation
- 빈도: 드물게 (분~시간 단위)
- 시간: 수백 ms ~ 수 초
- 알고리즘: Mark-Sweep 또는 Mark-Compact
- STW: 긴 일시 정지 (문제!)
Full GC:
- 대상: Young + Old + Metaspace
- 빈도: 매우 드물게
- 시간: 수 초 ~ 수십 초
- 가능하면 발생하지 않도록 튜닝해야 함!
7. V8 GC (JavaScript)
7.1 V8 Orinoco GC 아키텍처
V8 Orinoco GC 구조
===================
V8 Heap:
+------------------+---------------------------+
| Young Generation | Old Generation |
| (Semi-space) | (Mark-Sweep/Mark-Compact) |
| | |
| [From] [To] | [Old Space] |
| 1-8MB 1-8MB | [Large Object Space] |
| | [Code Space] |
| | [Map Space] |
+------------------+---------------------------+
Young Gen: Scavenger (Copying GC)
Old Gen: Mark-Compact (주요), Mark-Sweep (보조)
7.2 Scavenger (Young Generation GC)
Scavenger 동작
===============
Semi-space 모델: From-Space와 To-Space
1. 객체가 From-Space에 할당됨
2. From-Space가 가득 차면 Scavenge 시작
3. 살아있는 객체를 To-Space로 복사
4. 2번 살아남은 객체는 Old Generation으로 승격
5. From/To Space 역할 교환
할당 (Bump Pointer):
[allocated | allocated | ... | --> free space ]
^
allocation pointer
다음 할당: pointer를 크기만큼 전진 -> O(1)!
7.3 Major GC (Old Generation)
V8 Major GC 최적화 기법
========================
1. Incremental Marking (점진적 마킹)
- 전체 마킹을 작은 단위로 분할
- 애플리케이션 실행과 번갈아 수행
[App][Mark][App][Mark][App][Mark]...[Sweep]
vs. 기존:
[App]......[ Mark | Sweep ]......[App]
^--- 긴 STW pause ---^
2. Concurrent Marking (동시 마킹)
- 별도 스레드에서 마킹 수행
- 메인 스레드를 거의 블로킹하지 않음
Main Thread: [App][App][App][App][Short STW][App]
GC Thread: [ Concurrent Marking ][Done]
3. Lazy Sweeping (지연 스위핑)
- 즉시 모든 메모리를 쓸지 않음
- 새 할당이 필요할 때 점진적으로 sweep
4. Idle-time GC
- requestIdleCallback 같은 유휴 시간 활용
- 프레임 사이의 빈 시간에 GC 수행
8. JVM GC 전략
8.1 JVM GC 종류 비교
JVM GC 비교표
=============
+-------------+----------+---------+--------+------------+
| GC | 세대별 | 동시성 | STW | 적합 환경 |
+-------------+----------+---------+--------+------------+
| Serial | O | X | 긴 | 작은 힙 |
| Parallel | O | 부분 | 중간 | 처리량 우선 |
| CMS | O | O | 짧은 | 응답시간 우선|
| G1 | Region | O | 짧은 | 범용 |
| ZGC | Region | O | 매우짧 | 대용량 힙 |
| Shenandoah | Region | O | 매우짧 | 대용량 힙 |
+-------------+----------+---------+--------+------------+
STW 기준:
- Serial/Parallel: 수백 ms ~ 수 초
- CMS: 수십 ms (하지만 concurrent mode failure 시 수 초)
- G1: 수십 ms (목표 시간 설정 가능)
- ZGC: 1ms 미만 (!)
- Shenandoah: 1ms 미만
8.2 G1 GC (Garbage First)
G1 GC 구조
===========
힙을 동일 크기의 Region으로 분할 (1-32MB)
+---+---+---+---+---+---+---+---+
| E | S | O | O | H | E | E | |
+---+---+---+---+---+---+---+---+
| O | | E | S | O | O | | E |
+---+---+---+---+---+---+---+---+
E: Eden Region
S: Survivor Region
O: Old Region
H: Humongous Region (객체가 Region 크기의 50% 초과)
(공백): Free Region
동작 과정:
1. Young GC: Eden/Survivor Region만 수집
2. Concurrent Marking: 전체 힙의 liveness 분석
3. Mixed GC: Young + 가비지가 많은 Old Region 선택적 수집
-> "Garbage First" = 가비지가 가장 많은 Region부터 수집
설정:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 // 목표 STW 시간 (기본 200ms)
-XX:G1HeapRegionSize=4m // Region 크기
8.3 ZGC
ZGC 핵심 특징
=============
목표: 어떤 힙 크기에서도 STW를 1ms 미만으로 유지
핵심 기술:
1. Colored Pointers (색상 포인터)
- 64비트 포인터의 상위 비트를 메타데이터로 활용
63 47 46 45 44 43 42 0
+------+---+---+---+---+-------+
|unused|rem|mrk1|mrk0|fin| addr |
+------+---+---+---+---+-------+
- Remapped, Marked0, Marked1, Finalizable 비트
- 주소 공간: 4TB (42비트)
2. Load Barriers (로드 배리어)
- 객체 참조를 읽을 때마다 배리어 코드 실행
- 객체가 이동되었는지 확인하고 참조 업데이트
- Write Barrier 대신 Load Barrier 사용
3. Concurrent Relocation (동시 재배치)
- 애플리케이션 실행 중 객체 이동
- Load Barrier가 이동된 객체를 즉시 업데이트
설정:
-XX:+UseZGC
-XX:SoftMaxHeapSize=4g // 소프트 힙 제한
-XX:ZCollectionInterval=5 // GC 간격(초)
8.4 Shenandoah GC
Shenandoah GC 특징
==================
ZGC와 유사한 목표: 매우 짧은 STW
핵심 차이점:
- ZGC: Colored Pointers + Load Barriers
- Shenandoah: Brooks Pointers + Read/Write Barriers
Brooks Pointer:
+-------------------+
| Brooks Pointer | --> 실제 객체 위치 (자기 자신 또는 새 위치)
+-------------------+
| Object Header |
| Object Fields |
+-------------------+
객체 이동 시:
1. 새 위치에 객체 복사
2. 원래 위치의 Brooks Pointer를 새 위치로 업데이트
3. 이후 접근 시 Brooks Pointer를 통해 새 위치로 리다이렉트
설정:
-XX:+UseShenandoahGC
-XX:ShenandoahGCHeuristics=adaptive
8.5 GC 튜닝 가이드
JVM GC 튜닝 실전
=================
1. 힙 크기 설정
-Xms4g -Xmx4g // 초기/최대 힙 크기를 동일하게
-XX:NewRatio=2 // Old:Young = 2:1
-XX:SurvivorRatio=8 // Eden:S0:S1 = 8:1:1
2. GC 선택 가이드
- 힙 크기가 작고 단순: Serial GC
- 처리량 최우선: Parallel GC
- 응답 시간이 중요한 서비스: G1 GC
- 대용량 힙, 초저지연: ZGC 또는 Shenandoah
3. GC 로그 분석
-Xlog:gc*:file=gc.log:time,uptime,level,tags
로그 확인 포인트:
- GC 빈도와 소요 시간
- Young GC와 Old GC 비율
- Allocation Rate vs Promotion Rate
- Full GC 발생 여부 (발생하면 문제!)
4. 주의사항
- Full GC가 반복되면 메모리 누수 의심
- Promotion Rate가 높으면 Young 크기 증가 고려
- Humongous 할당이 많으면 Region 크기 증가
9. Python GC
9.1 Reference Counting + Generational Cycle Collector
Python GC 이중 전략
===================
1차: Reference Counting (기본)
- 모든 객체에 참조 카운트 유지
- 카운트가 0이 되면 즉시 해제
- CPython의 핵심 메모리 관리
2차: Generational Cycle Collector (순환 참조 처리)
- 3개 세대: Generation 0, 1, 2
- 새 객체는 Gen 0에 할당
- 살아남으면 다음 세대로 승격
수집 빈도:
Gen 0: 매우 자주 (700개 할당마다)
Gen 1: 가끔 (Gen 0이 10번 수집될 때마다)
Gen 2: 드물게 (Gen 1이 10번 수집될 때마다)
# Python 순환 참조 예시
import gc
class Node:
def __init__(self, value):
self.value = value
self.next = None
# 순환 참조 생성
a = Node(1)
b = Node(2)
a.next = b # a -> b
b.next = a # b -> a (순환!)
# Reference Counting으로는 해제 불가
del a
del b
# a_obj.ref_count = 1 (b_obj.next가 참조)
# b_obj.ref_count = 1 (a_obj.next가 참조)
# Cycle Collector가 감지하여 해제
gc.collect() # 강제 순환 참조 수집
# GC 통계 확인
print(gc.get_stats())
# [{'collections': 100, 'collected': 500, 'uncollectable': 0}, ...]
10. Go GC
10.1 Concurrent Tri-color Mark-Sweep
Go GC 특징
==========
- Non-generational (세대별 구분 없음)
- Concurrent tri-color mark-sweep
- 매우 짧은 STW (보통 0.1ms 미만)
- GOGC로 GC 빈도 조절
GC 사이클:
1. Mark Setup (STW): write barrier 활성화 (~0.1ms)
2. Concurrent Mark: 애플리케이션과 동시 실행
3. Mark Termination (STW): 마킹 완료 확인 (~0.1ms)
4. Concurrent Sweep: 백그라운드에서 메모리 회수
Write Barrier:
- 마킹 중 객체 참조 변경을 추적
- Dijkstra-style write barrier 사용
- 새로 참조되는 객체를 gray로 표시
10.2 GOGC 튜닝
// Go GC 튜닝
import "runtime/debug"
// GOGC: 힙 성장 비율 설정 (기본 100)
// 100 = 라이브 힙이 2배가 되면 GC 실행
// 50 = 라이브 힙이 1.5배가 되면 GC 실행
// 200 = 라이브 힙이 3배가 되면 GC 실행
debug.SetGCPercent(100)
// GOMEMLIMIT: 메모리 상한 설정 (Go 1.19+)
// GC가 이 제한을 초과하지 않도록 더 공격적으로 수집
debug.SetMemoryLimit(4 * 1024 * 1024 * 1024) // 4GB
// GC 통계 확인
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Alloc: %d MB\n", stats.Alloc/1024/1024)
fmt.Printf("NumGC: %d\n", stats.NumGC)
fmt.Printf("GCPauseTotal: %v\n",
time.Duration(stats.PauseTotalNs))
11. Rust 소유권 시스템
11.1 GC 없는 메모리 안전
// Rust 소유권 규칙
// 1. 각 값은 하나의 소유자(owner)만 가진다
// 2. 소유자가 스코프를 벗어나면 값이 해제된다 (RAII)
// 3. 소유권은 이동(move)하거나 빌려줄(borrow) 수 있다
fn main() {
let s1 = String::from("hello"); // s1이 소유자
let s2 = s1; // 소유권이 s2로 이동(move)
// println!("{}", s1); // 컴파일 에러! s1은 더 이상 유효하지 않음
println!("{}", s2); // OK
} // s2가 스코프를 벗어남 -> String 메모리 해제
// Stack vs Heap 할당:
fn example() {
let x = 5; // Stack (Copy trait)
let y = x; // Stack 복사 (Copy)
println!("{} {}", x, y); // 둘 다 유효
let s1 = String::from("hello"); // Heap 할당
let s2 = s1; // Move (소유권 이동)
// s1은 더 이상 사용 불가
}
11.2 빌림 (Borrowing)
// 불변 빌림 (Immutable Borrow) - 여러 개 가능
fn calculate_length(s: &String) -> usize {
s.len()
} // s는 참조만 빌렸으므로 원본에 영향 없음
// 가변 빌림 (Mutable Borrow) - 하나만 가능
fn change(s: &mut String) {
s.push_str(" world");
}
fn main() {
let mut s = String::from("hello");
// 불변 빌림: 여러 개 OK
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
// 가변 빌림: 하나만!
let r3 = &mut s;
// let r4 = &mut s; // 컴파일 에러! 동시에 2개의 가변 빌림 불가
r3.push_str("!");
// 핵심 규칙:
// - 불변 참조 N개 OR 가변 참조 1개 (동시에 불가)
// - 참조는 항상 유효해야 함 (dangling reference 방지)
}
11.3 수명 (Lifetime)
// 수명 어노테이션으로 참조의 유효 범위 명시
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
// 'a는 x와 y 중 더 짧은 수명을 따름
fn main() {
let string1 = String::from("long string");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
println!("{}", result); // OK: string2가 아직 유효
}
// println!("{}", result); // 컴파일 에러! string2가 이미 해제됨
}
// RAII (Resource Acquisition Is Initialization)
struct FileWrapper {
file: std::fs::File,
}
impl Drop for FileWrapper {
fn drop(&mut self) {
// 스코프를 벗어날 때 자동으로 호출
// 파일 핸들, 네트워크 연결 등 리소스 해제
println!("File closed automatically!");
}
}
11.4 Rust vs GC 언어 비교
Rust vs GC 언어 메모리 관리 비교
=================================
+------------------+-----------+----------+--------+
| | Rust | Java/Go | C/C++ |
+------------------+-----------+----------+--------+
| 메모리 안전 | 컴파일타임 | 런타임GC | 수동 |
| GC pause | 없음 | 있음 | 없음 |
| 메모리 오버헤드 | 낮음 | 높음 | 낮음 |
| Use-after-free | 불가 | 불가 | 가능! |
| Double free | 불가 | 불가 | 가능! |
| Memory leak | 가능* | 가능* | 가능 |
| 학습 곡선 | 매우높음 | 낮음 | 높음 |
+------------------+-----------+----------+--------+
* Rust에서도 Rc + RefCell 순환 참조,
명시적 mem::forget 등으로 메모리 누수 가능
12. 메모리 누수 탐지와 디버깅
12.1 JavaScript 메모리 누수 패턴
// 패턴 1: 제거되지 않은 이벤트 리스너
class Component {
constructor() {
// 누수! 컴포넌트 제거 시 리스너가 남음
window.addEventListener('resize', this.handleResize);
}
handleResize = () => {
// this 참조로 인해 Component가 GC 안 됨
}
// 해결: cleanup 메서드
destroy() {
window.removeEventListener('resize', this.handleResize);
}
}
// 패턴 2: 클로저가 외부 변수를 캡처
function createLeak() {
const hugeData = new Array(1000000).fill('x');
// 이 함수가 살아있는 한 hugeData도 해제 안 됨
return function() {
console.log(hugeData.length);
};
}
const leakyFn = createLeak();
// hugeData가 계속 메모리에 유지됨
// 패턴 3: 전역 캐시 무한 증가
const cache = {};
function addToCache(key, value) {
cache[key] = value; // 캐시 크기 제한 없음!
}
// 해결: WeakMap 또는 LRU Cache 사용
const weakCache = new WeakMap();
// 키 객체가 GC되면 엔트리도 자동 제거
12.2 Chrome DevTools Heap Snapshot
Chrome DevTools 메모리 분석
============================
1. Heap Snapshot 촬영
DevTools -> Memory -> Take Heap Snapshot
2. 비교 분석 (3 Snapshot Technique)
a) 앱 초기 상태에서 Snapshot 1 촬영
b) 의심되는 작업 수행
c) Snapshot 2 촬영
d) 같은 작업 반복
e) Snapshot 3 촬영
f) Snapshot 2와 3의 차이 분석
-> 반복할수록 증가하는 객체 = 누수!
3. Allocation Timeline
DevTools -> Memory -> Allocation instrumentation on timeline
- 시간에 따른 메모리 할당 패턴 시각화
- 파란 막대: 할당 후 아직 살아있음
- 회색 막대: 할당 후 GC됨
4. Retainers 확인
특정 객체를 선택하면:
- Distance: GC Root에서의 거리
- Retained Size: 이 객체가 GC되면 해제될 총 크기
- Retainers: 이 객체를 참조하고 있는 체인
12.3 Node.js 메모리 디버깅
// Node.js 메모리 모니터링
const v8 = require('v8');
const process = require('process');
// 힙 통계
function logMemory() {
const heap = v8.getHeapStatistics();
console.log({
total_heap_size: `${(heap.total_heap_size / 1024 / 1024).toFixed(2)} MB`,
used_heap_size: `${(heap.used_heap_size / 1024 / 1024).toFixed(2)} MB`,
heap_size_limit: `${(heap.heap_size_limit / 1024 / 1024).toFixed(2)} MB`,
external_memory: `${(heap.external_memory / 1024 / 1024).toFixed(2)} MB`
});
}
setInterval(logMemory, 5000);
// --inspect 플래그로 실행하여 Chrome DevTools 연결
// node --inspect app.js
// chrome://inspect 에서 연결
// Heap Snapshot을 코드에서 생성
const { writeHeapSnapshot } = require('v8');
// 특정 조건에서 스냅샷 촬영
if (process.memoryUsage().heapUsed > 500 * 1024 * 1024) {
writeHeapSnapshot();
console.log('Heap snapshot written!');
}
12.4 JVM 메모리 디버깅
# jmap: 힙 덤프 생성
jmap -dump:format=b,file=heap_dump.hprof <PID>
# jstat: GC 통계 실시간 모니터링
jstat -gcutil <PID> 1000
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 45.23 67.89 34.56 97.12 95.00 150 1.234 3 0.567 1.801
# 컬럼 의미:
# S0/S1: Survivor Space 사용률
# E: Eden Space 사용률
# O: Old Generation 사용률
# YGC/YGCT: Young GC 횟수/시간
# FGC/FGCT: Full GC 횟수/시간 (FGC가 증가하면 문제!)
VisualVM / Eclipse MAT 분석
============================
1. Heap Dump 열기 (hprof 파일)
2. Dominator Tree 확인
- 가장 많은 메모리를 보유한 객체 찾기
- Shallow Size: 객체 자체 크기
- Retained Size: 객체 + 이 객체만 참조하는 모든 객체 크기
3. Leak Suspects Report
- 자동으로 의심스러운 패턴 탐지
- "1 instance of X loaded by Y occupies Z bytes"
4. OQL (Object Query Language)
SELECT * FROM java.util.HashMap WHERE size > 10000
-> 비정상적으로 큰 컬렉션 찾기
13. 흔한 메모리 누수 패턴 종합
13.1 언어별 주요 누수 원인
메모리 누수 주요 원인
====================
JavaScript/Node.js:
1. 전역 변수에 데이터 축적
2. 제거되지 않은 이벤트 리스너
3. setInterval/setTimeout 미정리
4. 클로저가 큰 객체를 캡처
5. Detached DOM 노드 (DOM에서 제거되었지만 JS에서 참조)
Java:
1. static 컬렉션에 무한 축적
2. ThreadLocal 미정리
3. 커넥션/스트림 미닫기
4. 커스텀 ClassLoader 누수
5. 리스너/콜백 미해제
Python:
1. 순환 참조 (gc.collect()로 해결 가능)
2. __del__ 메서드가 있는 순환 참조 (gc가 수거 못 할 수 있음)
3. C 확장 모듈의 메모리 관리 오류
4. 전역 딕셔너리 무한 성장
Go:
1. goroutine 누수 (종료되지 않는 goroutine)
2. time.After 반복 사용 (timer 누적)
3. 슬라이스의 기저 배열 참조 유지
4. sync.Pool 오용
13.2 Detached DOM 노드 (JavaScript)
// Detached DOM 누수 예시
let detachedNodes = [];
function createLeak() {
for (let i = 0; i < 100; i++) {
const div = document.createElement('div');
div.innerHTML = 'content '.repeat(1000);
document.body.appendChild(div);
// DOM에서 제거하지만 JS 배열에서 참조 유지!
document.body.removeChild(div);
detachedNodes.push(div); // 누수!
}
}
// 해결: 참조도 함께 제거
function cleanup() {
detachedNodes = [];
// 또는 WeakRef 사용
}
13.3 Goroutine 누수 (Go)
// goroutine 누수 예시
func leakyFunction() {
ch := make(chan int)
go func() {
val := <-ch // 이 채널에 아무도 보내지 않으면 영원히 대기!
fmt.Println(val)
}()
// ch에 값을 보내지 않고 함수 종료
// goroutine은 영원히 살아있음 -> 누수!
}
// 해결: context를 사용한 취소
func properFunction(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // context 취소 시 goroutine 종료
}
}()
}
14. 프로파일링 도구 비교
메모리 프로파일링 도구 비교
============================
JavaScript/Node.js:
+----------------------------+--------------------+
| 도구 | 용도 |
+----------------------------+--------------------+
| Chrome DevTools Memory | 브라우저 힙 분석 |
| node --inspect | Node.js 원격 디버깅|
| clinic.js | 성능/메모리 분석 |
| heapdump 패키지 | 프로그래밍 방식 |
+----------------------------+--------------------+
Java:
+----------------------------+--------------------+
| VisualVM | 범용 프로파일링 |
| Eclipse MAT | 힙 덤프 분석 |
| JFR (Flight Recorder) | 저오버헤드 프로파일|
| async-profiler | CPU+힙 프로파일 |
| jmap/jhat | 커맨드라인 분석 |
+----------------------------+--------------------+
Go:
+----------------------------+--------------------+
| pprof | 내장 프로파일러 |
| runtime.ReadMemStats | 런타임 통계 |
| go tool trace | 실행 추적 |
+----------------------------+--------------------+
Rust:
+----------------------------+--------------------+
| Valgrind (Massif) | 힙 프로파일링 |
| heaptrack | 힙 추적 |
| DHAT | 동적 힙 분석 |
+----------------------------+--------------------+
범용:
+----------------------------+--------------------+
| Valgrind (Memcheck) | 메모리 오류 탐지 |
| AddressSanitizer (ASan) | 빠른 메모리 오류 |
| LeakSanitizer (LSan) | 누수 전용 탐지 |
+----------------------------+--------------------+
15. 퀴즈
메모리 관리와 가비지 컬렉션에 대한 이해를 점검해 보자.
Q1. Mark-and-Sweep GC가 Reference Counting보다 순환 참조 처리에 유리한 이유는?
A: Reference Counting은 각 객체의 참조 수를 추적하므로, A와 B가 서로를 참조하면 둘 다 참조 카운트가 1 이상으로 유지되어 영원히 해제되지 않는다. 반면 Mark-and-Sweep은 GC Root(스택, 전역 변수)에서 도달 가능한 객체만 마킹하므로, 순환 참조가 있더라도 GC Root에서 도달할 수 없으면 마킹되지 않아 수거된다. 즉 "도달 가능성(reachability)" 기반이므로 순환 참조와 무관하게 정확히 사용 중인 객체만 식별한다.
Q2. JVM에서 Full GC가 자주 발생하는 원인과 해결 방법은?
A: Full GC가 자주 발생하는 주요 원인은 다음과 같다. (1) Old Generation이 너무 작아서 자주 가득 참. 힙 크기(-Xmx)를 늘리거나 Old 비율을 조정한다. (2) 메모리 누수로 Old Gen에 객체가 계속 쌓임. 힙 덤프를 분석하여 누수 원인을 찾아야 한다. (3) Young Generation이 너무 작아서 객체가 조기 승격됨. -XX:NewRatio나 -XX:NewSize를 조정한다. (4) Humongous 할당이 잦아 Old Gen을 빠르게 채움(G1). Region 크기를 늘린다. jstat으로 GC 패턴을 모니터링하고, 힙 덤프로 누수를 분석하는 것이 핵심이다.
Q3. Rust의 소유권 시스템에서 동시에 가변 빌림을 2개 허용하지 않는 이유는?
A: 데이터 레이스(data race)를 컴파일 타임에 방지하기 위해서다. 데이터 레이스는 두 개 이상의 포인터가 동시에 같은 데이터에 접근하고, 그 중 하나 이상이 쓰기를 수행하며, 접근이 동기화되지 않을 때 발생한다. Rust는 "불변 참조 여러 개 OR 가변 참조 하나"라는 규칙으로 이 세 조건 중 하나를 구조적으로 제거한다. 가변 빌림이 하나뿐이면 다른 코드가 동시에 같은 데이터를 읽거나 쓸 수 없으므로, GC 없이도 메모리 안전성과 스레드 안전성을 동시에 보장한다.
Q4. V8의 Incremental Marking이 기존 Mark-and-Sweep보다 나은 점은?
A: 기존 Mark-and-Sweep은 마킹 단계 전체를 한 번에 수행하므로, 힙이 크면 수백 ms의 Stop-the-World(STW) 일시 정지가 발생한다. 이는 60fps 렌더링(프레임당 16.6ms)에서 심각한 버벅임을 유발한다. Incremental Marking은 마킹 작업을 작은 단위로 분할하여 애플리케이션 실행과 번갈아 수행한다. 각 마킹 단위는 짧은 시간만 차지하므로 사용자가 인지하는 일시 정지가 크게 줄어든다. Tri-color marking을 기반으로 중단/재개가 가능하며, write barrier를 통해 마킹 중 변경된 참조도 정확히 추적한다.
Q5. Chrome DevTools의 3-Snapshot Technique로 메모리 누수를 탐지하는 과정을 설명하시오.
A: (1) 앱의 초기 상태에서 첫 번째 힙 스냅샷을 촬영한다. (2) 누수가 의심되는 작업(예: 페이지 이동 후 돌아오기)을 수행한다. (3) 두 번째 힙 스냅샷을 촬영한다. (4) 같은 작업을 다시 반복한다. (5) 세 번째 힙 스냅샷을 촬영한다. (6) 스냅샷 2와 3을 비교하여, 작업을 반복할 때마다 지속적으로 증가하는 객체를 찾는다. 해당 객체가 누수의 원인이다. Retainers 패널에서 어떤 참조 체인이 해당 객체를 유지하고 있는지 확인하면 누수의 근본 원인을 파악할 수 있다.
16. 참고 자료
- Computer Systems: A Programmer's Perspective - Bryant & O'Hallaron (3rd Edition)
- The Garbage Collection Handbook - Jones, Hosking, Moss (2nd Edition, 2023)
- V8 Blog: Orinoco - https://v8.dev/blog/trash-talk
- JVM GC Tuning Guide - https://docs.oracle.com/en/java/javase/21/gctuning/
- The Rust Programming Language - https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- Go GC Guide - https://tip.golang.org/doc/gc-guide
- Chrome DevTools Memory - https://developer.chrome.com/docs/devtools/memory-problems/
- ZGC Documentation - https://wiki.openjdk.org/display/zgc
- jemalloc - https://jemalloc.net/
- Python GC Documentation - https://docs.python.org/3/library/gc.html
- Understanding V8 Memory Structure - https://deepu.tech/memory-management-in-v8/
- Shenandoah GC - https://wiki.openjdk.org/display/shenandoah
- Valgrind Quick Start - https://valgrind.org/docs/manual/quick-start.html
현재 단락 (1/874)
메모리 관리는 모든 프로그래밍 언어의 핵심 기반이다. GC(가비지 컬렉션)가 자동으로 메모리를 관리해 준다고 해도, 내부 동작을 이해해야 성능 문제를 진단하고 메모리 누수를 해결할...