---
title: "GC 는 한가할 때 도는 것이 아니다"
description: "자리가 모자랄 때 돕니다. 그래서 게임에서는 프레임 한가운데서 돌고, 그 순간 화면이 한 번 걸립니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#", "최적화", "GC"]
canonical: https://blog.unialgames.com/posts/csharp-gc/
---
이 시리즈에서 "GC 가 언젠가 치운다" 는 말을 여러 번 했습니다.
박싱에서도, 확장 메서드에서도, LINQ 에서도 "쌓이면 GC 가 돈다" 로 끝냈습니다.

그 언젠가가 언제인지 미뤄 뒀습니다. 마지막으로 그 이야기를 합니다.

> **GC 는 한가할 때 도는 것이 아닙니다. `new` 를 했는데 자리가 없을 때 그 자리에서 돕니다.**

## 도는 시점은 우리가 못 고른다

앞에서 힙을 봤습니다. `new` 를 하면 힙에 자리를 잡고, 아무도 안 가리키면 나중에 GC 가 치운다고 했습니다.

그 나중이 언제인지 정하는 것은 이렇습니다.

`new` 를 부릅니다. 힙에 자리가 남아 있으면 그냥 잡고 끝납니다. 아주 쌉니다.
**자리가 없으면** 그 순간 GC 가 돕니다. 도는 동안 우리 코드는 멈춰 있습니다.

즉 GC 를 부르는 것은 **할당**입니다. 시간도 아니고 메모리 압박도 아니고 할당입니다.
그리고 할당은 우리 코드의 아무 줄에서나 일어날 수 있습니다.

게임에서 이게 문제가 되는 이유가 여기 있습니다.
초에 예순 번 도는 루프 어딘가에서 할당이 임계치를 넘기면, 그 프레임 한가운데서 GC 가 돕니다.
평소 16밀리초에 끝나던 프레임이 갑자기 30밀리초가 됩니다. 화면이 한 번 걸립니다.

**평균은 멀쩡한데 가끔 튀는** 것이 GC 때문인 경우가 많습니다.

## 무엇을 치우고 무엇을 남기나

GC 는 살아 있는 것부터 찾습니다. 버려진 것을 찾는 것이 아닙니다.

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "1", "title": "뿌리에서 출발한다", "text": "지금 실행 중인 메서드들의 지역 변수, static 필드, 이런 것들이 뿌리입니다. 여기 있는 것은 무조건 살아 있습니다." },
    { "tag": "2", "title": "손이 닿는 것을 표시한다", "text": "뿌리가 가리키는 객체, 그 객체가 가리키는 객체로 계속 따라갑니다. 닿은 것에 표시를 남깁니다." },
    { "tag": "3", "title": "표시 없는 것을 지운다", "text": "닿지 않은 것은 아무도 못 쓰는 것입니다. 살아 있는 것만 확인했으니 나머지는 볼 필요도 없습니다." },
    { "tag": "4", "title": "남은 것을 앞으로 밀어 붙인다", "text": "빈틈이 생기면 다음 할당이 자리를 찾기 어렵습니다. 살아남은 것을 모아 붙이고 주소를 고쳐 씁니다." }
  ]
}
</script>
</x-steps>

앞에서 `static` 필드가 프로그램이 끝날 때까지 안 치워진다고 한 이유가 1번입니다. static 필드가 뿌리입니다.
그래서 `static List` 에 계속 넣기만 하면 그 안의 객체들도 전부 2번에서 살아 있는 것으로 표시됩니다.

**참조 세는 방식이 아닙니다.** 서로 가리키는 두 객체가 있어도, 뿌리에서 손이 안 닿으면 둘 다 치워집니다.
순환 참조로 못 치우는 문제는 C# 에는 없습니다.

## 세대로 나눠서 자주 도는 곳만 돈다

객체를 하나 만들 때마다 힙 전체를 훑으면 너무 비쌉니다. 그래서 관찰 하나를 이용합니다.

**대부분의 객체는 아주 빨리 죽습니다.** 메서드 안에서 만들어 쓰고 버려지는 것들입니다.
반대로 오래 산 것은 앞으로도 오래 삽니다.

그래서 힙을 세 칸으로 나눕니다.

| | 무엇이 있나 | 얼마나 자주 도나 |
| --- | --- | --- |
| 0세대 | 방금 만든 것 | 자주. 아주 빠름 |
| 1세대 | 0세대에서 한 번 살아남은 것 | 가끔 |
| 2세대 | 오래 산 것 | 드물게. 대신 오래 걸림 |

`new` 는 전부 0세대에 들어갑니다. 0세대가 차면 0세대만 훑습니다. 대개 밀리초 아래로 끝납니다.
거기서 살아남은 것은 1세대로 올라갑니다.

문제는 2세대입니다. 힙 전체를 훑고 4번의 밀어 붙이기까지 하니 오래 걸립니다.
2세대가 도는 것은 드물지만, 돌면 프레임이 확실히 튑니다.

