---
title: "보헴 GC 는 객체를 옮기지 않는다"
description: "보수적이고, 비압축이고, 비세대입니다. 이 세 단어가 프레임에서 어떻게 나타나는지 하나씩 봅니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/unity/runtime"
tags: ["Unity", "최적화", "GC"]
canonical: https://blog.unialgames.com/posts/unity-boehm-gc/
---
지금 대부분의 유니티 프로젝트는 Boehm 이라는 GC 위에서 돕니다.
Mono 백엔드도 그렇고, 모바일에서 쓰는 IL2CPP 도 그렇습니다.

이름은 만든 사람 이름입니다. 원래 C 와 C++ 처럼 **GC 가 없는 언어에 GC 를 붙이려고** 만든 것입니다.
그 출신이 성질을 거의 다 설명합니다.

> **Boehm 은 세 가지를 안 합니다. 정확히 알지 않고, 옮기지 않고, 나이를 따지지 않습니다.**

셋 다 "덜 똑똑한" 것인데, 대신 아무 언어에나 붙일 수 있습니다. 그 맞바꿈의 결과가 우리에게 옵니다.

문서와 자료에서는 이 셋을 각각 한 단어로 부릅니다. 먼저 이름을 붙여 두고 시작합니다.

| 안 하는 것 | 부르는 이름 | 우리에게 오는 결과 |
| --- | --- | --- |
| 무엇이 주소인지 정확히 알기 | **보수적** (conservative) | 안 쓰는 객체가 살아남는 일이 생깁니다 |
| 살아남은 것을 앞으로 밀기 | **비압축** (non-compacting) | 빈틈이 남아서, 빈 메모리가 있어도 힙이 커집니다 |
| 나이로 나눠서 훑기 | **비세대** (non-generational) | 매번 힙 전체를 훑어서, 힙이 커질수록 한 번이 비싸집니다 |

방식으로 부르면 **표시하고 쓸기**(mark-sweep)입니다.
살아 있는 것에 표시하고, 표시 안 된 자리를 비웁니다. 그 뒤에 밀어 붙이는 단계가 없습니다.
그 없는 단계의 이름이 압축(compaction)이고, 그래서 비압축입니다.

## 하나. 보수적: 정확히 알지 않는다

GC 는 뿌리에서 출발해 참조를 따라간다고 했습니다. 그러려면 **어느 값이 주소인지** 알아야 합니다.

정밀한 GC 는 이걸 압니다. 컴파일러가 "이 자리는 참조다" 라는 표를 같이 만들어 주기 때문입니다.

Boehm 은 그 표가 없습니다. 그래서 스택과 레지스터를 통째로 훑으면서
**주소처럼 생긴 값을 전부 주소로 칩니다.**

이 방식을 **보수적 스캔**(conservative scanning)이라고 부릅니다.
반대쪽은 정밀(precise 또는 exact)이라고 합니다.

<figure class="diagram"><svg viewBox="0 0 620 170" role="img" aria-label="보수적 스캔이 숫자를 주소로 착각하는 모습"><text x="20" y="26" font-size="12.5" opacity="0.75">스택을 훑는다</text><rect x="20" y="38" width="150" height="26" rx="4" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="30" y="56" class="mono" font-size="12">0x7F2A40  (진짜 주소)</text><rect x="20" y="72" width="150" height="26" rx="4" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="30" y="90" class="mono" font-size="12">8333888   (그냥 정수)</text><rect x="20" y="106" width="150" height="26" rx="4" fill="none" stroke="currentColor" stroke-opacity="0.3" /><text x="30" y="124" class="mono" font-size="12">0x000010  (작은 수)</text><line x1="180" y1="51" x2="300" y2="51" stroke="currentColor" stroke-opacity="0.3" /><text x="310" y="55" font-size="12.5">주소다. 따라간다</text><line x1="180" y1="85" x2="300" y2="85" stroke="currentColor" stroke-opacity="0.3" /><text x="310" y="89" font-size="12.5">주소처럼 생겼다. 따라간다</text><line x1="180" y1="119" x2="300" y2="119" stroke="currentColor" stroke-opacity="0.3" /><text x="310" y="123" font-size="12.5">주소 범위 밖이다. 무시</text><text x="20" y="158" font-size="12" opacity="0.7">가운데 줄이 문제입니다. 그 값이 가리키는 자리의 객체가 살아남습니다.</text></svg><figcaption>확신이 없으면 살려 둡니다. 잘못 지우는 것보다는 나으니까요.</figcaption></figure>

