메모리 관리 전략: 수동 · 참조 카운팅 · 추적 GC
목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용
이 챕터를 마치면: (1) 세 가지 회수 전략의 트레이드오프를 설명하고, (2) 참조 카운팅의 비용과 순환 문제·해법을 깊이 알고, (3) 추적 GC의 mark-sweep·삼색 추상·복사·세대별 기법을 설명하고, (4) "지연 vs 처리량" 관점에서 어떤 전략이 어디에 맞는지 판단할 수 있다.
0. 학습 지도 (105분)
| 구간 | 분 | 내용 |
|---|---|---|
| 1 | 10 | 근본 질문: "무엇이 쓰레기인가"를 누가 정하나 |
| 2 | 15 | 수동 관리와 RAII |
| 3 | 25 | 참조 카운팅: 비용·순환·해법 |
| 4 | 35 | 추적 GC: mark-sweep·삼색·복사·세대별 |
| 5 | 12 | 비교와 선택(지연 vs 처리량) |
| 6 | 8 | RC와 추적 GC의 이중성 |
| 복습 | 15 | 인출 |
1. 근본 질문 — "이건 더 이상 안 쓴다"를 누가, 어떻게 아는가
힙에 올린 객체는 언젠가 회수해야 한다. 안 그러면 누수로 메모리가 마른다. 그런데 "이 객체는 이제 아무도 안 쓴다"를 누가 판단하고, 언제, 어떻게 회수하느냐가 언어/런타임을 가르는 결정적 설계다. 답은 셋이다.
- 수동(manual): 개발자가 직접
free. (C) - 참조 카운팅(RC): 객체마다 참조 수를 세어 0이면 즉시 회수. (C++
shared_ptr, CPython, ObjC/Swift ARC) - 추적 GC(tracing): 런타임이 주기적으로 "도달 가능한" 객체를 찾고 나머지를 회수. (Java, Go, JS, C#, …)
이 세 답의 트레이드오프가 이 세션의 전부다.
2. 수동 관리와 RAII
2.1 수동 관리
개발자가 소유권(ownership)을 머릿속으로 추적하며 malloc/free를 짝지운다.
- 장점: 완전한 제어, 예측 가능한 시점, 런타임 오버헤드 없음. 언제 해제되는지 정확히 안다.
- 단점: 인간이 실수한다. 누수(free 누락), 이중 해제, use-after-free(세션 9). 소유권이 함수·스레드를 넘나들면 추적이 급격히 어려워진다.
2.2 RAII — 수동의 자동화(반자동)
C++의 RAII(Resource Acquisition Is Initialization) 는 "자원의 수명을 객체의 수명에 묶는" 기법이다. 객체가 스코프를 벗어나면 소멸자(destructor) 가 자동 호출되어 자원을 해제한다. 스택 객체의 결정적 소멸(세션 3)을 이용하므로 누수·이중 해제를 구조적으로 줄인다. std::unique_ptr(단독 소유), std::shared_ptr(공유 소유=참조 카운팅)이 이 위에 선다. 즉 C++의 "스마트 포인터"는 수동과 자동 사이의 다리다.
3. 참조 카운팅(RC) — 세어서 회수한다
3.1 메커니즘
각 객체에 참조 카운트를 둔다. 새 참조가 생기면 +1, 사라지면 −1, 0이 되는 순간 즉시 회수한다. 회수할 때 그 객체가 가리키던 자식들의 카운트도 −1 하여 연쇄적으로 회수가 일어날 수 있다.
A → obj (count=1)
B도 obj 참조 → count=2
A 손뗌 → count=1
B 손뗌 → count=0 → 즉시 해제 (obj의 자식들도 -1)
3.2 장점
- 결정적·즉시적: 마지막 참조가 사라지는 즉시 회수된다. GC 같은 "언젠가"가 아니다. 소멸 시점이 명확해 RAII·파일/락 같은 비메모리 자원 관리에도 적합.
- 점진적: 회수가 프로그램 실행에 분산되어, 큰 일시 정지가 없다(원칙적으로).
3.3 비용 ① — 카운트 갱신, 특히 멀티스레드
참조를 대입할 때마다 카운트를 증감해야 한다. 단일 스레드면 싸지만, 여러 스레드가 같은 객체를 공유하면 카운트 증감이 원자적 연산(atomic) 이어야 한다 — 이건 일반 덧셈보다 훨씬 비싸다. 게다가 여러 코어가 같은 카운트(같은 캐시 라인)를 두드리면 캐시 라인이 코어 사이를 핑퐁한다(세션 10의 일관성 비용). 그래서 공유 객체가 많은 멀티스레드 프로그램에서 RC는 의외로 비쌀 수 있다.
3.4 비용 ②(치명적) — 순환 참조
RC의 근본 약점. 두 객체가 서로를 가리키면, 바깥에서 아무도 안 써도 서로 때문에 카운트가 0이 되지 않아 영원히 회수되지 않는다.
A.next → B, B.prev → A
바깥 참조가 모두 사라져도: A.count = 1 (B가 가리킴), B.count = 1 (A가 가리킴)
→ 둘 다 0이 안 됨 → 누수
이건 트리·그래프·관찰자 패턴·클로저 캡처 등에서 흔히 생긴다.
3.5 순환 해법
- 약한 참조(weak reference): 카운트를 올리지 않는 참조. 순환의 한쪽을 약한 참조로 만들면(예: 자식→부모를 weak), 강한 참조 사이클이 끊겨 회수된다. 단, 약한 참조는 대상이 이미 죽었을 수 있어 접근 전 살아있는지 확인해야 한다.
- 사이클 수집기(cycle collector): RC를 기본으로 쓰되, 주기적으로 "순환 쓰레기"만 찾아 회수하는 보조 추적기를 둔다. CPython이 정확히 이 방식(참조 카운팅 + 주기적 사이클 GC)이다.
4. 추적 GC — 찾아서 회수한다
RC가 "안 쓰는 걸 센다"면, 추적 GC는 정반대다 — "쓰는 걸 찾고 나머지를 버린다."
4.1 도달 가능성(reachability)
GC의 정의: 루트(roots)에서 참조를 따라 도달할 수 있는 객체는 살아 있고, 도달 못 하는 객체는 쓰레기다. 루트는 "지금 프로그램이 직접 손에 쥔 것" — 전역 변수, 각 스레드 스택의 지역 변수, 레지스터다. 루트에서 출발해 객체 그래프를 따라가며 살아 있는 것을 가려낸다. 순환이라도 루트에서 도달 못 하면 쓰레기로 잡힌다(RC가 못 푸는 걸 자동으로 푼다).
4.2 Mark-Sweep — 기본형
1. Mark: 루트에서 시작해 도달 가능한 모든 객체를 표시(그래프 순회: DFS/BFS).
2. Sweep: 힙 전체를 훑으며 표시 안 된 객체를 회수, 표시는 다음을 위해 지운다.
루트 → A → B (도달 가능: 표시)
C (아무도 안 가리킴: 미표시 → 회수)
D ↔ E (서로만 가리키고 루트서 도달 불가 → 회수!) ← 순환 자동 처리
- 장점: 순환을 자동 처리, 개발자가 해제를 신경 안 씀.
- 단점: 회수된 자리들이 힙 곳곳에 흩어져 외부 단편화가 생긴다(세션 7). 그리고 마크/스윕 동안 보통 프로그램을 멈춘다(STW).
4.3 삼색 추상(tri-color) — 점진/동시 GC의 언어
마크 과정을 세 색으로 모델링하면 점진적·동시적 GC를 정확히 다룰 수 있다.
- 흰색(white): 아직 도달 못 함(쓰레기 후보).
- 회색(gray): 도달했지만 그 자식들은 아직 다 안 봄.
- 검은색(black): 도달했고 자식들도 다 처리함.
마크는 "회색이 없어질 때까지 회색을 검게(자식을 회색으로) 바꾸는" 과정이다. 끝나면 검은색=살아있음, 흰색=쓰레기. 이 추상의 핵심 불변식은 "검은 객체가 흰 객체를 직접 가리켜선 안 된다" — 이를 어기면 살아있는 객체를 쓰레기로 오인할 수 있다. 동시 GC에서 프로그램이 마크 도중 포인터를 바꾸면 이 불변식이 깨질 수 있어, 쓰기 장벽(write barrier) 으로 변경을 가로채 보정한다.
4.4 단편화를 푸는 두 갈래
- Mark-Compact(압축): 마크 후, 살아있는 객체들을 한쪽으로 밀어 모은다. 단편화가 사라지고 이후 할당이 단순해지지만, 객체 이동 비용이 든다(참조도 갱신).
- 복사(copying) GC: 힙을 두 공간(반반)으로 나눠, 살아있는 객체를 반대 공간으로 복사하며 모으고 옛 공간을 통째로 비운다(Cheney 알고리즘). 장점: 할당이 포인터 하나 밀기(bump pointer) 로 초고속, 단편화 없음. 단점: 메모리의 절반을 항상 비워둬야 한다.
4.5 세대별 GC(generational) — 실전의 주류
약한 세대 가설(weak generational hypothesis): "대부분의 객체는 금방 죽는다(young die young)." 실제로 임시 객체가 압도적으로 많고 곧 버려진다.
여기에 베팅해 힙을 세대로 나눈다.
- 젊은 세대(young / nursery): 새 객체가 태어나는 작은 공간. 자주, 빠르게 수집(minor GC). 대부분 금방 죽으니 살아남는 소수만 옮기면 돼 효율적(복사 GC와 궁합).
- 늙은 세대(old / tenured): 젊은 세대에서 여러 번 살아남은 객체가 승격. 드물게 수집(major GC).
문제: 젊은 세대만 수집할 때, 늙은 객체가 젊은 객체를 가리키는 참조(old→young)를 놓치면 살아있는 젊은 객체를 쓰레기로 오인한다. 그래서 그런 참조를 만들 때 쓰기 장벽으로 기록해두는 기억 집합(remembered set) / 카드 테이블(card table) 을 쓴다. 이로써 젊은 세대 수집 시 전체 힙을 안 훑고도 정확히 처리한다.
4.6 비용 — STW, 처리량 vs 지연
- STW(stop-the-world) 정지: 수집 동안 애플리케이션 스레드가 멈춰 그 순간 응답이 정지한다. 평균 처리량(throughput)은 좋아도 최악 지연(tail latency) 이 튀어 실시간성에 불리하다.
- 처리량 vs 지연 트레이드오프: 자주 조금씩 수집하면 지연은 짧아지나 총 오버헤드가 늘고, 드물게 크게 수집하면 처리량은 좋아도 한 번의 정지가 길어진다.
- 메모리 헤드룸: GC는 여유 공간이 있어야 효율적이라 RSS가 더 든다.
- 동시/점진 GC(concurrent/incremental): 마크/스윕의 상당 부분을 애플리케이션과 동시에 돌려 정지를 잘게 쪼갠다(쓰기 장벽으로 정확성 유지). 현대 런타임(예: Go의 동시 마크-스윕, JVM의 G1/ZGC류)이 이 방향으로 정지 시간을 ms 이하로 줄였다.
5. 비교와 선택
| 수동 | 참조 카운팅 | 추적 GC | |
|---|---|---|---|
| 회수 시점 | 개발자 지정 | 카운트 0 즉시 | 수집기가 주기적으로 |
| 결정성 | 높음 | 높음 | 낮음 |
| 순환 참조 | 개발자 책임 | 누수(보완 필요) | 자동 처리 |
| 일시 정지 | 없음 | 작음(연쇄 해제 시 가끔 큼) | 있음(STW, 완화 가능) |
| 처리량 | 최고 | 중간(카운트 갱신) | 높음(세대별) |
| 주된 비용 | 인간 실수 | 카운트·atomic·라인 핑퐁 | 정지·메모리 헤드룸 |
| 대표 | C | shared_ptr, CPython, ARC | Java, Go, JS, C# |
선택 직관:
- 지연이 절대적(실시간·임베디드·게임 프레임): 수동 또는 RC(또는 정지가 짧은 GC + 튜닝).
- 처리량·개발 편의 우선(서버·앱): 세대별 추적 GC.
- 비메모리 자원의 결정적 해제 필요(파일·소켓·락): RAII/RC가 자연스럽다(GC는 finalizer 시점이 불확실).
6. RC와 추적 GC의 이중성
마지막 통찰: RC와 추적 GC는 같은 문제의 두 얼굴이다. RC는 "참조를 셈"으로 죽음을 즉시 알지만 순환을 못 본다. 추적 GC는 "도달 가능성을 봄"으로 순환까지 처리하지만 즉시성을 잃는다. 그래서 현실의 고성능 런타임은 둘을 섞는다 — 예: RC를 기본으로 쓰되 사이클 수집기로 순환을 처리(CPython), 또는 세대별 추적 GC에 RC 비슷한 최적화를 가미. "세는 것"과 "찾는 것" 사이의 선택과 혼합이 메모리 관리 설계의 본질이다.
7. 흔한 오해 바로잡기
- ❌ "ARC/참조 카운팅도 GC다(다 같다)." → RC와 추적 GC는 원리가 정반대다. RC는 세고, GC는 도달 가능성을 찾는다. RC는 순환을 자동으로 못 푼다.
- ❌ "GC가 있으면 메모리 누수가 없다." → 도달 가능하지만 논리적으로 안 쓰는 객체(예: 안 비우는 캐시·전역 컬렉션에 쌓인 참조)는 GC가 회수 못 한다. 누수는 여전히 가능.
- ❌ "GC는 항상 느리다." → 세대별·동시 GC는 처리량이 높고 정지도 ms 이하로 줄었다. 다만 지연 분포(tail)와 메모리 헤드룸이 비용.
- ❌ "참조 카운팅은 항상 싸다." → 멀티스레드 공유 객체에선 atomic 증감과 캐시 라인 핑퐁으로 비쌀 수 있다.
- ❌ "finalizer로 파일을 닫으면 된다." → GC 회수 시점이 불확실해 자원이 늦게 풀린다. 결정적 해제는 RAII/명시적 close가 맞다.
8. 한 장 정리
- 회수 전략은 수동 / 참조 카운팅 / 추적 GC — "누가 쓰레기를 판단하나"의 세 답.
- RC: 세서 즉시 회수(결정적), 그러나 순환에 약함(약한 참조·사이클 수집기로 보완)·멀티스레드 atomic 비용.
- 추적 GC: 루트에서 도달 가능성으로 찾아 순환까지 처리. mark-sweep → 삼색(동시 GC) → 복사/압축(단편화 해소) → 세대별(약한 세대 가설 + 쓰기 장벽/기억 집합)이 실전 주류.
- 비용 축: 결정성·지연(RC/수동) vs 처리량·편의(GC). 비메모리 자원은 RAII가 자연스럽다.
- RC와 추적 GC는 이중성 — 세는 것 vs 찾는 것, 현실은 혼합.
9. 복습 (15분) — 답을 가리고
Q1. RC가 자동으로 못 푸는 문제와 두 가지 보완책은?
순환 참조. 서로 가리키면 카운트가 0이 안 돼 회수 못 함. 보완: ① 약한 참조(카운트 안 올림)로 순환의 한쪽을 끊기, ② 사이클 수집기(주기적 추적)로 순환 쓰레기만 회수(CPython 방식).
Q2. 추적 GC의 "도달 가능성"과 루트를 설명하라.
루트(전역·각 스레드 스택의 지역변수·레지스터)에서 참조를 따라 도달 가능한 객체는 살아있고, 도달 못 하면 쓰레기. 순환이라도 루트에서 못 닿으면 회수된다.
Q3. 삼색 추상의 세 색과, 동시 GC가 지켜야 하는 불변식은?
흰(미도달=쓰레기 후보), 회(도달했으나 자식 미처리), 검(도달+자식 처리 완료). 불변식: 검은 객체가 흰 객체를 직접 가리켜선 안 된다. 동시 변경은 쓰기 장벽으로 보정.
Q4. 복사(copying) GC의 장단점은?
장점: 할당이 포인터 밀기(bump)로 초고속, 단편화 없음, 살아있는 것만 복사. 단점: 힙의 절반을 항상 비워둬야 함.
Q5. 세대별 GC의 가설과, old→young 참조를 처리하는 장치는?
약한 세대 가설("대부분 금방 죽는다"). 젊은 세대만 수집할 때 늙은→젊은 참조를 놓치지 않도록, 쓰기 장벽으로 그 참조를 기억 집합/카드 테이블에 기록해둔다.
Q6. STW가 처리량은 좋아도 문제가 되는 이유와 완화책은?
수집 동안 앱이 멈춰 최악 지연(tail latency)이 튄다. 동시/점진 GC로 마크·스윕을 앱과 함께 돌려 정지를 잘게 쪼개 완화한다.
Q7. "GC가 있으면 누수가 없다"가 틀린 이유는?
도달 가능하지만 논리적으로 안 쓰는 객체(안 비우는 캐시, 전역 컬렉션에 쌓인 참조 등)는 GC가 살아있다고 보고 회수하지 않는다. 의미적 누수는 여전히 가능.
다음 배치: 세션 9~10 (메모리 버그와 도구 · 동시성과 메모리 + 종합). 이후 전체를 합본 HTML 교재로 묶습니다.