Back to notes
    트러블 슈팅엔진렌더링그래픽스작성 · 2026-07-21

    카메라가 우주로 날아간 이야기

    메모리 오염인 줄 알았는데, 범인은 Mono GC였다.

    목차

    왜 이 글을 쓰게 되었을까?

    프로젝트를 진행하면서 가장 오래 붙잡고 있었던 버그가 하나 있었다. 분명 에디터에서 정상적으로 설정한 값인데, 런타임을 실행하면 어느 순간부터 값이 이상하게 변했다. 처음에는 카메라만 이상한 줄 알았다.

    문을 열기 위해 버튼을 눌렀을 뿐인데 카메라가 갑자기 엉뚱한 방향으로 날아가 버렸다. 하지만 디버깅을 계속할수록 문제는 카메라가 아니었다.

    Script에 저장한 필드 값 자체가 런타임 도중 쓰레기 값으로 변하고 있었다. 이 글은 그 원인을 추적했던 과정을 정리한 기록이다.


    사건 발생

    내 오류와 유사한 영상

    증상은 일정하지 않았다. 어떤 때는 정상적으로 동작했고, 어떤 때는

    • bool 값이 갑자기 바뀌고
    • 캐릭터가 멈추고
    • Camera가 말도 안 되는 좌표를 바라봤다.
    • 심할 때는 에디터가 꺼지기도 했다.

    가장 이상했던 점은 내가 수정하지 않은 값이 스스로 바뀌고 있다는 것이었다. 처음에는 단순한 메모리 오염이라고 생각했다.


    처음에는 GC를 의심하지 않았다

    버그는 카메라 스크립트를 구현하던 시기에 발생했다. 그래서 스크립팅을 위주로 확인했다. 그 뒤로 하나씩 확인하기 시작했다.

    • Quaternion 계산
    • Transform
    • Camera Follow
    • Delta Time

    하지만 로그를 아무리 찍어봐도 계산 자체는 모두 정상이었다. 카메라는 정상적으로 계산하고 있었다.

    그런데 계산에 사용되는 Script 필드 값만 어느 순간부터 이상해지고 있었다. 그제야 이건 단순한 계산 문제가 아니라는 생각이 들기 시작했다.


    일단 프로젝트를 끝내야 했다

    당시에는 프로젝트 발표가 더 중요했다. 원인을 끝까지 추적할 시간이 부족했다. 여러 가지를 실험하던 중 Script 필드를 static으로 변경하자 문제가 사라졌다.

    왜 해결됐는지는 알 수 없었다. 하지만 데모는 정상적으로 진행할 수 있었고 프로젝트도 무사히 마무리할 수 있었다.

    지금 생각하면 근본적인 해결은 아니었지만, 당시에는 가장 현실적인 선택이었다.


    프로젝트가 끝난 뒤 다시 원인을 추적했다

    프로젝트가 끝난 뒤 Runtime 구조를 처음부터 다시 보기 시작했다. Mono Runtime 자체는 기존 팀원이 구현해둔 구조였고,

    나는 그 위에서 스켈레탈 애니메이션과 Animator, Quaternion, Entity API 등을 계속 확장하고 있었다. Runtime을 살펴보다가 Managed 객체를 그대로 보관하는 구조를 발견했다.

    cpp
    class ScriptInstance
    {
    		// ...
    
    private:
        MonoObject* m_Instance = nullptr;
    };
    

    그리고 실제 C# 메서드를 호출할 때도 그대로 사용하고 있었다.

    cpp
    m_ScriptClass->invokeMethod(
        m_Instance,
        m_OnUpdateMethod,
        &param
    );
    

    처음에는 이것이 왜 문제인지 이해하지 못했다.


    GC를 의심하기 시작했다

    Mono 문서를 읽기 시작하면서 처음으로 GCHandle이라는 개념을 알게 되었다. 당시에는 단순히

    "GC가 객체를 수집하지 못하도록 막는 용도"

    정도로만 이해했다. 그래서 첫 번째 수정에서는 GCHandle만 추가했다.

    cpp
    unsigned int m_GCHandle = 0;
    
    MonoObject* getManagedObject()
    {
        return mono_gchandle_get_target(m_GCHandle);
    }
    

    이 정도면 해결될 줄 알았다. 하지만 버그는 그대로였다.


    GCHandle을 만들었는데 왜 계속 터질까?

    이 부분에서 가장 오래 막혔다. GCHandle까지 추가했는데도 증상은 변하지 않았다. 그래서 Runtime 코드를 한 줄씩 다시 보기 시작했다.

    그러다 이상한 점을 발견했다. GCHandle은 생성하려고 했지만, 실제 메서드 호출은 여전히 기존 포인터를 그대로 사용하고 있었다.

    cpp
    m_ScriptClass->invokeMethod(
        m_Instance,
        m_OnUpdateMethod,
        &param
    );
    

    필드 접근도 마찬가지였다.

    cpp
    mono_field_get_value(
        m_Instance,
        field.m_ClassField,
        buffer
    );
    

    GCHandle은 만들어 놓고, 정작 실제 객체 접근은 한 번도 사용하지 않고 있었던 것이다.


    구조를 다시 수정했다

    먼저 ScriptInstance가 생성될 때 실제 GCHandle을 생성하도록 수정했다.

    cpp
    m_Instance = scriptClass->instantiate();
    
    m_GCHandle = mono_gchandle_new(
        m_Instance,
        false
    );
    

    그다음 Managed 객체를 사용하는 모든 위치를 수정했다. 기존에는 저장해 둔 m_Instance를 그대로 사용했다.

    cpp
    mono_field_get_value(
        m_Instance,
        field.m_ClassField,
        buffer
    );
    

    이를 다음과 같이 변경했다.

    cpp
    MonoObject* currentInstance =
        mono_gchandle_get_target(m_GCHandle);
    
    mono_field_get_value(
        currentInstance,
        field.m_ClassField,
        buffer
    );
    

    같은 방식으로

    • 생성자 호출
    • OnCreate
    • OnUpdate
    • Field Get
    • Field Set
    • Boolean Method 호출

    모든 접근 지점을 GCHandle 기반으로 수정했다. 마지막으로 ScriptInstance가 소멸될 때 GCHandle도 함께 해제하도록 변경했다.

    cpp
    ScriptInstance::~ScriptInstance()
    {
        mono_gchandle_free(m_GCHandle);
    }
    

    왜 이런 문제가 발생했을까?

    우리 엔진이 사용하는 Mono의 버전은 SGen GC를 사용하고 있었다. 문제는 C++에서 C# 객체를 처음 만들었을 때 얻은 MonoObject* 를 계속 저장해두고 사용했다는 점이다. 내가 추측한 흐름은 단순하다.

    text
    C# 객체 생성
    → C++이 처음 주소를 저장
    → GC 실행
    → 객체가 다른 주소로 이동할 수 있음
    → C++은 예전 주소를 계속 사용
    → 필드 값이 null이나 쓰레기값처럼 보임
    

    즉 카메라 계산식(스크립트)가 잘못된 것이 아니라, 카메라가 참조하던 Script 객체를 C++이 더 이상 올바르게 바라보지 못했을 가능성이 높았다. 그래서 위와 같이 mono_gchandle_get_target 이란 메소드 사용으로 매번 갱신 된 객체를 추적하도록 수정하여 문제를 해결 할 수 있었다. 다만 수정 당시에 GC 전후의 메모리 주소 로그를 찍어보지 않아 이 원인이 직접적이었다고 할 수는 없으나, GCHandle 기반으로 바꾼 뒤 해결됐다는 점은 확실하다.


    회고

    처음에는 카메라 버그라고 생각했다. 그다음에는 메모리 오염을 의심했다. 하지만 결국 원인은 Managed Runtime의 객체를 Native 포인터처럼 다루고 있었던 구조였다.

    이번 경험을 통해 가장 크게 배운 것은 기능 구현이 아니라 객체의 생명주기(Ownership) 였다. C++에서는 포인터 하나를 저장하면 끝나는 일이, Managed Runtime에서는 GC와 함께 생각해야 하는 문제가 된다.

    스켈레탈 애니메이션을 구현하다 시작된 버그였지만, 결국 Mono Runtime의 동작 방식을 가장 깊게 이해하게 만든 경험이었다. 그리고 아무리 다이어그램으로 만들어서 설명 할 려 해도 너무 어려워서 그림은 없다…