정수 하나가 우연히 어떤 객체의 주소와 같은 값이면, 그 객체는 아무도 안 쓰는데도 살아남습니다.
이걸 잘못된 보존이라고 부릅니다.

**이건 버그가 아니라 설계입니다.** 확신이 없을 때 살려 두는 쪽을 고른 것입니다.
반대로 골랐다면 살아 있는 객체를 지워서 프로그램이 죽었을 겁니다.

실무에서 이게 문제가 되는 경우는 드뭅니다. 다만 알아 둘 값어치는 있습니다.
**"분명히 다 놓았는데 메모리가 안 줄어드는"** 일이 가끔 있고, 그중 일부가 이것입니다.
64비트에서는 주소 공간이 넓어서 우연히 겹칠 확률이 훨씬 낮아졌습니다.

## 둘. 비압축: 옮기지 않는다

이게 실무에서 제일 크게 옵니다.

정밀한 GC 는 치운 뒤에 **살아남은 객체를 앞으로 밀어 붙입니다.** 빈틈을 없애는 것입니다.
그러려면 그 객체를 가리키던 주소를 전부 찾아 고쳐 써야 하는데, 정확히 알고 있으니 할 수 있습니다.

이 미는 단계가 **압축**(compaction)입니다.

Boehm 은 못 합니다. 방금 본 것처럼 **주소인지 아닌지도 확신을 못 하는데** 그 값을 고쳐 쓸 수는 없습니다.
그냥 정수였다면 남의 계산을 망가뜨리게 됩니다.
보수적이라서 비압축인 것이고, 두 성질은 따로 고른 것이 아니라 하나가 다른 하나를 막은 것입니다.

그래서 유니티 문서에도 이렇게 적혀 있습니다. 유니티는 객체들 사이의 빈틈을 메우려고 재배치하지 않는다고요.

결과는 조각입니다.

<x-heap>
<script type="application/json">
{
  "label": "옮기지 않으면 무슨 일이",
  "steps": [
    {
      "code": "// 같은 크기 객체를 다섯 개 만든다",
      "why": "힙에 차례로 자리를 잡습니다. 아직 빈틈이 없습니다.",
      "stack": [{ "name": "a", "ref": 0 }, { "name": "b", "ref": 1 }, { "name": "c", "ref": 2 }, { "name": "d", "ref": 3 }, { "name": "e", "ref": 4 }],
      "heap": [
        { "id": 0, "title": "객체 A", "fields": ["64바이트"] },
        { "id": 1, "title": "객체 B", "fields": ["64바이트"] },
        { "id": 2, "title": "객체 C", "fields": ["64바이트"] },
        { "id": 3, "title": "객체 D", "fields": ["64바이트"] },
        { "id": 4, "title": "객체 E", "fields": ["64바이트"] }
      ]
    },
    {
      "code": "b = null;  d = null;   // 하나 걸러 버린다",
      "why": "B 와 D 가 죽습니다. 둘 사이에 C 가 살아 있습니다.",
      "stack": [{ "name": "a", "ref": 0 }, { "name": "b", "value": "null", "hot": true }, { "name": "c", "ref": 2 }, { "name": "d", "value": "null", "hot": true }, { "name": "e", "ref": 4 }],
      "heap": [
        { "id": 0, "title": "객체 A", "fields": ["64바이트"] },
        { "id": 1, "title": "객체 B", "fields": ["버려짐"], "dead": true },
        { "id": 2, "title": "객체 C", "fields": ["64바이트"] },
        { "id": 3, "title": "객체 D", "fields": ["버려짐"], "dead": true },
        { "id": 4, "title": "객체 E", "fields": ["64바이트"] }
      ]
    },
    {
      "code": "// GC 가 돈다",
      "why": "두 자리가 비었습니다. 그런데 살아남은 것을 앞으로 밀어 주지 않으니, 구멍 둘이 떨어진 채로 남습니다.",
      "stack": [{ "name": "a", "ref": 0 }, { "name": "b", "value": "null" }, { "name": "c", "ref": 2 }, { "name": "d", "value": "null" }, { "name": "e", "ref": 4 }],
      "heap": [
        { "id": 0, "title": "객체 A", "fields": ["64바이트"] },
        { "id": 5, "title": "(빈 구멍)", "fields": ["64바이트"], "dead": true },
        { "id": 2, "title": "객체 C", "fields": ["64바이트"] },
        { "id": 6, "title": "(빈 구멍)", "fields": ["64바이트"], "dead": true },
        { "id": 4, "title": "객체 E", "fields": ["64바이트"] }
      ]
    },
    {
      "code": "var 큰것 = new byte[128];",
      "why": "빈 공간은 합쳐서 128바이트입니다. 그런데 이어진 것은 64바이트씩이라 128바이트가 어느 구멍에도 안 들어갑니다. 힙을 늘립니다.",
      "stack": [{ "name": "a", "ref": 0 }, { "name": "c", "ref": 2 }, { "name": "e", "ref": 4 }, { "name": "큰것", "ref": 7, "hot": true }],
      "heap": [
        { "id": 0, "title": "객체 A", "fields": ["64바이트"] },
        { "id": 5, "title": "(빈 구멍)", "fields": ["여전히 빔"], "dead": true },
        { "id": 2, "title": "객체 C", "fields": ["64바이트"] },
        { "id": 6, "title": "(빈 구멍)", "fields": ["여전히 빔"], "dead": true },
        { "id": 4, "title": "객체 E", "fields": ["64바이트"] },
        { "id": 7, "title": "byte[128]", "fields": ["새로 늘린 자리"], "hot": true }
      ]
    }
  ],
  "note": "빈 공간의 총합이 아니라 이어진 빈 공간의 크기가 자리를 정합니다."
}
</script>
</x-heap>

