2026년 3월 31일

가비지 컬렉션: 현대 프로그래밍 언어의 보이지 않는 청소부

110
가비지 컬렉션: 현대 프로그래밍 언어의 보이지 않는 청소부

가비지 컬렉션: 현대 프로그래밍 언어의 보이지 않는 청소부

가비지 컬렉션: 현대 프로그래밍 언어의 보이지 않는 청소부

개념 소개

개념 소개

소프트웨어 개발에서 메모리 관리는 애플리케이션의 성능과 안정성에 직접적인 영향을 미치는 핵심 요소입니다. C나 C++ 같은 저수준 언어에서는 개발자가 malloc, free와 같은 함수를 사용하여 메모리를 직접 할당하고 해제해야 합니다. 이는 강력한 제어권을 제공하지만, 동시에 메모리 누수(Memory Leak)댕글링 포인터(Dangling Pointer)와 같은 심각한 오류를 유발할 수 있는 위험을 안고 있습니다. 작은 실수 하나가 애플리케이션 크래시나 예측 불가능한 동작으로 이어질 수 있죠.

이러한 문제를 해결하고 개발자의 부담을 덜어주기 위해 등장한 개념이 바로 **가비지 컬렉션(Garbage Collection, GC)**입니다. 가비지 컬렉션은 프로그램이 더 이상 사용하지 않는 메모리(이를 "가비지" 또는 "쓰레기"라고 부릅니다)를 자동으로 찾아내어 해제하는 메커니즘입니다. 자바(Java), 파이썬(Python), 자바스크립트(JavaScript), Go 등 대부분의 현대적인 고급 프로그래밍 언어는 가비지 컬렉션을 내장하고 있어, 개발자가 명시적으로 메모리를 관리할 필요 없이 비즈니스 로직에 집중할 수 있게 돕습니다.

가비지 컬렉션이 중요한 이유는 다음과 같습니다.

  1. 개발 생산성 향상: 개발자가 수동 메모리 관리에 신경 쓸 필요 없이 핵심 기능 구현에 집중할 수 있습니다.
  2. 메모리 관련 오류 감소: 메모리 누수, 이중 해제(double-free), 댕글링 포인터 등의 흔한 오류를 자동으로 방지합니다. 이는 애플리케이션의 안정성을 크게 높여줍니다.
  3. 안전성 증대: 해제된 메모리에 접근하여 발생하는 보안 취약점 등을 예방할 수 있습니다.

물론 가비지 컬렉션이 만능은 아닙니다. GC가 동작하는 동안 애플리케이션의 실행이 잠시 멈추는 "Stop-The-World" 현상이나, GC 자체의 오버헤드 때문에 성능 저하가 발생할 수 있습니다. 따라서 GC의 원리를 이해하고 효율적인 코드를 작성하는 것은 초중급 개발자에게 매우 중요한 역량입니다.

핵심 원리 설명

핵심 원리 설명

가비지 컬렉션은 다양한 알고리즘으로 구현될 수 있지만, 가장 보편적인 두 가지 기본 원리는 **참조 카운팅(Reference Counting)**과 **마크 앤 스윕(Mark-and-Sweep)**입니다.

1. 참조 카운팅 (Reference Counting)

비유: 도서관에서 책을 빌려 갈 때마다 대출 카드에 기록하고, 반납할 때마다 기록을 지우는 것과 같습니다. 어떤 책의 대출 기록이 0이 되면, 그 책은 아무도 읽지 않는다고 판단하여 서가에서 제거합니다.

이 방식은 각 객체에 대한 참조(reference) 수를 세어, 참조 수가 0이 되면 해당 객체가 더 이상 사용되지 않는다고 판단하고 즉시 메모리에서 해제합니다. 파이썬이 이 방식을 주된 GC 메커니즘으로 사용합니다.

장점:

  • 메모리 해제가 즉시 이루어져 메모리 사용량이 효율적입니다.
  • 애플리케이션이 잠시 멈추는 "Stop-The-World" 현상이 적습니다.

