한양과학기술고등학교

에듀테크 프로젝트 대시보드

가비지 컬렉션 (Garbage Collection)

가비지 컬렉션 (Garbage Collection)

프로그램이 new 로 만든 객체는 힙(heap)에 쌓인다. 더 이상 아무도 가리키지 않는 객체는 쓰레기(garbage) — 이걸 자동으로 찾아 메모리를 되돌려주는 게 GC다. 핵심 아이디어는 딱 하나: "루트(root)에서 참조를 따라 도달할 수 있으면 살아있고, 못 하면 쓰레기다." 아래에서 직접 객체를 만들고 참조를 끊은 뒤 GC를 돌려 보자.

Mark & Sweep 시뮬레이터

루트 (스택 변수·전역) Mark: 도달 가능 (살아있음) 도달 불가 (쓰레기 후보) Sweep: 회수됨
객체를 할당하고 참조를 이어 보세요. 노드는 드래그로 옮길 수 있습니다.

Mark 단계: 루트에서 BFS/DFS로 참조를 따라가며 만나는 모든 객체에 "살아있음" 표시. Sweep 단계: 힙 전체를 훑어 표시 안 된 객체의 메모리를 해제. 표시는 지워 다음 GC를 준비(unmark). 이 방식은 순환 참조도 문제없이 회수한다 — 루트에서 못 닿으면 서로 가리켜도 쓰레기다.

참조 카운팅과 순환 참조 누수

또 다른 방식은 객체마다 "나를 가리키는 참조 수(refcount)"를 세는 것이다 (CPython의 기본 방식). 참조가 0이 되는 즉시 해제되어 예측 가능하지만, 서로를 가리키는 사이클은 카운트가 0으로 안 떨어져 영원히 누수된다.

버튼을 순서대로 눌러 refcount가 어떻게 변하는지 보세요.

해결책: CPython은 참조 카운팅 + 주기적으로 순환 탐지 GC(세대별 mark & sweep)를 함께 돌린다. Java·Go·자바스크립트(V8)는 참조 카운팅을 안 쓰고 추적형(tracing) GC만 쓴다.

세대별 GC (Generational)

약한 세대 가설: "대부분의 객체는 금방 죽는다." 그래서 힙을 나이대로 나눈다.

영역담기는 객체GC 빈도 / 비용
Young (Eden + Survivor)방금 만든 객체. 임시 변수, 반복문 안에서 만든 리스트 등자주 · 빠름 (Minor GC — 살아남은 소수만 복사)
Old (Tenured)Minor GC를 여러 번 살아남아 승격(promote)된 객체. 캐시, 세션 등드물게 · 느림 (Major/Full GC — 전체 스캔)

젊은 영역만 자주 청소하면 매번 힙 전체를 훑지 않아도 되므로 멈춤 시간(stop-the-world)이 짧아진다. HotSpot JVM의 G1, ZGC, 파이썬의 3세대 GC가 모두 이 아이디어를 쓴다.

GC 알고리즘 비교

참조 카운팅

refcount==0 즉시 해제. 지연 없고 단순. 사이클 누수 + 카운트 갱신 오버헤드 + 멀티스레드에서 원자적 연산 필요.

Mark & Sweep

루트에서 추적해 표시 후 미표시 회수. 사이클 OK. 단점: 청소 중 멈춤, 힙에 구멍(단편화).

Mark-Compact

Sweep 후 살아있는 객체를 한쪽으로 밀어 단편화 제거. 할당이 포인터 이동만으로 끝나 빠름. 이동 비용 큼.

Copying (Stop & Copy)

공간을 반으로 나눠 살아있는 것만 반대편으로 복사. 압축 공짜, 쓰레기 순회 안 함. 메모리 절반만 사용.

Generational

나이별로 영역 분리, 젊은 영역을 자주·빠르게. 실전 JVM/파이썬의 기본. 세대 간 참조 추적(write barrier) 필요.

Tri-color / Concurrent

흰·회·검 3색으로 표시하며 앱과 동시에 마킹해 멈춤을 ms 단위로. Go GC, ZGC. 구현 복잡.

Mark & Sweep — 파이썬 의사코드

# 힙에 있는 모든 객체 목록과, 스택/전역에서 직접 가리키는 roots 목록이 있다고 가정
def gc(heap, roots):
    # ── 1) MARK: 루트에서 도달 가능한 객체 모두 표시 ──
    worklist = list(roots)
    while worklist:
        obj = worklist.pop()
        if not obj.marked:
            obj.marked = True
            worklist.extend(obj.references)   # 이 객체가 가리키는 것들도 큐에

    # ── 2) SWEEP: 표시 안 된 객체 회수, 표시된 객체는 unmark ──
    survivors = []
    for obj in heap:
        if obj.marked:
            obj.marked = False              # 다음 GC를 위해 초기화
            survivors.append(obj)
        else:
            free(obj)                       # 메모리 반환 (도달 불가 = 쓰레기)
    return survivors

# 루트 = 활성 스택 프레임의 지역변수 + 전역변수 + CPU 레지스터
# "도달 가능성(reachability)"이 곧 "살아있음"의 정의다.

정리: ① 살아있음 = 루트에서 도달 가능. ② 참조 카운팅은 빠르지만 사이클을 못 잡는다. ③ 추적형 GC(mark & sweep 계열)는 사이클을 잡지만 멈춤이 생긴다. ④ 세대별·동시적 기법으로 멈춤을 줄인다. ⑤ GC가 있어도 불필요한 참조를 남겨두면(전역 리스트에 계속 append 등) 메모리 누수는 여전히 일어난다.