가비지컬렉터

황현중·2025년 12월 1일

1. 가비지 컬렉터가 왜 필요할까?

C, C++ 같은 언어에서는 메모리를 직접 관리해야 한다.

void* p = malloc(100);
// ...
free(p);
  • free를 깜빡하면 → 메모리 누수
  • 이미 해제한 메모리를 또 쓰면 → 크래시, 이상한 버그

C#/.NET은 이런 귀찮고 위험한 일을 줄이기 위해 가비지 컬렉터(GC)를 도입했다.

GC = 더 이상 사용되지 않는 객체를 자동으로 찾아서 메모리를 회수해주는 시스템

개발자는 보통 new로 객체만 만들고, “언제 free 할지”는 신경 안 써도 되게 만든 것.


2. 스택(stack) vs 힙(heap) 간단 복습

C# 메모리 구조를 아주 단순하게 나누면 이렇게 생각할 수 있다.

2-1. 스택(stack)

  • 지역 변수, 매개변수 등이 주로 저장되는 영역
  • 메서드가 호출될 때 쌓이고, 리턴될 때 자동으로 제거된다 (LIFO 구조)
void Foo()
{
    int x = 10;   // x는 스택에 위치
} // Foo가 끝나면 x도 자동으로 사라짐

2-2. 힙(heap)

  • new로 생성되는 객체들이 저장되는 곳
  • string, List<T>, 사용자 정의 클래스 인스턴스 등
void Foo()
{
    var list = new List<int>(); // List 객체는 힙에 생성
} // Foo가 끝나도 힙의 List는 자동으로 사라지지 않는다

정리하면:

  • : 실제 객체가 있는 곳
  • 스택 : 그 객체를 가리키는 참조(주소)를 들고 있는 변수들이 있는 곳

3. 언제 객체가 “가비지(쓰레기)”가 될까?

GC가 객체를 지우려면 기준이 있어야 한다. 핵심은 딱 하나다:

“어디에서도 더 이상 참조하지 않는 객체” → 쓰레기

3-1. 예시 1 – 메서드 안의 지역 변수

void Foo()
{
    var list = new List<int>();
    list.Add(1);
    list.Add(2);
} // 여기서 Foo 종료
  • list는 스택에 있는 지역 변수
  • Foo가 끝나면 스택 프레임이 사라지고, list도 함께 사라진다
  • 이제 힙에 있는 new List<int>()를 가리키는 참조가 아무 것도 없다

GC 입장에서는 이 객체는 이제 “어디에서도 도달 불가” → 가비지가 된다.

3-2. 예시 2 – 여전히 참조가 남아 있는 경우

List<int> _cache;

void Make()
{
    _cache = new List<int>();
}

void Use()
{
    _cache.Add(1);
}
  • _cache는 필드(멤버 변수) → 프로그램이 살아 있는 동안 계속 유지될 수 있다
  • 따라서 _cache가 가리키는 List는 GC 대상이 아니다 (아직 “살아 있는 객체”)

정리하면:

GC는 “참조가 끊어진 객체”만 지운다.
참조가 남아 있으면, 아무리 우리가 “더 이상 안 쓰는데…”라고 생각해도 GC는 지우지 않는다.

4. GC가 실제로 하는 일 (직관적 버전)

내부 구현은 엄청 복잡하지만, 개념적으로는 대략 이런 일을 한다고 보면 된다.

  1. Root(루트) 찾기
    스택의 지역 변수들, static 필드들, CPU 레지스터 등 → 프로그램이 직접 들고 있는 참조들을 시작점으로 잡는다.
  2. 도달 가능한 객체 표시(mark)
    루트에서 참조를 따라가면서, 도달할 수 있는 모든 객체를 “살아 있음”으로 표시한다.
  3. 도달 불가능한 객체 찾기
    어떤 루트에서도 도달할 수 없는 객체들 = 더 이상 사용되지 않는 객체들 = 가비지.
  4. 가비지 제거 + 힙 정리(compact)
    가비지 객체들을 제거하고, 남은 객체들을 한쪽으로 몰아서 메모리를 연속적으로 만들기도 한다.

이 과정에서 잠깐 GC가 도는 동안 해당 스레드가 멈추는 구간(stop-the-world)이 생길 수 있다.
성능 튜닝할 때는 이 GC pause도 고려해야 한다.