단점:

  • 순환 참조(Circular Reference) 문제: 두 개 이상의 객체가 서로를 참조하고 있을 경우, 외부에서 더 이상 참조하지 않더라도 각 객체의 참조 카운트는 1 이상이 되어 메모리에서 해제되지 않는 문제가 발생합니다. (예: AB를 참조하고 BA를 참조)
  • 참조 카운트를 업데이트하는 오버헤드가 발생합니다.

2. 마크 앤 스윕 (Mark-and-Sweep)

비유: 청소부가 창고(메모리)에 있는 물건들(객체)을 보면서, 주인(프로그램)이 현재 사용하고 있는 물건들에는 '사용 중'이라는 스티커(Mark)를 붙입니다. 스티커가 붙지 않은 물건들은 버려도 되는 쓰레기(Garbage)로 간주하고 모두 치워버립니다(Sweep).

이 방식은 프로그램이 실행 중인 동안 잠시 멈춘 후, "루트(root)" 객체(예: 전역 변수, 현재 실행 중인 함수의 지역 변수 등)부터 시작하여 도달 가능한 모든 객체를 탐색하며 '사용 중'으로 표시(Mark)합니다. 이 과정이 끝나면, 표시되지 않은 모든 객체(즉, 도달 불가능한 객체)들을 '가비지'로 간주하고 메모리에서 해제(Sweep)합니다. 자바스크립트, 자바, Go 등이 이 방식을 사용합니다.

장점:

  • 순환 참조 문제를 효과적으로 해결할 수 있습니다. 도달 불가능한 객체는 순환 참조를 포함하더라도 모두 해제됩니다.
  • 참조 카운팅에 비해 오버헤드가 적을 수 있습니다.

단점:

  • GC가 동작하는 동안 애플리케이션의 실행이 잠시 멈추는 "Stop-The-World" 현상이 발생할 수 있습니다. 이는 실시간성이 중요한 애플리케이션에서 지연을 유발할 수 있습니다.
  • 메모리 파편화(Fragmentation)가 발생할 수 있습니다. (이를 해결하기 위해 "컴팩션(Compaction)" 단계가 추가되기도 합니다.)

많은 현대 GC는 이 두 가지 기본 원리를 조합하거나, **세대별 GC(Generational GC)**와 같은 최적화 기법을 사용하여 성능을 향상시킵니다. 세대별 GC는 대부분의 객체가 짧은 시간 안에 가비지가 된다는 "약한 세대 가설(Weak Generational Hypothesis)"에 기반하여, 객체를 '젊은 세대(Young Generation)'와 '오래된 세대(Old Generation)'로 나누어 젊은 세대에 더 자주 GC를 수행함으로써 전체적인 GC 시간을 줄입니다.

코드 예제

Python 예제: 참조 카운팅과 순환 참조 해결

파이썬은 기본적으로 참조 카운팅을 사용하며, gc 모듈을 통해 순환 참조를 탐지하고 해제하는 마크 앤 스윕 보완책을 제공합니다.

import sys
import gc

class Node:
    def __init__(self, name):
        self.name = name
        self.next = None
        print(f"Node {self.name} created. Ref count: {sys.getrefcount(self) - 1}") # sys.getrefcount는 자기 자신 참조 포함

    def __del__(self):
        print(f"Node {self.name} deleted.")

def reference_counting_example():
    print("\n--- 참조 카운팅 기본 동작 ---")
    a = Node("A") # a가 Node("A")를 참조
    b = a         # b도 Node("A")를 참조
    c = a         # c도 Node("A")를 참조
    print(f"Node A current ref count: {sys.getrefcount(a) - 1}")

    del b         # b 참조 해제
    print(f"Node A after del b ref count: {sys.getrefcount(a) - 1}")

    del c         # c 참조 해제
    print(f"Node A after del c ref count: {sys.getrefcount(a) - 1}")

    # a가 더 이상 참조되지 않으면 __del__ 호출
    # 이 시점에서 Node A current ref count: 1이 되면서 Node A deleted. 출력 (콘솔 환경에 따라 다를 수 있음)