그리고 큰 객체는 세대와 상관없이 따로 관리됩니다.
대략 85KB 를 넘는 것, 그러니까 큰 배열 같은 것입니다.
이쪽은 밀어 붙이기를 잘 안 해서 빈틈이 쌓입니다. 큰 배열을 계속 새로 만들면 여기가 지저분해집니다.

## 여기까지는 .NET 런타임 이야기다

한 가지 짚고 갑니다. 위의 세대와 밀어 붙이기는 **.NET 런타임의 GC** 가 그렇게 한다는 뜻입니다.
C# 이라는 언어가 정한 것이 아닙니다.

같은 C# 코드라도 어느 런타임 위에서 도느냐에 따라 GC 가 다르게 동작합니다.
예를 들어 유니티가 쓰는 GC 는 **밀어 붙이기를 하지 않습니다.**
살아남은 객체를 옮기지 않으니 빈틈이 그대로 남고, 그래서 힙이 조각조각 납니다.

바뀌지 않는 것은 앞의 두 가지입니다. **할당이 GC 를 부른다**는 것과
**뿌리에서 손이 닿는 것만 살아남는다**는 것입니다. 이 둘은 어디서든 같습니다.
그러니 대응도 같습니다. 할당을 줄이면 됩니다.

## 그래서 무엇을 하면 되나

GC 를 없앨 수는 없습니다. **덜 부르게** 할 수 있을 뿐이고, 그 방법은 하나뿐입니다.
할당을 줄이는 것입니다.

이 시리즈에서 나온 것들이 전부 여기로 모입니다.

- **박싱을 안 한다.** `ArrayList` 대신 `List<int>`, `IComparable` 대신 `IComparable<T>`
- **문자열을 반복문에서 잇지 않는다.** 값이 안 바뀌면 아예 안 만든다
- **매 프레임 도는 자리에 LINQ 를 안 쓴다.** 열거자와 람다 객체가 매번 생긴다
- **리플렉션 결과를 들고 있는다.** `GetValue` 는 박싱한다
- **struct 를 쓸 자리에 class 를 쓰지 않는다.** 작고 많은 것은 힙에 안 가는 편이 낫다

그리고 하나가 더 있습니다. **아예 다시 쓰는 것**입니다.

```csharp
private readonly List<적> _버퍼 = new List<적>();

void Update()
{
    _버퍼.Clear();          // 새로 만들지 않고 비웁니다
    적찾기(_버퍼);
}
```

매 프레임 `new List` 를 하는 대신 하나를 만들어 두고 `Clear()` 로 비워 씁니다.
이 생각을 객체 전체로 확장한 것이 [오브젝트 풀](/posts/object-pool-cost/)입니다.
총알이나 이펙트처럼 계속 만들고 버리는 것을 미리 만들어 두고 껐다 켜는 것입니다.
언제 이득이고 언제 손해인지는 그쪽에 따로 정리해 뒀습니다.

## 하지 말아야 할 것

**`GC.Collect()` 를 직접 부르지 않습니다.**

```csharp
GC.Collect();   // 대개 손해입니다
```

강제로 부르면 2세대까지 전부 돕니다. 가장 비싼 것을 일부러 부르는 셈입니다.
그리고 아직 죽지 않은 객체를 억지로 1세대, 2세대로 올려 보내서 나중을 더 나쁘게 만듭니다.

쓸 만한 자리가 아주 없지는 않습니다. 로딩 화면처럼 **어차피 멈춰 있는 순간**에
큰 것을 한꺼번에 버린 직후라면 여기서 한 번 치우는 편이 낫습니다.
그 밖에는 GC 가 알아서 하게 두는 편이 낫습니다.

**재 보지 않고 고치지 않습니다.** 프레임이 튀는 이유는 GC 말고도 많습니다.
유니티라면 프로파일러의 GC Alloc 열을 먼저 봅니다.
프레임당 할당이 0 인데 GC 를 의심하는 것은 시간 낭비입니다.

## 정리

- GC 는 시간이 아니라 **할당** 때문에 돕니다. `new` 했는데 자리가 없으면 그 자리에서 돕니다
- 그래서 게임에서는 프레임 한가운데서 돌고, 그 프레임이 튑니다
- 버려진 것을 찾는 게 아니라 **뿌리에서 손이 닿는 것**을 찾습니다. 나머지는 지웁니다
- static 필드가 뿌리라서, 거기 매달린 것은 프로그램이 끝날 때까지 안 치워집니다
- 0세대는 싸고 2세대는 비쌉니다. 대부분의 객체가 금방 죽는다는 관찰을 이용한 것입니다
- 줄이는 방법은 하나입니다. **할당을 줄이거나, 만든 것을 다시 쓰는 것**입니다
- `GC.Collect()` 를 직접 부르는 것은 대개 손해입니다

그런데 GC 가 아는 것은 **힙에 있는 것뿐**입니다.
프로그램이 쓰는 것 중에는 힙에 없는 것도 있습니다. 파일이나 소켓처럼 운영체제가 빌려준 것들입니다.

그런 것들은 GC 가 치워 주기를 기다리면 안 됩니다. 다 쓴 즉시 닫아야 합니다.