🧠 메모리 교재 · 08_메모리관리와GC

메모리 관리 전략: 수동 · 참조 카운팅 · 추적 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. 근본 질문 — "이건 더 이상 안 쓴다"를 누가, 어떻게 아는가

힙에 올린 객체는 언젠가 회수해야 한다. 안 그러면 누수로 메모리가 마른다. 그런데 "이 객체는 이제 아무도 안 쓴다"를 누가 판단하고, 언제, 어떻게 회수하느냐가 언어/런타임을 가르는 결정적 설계다. 답은 셋이다.

  1. 수동(manual): 개발자가 직접 free. (C)
  2. 참조 카운팅(RC): 객체마다 참조 수를 세어 0이면 즉시 회수. (C++ shared_ptr, CPython, ObjC/Swift ARC)
  3. 추적 GC(tracing): 런타임이 주기적으로 "도달 가능한" 객체를 찾고 나머지를 회수. (Java, Go, JS, C#, …)

이 세 답의 트레이드오프가 이 세션의 전부다.


2. 수동 관리와 RAII

2.1 수동 관리

개발자가 소유권(ownership)을 머릿속으로 추적하며 malloc/free를 짝지운다.

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 장점

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 순환 해법


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       (서로만 가리키고 루트서 도달 불가 → 회수!)  ← 순환 자동 처리

4.3 삼색 추상(tri-color) — 점진/동시 GC의 언어

마크 과정을 세 색으로 모델링하면 점진적·동시적 GC를 정확히 다룰 수 있다.

마크는 "회색이 없어질 때까지 회색을 검게(자식을 회색으로) 바꾸는" 과정이다. 끝나면 검은색=살아있음, 흰색=쓰레기. 이 추상의 핵심 불변식은 "검은 객체가 흰 객체를 직접 가리켜선 안 된다" — 이를 어기면 살아있는 객체를 쓰레기로 오인할 수 있다. 동시 GC에서 프로그램이 마크 도중 포인터를 바꾸면 이 불변식이 깨질 수 있어, 쓰기 장벽(write barrier) 으로 변경을 가로채 보정한다.

ROOT A B C D E 미도달 → 회수 순환이라도 미도달 → 회수 검=도달(살아있음) 흰=미도달(쓰레기)
도달 가능성 — 루트에서 닿는 A·B는 살아있고(검), C와 순환 D↔E는 루트에서 못 닿아 쓰레기(흰)로 회수. RC가 못 푸는 순환을 추적 GC는 자동 처리.

4.4 단편화를 푸는 두 갈래

4.5 세대별 GC(generational) — 실전의 주류

약한 세대 가설(weak generational hypothesis): "대부분의 객체는 금방 죽는다(young die young)." 실제로 임시 객체가 압도적으로 많고 곧 버려진다.

여기에 베팅해 힙을 세대로 나눈다.

문제: 젊은 세대만 수집할 때, 늙은 객체가 젊은 객체를 가리키는 참조(old→young)를 놓치면 살아있는 젊은 객체를 쓰레기로 오인한다. 그래서 그런 참조를 만들 때 쓰기 장벽으로 기록해두는 기억 집합(remembered set) / 카드 테이블(card table) 을 쓴다. 이로써 젊은 세대 수집 시 전체 힙을 안 훑고도 정확히 처리한다.

4.6 비용 — STW, 처리량 vs 지연


5. 비교와 선택

수동 참조 카운팅 추적 GC
회수 시점 개발자 지정 카운트 0 즉시 수집기가 주기적으로
결정성 높음 높음 낮음
순환 참조 개발자 책임 누수(보완 필요) 자동 처리
일시 정지 없음 작음(연쇄 해제 시 가끔 큼) 있음(STW, 완화 가능)
처리량 최고 중간(카운트 갱신) 높음(세대별)
주된 비용 인간 실수 카운트·atomic·라인 핑퐁 정지·메모리 헤드룸
대표 C shared_ptr, CPython, ARC Java, Go, JS, C#

선택 직관:


6. RC와 추적 GC의 이중성

마지막 통찰: RC와 추적 GC는 같은 문제의 두 얼굴이다. RC는 "참조를 셈"으로 죽음을 즉시 알지만 순환을 못 본다. 추적 GC는 "도달 가능성을 봄"으로 순환까지 처리하지만 즉시성을 잃는다. 그래서 현실의 고성능 런타임은 둘을 섞는다 — 예: RC를 기본으로 쓰되 사이클 수집기로 순환을 처리(CPython), 또는 세대별 추적 GC에 RC 비슷한 최적화를 가미. "세는 것"과 "찾는 것" 사이의 선택과 혼합이 메모리 관리 설계의 본질이다.


7. 흔한 오해 바로잡기


8. 한 장 정리


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 교재로 묶습니다.