def circular_reference_example():
    print("\n--- 순환 참조 문제와 GC의 역할 ---")
    gc.collect() # 이전 가비지 수거

    class CircularNode:
        def __init__(self, name):
            self.name = name
            self.peer = None
            print(f"CircularNode {self.name} created.")

        def __del__(self):
            print(f"CircularNode {self.name} deleted.")

    node1 = CircularNode("1")
    node2 = CircularNode("2")

    # 순환 참조 생성
    node1.peer = node2
    node2.peer = node1

    # 이 시점에서 node1과 node2의 참조 카운트는 2 (자기 자신 + peer)
    # 외부에서 node1, node2 변수를 지워도 참조 카운트는 1로 유지되어 해제되지 않음
    # print(f"Node 1 ref count: {sys.getrefcount(node1) - 1}") # 이 시점에선 2 (node1 변수 + node2.peer)

    print("순환 참조가 생성되었습니다. 외부 참조를 제거합니다.")
    del node1
    del node2
    print("외부 참조 제거 완료. 가비지 컬렉터를 강제로 실행합니다.")

    # Python의 GC가 순환 참조를 탐지하여 해제 (마크 앤 스윕 기반)
    collected = gc.collect()
    print(f"Collected {collected} objects.")
    print("만약 CircularNode 1 deleted. / CircularNode 2 deleted. 메시지가 보이지 않았다면 GC가 작동하지 않은 것입니다.")

# gc.disable() # GC를 비활성화하고 순환 참조 예제를 실행하면 __del__이 호출되지 않음
reference_counting_example()
circular_reference_example()

JavaScript 예제: 도달 가능성(Reachability) 기반 GC

자바스크립트는 마크 앤 스윕 알고리즘의 변형인 도달 가능성(Reachability) 개념을 사용합니다. 어떤 객체가 루트(전역 객체, 현재 스택 프레임의 변수 등)로부터 참조 체인을 통해 도달할 수 없다면 가비지로 간주합니다.

// JavaScript 가비지 컬렉션은 명시적으로 제어할 수 없으므로,
// 객체의 생명 주기를 이해하는 방식으로 예제를 작성합니다.

function createObject() {
    let obj = {
        name: "Temporary Object",
        data: Array(100000).fill('large_data_string') // 큰 데이터로 메모리 사용 시뮬레이션
    };
    console.log(`Object '${obj.name}' created.`);
    return obj;
}

function longLivedClosure() {
    let outerVariable = "I am a long-lived outer variable.";
    let largeData = {
        id: 1,
        payload: Array(500000).fill('another_large_string')
    };

    // 이 클로저는 outerVariable과 largeData를 캡처하여 유지합니다.
    // 이 클로저가 살아있는 한 largeData도 GC되지 않습니다.
    return function() {
        console.log(`Closure accessed: ${outerVariable}`);
        // largeData를 직접 사용하지 않아도 클로저 스코프에 캡처되어 GC되지 않음
    };
}

console.log("--- JavaScript GC 기본 동작 ---");
let myObj = createObject(); // myObj는 createObject()가 반환한 객체를 참조
// 이 시점에서 myObj는 '루트'에서 도달 가능합니다.

// myObj에 대한 참조를 제거
myObj = null; // 이제 이전 객체는 '루트'에서 도달 불가능해졌으므로 GC 대상이 됩니다.
console.log("myObj 참조가 제거되었습니다. 이전 객체는 GC 대상이 됩니다.");
// 실제 GC 시점은 JS 엔진에 따라 다름

// --- 클로저와 메모리 ---
console.log("\n--- 클로저와 메모리 누수(잠재적) ---");
let keepClosureAlive = longLivedClosure(); // 클로저를 전역 변수에 할당하여 유지
console.log("longLivedClosure()가 반환한 클로저가 전역 변수에 의해 유지됩니다.");
keepClosureAlive(); // 클로저 실행

// 만약 keepClosureAlive가 더 이상 필요 없어지면 참조를 제거해야 합니다.
// 그렇지 않으면 클로저가 캡처한 largeData도 계속 메모리에 남습니다.
keepClosureAlive = null;
console.log("keepClosureAlive 참조가 제거되었습니다. 이제 클로저와 캡처된 largeData는 GC 대상이 됩니다.");

