게임 프로그래밍 메모리 버그가 반복된다면 하지 말아야 할 선택
에디터에서는 멀쩡한데 실제 빌드만 실행하면 종료되고, 스테이지를 여러 번 오간 뒤에야 메모리가 치솟는다면 문제는 대개 거대한 한 줄의 코드에 있지 않습니다. 객체의 소유권과 수명, 할당 시점에 관한 작은 선택이 겹쳐 재현하기 어려운 게임 프로그래밍 버그가 됩니다.
특히 프레임마다 수천 개의 객체가 생성되고 비동기 로딩과 멀티스레드가 함께 움직이는 게임에서는 일반 애플리케이션의 메모리 습관이 통하지 않을 때가 많습니다. 아래 사례는 실패가 잦은 선택을 중심으로, 무엇을 관찰하고 어떤 규칙으로 바꿔야 하는지 설명합니다.
소유자를 정하지 않은 포인터부터 늘리지 마세요
모두가 참조하면 아무도 해제하지 못합니다
첫 번째 실패는 포인터의 문법보다 누가 객체를 없앨 책임이 있는지 정하지 않은 것에서 시작합니다. 플레이어가 무기를 가리키고, 무기가 이펙트를 가리키며, 이펙트 콜백이 다시 플레이어를 잡고 있다면 코드는 자연스러워 보여도 수명 관계는 원형이 됩니다. 한 객체를 지웠을 때 남은 포인터는 댕글링 포인터가 되고, 아무도 지우지 않으면 누수가 됩니다.
모든 포인터를 무조건 shared_ptr로 바꾸는 대응도 자주 실패합니다. 참조 카운트는 소유권을 명확하게 만드는 대신 흐리게 감출 수 있고, 순환 참조와 원자적 카운트 갱신 비용까지 가져옵니다. 월드나 씬이 객체를 소유하고 다른 시스템은 핸들로 조회하는 방식처럼, 소유 경로는 하나로 좁히고 관찰 경로는 약하게 유지하는 편이 안전합니다.
- unique_ptr: 단일 소유자가 분명하고 소유권 이동이 필요한 객체에 사용합니다.
- weak_ptr 또는 약한 핸들: 대상이 이미 사라졌을 가능성을 허용하는 관찰자에 사용합니다.
- 세대 번호가 포함된 ID: 슬롯이 재사용될 때 오래된 참조를 구분해야 하는 ECS나 오브젝트 풀에 적합합니다.
- 원시 포인터: 소유하지 않는 짧은 참조라는 팀 규칙과 수명 보장이 있을 때만 제한적으로 사용합니다.
포인터 종류를 고르기 전에 “이 객체를 마지막으로 파괴하는 시스템은 어디인가?”를 한 문장으로 답해 보세요. 답이 둘 이상이면 코드보다 설계부터 손봐야 합니다.
프레임 안에서 무심코 동적 할당하지 마세요
평균 프레임만 보면 스파이크를 놓칩니다
게임 루프에서 작은 컨테이너나 문자열을 매번 생성하는 코드는 처음에는 문제없이 보입니다. 그러나 전투가 복잡해지면 할당기 잠금, 힙 단편화, 캐시 미스가 한 프레임에 겹칩니다. 평균이 초당 60프레임이어도 특정 프레임이 25ms나 40ms로 튀면 플레이어는 끊김을 느낍니다. 메모리 비용은 사용량뿐 아니라 할당 횟수와 발생 시점으로 측정해야 합니다.
실패한 팀은 흔히 모든 객체를 풀에 넣는 극단으로 이동합니다. 크기와 수명이 서로 다른 객체까지 하나의 풀로 관리하면 미사용 메모리가 커지고 초기화 누락 버그가 발생합니다. 총알, 파티클처럼 생성과 파괴가 빈번하고 최대 동시 개수를 추정할 수 있는 대상부터 적용하고, 임시 계산 데이터는 프레임 아레나처럼 한꺼번에 초기화할 수 있는 구조가 더 단순합니다.
| 상황 | 흔한 실패 | 적합한 선택 |
|---|---|---|
| 매 프레임 임시 목록 | vector를 반복 생성 | 용량 재사용 또는 프레임 아레나 |
| 총알과 파티클 | 개별 new/delete | 타입별 오브젝트 풀 |
| 장시간 유지 리소스 | 거대한 공용 풀 | 명시적 소유자와 일반 할당 |
- 프로파일러에 프레임별 할당 횟수와 바이트를 함께 표시합니다.
- 컨테이너의
reserve는 실제 상한을 근거로 설정하고 무조건 크게 잡지 않습니다. - 풀에서 꺼낸 객체는 생성자에 기대지 말고 모든 런타임 상태를 명시적으로 재설정합니다.
리소스 로딩 완료를 객체 생존과 혼동하지 마세요
비동기 콜백은 늦게 도착할 수 있습니다
메뉴에서 전투 씬으로 빠르게 이동했을 때만 크래시가 난다면 비동기 작업을 의심해야 합니다. 텍스처 로딩 요청을 보낸 UI가 먼저 파괴됐는데 완료 콜백이 원시 포인터를 캡처하면, 작업 자체는 성공했어도 결과를 받을 객체는 이미 없습니다. 이런 use-after-free는 네트워크 속도와 디스크 캐시에 따라 발생 시점이 바뀌므로 개발자 PC에서 재현되지 않는 경우가 많습니다.
“씬 전환 때 모든 작업이 끝날 때까지 기다린다”는 해결책은 안전해 보이지만 로딩 화면을 멈추고 종료 시간을 늘립니다. 더 나은 방법은 요청마다 취소 토큰과 소유 세대를 부여하고, 완료 시점에 대상의 유효성을 다시 확인하는 것입니다. 결과 데이터의 소유권도 작업 스레드에서 게임 스레드로 이동하는 지점을 명확히 해야 합니다. 완료됐다는 사실은 소비자가 살아 있다는 뜻이 아닙니다.
- 요청 생성 시 객체 핸들이나 세대 번호를 함께 기록합니다.
- 씬 언로드가 시작되면 관련 요청을 취소 상태로 바꿉니다.
- 완료 큐를 게임 스레드에서 소비하며 핸들의 유효성을 재검사합니다.
- 실패·취소·성공 경로가 동일하게 임시 버퍼를 반환하는지 테스트합니다.
프로젝트 발표나 기술 세션에서 보이는 완성된 비동기 시스템만 그대로 복사하는 것도 피해야 합니다. GDC의 성격과 개발 지식 공유 맥락을 이해하되, 발표 사례의 플랫폼과 엔진 버전, 작업 규모가 자신의 게임과 같은지 먼저 확인해야 합니다.
메모리 누수를 실행 종료 시점에만 검사하지 마세요
정상 캐시와 되돌아오지 않는 메모리를 구분합니다
프로그램을 종료할 때 남은 할당 목록만 보고 누수라고 판단하면 리소스 캐시와 전역 수명 객체까지 한꺼번에 잡힙니다. 반대로 종료 시 운영체제가 회수한다는 이유로 전부 무시하면 플레이 도중 계속 증가하는 진짜 누수를 놓칩니다. 게임에서 중요한 기준은 같은 행동을 반복한 뒤 메모리가 기준선으로 돌아오는가입니다.
예를 들어 로비와 전투 스테이지를 열 번 왕복한 뒤 첫 번째 왕복보다 상주 메모리가 계속 증가한다면 조사 가치가 큽니다. 다만 셰이더 캐시나 스트리밍 풀처럼 고수위 표시를 유지하도록 설계된 시스템도 있으므로 카테고리별 수치를 분리해야 합니다. CPU 힙, GPU 리소스, 오디오 버퍼, 스크립트 런타임을 하나의 숫자로만 보면 담당 시스템을 찾기 어렵습니다.
- 기준 스냅샷: 로비 진입 직후 안정화된 메모리를 기록합니다.
- 반복 시나리오: 로비와 전투를 최소 10회 왕복하고 매회 동일한 지점에서 측정합니다.
- 차이 분석: 타입별 살아 있는 객체 수와 총 바이트를 비교합니다.
- 허용 범위: 캐시 상한과 플랫폼별 예산을 수치로 문서화합니다.
메모리 예산은 희망치가 아니라 기능별 자원 배분입니다. 계획과 예산을 연결하는 개념처럼 캐릭터, 월드, UI, 오디오에 상한을 배분하면 최적화 우선순위를 정하기 쉬워집니다. 예산을 넘겼다는 사실만으로 실패를 선언하지 말고 어떤 기능이 얼마를 초과했는지 보여줘야 합니다.
디버그 빌드의 안전망을 출시 빌드에도 기대하지 마세요
최적화가 숨은 수명 오류를 드러냅니다
디버그 빌드는 메모리를 특정 패턴으로 채우거나 반복 검사하며 우연히 잘못된 접근을 발견하게 해줍니다. 반면 출시 빌드에서는 인라이닝, 코드 제거, 레지스터 재사용으로 실행 순서와 메모리 배치가 달라집니다. 초기화하지 않은 값이나 범위를 벗어난 접근이 디버그에서만 정상처럼 보였다면 최적화가 버그를 만든 것이 아니라 원래 있던 버그를 노출한 것입니다.
출시 직전에만 릴리스 빌드를 검사하는 일정도 피해야 합니다. 심볼 파일을 보관하지 않거나 크래시 덤프에 빌드 식별자가 없으면 사용자에게 받은 주소를 소스 코드와 연결할 수 없습니다. 개발 중에도 최적화와 진단 기능을 적절히 섞은 검증 빌드를 운영하고, 자동 플레이 테스트를 같은 시나리오로 반복하는 편이 비용이 적게 듭니다.
- 개발 초기부터 주기적으로 최적화 빌드의 스모크 테스트를 실행합니다.
- AddressSanitizer, UndefinedBehaviorSanitizer 등 플랫폼이 지원하는 진단기를 별도 구성으로 운용합니다.
- 실행 파일과 일치하는 심볼, 커밋 해시, 콘텐츠 버전을 함께 보관합니다.
- 크래시 리포트에는 마지막 씬, 메모리 사용량, 최근 리소스 요청을 남깁니다.
재현되지 않는 크래시는 정보가 부족한 크래시입니다. 로그를 무작정 늘리기보다 빌드 식별자와 객체 핸들, 최근 수명 이벤트를 연결해 남기세요.
진단 도구의 실행 비용 때문에 매번 사용할 수 없다면 야간 빌드나 짧은 핵심 경로부터 시작하면 됩니다. 도구를 켠 한 번의 긴 플레이보다, 동일한 이동·전투·재시작 경로를 자동으로 수십 번 반복하는 테스트가 누적 오류를 찾는 데 유리합니다.
게임 기획 변경을 메모리 설계 밖의 일로 두지 마세요
콘텐츠 규모가 바뀌면 상한도 다시 계산해야 합니다
개발 초기에 동시 등장 적을 20명으로 가정해 풀과 애니메이션 버퍼를 만들었는데, 재미를 위해 80명으로 늘렸다면 메모리 설계도 함께 달라져야 합니다. 그런데 코드 팀이 변경 사실을 뒤늦게 알면 풀 부족 시 즉석 할당이 발생하거나, 안전을 위해 잡아 둔 예비 공간이 여러 시스템에서 중복됩니다. 기획 숫자는 곧 메모리 입력값입니다.
반대로 최악의 경우만 상상해 모든 배열을 최대 크기로 선할당하면 저사양 기기에서 시작부터 예산을 초과할 수 있습니다. 기본 용량과 확장 단위, 절대 상한을 분리하고 상한 초과 시 어떤 품질을 낮출지 결정해야 합니다. 예를 들어 멀리 있는 적의 이펙트를 생략하거나 데칼의 유지 시간을 줄이는 방식은 크래시보다 훨씬 나은 실패입니다.
역할 간 의사소통에서는 용어도 중요합니다. 게임 기획자의 역할에 관한 설명을 참고하면 규칙과 콘텐츠 수치가 구현 조건으로 이어지는 이유를 이해하기 쉽습니다. 개발자는 “메모리가 부족하다”가 아니라 “동시 적 20명에서 80명으로 바뀌면 애니메이션 버퍼가 약 4배 필요하다”처럼 변화량을 전달해야 합니다.
- 동시 캐릭터, 투사체, 음성, 파티클의 기본값·목표값·절대 상한을 나눠 기록합니다.
- 상한을 넘을 때 제거할 시각 효과와 유지해야 할 게임플레이 객체를 구분합니다.
- 콘텐츠 리뷰에서 프레임 시간뿐 아니라 메모리 예산 변화도 함께 승인합니다.
- 플랫폼별 품질 설정이 실제 할당량을 줄이는지 기기에서 검증합니다.
보스전 12회 반복에서 사라진 480MB를 추적한 과정
느낌이 아니라 수명 이벤트로 범위를 좁혔습니다
한 액션 게임 팀은 보스전을 한두 번 플레이할 때 아무 문제를 보지 못했습니다. 그러나 전투를 재시작할 때마다 상주 메모리가 약 40MB씩 늘었고, 열두 번째 재시작에서 저사양 기기가 종료됐습니다. 첫 추측은 보스 텍스처 누수였지만 GPU 캡처에서는 텍스처 수가 일정했습니다. CPU 객체 스냅샷을 비교하자 파괴된 보스마다 충돌 이벤트 리스너와 리플레이 샘플 버퍼가 남아 있었습니다.
원인은 두 가지 실패가 겹친 것이었습니다. 이벤트 버스가 보스 컴포넌트의 람다를 강한 참조로 보관했고, 람다는 리플레이 기록기를 다시 소유했습니다. 여기에 재시작 경로에서는 등록 해제 함수가 호출되지 않았습니다. 팀은 소멸자에서 무조건 해제하는 임시 수정 대신, 구독 토큰을 소유하는 RAII 객체를 만들고 월드 종료 시 구독 수가 0인지 검증하는 테스트를 추가했습니다.
- 보스전 입장 직전과 퇴장 후 안정화 시점의 스냅샷을 각각 저장했습니다.
- 두 스냅샷에서 증가한 타입을 바이트가 아닌 인스턴스 개수 기준으로 먼저 정렬했습니다.
- 남은 객체의 참조 경로를 따라 이벤트 버스와 캡처 람다의 순환 관계를 확인했습니다.
- 구독 토큰 도입 후 재시작을 30회 반복하고 CPU 힙과 GPU 메모리를 함께 기록했습니다.
- 콘텐츠 팀이 새 보스 패턴을 추가해도 같은 테스트가 자동 실행되도록 빌드 파이프라인에 넣었습니다.
수정 뒤 첫 전투에서는 캐시 때문에 약 18MB가 증가했지만 이후 29회의 측정값은 허용 오차 안에서 같은 기준선으로 돌아왔습니다. 프레임 중 이벤트 해제 비용도 측정했으며 눈에 띄는 스파이크는 없었습니다. 이 사례에서 결정적인 행동은 더 큰 메모리 풀을 사전에 확보한 것이 아니라, 반복 가능한 행동과 비교 가능한 측정 지점을 만든 것이었습니다.
비슷한 문제가 있다면 게임 전체를 무작정 프로파일링하지 말고 가장 짧게 반복할 수 있는 장면 하나를 고르세요. 입장 전, 플레이 직후, 완전한 퇴장 후라는 세 지점에서 객체 수를 비교하면 사라지지 않은 시스템이 드러납니다. 그다음 소유 경로를 한 단계씩 끊어 확인하면, “가끔 터지는 메모리 버그”는 재현 가능한 수명 규칙 위반으로 바뀝니다.

- 다음글늦여름 게임잼, 기능을 덜 만들수록 게임 프로그래밍이 빨라진다 26.08.29
등록된 댓글이 없습니다.
