스크립트 최적화 (1) - C# 실행과 메모리 할당 - soo:bak
작성일 :
C# 코드가 실행되기까지
게임 루프의 원리 (2) - CPU-bound와 GPU-bound에서 GPU 작업이 최적화되어 있어도 CPU가 병목이면 프레임이 떨어진다는 점을 확인했습니다. CPU-bound 상태에서는 해상도를 줄이거나 셰이더를 단순화해도 프레임 시간이 개선되지 않으며, CPU 비용 자체를 직접 줄여야 합니다.
CPU가 프레임마다 수행하는 작업 중에서 개발자가 가장 직접적으로 제어할 수 있는 부분이 C# 스크립트 실행입니다. Update, FixedUpdate, LateUpdate에서 호출되는 게임 로직, AI 판단, 입력 처리 등이 모두 C# 코드에 해당합니다.
Unity는 C#을 스크립팅 언어로 사용합니다. C#은 C++과 달리 메모리를 개발자가 직접 해제하지 않습니다. 대신 가비지 컬렉터(GC)가 더 이상 사용되지 않는 메모리를 자동으로 회수합니다.
게임에서는 이 GC가 성능에 직접적인 영향을 줍니다. 60fps 게임에서 한 프레임의 예산은 약 16.6ms입니다.
이 시간 안에 게임 로직, 물리 연산, 렌더링 커맨드 생성까지 모두 끝나야 합니다. GC도 이 프레임 예산 안에서 CPU 시간을 소비합니다. 게임 로직에 12ms가 걸리는 프레임에서 GC가 5ms를 소비하면 총 17ms가 되어 16.6ms 예산을 초과하고, 플레이어는 끊김을 느낍니다.
모바일 기기는 데스크톱보다 CPU 성능이 낮으므로, 같은 양의 GC 작업이라도 프레임 예산을 초과할 가능성이 더 높습니다.
C# 스크립트의 CPU 비용은 두 가지 축으로 나뉩니다.
하나는 코드 실행 자체의 비용이고, 다른 하나는 메모리 할당과 가비지 컬렉션(GC)의 비용입니다.
이 글에서는 C# 코드가 기계어로 변환되어 실행되는 과정, 메모리 할당이 발생하는 지점, 그 비용을 줄이는 방법을 순서대로 살펴봅니다.
Mono와 IL2CPP
C#에서 기계어까지
C#은 컴파일 언어이지만, C++처럼 곧바로 기계어로 변환되지는 않습니다.
C# 코드는 먼저 IL(Intermediate Language, 중간 언어) 이라는 플랫폼 독립적인 바이트코드로 컴파일됩니다. IL은 아직 기계어가 아니므로, CPU가 이 IL을 실행하려면 한 번 더 변환이 필요합니다.
이 IL을 기계어로 변환하는 방식이 두 가지 있습니다. Mono와 IL2CPP입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
C# 소스 코드
│
│ C# 컴파일러 (Roslyn)
▼
IL (중간 언어)
┌───┴───┐
▼ ▼
Mono (JIT) IL2CPP (AOT)
│ │
실행 시점에 빌드 시점에
IL → 기계어 IL → C++ → 기계어
│ │
▼ ▼
기계어 기계어
Mono: JIT 컴파일
Mono 런타임은 JIT(Just-In-Time) 방식으로 IL을 기계어로 변환합니다.
게임이 실행된 후 특정 메서드가 처음 호출되면, 그 시점에 해당 메서드의 IL을 읽어 기계어를 생성합니다. 한 번 생성된 기계어는 캐시되어 다음 호출부터는 변환 없이 재사용됩니다.
1
2
첫 호출: IL 읽기 → 기계어 생성 → 캐시에 저장 → 실행
재호출: 캐시에서 로드 → 실행
JIT의 장점은 빌드 시간이 짧다는 점입니다. IL만 생성하면 빌드가 끝나고, 기계어 변환은 실행 시점에 일어납니다. Unity 에디터가 IL2CPP 대신 Mono를 사용하는 것도 이 빠른 반복 속도 때문입니다.
반면 JIT에는 두 가지 한계가 있습니다.
첫째, 첫 호출 시 변환 비용이 발생합니다. 게임 시작 직후나 새 씬 로드 직후에 처음 호출되는 메서드들이 동시에 JIT 컴파일되면, 순간적으로 프레임이 떨어질 수 있습니다.
둘째, iOS에서 JIT가 금지되어 있습니다. Apple의 보안 정책은 실행 시점에 새로운 실행 가능 코드를 메모리에 생성하는 것을 허용하지 않습니다. JIT 컴파일은 런타임에 기계어를 생성하여 메모리에 올리는 것이므로, 이 정책에 의해 차단됩니다. 따라서 iOS 빌드에서는 Mono JIT를 사용할 수 없습니다.
IL2CPP: AOT 컴파일
IL2CPP는 Unity가 개발한 AOT(Ahead-Of-Time) 컴파일 파이프라인입니다. 빌드 시점에 IL을 C++ 소스 코드로 변환하고, 그 C++ 코드를 플랫폼의 네이티브 C++ 컴파일러(예: Clang, MSVC)로 컴파일하여 기계어를 생성합니다.
1
2
3
4
5
6
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ C# 소스 │ → │ IL │ → │ C++ 소스 │ → │ 네이티브 │
│ │ │ (DLL) │ │ (자동생성) │ │ 바이너리 │
└────────────┘ └────────────┘ └────────────┘ └────────────┘
개발자 작성 C# 컴파일러 IL2CPP 변환 C++ 컴파일러
(Roslyn) (Clang 등)
실행 시점에는 이미 기계어가 완성되어 있으므로, JIT 변환 비용이 없습니다. iOS의 JIT 제한도 문제가 되지 않습니다.
현재 Unity의 모든 모바일 빌드(iOS, Android)에서 IL2CPP가 표준 백엔드로 사용됩니다.
IL2CPP의 이점
IL2CPP의 이점은 iOS 대응에 그치지 않습니다.
실행 속도 향상. IL2CPP가 생성한 C++ 코드는 플랫폼의 네이티브 C++ 컴파일러가 최적화합니다. Clang이나 GCC의 최적화 패스(인라이닝, 루프 벡터화, 데드 코드 제거 등)를 그대로 활용할 수 있습니다.
Mono JIT는 실행 시점에 빠르게 기계어를 생성해야 하므로 최적화에 쓸 수 있는 시간이 제한적입니다. 반면 IL2CPP는 빌드 시점에 충분한 시간을 들여 최적화하므로, 생성된 기계어의 품질이 더 높습니다.
코드 스트리핑으로 빌드 크기 감소. Unity는 빌드 과정에서 실제로 사용되지 않는 코드를 식별하여 제거하는 코드 스트리핑(Code Stripping)을 지원합니다.
코드 스트리핑은 Mono에서도 동작하지만, IL2CPP에서는 AOT 특성상 모든 코드가 네이티브 바이너리에 포함되므로 스트리핑의 빌드 크기 감소 효과가 더 큽니다. 예를 들어, .NET 라이브러리 중 게임에서 사용하지 않는 클래스나 메서드는 최종 빌드에 포함되지 않습니다.
이러한 이점의 대가로 빌드 시간이 증가합니다. IL → C++ 변환과 C++ 컴파일이 추가되기 때문입니다. 대규모 프로젝트에서는 빌드 시간이 수십 분에 이를 수 있습니다. 개발 중에는 에디터에서 Mono로 빠르게 테스트하고, 기기 테스트와 출시 빌드에서 IL2CPP를 사용하는 것이 일반적인 워크플로입니다.
값 타입과 참조 타입
스택과 힙
앞에서 C# 코드가 기계어로 변환되어 실행되는 과정을 다루었습니다. 코드가 실행되려면 데이터를 저장할 메모리가 필요합니다. 데이터가 어디에 저장되느냐에 따라 할당 비용과 GC 부담이 달라지므로, 메모리 구조를 먼저 파악해야 합니다.
C#에서 데이터가 저장되는 메모리 영역은 크게 스택(Stack) 과 힙(Heap) 두 곳으로 나뉩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
높은 주소
┌──────────────────────────────┐
│ 스택 (Stack) │ ← 아래로 성장
│ │ │
│ ▼ │
│ (사용 가능한 공간) │
│ ▲ │
│ │ │
│ 힙 (Heap) │ ← 위로 성장
├──────────────────────────────┤
│ 데이터 (Data) │ 정적/전역 변수
├──────────────────────────────┤
│ 코드 (Text) │ 실행 가능한 기계어
└──────────────────────────────┘
낮은 주소
스택은 함수 호출과 함께 자동으로 관리되는 메모리 영역입니다. 함수가 호출되면 해당 함수의 지역 변수를 위한 공간이 스택에 쌓이고, 함수가 끝나면 그 공간이 즉시 해제됩니다. 할당과 해제가 스택 포인터의 이동만으로 이루어지므로 비용이 극히 낮습니다.
힙은 프로그램이 명시적으로(또는 암묵적으로) 요청한 메모리가 할당되는 영역입니다. 힙에 할당된 메모리는 함수가 끝나도 자동으로 해제되지 않으며, C#에서는 가비지 컬렉터(GC)가 더 이상 참조되지 않는 힙 메모리를 찾아 회수합니다.
1
2
3
4
5
6
7
8
┌──────────┬──────────────────┬──────────────────┐
│ │ 스택 │ 힙 │
├──────────┼──────────────────┼──────────────────┤
│ 할당 │ 포인터 이동 │ 빈 공간 탐색 │
│ 해제 │ 자동 (함수 반환) │ GC 회수 │
│ 크기 │ 제한적 (1MB~) │ 시스템 메모리 │
│ GC 대상 │ 아님 │ 해당 │
└──────────┴──────────────────┴──────────────────┘
스택은 빠르지만 크기가 제한되어 있고(Unity 기본값 기준 스레드당 1MB), 힙은 크기의 자유도가 높지만 할당과 해제에 비용이 따릅니다.
C#에서는 값 타입과 참조 타입의 구분에 따라 데이터가 스택에 할당될지 힙에 할당될지가 결정됩니다.
값 타입 (Value Type)
C#의 값 타입에는 int, float, bool, Vector3, Quaternion, Color 같은 기본 타입과 struct로 선언한 사용자 정의 타입이 포함됩니다.
값 타입의 변수는 변수가 위치한 메모리 공간에 데이터 자체를 직접 저장합니다. 지역 변수로 선언된 값 타입이 스택에 할당됩니다. 함수가 끝나면 스택 프레임과 함께 사라지므로 GC가 관여하지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void MoveCharacter()
{
Vector3 direction = new Vector3(1, 0, 0); // 스택에 12바이트 할당
float speed = 5.0f; // 스택에 4바이트 할당
Vector3 velocity = direction * speed; // 스택에 12바이트 할당
}
// 함수 종료 → 스택 포인터 이동 → 28바이트 즉시 해제
스택:
┌──────────────────────────────┐
│ velocity (12 bytes) │ ← 스택 최상단
├──────────────────────────────┤
│ speed (4 bytes) │
├──────────────────────────────┤
│ direction (12 bytes) │
├──────────────────────────────┤
│ (이전 함수의 스택 프레임) │
└──────────────────────────────┘
값 타입은 대입 시 복사가 일어납니다. Vector3 a = b;라고 쓰면 b의 12바이트 데이터가 a의 공간에 복사됩니다. a를 수정해도 b는 영향을 받지 않습니다.
참조 타입 (Reference Type)
C#의 참조 타입에는 class로 선언한 타입, string, 배열(int[], GameObject[] 등), 델리게이트(메서드를 참조하는 타입) 등이 포함됩니다.
참조 타입의 변수는 데이터 자체가 아니라, 힙에 할당된 데이터의 주소(참조)를 저장합니다. new 키워드로 인스턴스를 생성하면 데이터는 힙에 할당되고, 변수에는 그 힙 주소만 저장됩니다.
변수 자체는 지역 변수이면 스택에, 클래스의 필드이면 해당 인스턴스와 함께 힙에 위치합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void SpawnEnemy()
{
Enemy enemy = new Enemy(); // 힙에 Enemy 크기만큼 할당
// 스택에는 참조(주소)만 저장
}
// 함수 종료 → 스택의 참조 변수 해제
// 힙의 Enemy 인스턴스는 남아있음 → GC가 나중에 수거
스택: 힙:
┌─────────────────────┐ ┌─────────────────────┐
│ enemy (참조/주소) │──────►│ Enemy 인스턴스 │
│ (4 또는 8 bytes) │ │ │
├─────────────────────┤ │ hp = 100 (int) │
│ ... │ │ position (V3) │
└─────────────────────┘ │ name (참조) ──┐ │
└───────────────┼─────┘
▼
┌─────────────────────┐
│ "Goblin" (string) │
└─────────────────────┘
참조 타입은 대입 시 참조(주소)만 복사됩니다. Enemy a = b;라고 쓰면 a와 b가 같은 힙 메모리를 가리킵니다. a와 b가 같은 객체를 참조하므로, a를 통해 수정한 데이터는 b로 접근해도 동일합니다.
값 타입과 참조 타입이 성능에 미치는 영향
두 타입의 성능 차이는 메모리 할당 위치에서 비롯됩니다.
1
2
3
4
5
6
7
8
9
10
11
값 타입 (지역 변수):
할당: 함수 진입 시 스택 프레임 확보 (SP 이동)
해제: 함수 종료 시 스택 프레임 반환 (SP 복원)
GC: 없음
참조 타입 (힙):
할당: 1. 힙에서 빈 공간 탐색
2. 메모리 블록 확보
3. 오브젝트 헤더 초기화
해제: GC가 수거할 때까지 유보
GC: 참조 추적 → 미참조 객체 판별 → 메모리 회수
위 비교에서 보이듯, 힙 할당은 스택 할당보다 단계가 많고 해제도 즉시 일어나지 않습니다. 그런데 할당 비용 자체보다 더 큰 문제는 해제를 담당하는 GC입니다.
Unity가 사용하는 GC는 Boehm GC입니다. 일반적인 .NET GC는 오브젝트를 생성 시점에 따라 세대(generation)로 분류합니다. “최근에 생성된 오브젝트일수록 금방 버려질 확률이 높다”는 통계적 경향을 이용하여, 최근 세대만 우선 검사함으로써 대부분의 GC를 빠르게 끝냅니다.
Boehm GC에는 이런 세대 구분이 없습니다. 실행될 때마다 힙의 모든 오브젝트를 전체 순회하여 아직 참조되는 것과 그렇지 않은 것을 구분합니다.
이 순회는 메인 스레드에서 실행되며, GC가 동작하는 동안 게임 로직이 멈춥니다. 힙에 오브젝트가 많을수록 순회 시간이 길어지고, 프레임 예산을 초과할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
ms
│ ┌──┐
20 ┤ │GC│
│ │ │
16 ┤── ── ── ── ── ── ── ┤ ├── ── ── ── 예산 (60fps)
│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
│ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
0 └──────────────────────────────────
1 2 3 4 5 6 7 8 9 10
↑
게임 로직(12ms) + GC(8ms) = 20ms
→ 예산 초과 → 프레임 드랍
모바일 최적화에서 “힙 할당을 줄여야 한다”는 말은 결국 GC가 실행될 빈도와 GC가 순회해야 할 오브젝트 수를 줄여야 한다는 의미입니다.
힙 할당이 누적되면 GC가 더 자주, 더 오래 실행됩니다. 핵심 전략은 매 프레임 실행되는 코드에서 힙 할당을 0에 가깝게 유지하는 것입니다.
숨은 힙 할당 패턴들
C# 코드에서 new 키워드로 class 인스턴스를 만들면 힙 할당이 발생한다는 점은 직관적입니다. 그런데 new를 명시적으로 쓰지 않아도 컴파일러나 런타임이 내부적으로 힙 할당을 수행하는 경우가 있습니다. 이런 패턴을 인지하지 못하면, 매 프레임 실행되는 코드에서 의도치 않게 GC 압박이 누적됩니다.
문자열 연결
string은 C#에서 참조 타입이며 불변(immutable)입니다. 불변이란 한 번 생성된 객체의 내용을 이후에 변경할 수 없다는 의미입니다.
문자열을 수정하는 것처럼 보이는 연산(연결, 치환, 부분 추출 등)은 기존 객체를 수정하는 것이 아니라 새로운 string 객체를 힙에 생성합니다. 따라서 두 string을 + 연산자로 연결하면 결과를 담은 새로운 string 객체가 힙에 할당됩니다.
1
string result = "HP: " + currentHP + "/" + maxHP;
컴파일러는 +로 이어진 연결을 string.Concat 단일 호출로 최적화하지만, 그래도 힙 할당은 발생합니다.
1
2
3
4
5
6
7
string.Concat("HP: ", currentHP.ToString(), "/", maxHP.ToString())
1. currentHP.ToString() → 새 string "75" 생성 (힙 할당 #1)
2. maxHP.ToString() → 새 string "100" 생성 (힙 할당 #2)
3. string.Concat 결과 → 새 string "HP: 75/100" 생성 (힙 할당 #3)
결과: 힙 할당 3회, ToString()이 반환한 "75"와 "100"은 GC 대상
이 코드가 매 프레임 UI 텍스트를 갱신하기 위해 Update()에서 호출된다면, 60fps 기준으로 초당 180회의 힙 할당이 이 한 줄에서만 발생합니다.
이처럼 + 연산자로 문자열을 조합하면 ToString() 변환과 최종 결합에서 새 객체가 만들어집니다.
대안은 StringBuilder입니다. StringBuilder는 내부에 char 배열 버퍼를 가지고 있어서, 문자열을 추가할 때 새 객체를 생성하지 않고 버퍼에 이어 씁니다.
1
2
3
4
5
6
7
8
9
10
11
12
// 클래스 필드로 한 번만 생성
private StringBuilder sb = new StringBuilder(64);
void UpdateHPText()
{
sb.Clear();
sb.Append("HP: ");
sb.Append(currentHP);
sb.Append("/");
sb.Append(maxHP);
hpText.text = sb.ToString(); // ToString()에서 한 번만 힙 할당
}
StringBuilder를 필드로 한 번 생성해두고 재사용하면, + 연결에서 발생하던 중간 문자열 할당이 사라집니다. 최종 ToString() 호출에서 결과 string 하나만 힙에 할당됩니다.
이전 값을 캐시해두고 값이 바뀔 때만 StringBuilder를 실행하면, 값이 변하지 않는 프레임에서는 힙 할당이 발생하지 않습니다.
LINQ
문자열 연결과 마찬가지로, 코드 표면에는 new가 보이지 않지만 내부적으로 힙 할당이 발생하는 또 다른 패턴이 LINQ입니다. LINQ(Language Integrated Query)는 컬렉션을 간결하게 다루는 문법으로, Where, Select, OrderBy, FirstOrDefault 같은 메서드 체인으로 데이터를 필터링하고 변환할 수 있습니다.
1
var activeEnemies = enemies.Where(e => e.IsAlive).OrderBy(e => e.Distance);
이 한 줄은 읽기 쉽지만, 내부적으로 여러 힙 할당을 수반합니다.
1
2
3
4
5
6
7
1. Where()가 이터레이터 객체 생성 (힙 할당)
2. OrderBy()가 이터레이터 객체 생성 (힙 할당)
3. 열거 시 정렬용 내부 버퍼 할당 (힙 할당)
※ 비캡처 람다(e => e.IsAlive 등)의 델리게이트는
컴파일러가 정적 필드에 캐시하므로 첫 호출 이후 재할당 없음.
외부 변수를 캡처하는 람다는 매 호출마다 클로저 + 델리게이트 할당 발생.
LINQ의 각 연산자는 결과를 즉시 계산하지 않고, 실제로 열거될 때 계산하는 지연 평가(lazy evaluation) 방식을 사용합니다. 이를 위해 연산자마다 이터레이터 객체를 힙에 생성하고, 정렬 등의 연산은 내부 버퍼도 힙에 할당합니다.
초기화 코드처럼 한 번만 실행되는 곳에서는 이 할당이 문제가 되지 않지만, 매 프레임 실행되는 경로에서는 for 루프와 직접적인 조건 분기로 대체하여 힙 할당을 피해야 합니다.
1
2
3
4
5
6
7
8
// LINQ 대신 for 루프로 대체
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i].IsAlive)
{
// 처리
}
}
박싱 (Boxing)
박싱은 값 타입을 object, System.ValueType, 또는 인터페이스 타입으로 변환할 때 발생합니다. 스택에 있던 값 타입 데이터가 힙에 새로 할당된 객체로 복사되므로, 힙 할당이 발생합니다.
1
2
3
4
5
6
7
8
9
10
int score = 100;
object boxed = score; // 박싱 발생
스택: 힙:
┌───────────────────┐ ┌─────────────────────┐
│ score = 100 │ │ 오브젝트 헤더 │
│ (4 bytes) │ │ (타입 정보 포함) │
├───────────────────┤ │ 값: 100 │
│ boxed (참조) ─────┼────────►│ (총 16~24 bytes) │
└───────────────────┘ └─────────────────────┘
힙에 할당되는 모든 참조 타입 객체에는 데이터 앞에 오브젝트 헤더가 붙습니다. 오브젝트 헤더는 런타임이 객체의 타입과 상태를 관리하기 위한 메타데이터로, 플랫폼에 따라 12~20바이트를 차지합니다. 4바이트짜리 int가 박싱되면 이 헤더가 추가되어 힙에 16~24바이트의 객체가 생성됩니다.
박싱이 자주 발생하는 패턴은 다음과 같습니다.
string.Format과 문자열 보간. string.Format("{0}", intValue)에서 intValue가 object 매개변수로 전달될 때 박싱됩니다. 문자열 보간($"HP: {hp}")도 .NET Standard 2.1 프로파일에서는 string.Format으로 컴파일되므로 동일하게 박싱이 발생합니다.
1
2
3
int hp = 75;
string text = string.Format("HP: {0}", hp); // hp가 object로 박싱됨
string text2 = $"HP: {hp}"; // 동일하게 박싱 발생
비제네릭 컬렉션. ArrayList나 Hashtable 같은 비제네릭 컬렉션은 요소를 object로 저장합니다. 값 타입을 추가할 때마다 박싱이 발생합니다.
1
2
3
ArrayList list = new ArrayList();
list.Add(42); // 42가 박싱됨
list.Add(3.14f); // 3.14f가 박싱됨
제네릭 컬렉션. List<int>는 요소를 int 타입 그대로 저장하므로 박싱이 발생하지 않습니다. Dictionary<TKey, TValue>도 마찬가지입니다.
비제네릭 컬렉션 대신 제네릭 컬렉션을 사용하면 값 타입 저장 시 박싱을 피할 수 있습니다.
다만 Dictionary의 키로 struct를 사용할 때 주의할 점이 있습니다. Dictionary는 키를 비교할 때 EqualityComparer<TKey>.Default를 사용합니다.
struct가 IEquatable<T>를 구현하면 Equals(T)가 호출되어 박싱이 발생하지 않지만, 구현하지 않으면 object.Equals(object)로 폴백되어 비교 대상이 박싱됩니다.
람다와 클로저
람다 식(lambda expression)도 사용 방식에 따라 힙 할당을 유발합니다. C#의 람다 식은 익명 메서드를 간결하게 표현하는 문법입니다.
1
enemies.Sort((a, b) => a.Distance.CompareTo(b.Distance));
람다가 자신의 매개변수만 사용하고 바깥 범위의 변수를 사용하지 않으면, 컴파일러가 델리게이트를 정적 필드에 캐시합니다. 캐시된 델리게이트가 재사용되므로, 반복 호출에서 추가 힙 할당이 발생하지 않습니다.
하지만 람다가 바깥 범위의 변수를 사용하면 상황이 달라집니다. 람다가 자신의 매개변수나 내부 지역 변수가 아닌, 바깥 범위에서 선언된 변수를 참조하는 것을 캡처(capture)라 합니다.
캡처가 발생하면 컴파일러는 해당 변수를 담는 숨겨진 클래스를 생성하고, 이 클래스의 인스턴스를 힙에 할당합니다.
힙에 할당하는 이유는 람다의 실행 시점 때문입니다. 예를 들어 다음 코드를 보면,
1
2
3
4
5
6
7
void SetupButton()
{
string menuId = "settings";
button.onClick.AddListener(() => OpenMenu(menuId));
}
// SetupButton()은 즉시 반환됨
// 버튼 클릭은 수 초~수 분 뒤에 발생 → 그때 람다가 실행됨
SetupButton()이 반환되면 스택 프레임이 사라지고, 지역 변수 menuId도 함께 사라집니다. 그런데 버튼 클릭 시 실행되는 람다는 여전히 menuId에 접근해야 합니다. 컴파일러가 menuId를 힙의 클로저 객체로 옮기는 이유가 이것입니다. 스택에 남겨두면 함수 반환 후 접근할 수 없기 때문입니다.
이렇게 캡처가 발생하는 람다를 클로저(closure)라고 합니다.
1
2
3
4
5
void FilterByRange(float maxRange)
{
var inRange = enemies.FindAll(e => e.Distance < maxRange);
// maxRange를 캡처 → 클로저
}
이 예시에서 maxRange는 바깥 함수의 지역 변수이므로 캡처 대상입니다. 캡처가 발생하면 컴파일러는 캡처된 변수를 필드로 가지는 숨겨진 클래스를 자동 생성하고, 함수가 호출될 때마다 이 클래스의 인스턴스를 힙에 할당합니다. FilterByRange가 매 프레임 호출된다면, 매 프레임 이 객체가 힙에 쌓입니다.
매 프레임 경로에서 캡처를 피하려면, 캡처 대상 변수를 클래스 필드로 옮기고 람다 대신 일반 메서드를 사용합니다.
1
2
3
4
5
6
// 캡처 발생 → 매 호출마다 클로저 + 델리게이트 힙 할당
void Update()
{
float maxRange = attackRange;
var inRange = enemies.FindAll(e => e.Distance < maxRange);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 캡처 제거 + 델리게이트 캐시 → 클로저·델리게이트 할당 제거
// (FindAll 자체는 결과를 담을 새 List를 반환하므로 힙 할당이 발생합니다)
private float maxRange;
private Predicate<Enemy> isInRangePredicate;
bool IsInRange(Enemy e) => e.Distance < maxRange;
void Awake()
{
isInRangePredicate = IsInRange; // 델리게이트를 한 번만 생성
}
void Update()
{
maxRange = attackRange;
var inRange = enemies.FindAll(isInRangePredicate);
}
foreach와 Enumerator
foreach 문은 컬렉션을 순회할 때 내부적으로 GetEnumerator()를 호출하여 Enumerator 객체를 얻습니다. 이 Enumerator가 class(참조 타입)로 구현되어 있으면 힙에 할당됩니다.
1
2
3
4
5
6
foreach (var item in collection) → var e = collection.GetEnumerator();
{ while (e.MoveNext())
// item 사용 {
} var item = e.Current;
// item 사용
}
List<T>와 Dictionary<TKey, TValue>의 Enumerator는 struct(값 타입)로 구현되어 있어 힙 할당이 발생하지 않습니다. 다만 컬렉션을 IEnumerable<T> 타입 변수에 담거나, 매개변수로 IEnumerable<T>을 받아 순회하면 struct Enumerator가 인터페이스 타입으로 변환되면서 박싱이 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
// List<Enemy> 타입 그대로 순회 → struct Enumerator → 박싱 없음
foreach (var e in enemies) { ... }
// IEnumerable<T>을 거쳐 순회 → struct Enumerator가 인터페이스로 변환 → 박싱 발생
IEnumerable<Enemy> enumerable = enemies;
foreach (var e in enumerable) { ... }
// 매개변수가 IEnumerable<T>인 경우도 동일
void ProcessEnemies(IEnumerable<Enemy> targets)
{
foreach (var e in targets) { ... } // 박싱 발생
}
Enumerator의 구현 방식이 확실하지 않은 컬렉션을 매 프레임 순회해야 한다면, for 루프와 인덱서를 사용하면 힙 할당을 피할 수 있습니다.
1
2
3
4
for (int i = 0; i < list.Count; i++)
{
var item = list[i];
}
params 배열
params 키워드가 붙은 매개변수는 가변 인자를 배열로 받습니다.
1
2
3
4
5
6
void LogMessage(string format, params object[] args)
{
// ...
}
LogMessage("Player {0} scored {1}", playerName, score);
params object[]는 호출할 때마다 새로운 배열을 힙에 생성합니다. 배열의 원소 타입이 object이므로, 인자가 값 타입이면 object로 변환하는 박싱도 추가로 발생합니다.
매 프레임 호출되는 메서드에 params를 사용하면, 호출마다 배열이 생성됩니다.
인자 수가 고정되어 있다면 오버로드를 만들어 배열 생성을 피할 수 있습니다. Unity의 Debug.LogFormat도 params object[]를 사용하므로 동일한 할당이 발생합니다.
1
2
3
4
5
6
// params object[] → 호출마다 배열 힙 할당
void LogMessage(string format, params object[] args) { ... }
// 인자 수별 오버로드 → 배열 할당 없음
void LogMessage(string format, object arg0) { ... }
void LogMessage(string format, object arg0, object arg1) { ... }
숨은 힙 할당 패턴 정리
1
2
3
4
5
6
7
8
9
10
┌──────────────────────────┬─────────────────────────────────────────┐
│ 패턴 │ 대안 │
├──────────────────────────┼─────────────────────────────────────────┤
│ string 연결 (+) │ StringBuilder 재사용 │
│ LINQ 체인 │ for 루프와 직접 조건 분기 │
│ 박싱 (int → object) │ 제네릭 컬렉션, 오버로드 │
│ 클로저 (외부 변수 캡처) │ 필드로 이동, 일반 메서드 │
│ foreach (일부 컬렉션) │ for + 인덱서 │
│ params 배열 │ 고정 인자 수 오버로드 │
└──────────────────────────┴─────────────────────────────────────────┘
매 프레임 실행되는 코드(Update, FixedUpdate, LateUpdate)가 가장 먼저 점검할 대상입니다. 초기화 코드나 씬 전환처럼 한 번만 실행되는 경로에서는 가독성과 유지보수성을 우선해도 됩니다.
오브젝트 풀링
Instantiate와 Destroy의 비용
앞에서 C# 코드 수준의 숨은 힙 할당 패턴을 살펴보았습니다. 힙 할당이 집중적으로 발생하는 또 다른 원인은 오브젝트의 반복적인 생성과 파괴입니다. 총알, 파티클 이펙트, 적 캐릭터, 아이템 등은 게임 플레이 중 빈번하게 생성되고 파괴되며, Unity에서 오브젝트를 직접 생성하고 파괴하는 API는 Instantiate()와 Destroy()입니다.
Instantiate()는 프리팹을 복제하여 새 GameObject를 만듭니다. 이 과정에서 네이티브 메모리와 관리 메모리 양쪽에 할당이 발생하고, 직렬화된 데이터가 복사되며, Awake와 OnEnable이 호출됩니다.
Destroy()는 GameObject를 파괴합니다.
Unity의 GameObject는 메모리가 두 곳에 나뉘어 있습니다. Transform, Mesh 등 실제 오브젝트 데이터는 Unity 엔진 내부의 C++ 코드가 관리하는 네이티브 메모리에 있고, MonoBehaviour 인스턴스나 GameObject 참조 등 C# 스크립트에서 사용하는 객체는 관리 메모리(managed memory) 힙에 있습니다.
Destroy()를 호출하면 Unity 엔진이 네이티브 메모리는 직접 해제하지만, 관리 메모리는 GC가 수거할 때까지 힙에 남아 있습니다.
Instantiate와 Destroy를 반복하면 이 관리 메모리가 쌓여 GC 부담이 커집니다.
1
2
3
4
5
Instantiate() → 네이티브 + 관리 메모리 할당
↓ 사용
Destroy() → 네이티브 해제, 관리 메모리는 힙에 잔존
↓ 반복
GC 실행 → 프레임 스파이크
초당 10발씩 총알을 발사하면, Instantiate와 Destroy가 매초 10회씩 반복됩니다. 이 할당이 쌓여 GC가 실행되면 프레임 스파이크로 이어집니다.
풀링의 원리
오브젝트 풀링(Object Pooling)은 오브젝트를 파괴하는 대신 비활성화하여 보관하고, 새로 필요할 때 다시 활성화하여 재사용하는 패턴입니다.
1
2
3
4
5
초기화: Instantiate × N → 모두 비활성화(SetActive(false))하여 풀에 보관
꺼내기: 풀에서 비활성 오브젝트를 가져와 SetActive(true)
반환: 사용이 끝나면 SetActive(false)하여 풀에 반환
→ Destroy()를 호출하지 않으므로 GC 대상이 되지 않음
풀링의 핵심은 런타임에 Instantiate/Destroy를 호출하지 않는 것입니다. 초기화 시점에 한 번만 오브젝트를 생성하고, 이후에는 활성화/비활성화로만 관리합니다. 힙 할당이 초기화 시점에 집중되고, 게임 플레이 중에는 Instantiate/Destroy로 인한 할당이 발생하지 않습니다.
1
2
3
4
5
6
7
8
풀링 없음:
Instantiate → 사용 → Destroy (매 생성마다 반복)
→ 힙 할당 누적 → GC 스파이크
풀링 적용:
초기화 시: Instantiate × N (한 번)
이후: SetActive(true) → 사용 → SetActive(false)
→ 게임 플레이 중 Instantiate/Destroy 없음
풀링이 적합한 대상
모든 오브젝트에 풀링을 적용할 필요는 없습니다. 풀링이 효과적인 대상은 짧은 수명 주기로 반복 생성/파괴되는 오브젝트입니다. 총알, 파티클 이펙트, 대미지 텍스트처럼 초 단위로 생성과 파괴가 반복되는 오브젝트가 대표적입니다. 반면 플레이어 캐릭터나 배경 지형처럼 씬에 한 번 생성되고 유지되는 오브젝트에는 풀링이 불필요합니다.
풀의 크기는 동시에 활성화될 수 있는 오브젝트의 최대 수를 기준으로 결정합니다. 총알이 화면에 동시에 최대 30개까지 존재한다면 풀 크기를 30~40개로 설정합니다. 풀이 부족하면 추가로 Instantiate하거나, 가장 오래된 활성 오브젝트를 강제 반환하는 정책을 정할 수 있습니다.
Unity의 ObjectPool
Unity 2021.1부터 UnityEngine.Pool 네임스페이스에 ObjectPool<T> 클래스가 제공됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine.Pool;
public class BulletPool : MonoBehaviour
{
public GameObject bulletPrefab;
private ObjectPool<GameObject> pool;
void Awake()
{
pool = new ObjectPool<GameObject>(
createFunc: () => Instantiate(bulletPrefab),
actionOnGet: bullet => bullet.SetActive(true),
actionOnRelease: bullet => bullet.SetActive(false),
actionOnDestroy: bullet => Destroy(bullet),
defaultCapacity: 30,
maxSize: 100
);
}
public GameObject GetBullet() => pool.Get(); // 풀에서 꺼내기
public void ReturnBullet(GameObject bullet) => pool.Release(bullet); // 풀에 반환
}
생성자에 전달하는 콜백은 각각 다음 시점에 호출됩니다. createFunc은 Get() 호출 시 풀이 비어 있으면 새 오브젝트를 생성합니다. actionOnGet은 Get() 호출 시마다, actionOnRelease는 Release() 호출 시마다 실행됩니다.
Release() 시 풀이 이미 maxSize에 도달해 있으면, actionOnRelease 실행 후 오브젝트를 풀에 보관하지 않고 actionOnDestroy로 폐기합니다. Clear()나 Dispose() 호출 시에도 보관 중인 모든 오브젝트에 대해 actionOnDestroy가 호출됩니다.
defaultCapacity는 내부 컬렉션의 초기 용량이고, maxSize는 풀에 보관할 최대 오브젝트 수입니다. maxSize가 풀의 크기를 제한하므로, 풀이 무한히 커지지 않습니다.
ObjectPool<T>은 스레드 안전(thread-safe)하지 않습니다. 스레드 안전이란 여러 스레드가 동시에 같은 객체에 접근해도 데이터가 깨지지 않는 것을 의미합니다. ObjectPool<T>은 이 보장이 없으므로, Unity의 메인 스레드에서만 사용해야 합니다. 멀티스레드 환경에서 풀링이 필요하다면 lock 등 별도의 동기화를 추가해야 합니다.
풀링 사용 시 주의점
풀링은 런타임 힙 할당을 줄여 주지만, 직접 관리해야 할 부분이 생깁니다.
상태 초기화. 풀에서 꺼낸 오브젝트에는 이전 사용 시의 상태가 남아 있을 수 있습니다. 총알의 속도, 방향, 대미지 등을 꺼낼 때마다 초기화해야 합니다. actionOnGet 콜백이 이 초기화를 수행하기에 적합합니다.
반환 누락. 오브젝트를 풀에서 꺼낸 후 반환하지 않으면, 풀이 점점 비어 새 오브젝트를 계속 생성하게 됩니다. 오브젝트의 수명이 끝나는 모든 경로(충돌, 시간 초과, 화면 밖 이동 등)에서 반환을 호출해야 합니다.
초기 메모리 비용. 풀에 미리 생성하는 오브젝트만큼 게임 시작 시점부터 메모리를 차지합니다. 필요한 만큼만 미리 만들고, 부족할 때 추가 생성하는 방식으로 초기 비용을 조절할 수 있습니다.
C# 할당 패턴을 넘어서
C# 언어 차원에서 힙 할당을 유발하는 패턴을 제거하면, C# 코드 자체에서 오는 GC 압박은 줄어듭니다. 하지만 Unity 게임에서 스크립트 비용의 상당 부분은 Unity API 호출에서도 발생합니다.
GetComponent, Find, SendMessage 같은 API는 C# 코드에서 한 줄이지만, 내부적으로 C# 에서 네이티브 코드(C++)로 호출 경계를 넘으면서 전환 비용, 씬 전체 검색, 배열 할당 등 숨은 비용이 발생합니다.
이러한 Unity API 수준의 비용 구조와 대안은 스크립트 최적화 (2) - Unity API와 실행 비용에서 다룹니다.
마무리
- Mono(JIT)는 실행 시점에, IL2CPP(AOT)는 빌드 시점에 IL을 기계어로 변환합니다. 모바일 빌드에서는 IL2CPP가 표준 백엔드입니다.
- 지역 변수로 선언된 값 타입은 스택에 할당되어 GC 대상이 아니고, 참조 타입은 힙에 할당되어 GC가 관리합니다.
- string 연결, LINQ, 박싱, 클로저, foreach Enumerator, params 배열은 명시적인
new없이도 힙 할당을 유발합니다. - 매 프레임 실행되는 코드에서 힙 할당을 0에 가깝게 유지하는 것이 GC 스파이크를 예방하는 핵심 전략입니다.
- 오브젝트 풀링은 미리 생성한 오브젝트를 재사용하여 런타임 Instantiate/Destroy로 인한 힙 할당과 GC 부담을 줄입니다.
코드 한 줄이 힙 할당을 유발하는지는 C#의 타입 시스템과 컴파일러 동작에 달려 있습니다. 이 글에서 다룬 패턴을 인지하면, 프로파일러에서 GC 스파이크를 발견했을 때 원인을 찾는 출발점이 됩니다.
관련 글
시리즈
- 스크립트 최적화 (1) - C# 실행과 메모리 할당 (현재 글)
- 스크립트 최적화 (2) - Unity API와 실행 비용