// 이벤트를 통한 메모리 누수 방지 (DOM 요소를 참조하는 경우)
console.log("\n--- 이벤트 리스너를 통한 메모리 관리 ---");
// 가상의 DOM 요소 생성
const btn = {
    id: "myButton",
    _eventListeners: {},
    addEventListener: function(event, handler) {
        this._eventListeners[event] = handler;
        console.log(`Event listener for ${event} added.`);
    },
    removeEventListener: function(event) {
        delete this._eventListeners[event];
        console.log(`Event listener for ${event} removed.`);
    }
};

let dataToProcess = { value: 123, largePayload: Array(100000).fill('event_data') };

function clickHandler() {
    console.log("Button clicked! Processing data:", dataToProcess.value);
    // dataToProcess를 참조하고 있으므로, 이 핸들러가 살아있는 한 dataToProcess도 GC되지 않음
}

btn.addEventListener('click', clickHandler);
// 만약 btn이 DOM에서 제거되더라도 clickHandler가 btn._eventListeners에 남아있고,
// clickHandler가 dataToProcess를 참조한다면 dataToProcess는 GC되지 않을 수 있습니다.

// 메모리 누수를 방지하려면 이벤트 리스너를 명시적으로 제거해야 합니다.
// 예를 들어, 컴포넌트가 언마운트될 때
btn.removeEventListener('click');
// 이제 clickHandler와 dataToProcess는 더 이상 btn._eventListeners를 통해 도달 불가능하며,
// 만약 다른 곳에서 참조하지 않는다면 GC 대상이 됩니다.
dataToProcess = null; // dataToProcess에 대한 모든 참조 제거
console.log("이벤트 리스너와 데이터 참조가 제거되었습니다.");

실무 적용 사례

가비지 컬렉션은 개발자가 직접 메모리를 관리하지 않아도 되게 해주지만, 그 동작 방식을 이해하는 것은 매우 중요합니다.

  1. 메모리 누수 디버깅: 프로덕션 환경에서 애플리케이션의 메모리 사용량이 지속적으로 증가한다면 메모리 누수를 의심해야 합니다. GC가 특정 객체를 가비지로 인식하지 못하고 계속 유지하는 상황이죠. 파이썬의 gc 모듈, 자바스크립트의 브라우저 개발자 도구 (메모리 탭), 자바의 JVisualVM 같은 도구를 활용하여 어떤 객체가 예상보다 오래 살아남아 있는지 분석할 수 있습니다.
  2. 성능 최적화: GC는 "Stop-The-World" 현상을 유발할 수 있습니다. 특히 대용량 데이터를 처리하거나 실시간성이 중요한 애플리케이션에서는 GC 일시 정지가 사용자 경험에 큰 영향을 줄 수 있습니다.
    • 객체 재사용: 새로운 객체를 빈번하게 생성하기보다는 재사용 가능한 객체 풀(Object Pool)을 활용하여 GC 부하를 줄일 수 있습니다.
    • 데이터 구조 선택: 메모리 효율적인 데이터 구조를 선택하여 전체 객체 수를 줄이는 것도 한 방법입니다.
    • GC 튜닝: JVM 기반 언어에서는 GC 알고리즘(G1, CMS, ZGC 등)과 힙(Heap) 크기를 튜닝하여 GC 성능을 최적화할 수 있습니다.
  3. 대용량 데이터 처리: 빅데이터 처리나 머신러닝 애플리케이션에서는 메모리에 로드되는 데이터의 양이 엄청납니다. 이때 불필요한 객체 참조를 빠르게 해제하여 GC가 효율적으로 메모리를 회수할 수 있도록 코드를 설계하는 것이 중요합니다. 예를 들어, 파이썬에서 대용량 리스트를 처리한 후에는 del 키워드를 사용하거나 None을 할당하여 참조를 명시적으로 끊어주는 것이 좋습니다.