정밀한 GC 였다면 여기서 힙이 안 커집니다. A 와 C 와 E 를 앞으로 밀어 붙이면
구멍 둘이 하나로 합쳐지고, 128바이트가 그 자리에 들어갑니다.
**밀어 붙이지 못하니 빈 공간이 있어도 못 씁니다.**

빈 메모리의 총합은 충분한데 힙이 커지는 것입니다.
그리고 앞에서 봤듯이 한 번 커진 힙은 안 줄어듭니다.

크기가 제각각인 객체를 계속 만들고 버리면 이 구멍이 쌓입니다.
문자열을 반복문에서 잇거나, 매 프레임 배열을 새로 만드는 코드가 딱 그런 모양입니다.

## 셋. 비세대: 나이를 따지지 않는다

정밀한 GC 들은 대개 세대를 씁니다. 대부분의 객체가 금방 죽는다는 관찰을 이용하는 것입니다.
방금 만든 것만 모아 자주 훑고, 오래 산 것은 드물게 훑습니다. 그러면 대부분의 수집이 아주 싸집니다.

이걸 **세대**(generational) GC 라고 합니다. Boehm 에는 그게 없어서 **비세대**입니다.
**한 번 돌 때마다 힙 전체를 훑습니다.**

그래서 힙이 커질수록 한 번 도는 시간이 같이 길어집니다.
게임이 진행되면서 힙이 100MB, 200MB 로 커지면 GC 한 번이 점점 비싸집니다.
초반에는 멀쩡하다가 후반부에 프레임이 튀기 시작하는 게임에 이 이유가 섞여 있습니다.

그리고 여기서 **작은 할당도 비싸다**는 결론이 나옵니다.
16바이트짜리 하나를 할당했을 뿐인데 그게 임계치를 넘기면 200MB 짜리 힙 전체를 훑습니다.
할당의 크기가 아니라 **횟수**가 중요하다는 말이 유니티에서 특히 강하게 성립하는 이유입니다.

## 정리

- 지금 대부분의 유니티 프로젝트는 Boehm 위에서 돕니다. IL2CPP 도 그렇습니다
- **보수적**(conservative)입니다. 주소처럼 생긴 값을 다 주소로 쳐서, 안 쓰는 객체가 살아남기도 합니다
- **비압축**(non-compacting)입니다. 빈틈이 그대로 남아서, 빈 메모리가 충분해도 이어진 자리가 없으면 힙이 커집니다
- **비세대**(non-generational)입니다. 매번 힙 전체를 훑어서, 힙이 커질수록 한 번이 비싸집니다
- 셋은 따로 놓인 것이 아닙니다. 정확히 모르는 것이 첫 단추입니다. 그래서 못 옮기고,
  세대로 나눠서 얻는 이득도 대부분 옮기는 것에서 나옵니다
- 셋 다 "아무 언어에나 붙일 수 있게" 하려고 포기한 것들입니다