5. 세대별(Generation) GC – Gen 0 / Gen 1 / Gen 2

.NET GC는 성능을 높이기 위해 힙을 세대(Generation)로 나누어 관리한다.

  • Gen 0 : 새로 생성된 객체들이 있는 영역 (아기)
  • Gen 1 : 한 번 GC를 버틴 애들
  • Gen 2 : 오래 살아남은 객체들 (노인)

경험적으로 이런 특징이 있다.

“대부분의 객체는 금방 죽고, 생각보다 오래 사는 객체는 적다.”

따라서 GC는:

  • Gen 0처럼 “금방 죽을 가능성이 큰 영역”을 위주로 자주 검사하고
  • Gen 2처럼 오래 사는 영역은 덜 자주 검사한다

지금 단계에서는:

“객체는 처음에 Gen 0에서 시작하고, GC를 통과해 살아남으면 점점 위 세대로 승격된다” 이 정도만 알고 있어도 충분하다.

6. GC는 언제 도는가?

우리가 new를 계속 호출해서 힙이 어느 정도 차면, 런타임이 “이제 한 번 청소할 때가 된 것 같은데?” 하고 GC를 돌린다.

또한:

  • 메모리 상황을 보고 필요할 때
  • CPU가 한가할 때 백그라운드로

등 여러 요인을 고려해서 자동으로 실행된다.

우리가 직접 호출할 수도 있다:

GC.Collect();

하지만 실무에서는 왠만하면 사용을 권장하지 않는다.
런타임이 보통 우리보다 더 좋은 타이밍에 돌릴 수 있고, 우리가 억지로 자주 호출하면 오히려 성능이 떨어질 수 있기 때문이다.


7. GC가 “못 하는 일” – IDisposable, using과의 관계

GC는 어디까지나 “관리 힙 메모리”만 치운다.
그런데 객체 안에는 메모리 말고도 이런 것들이 들어있을 수 있다.

  • 파일 핸들
  • 데이터베이스 연결
  • 네트워크 소켓
  • 윈도우 핸들, GDI 리소스 등

이런 것들은 OS 수준 자원이기 때문에, GC가 직접 정리해 줄 수 없다.
그래서 이런 타입들은 보통 IDisposable을 구현하고, 우리가 Dispose()를 호출해서 정리해야 한다.

using (var fs = new FileStream("test.txt", FileMode.Open))
{
    // 파일 사용
} // 여기서 자동으로 fs.Dispose() 호출 → 파일 핸들 반납

정리하면:

  • GC : 관리 힙 메모리 회수 담당
  • IDisposable / using : 파일 핸들, DB 연결 같은 비관리 자원 정리 담당

8. C#에서도 메모리 누수가 생길 수 있을까?

GC가 있는데도 왜 “메모리 누수” 얘기가 나올까? 이유는 아주 간단하다.

GC는 “참조가 있는 객체”는 절대로 지우지 않는다.

예를 들어:

static List<byte[]> _cache = new List<byte[]>();

void Leak()
{
    var big = new byte[10 * 1024 * 1024]; // 10MB
    _cache.Add(big);                      // 전역 리스트에 계속 추가
}
  • _cachestatic 필드라 프로그램이 끝날 때까지 참조를 유지할 수 있다.
  • 여기에 10MB짜리 배열을 계속 넣으면, GC 입장에서는 “참조가 있으니 지우면 안 된다”고 판단한다.
  • 결과적으로 메모리는 계속 늘어나서 사실상 누수처럼 보이는 상황이 된다.

즉, C#에서도:

우리가 참조를 계속 잡아두면, GC가 치울 수 없다.
그래서 “참조를 언제 끊어줄지”도 중요한 설계 포인트다.

GC는 “메모리 free를 자동으로 해주는 친구”지만, 우리가 참조를 어떻게 관리하느냐에 따라 효율과 안정성이 달라진다.

다음 단계로는:

  • Finalizer(종료자) vs IDisposable 차이
  • Large Object Heap(LOH)
  • Server GC vs Workstation GC
  • GC Pause(스톱 더 월드)와 성능 튜닝

같은 내용을 공부하면, C#/.NET 메모리 관리에 대한 이해가 한층 더 깊어진다.

0개의 댓글