자주 하는 실수와 해결법

  1. 순환 참조 (특히 Python):

    • 실수: 두 객체가 서로를 참조하여 GC가 이를 가비지로 인식하지 못하는 경우.
      class A:
          def __init__(self):
              self.b = None
      class B:
          def __init__(self):
              self.a = None
      
      obj_a = A()
      obj_b = B()
      obj_a.b = obj_b
      obj_b.a = obj_a
      
      del obj_a
      del obj_b # 여전히 메모리에 남아있을 수 있음 (gc.collect() 필요)
      
    • 해결법:
      • 약한 참조(Weak Reference) 사용: weakref 모듈을 사용하여 객체가 다른 객체를 참조하지만, 그 참조가 GC에 의해 객체가 해제되는 것을 막지 않도록 할 수 있습니다. 순환 참조를 끊을 필요가 있는 곳에 사용합니다.
      • 명시적 참조 해제: 더 이상 필요 없는 객체의 참조를 None으로 할당하거나 del 키워드를 사용하여 명시적으로 해제합니다.
      • gc.collect() 호출: 디버깅 시 강제로 GC를 실행하여 순환 참조가 해제되는지 확인할 수 있습니다.
  2. 클로저에 의한 불필요한 객체 유지 (JavaScript):

    • 실수: 클로저가 외부 스코프의 큰 객체를 캡처하고 있는데, 이 클로저 자체가 오랫동안 살아남아 캡처된 객체도 함께 메모리에 유지되는 경우.
      let globalRef = null;
      function createLargeClosure() {
          let largeData = Array(1000000).fill('some_string');
          globalRef = function() { // 이 클로저는 largeData를 캡처
              console.log(largeData.length);
          };
      }
      createLargeClosure(); // globalRef에 클로저 할당
      // globalRef가 null이 되지 않는 한 largeData는 GC되지 않음
      
    • 해결법:
      • 클로저 참조 해제: 클로저가 더 이상 필요 없을 때, 해당 클로저를 참조하는 변수에 null을 할당하여 참조를 끊습니다. (globalRef = null;)
      • 필요한 변수만 캡처: 클로저 내에서 외부 변수를 사용할 때, 필요한 변수만 캡처하도록 스코프를 최소화합니다.
  3. 이벤트 리스너 미제거 (JavaScript):

    • 실수: DOM 요소에 이벤트 리스너를 추가했지만, 해당 DOM 요소가 페이지에서 제거될 때 리스너를 함께 제거하지 않아 리스너와 리스너가 참조하는 데이터가 메모리에 남아있는 경우.
    • 해결법:
      • removeEventListener 사용: 컴포넌트가 언마운트되거나 더 이상 필요 없을 때 removeEventListener를 호출하여 리스너를 명시적으로 제거합니다.
      • AbortController 활용 (최신 JS): 여러 리스너를 한 번에 제거할 수 있는 AbortController를 사용하여 이벤트 리스너 관리를 간소화할 수 있습니다.
  4. 전역 변수/캐시의 부적절한 관리:

    • 실수: 전역 스코프에 대량의 데이터를 저장하거나, 캐시를 무한정 늘려나가면서 메모리를 해제하지 않는 경우.
    • 해결법:
      • 스코프 최소화: 가능한 한 지역 변수를 사용하고, 전역 변수 사용은 최소화합니다.
      • 캐시 정책 구현: LRU(Least Recently Used)와 같은 캐시 제거 정책을 구현하여 캐시 크기를 제한하고, 오래되거나 사용되지 않는 데이터를 자동으로 제거합니다.

더 공부할 리소스 추천

  • MDN Web Docs - Memory Management: 자바스크립트의 메모리 관리와 가비지 컬렉션에 대한 훌륭한 개요를 제공합니다.
  • Python gc Module Documentation: 파이썬의 가비지 컬렉터에 대한 공식 문서입니다.
  • "The Garbage Collection Handbook" by Richard Jones, Antony Hosking, Eliot Moss: 가비지 컬렉션 알고리즘에 대한 심도 깊은 내용을 다루는 전문 서적입니다. (초중급에게는 다소 어려울 수 있으나, GC 전문가가 되고 싶다면 필독서)
  • YouTube - "Demystifying the JavaScript Event Loop and Callback Queue" (그리고 관련 메모리 관리 비디오): 이벤트 루프와 함께 자바스크립트의 메모리 관리 및 GC 동작 방식을 시각적으로 이해하는 데 도움이 됩니다.
  • 각 언어별 런타임/VM 문서: Java HotSpot VM, V8 엔진(JavaScript), CPython 구현 등 각 언어의 런타임이 GC를 어떻게 구현하는지에 대한 기술 문서를 찾아보면 더욱 깊이 있는 이해를 얻을 수 있습니다.