유니티 메모리 관리를 능숙하게 하는 개발자가 실력자이다
완성된 게임이 출시됐는데 사용자들이 자꾸만 강제 종료된다고 보고한다. 프레임 속도도 괜찮은데, 그래픽도 깔끔한데 왜 자꾸 꺼질까. 대부분의 경우 범인은 메모리다. 저사양 스마트폰에서 필요한 용량을 할당받지 못한 앱이 OS에 강제 종료당하는 것이다. 로직은 완벽하게 짜놨는데 메모리 관리에서 실수해서 작품을 망치는 개발자가 얼마나 많은가.
개발을 몇 년 해보면 단순한 코딩 실력에서는 초보와 고수가 그리 크게 차이 나지 않는다는 걸 느낀다. 누구나 함수를 쓰고, 루프를 돌리고, 로직을 짠다. 하지만 최적화와 코드의 가독성, 런타임에 얼마나 많은 리소스를 쓰고 있는지 체크하고 제어할 수 있는 능력에서는 차이가 극명하게 드러난다. 그것이 바로 초보와 고수를 나누는 경계선이다. 완성도 높은 제품을 만들어낼 수 있는 기술을 갖춘 사람이 고수인데, 그 기술의 핵심은 바로 이것이다.
핸드폰이 거부하는 앱
유니티로 게임을 만들 때 할당과 해제의 흐름을 깊이 이해하고 능숙하게 다룰 수 있는 역량은 필수다. 프로그램이 흠없이 작동하도록 짜놨는데 이 부분을 엉망으로 해서 모바일에서 크래시되면 모든 노력이 물거품이 된다. 사용자들이 저사양 기기에서 앱을 실행할 수 없게 되고, 고사양 전용이라는 꼬리표가 붙으면서 타겟 층이 급격히 줄어든다.
PC와 모바일의 환경 차이는 크다. PC는 가상 메모리 시스템이 있어서 물리적 용량을 초과해도 디스크로 대체할 수 있지만, 모바일은 그렇지 않다. 4GB 스마트폰이라 해도 개발자가 실제로 쓸 수 있는 공간은 2GB 정도다. 나머지는 운영 체제와 백그라운드 앱, 시스템 프로세스가 점유한다. 이 한계를 초과하는 순간 OS가 앱을 강제로 꺼버린다. 성능만 떨어지는 수준에서 끝나는 게 아니라 완전히 닫혀버린다는 뜻이다.
처음 배웠던 실수들
개발 초기, 나는 GetComponent를 너무 자유롭게 썼다. 매 프레임마다 필요한 컴포넌트를 찾아 할당했고, 가비지 컬렉터는 계속 뒷처리를 해야 했다. 성능 프로파일러를 켜보니 GC 스파이크가 매 프레임 터지고 있었다. 그제야 내가 악순환을 만들고 있었다는 걸 깨달았다.
문자열도 마찬가지였다. 태그 비교를 할 때마다 문자열을 새로 만들었고, JSON이나 XML을 파싱할 때마다 임시 객체들이 힙 메모리를 채웠다. 게임 로직 중간에 이런 일들이 반복되면 할당과 해제가 계속 일어난다.
| 실수 | 원인 | 결과 |
|---|---|---|
GetComponent 반복 호출 | 매번 컴포넌트 탐색 | 메모리 할당, GC 스파이크 |
문자열 직접 비교 (tag == "Player") | 내부적으로 새 문자열 생성 | 불필요한 할당 |
| 박싱/언박싱 | 값 타입을 참조 타입으로 변환 | 힙 낭비 |
코루틴 내 new 키워드 | 매 사이클마다 객체 생성 | 누적 할당 |
발견이 늦으면 돌이킬 수 없다
내가 배운 가장 중요한 교훈은 이런 문제를 개발 막바지에서 처리하려면 이미 너무 늦다는 것이다. 그 시점에서 아티스트에게 텍스처 해상도를 계속 낮춰달라고 요청하게 되는 악순환에 빠진다. 프로파일러를 켜보니 실제 원인은 렌더링이 아니라 로직이었다는 식의 헛수고를 반복하게 된다.
프로파일링은 개발 초기부터, 자주, 그리고 반드시 타겟 모바일 기기에서 해야 한다. 에디터에서의 결과와 실제 빌드 후 핸드폰에서의 결과는 완전히 다르다. 특히 할당 패턴이 다르게 나타난다. 에디터에서는 멀쩡하다가 모바일에서만 강제 종료되는 일이 대부분이다.
GC를 아군으로 만들기
C#은 자동 메모리 관리(Garbage Collection)를 사용한다. 개발자가 명시적으로 해제하지 않아도 런타임이 알아서 정리한다. 편리한 반면, GC가 작동할 때 프레임 드롭이 생긴다. 특히 모바일에서는 이 순간이 눈에 띈다.
GC를 없앨 순 없지만 최소화할 수는 있다. 첫째, 게임 로직 중에 새로운 할당을 하지 않는 것이다. 필요한 객체들은 Start에서 미리 만들어놓고 재사용한다. 둘째, GetComponent는 한 번만 호출한 후 변수에 저장해 계속 쓴다. 셋째, 문자열 비교는 CompareTag를 사용한다.
// 피해야 할 방식
if (gameObject.tag == "Player") { } // 매번 새 문자열 생성
// 올바른 방식
if (gameObject.CompareTag("Player")) { } // 할당 없음
실행 중 GC가 발생하는 건 피할 수 없지만, 그 빈도와 규모를 줄일 수 있다면 사용자 경험은 크게 달라진다.
모바일의 숨은 문제: 열 스로틀링
할당 외에도 신경 써야 할 부분이 있다. 핸드폰의 온도다. 게임을 오래 돌리다 보면 CPU와 GPU가 뜨거워지고, OS는 자동으로 칩 성능을 제한한다(스로틀링). 그러면 같은 로직이 더 느리게 실행된다. 프로파일링 결과가 시간마다 다른 이유가 바로 이것이다.
PC는 큰 쿨러로 열을 관리할 여유가 있지만, 핸드폰에는 그런 여유가 없다. 따라서 모바일 타이틀은 PC처럼 성능을 극한까지 밀어붙일 수 없다. 대신 안정적으로 계속 작동하는 데 초점을 맞춰야 한다. 목표 프레임이 30fps라면 가용한 33ms를 모두 써서는 안 되고, 여유를 두고 25ms 정도만 써야 한다. 그래야 나중에 스로틀링이 생겨도 목표를 유지할 수 있다.
추측이 아닌 측정
문제를 추론만 해서는 안 된다. 유니티 프로파일러를 켜고 정확히 어디가 병목인지 파악해야 한다. CPU 병목인지 GPU 병목인지를 구분하는 게 특히 중요하다. CPU 문제면 로직을 개선해야 하고, GPU 문제면 렌더링을 줄여야 한다. 잘못 판단하면 아티스트 팀만 고생한다.
메모리 프로파일러로 스냅샷을 떠서 어떤 객체가 얼마나 많은 용량을 차지하고 있는지 확인한다. 예상과 다를 수 있다. 신경 쓰지 않던 부분에서 누수가 생기고 있을 수도 있다. 프로파일러 없이 최적화를 시도하는 건 눈을 감고 길을 찾는 것과 같다.
| 지표 | 의미 | 조치 |
|---|---|---|
| WaitForTargetFPS | 성능이 충분 | 좋은 신호 |
| WaitForGPU | GPU 병목 | 렌더링 감소 |
| GC.Alloc 증가 | 새로운 할당 | 로직 재검토 |
마치며
초보 개발자는 “작동하는 게임"을 만들고, 경험 많은 개발자는 “어떤 환경에서나 안정적으로 작동하는 게임"을 만든다. 그 차이는 코딩 솜씨가 아니라 이 분야에 대한 이해도에서 나온다. C#이 어떻게 메모리를 다루는지 깊이 알고, 처음부터 자주 점검하고, 실제 기기에서 테스트하는 개발자가 진정한 실력자다. 이 능력을 갖추는 것이 좋은 작품을 만드는 첫 번째 조건이다.
자주 묻는 질문
Q. 에디터에서 테스트하면 안 되나요?
A. 에디터는 일부 할당 패턴이 최적화되거나 다르게 처리됩니다. 반드시 대상 모바일 기기에 빌드해야 정확한 상황을 볼 수 있습니다.
Q. GC 스파이크를 완전히 없앨 수 있나요?
A. 완전히 없앨 순 없습니다. 하지만 초기에 필요한 객체를 미리 만들어놓고 게임 중에는 할당을 최소화하면 빈도와 크기를 크게 줄일 수 있습니다.
Q. GetComponent 대신 무엇을 써야 하나요?
A. TryGetComponent를 쓰거나, Start에서 미리 GetComponent로 참조를 얻어 저장해놓고 계속 재사용합니다.
Q. 모바일에서만 신경 써야 하나요?
A. 이 부분은 모든 플랫폼에서 중요하지만, 모바일의 제약이 훨씬 크기 때문에 더 신경 써야 합니다. 모바일에서 잘 돌아가도록 최적화하면 PC에서는 당연히 잘 작동합니다.
Q. 성능 프로파일링은 언제부터 시작하나요?
A. 프로토타입 단계는 제외하고, 본격적인 개발이 시작되면 초기부터 습관처럼 시작합니다. 늦게 시작하면 이미 손쓰기 너무 어려워져 있습니다.