[궁금시리즈] 2-4. Garbage Collector(GC)는 어떻게 메모리를 관리할까?

3 minute read

C++에서는 개발자가 직접 메모리를 해제해야 한다.

Player* player = new Player();

delete player;

만약 delete를 호출하지 않으면 메모리는 계속 남아 있게 된다. 이를 메모리 누수(Memory Leak)라고 한다.

반면 C#에서는

Player player = new Player();

객체를 생성했지만 어디에서도 delete를 호출하지 않는다.

그렇다면

Heap에 생성된 객체는 언제 사라질까?

정답은 Garbage Collector(GC)가 관리하기 때문이다.

이번 글에서는 GC가 왜 필요한지, 어떤 방식으로 메모리를 관리하는지 알아보자.


GC란?

GC(Garbage Collector)는

더 이상 사용하지 않는 객체를 자동으로 찾아 메모리를 회수하는 시스템이다.

즉, 개발자가 직접 메모리를 해제하지 않아도 CLR이 자동으로 관리해 준다.


GC가 필요한 이유

다음 코드를 보자.

void CreatePlayer()
{
    Player player = new Player();
}

메서드가 종료되면

Stack

player

삭제

지역 변수는 사라진다.

하지만 Heap에는

Player 객체

가 그대로 남아 있다.

이 객체를 계속 두면 프로그램의 메모리는 점점 증가한다.

그래서 GC가

“이 객체를 참조하는 변수가 없네?”

라고 판단하면 메모리를 회수한다.


GC는 언제 실행될까?

많은 사람들이

“객체가 없어지면 바로 GC가 실행된다.”

라고 생각한다.

하지만 그렇지 않다.

다음 코드가 있다고 하자.

void Create()
{
    Player player = new Player();
}

메서드가 끝난 직후

Heap

Player

즉시 삭제되지 않는다. GC는 CLR이 필요하다고 판단하는 시점에 실행된다.

예를 들어

  • Heap 메모리가 부족할 때
  • 일정량 이상의 객체가 생성되었을 때
  • 개발자가 GC.Collect()를 호출했을 때(권장하지 않음)

등의 상황에서 동작한다.


GC는 어떻게 객체를 찾을까?

GC는 Heap 전체를 무작정 삭제하지 않는다.

먼저 Root(루트)부터 탐색한다. 대표적인 Root는

  • 지역 변수
  • static 변수
  • CPU 레지스터
  • 스레드 스택

등이다.

예를 들어

Player player = new Player();

메모리는

Root

↓

player

↓

Player 객체

가 된다.

GC는 Root에서 시작하여 참조 가능한 객체를 모두 표시한다.

이를 Mark 단계 라고 한다.


참조가 없는 객체

다음 코드를 보자.

Player player = new Player();

player = null;

이제

Root

X

Player

가 된다.

즉, 어느 곳에서도 Player 객체를 참조하지 않는다.

이 객체는 Garbage가 된다.

GC는 이 객체를 회수한다.


GC의 동작 과정

GC는 크게 세 단계로 동작한다.   1. Mark 참조 가능한 객체를 표시한다.

Root

↓

A

↓

B

A와 B는 살아있는 객체이다.


2. Sweep

표시되지 않은 객체를 제거한다.

A

B

C

D

C

D

삭제

3. Compact 메모리 조각(Fragmentation)을 제거하기 위해 객체를 앞으로 이동시킨다.

A

빈 공간

B

빈 공간

C

A

B

C

Heap을 정리하면 새 객체를 효율적으로 배치할 수 있다.


세대(Generation)란?

GC의 가장 중요한 특징 중 하나는 Generation GC이다.

CLR은 객체를 세 개의 세대로 관리한다.

Gen 0

↓

Gen 1

↓

Gen 2

Gen 0 방금 생성된 객체

예를 들어

new Player();

대부분 여기에서 시작한다.


Gen 1 한 번 살아남은 객체


Gen 2 오랫동안 살아있는 객체

예를 들어

  • 싱글톤
  • 캐시
  • 게임 매니저

등이 있다.


왜 세대로 나눌까?

Microsoft가 분석한 결과

대부분의 객체는 오래 살아남지 않는다.

예를 들어

for(int i=0;i<10000;i++)
{
    new Player();
}

대부분 반복문이 끝나면 바로 필요 없어지는 객체이다.

그래서 Gen0만 자주 검사하면 GC 속도가 훨씬 빨라진다.


GC가 느린 이유

GC는 Heap 전체를 검사하고 객체를 이동시키며 참조도 수정해야 한다.

따라서 실행 중에는 애플리케이션이 잠시 멈출 수 있다.

이를 GC Pause 라고 한다.

게임에서는 프레임 드랍의 원인이 되기도 한다.


GC를 줄이려면?

가장 중요한 방법은 불필요한 객체 생성을 줄이는 것이다.

예를 들어

Update()
{
    new Player();
}

보다는 객체를 재사용하는 것이 좋다.

대표적인 방법은

  • Object Pool
  • StringBuilder
  • List 재사용
  • Boxing 최소화

등이다.


GC.Collect()를 호출하면?

가끔

GC.Collect();

를 직접 호출하는 코드를 볼 수 있다.

하지만 대부분의 경우 직접 호출하는 것은 권장되지 않는다.

CLR은 현재 메모리 상태를 가장 잘 알고 있기 때문에 개발자가 강제로 실행하면 오히려 성능이 더 나빠질 수 있다.


마무리

GC는 더 이상 사용되지 않는 객체를 자동으로 찾아 메모리를 회수하는 .NET의 핵심 기능이다. 덕분에 개발자는 직접 메모리를 해제하지 않아도 되지만, GC가 동작하는 동안에는 일정한 비용이 발생한다.

특히 게임이나 실시간 애플리케이션에서는 불필요한 객체 생성을 줄여 GC가 자주 실행되지 않도록 하는 것이 중요한 최적화 전략이다.

다음 글에서는 메모리 누수(Memory Leak)는 GC가 있는데도 왜 발생할까?를 알아보겠다.


핵심 정리

  • GC는 더 이상 참조되지 않는 객체를 자동으로 회수한다.
  • 객체는 참조가 사라졌다고 즉시 삭제되지 않는다.
  • GC는 Root를 기준으로 살아있는 객체를 찾는다.
  • GC는 Mark → Sweep → Compact 과정으로 메모리를 정리한다.
  • .NET은 Gen0, Gen1, Gen2 세대 기반 GC를 사용한다.
  • 대부분의 객체는 Gen0에서 생성되고 빠르게 회수된다.
  • 불필요한 객체 생성을 줄이는 것이 GC 최적화의 핵심이다.
  • GC.Collect()는 특별한 이유가 없다면 직접 호출하지 않는 것이 좋다.