해당 글은 ShellEngine의 GC를 만들 때의 기억을 되살려 작성한 글입니다.
엔진을 직접 만들기 시작했을 때, 가장 먼저한 고민은 바로 메모리 관리와 라이프 타임, 그리고 누가 이 객체를 해제해야 하는가이다.
World가 GameObject를 가지고, GameObject가 Component를 가지고, 컴포넌트가 또 다른 SObject를 참조한다. 에디터까지 붙으면 선택 상태, 프리팹, 직렬화 데이터까지 수명 문제에 끼어들테고, 멀티스레드까지 생각하면 엄청나게 복잡해질 것이라 예상했다.
또한 이전에 C++을 다룰 때 직접 delete로 관리할 때 반드시 이 두 상황 중 하나를 만났다.
그래서 리플렉션 기반 참조 추적을 이용한 mark-and-sweep 방식의 GC를 만들기로 했다.
처음에는 간단해 보였고, 실제로도 처음엔 간단했다.
위에서 말한 두 문제는 스마트 포인터로도 해결되긴 한다.
하지만 스마트 포인터도 엔진과 같은 대형 프로젝트에서 쓸 때 아래의 문제점이 있다.
스마트 포인터를 쓰더라도 여전히 프로그래머는 이 객체를 누가 소유 할 것이고 언제 해제 될 것인지 알아야 한다. 즉, 객체 소유 구조를 사람이 잘 설계해야 한다.
순환 참조 문제도 weak_ptr을 쓰면 해결 할 수 있겠지만 어디를 weak_ptr로 해야 하는지 항상 정확히 판단해야 한다. 이는 실수하기 쉬운 구조가 된다.
결정적으로, 엔진을 사용하는 사용자 입장에서 모든 소유 관계를 일일이 어떻게 설계할지 생각하지 않아도 엔진이 알아서 처리해주니 생산성이 증가한다.
이러한 이유들로 스마트 포인터는 엔진에서 raw한 부분 외에는 쓰지 않게 설계했다.
SObject의 수명을 엔진이 관리하게 하기
관련 커밋: 24f565e, be4f988 - 2024년 5월쯤
엔진에서 사용 할 오브젝트는 SObject를 상속하게 만들었다.
이 부분은 언리얼 엔진을 다뤄봤던 경험에서 아이디어를 얻었다.

SObject 목록을 SObjectManager가 관리하며 모든 SObject를 등록하고, RootSet에서 참조를 따라가고, 방문되지 않은 객체를 회수한다. 엔진 전반의 수명 관리 규칙이 하나로 통일됐고, 파괴 시점을 직접 추적하느라 로직이 흩어지던 문제가 사라졌다.
하지만 사용하다보니 문제가 생겼는데, 컨테이너 안에 들어있거나 SObject객체 내부의 SObject객체는 어떻게 추적할지였다.
class Derived : public Base
{
SCLASS(Derived)
private:
PROPERTY(other)
Derived* other = nullptr;
}
GC이전에 런타임 리플렉션 시스템을 개발해뒀다.
언리얼처럼 PROPERTY()매크로로 멤버를 등록하면 런타임중에 정보를 읽을 수 있다.
그 정보를 GC가 그대로 읽으면 어떨까.
결과는 꽤 깔끔했다. SObject* 타입 프로퍼티를 참조로 간주하고, 컨테이너 원소 타입도 메타데이터로 해석하니, 별도의 등록 코드 없이 대부분의 참조가 자동으로 추적됐다.
그러나 중첩 컨테이너의 경우가 문제였다. 컨테이너 내부 컨테이너의 SObject*를 제대로 못 쫓으면 살아 있는 참조인데 GC가 회수해버리는, 재현도 어려운 크래시가 생긴다.
그래서 리플렉션을 수정해서 컨테이너 프로퍼티를 추상화해 iterator 기반 순회, 중첩 컨테이너 지원, 무효 객체 정리와 null 처리 안정화 작업이 이어졌다.
중첩 컨테이너의 원소 타입을 알아내거나 몇번 중첩 돼 있는지는 템플릿 메타 프로그래밍으로 해결했다!
엔진 규모가 커질수록 GC가 프레임 루프에 부담을 주기 시작했다. 참조를 많이 따라갈수록 비용이 선형인 O(n)으로 늘어나니 당연한 결과였다. 시험삼아 1만개의 오브젝트가 한번에 지워지웠을 때 프리징도 일어났다.
그래서 성능 향상을 위해 멀티스레딩을 이용하기로 했다.
무턱대고 스레드를 붙인 게 아니었다.
먼저 추적 구조를 정리하고, 병렬화 가능한 단위를 분리하고 멀티스레드 마킹을 도입했다.
마킹에 mutex와 같은 락을 쓰면 안 쓰니만도 못하다고 생각해 마킹 변수는 atomic_flag의 test_and_set을 이용해 원자적으로 비교하고 처리하기로 했다.
만약 이미 마킹이 돼 있다면 그 스레드에서는 다른 SObject를 추적하게 만들었다.
// 마킹 과정
for (SObject* objPtr : objs)
{
if (!objPtr->bMark.test_and_set(std::memory_order::memory_order_relaxed))
{
if (!objPtr->bPendingKill)
objPtr->OnDestroy();
}
}
성능 측정 결과 18.59%정도의 성능 향상이 있었다. 감으로 20퍼 정도가 빨라졌다는 것이고, 프레임상 1.5ms 정도가 줄어들었는데, 게임에서 1ms의 차이도 크기 때문에 만족할만한 결과였다.
글 기준 최근(2026-02)에 작업한 일이다.
한 프로젝트를 진행하던 중 구조상 문제점이 생겨서 개발하게 됐다.
관련 커밋: 921d69e, 439e0f9
리플렉션 기반 추적은 강력하지만, 모든 참조가 항상 리플렉션 가능한 클래스 안에만 있지는 않다. 상태 머신 노드, 애니메이션 상태 데이터, 캐시용 네이티브 구조체... 이런 것들은 SObject가 아니지만 내부에 SObject*를 들고 있을 수 있다.
struct AnimationFrame : core::GCObject
{
render::Texture* texture = nullptr; // Texture는 SObject
SH_USER_API void PushReferenceObjects(core::GarbageCollection& gc) override
{
gc.PushReferenceObject(texture);
}
};
그래서 그러한 것들을 GCObject를 상속 받는 구조로 만들었다.
구조체가 내가 들고 있는 참조가 무엇인지를 스스로 알고, PushReferenceObject()를 오버라이딩 하여 GC에 무엇을 추적할지 전달한다. 이전까지는 GC가 알아서 추적하는 시스템이었다면, 이후에는 자동 추적 + 수동 추적이 결합된 시스템이 됐다.
이 변화가 중요한 이유는, 이전 같은 전용 추적 구조는 시스템이 커질수록 예외 처리가 쌓이기 때문이다.
이전에는 SVector나 SMap같은 추적용 컨테이너를 만들 때 GC코드를 수정하고 trackingContainers에 집어 넣었다.
즉, 새로운 참조 보관 형태가 생길 때마다 GC 코드에 분기가 늘어났다. 하지만 GCObject를 상속 받아 PushReferenceObjects를 구현하는 구조로 바뀌었기 때문에, GC 자체는 새로운 타입에 무관해졌다.
컴파일 시간을 줄이기 위해서나, 헤더간 순환 참조 때문에 전방 선언을 통해 클래스를 선언하는 경우가 있다.
그러나 템플릿을 이용해 타입 정보를 기록하는데, 전방선언을 한 클래스의 경우 완전한 정보가 아니기 때문에 GC는 SObject타입의 포인터 프로퍼티를 추적하지 못하게 되어 문제가 생겼다.
PROPERTY(player, core::PropertyOption::sobjPtr)
Player* player = nullptr;
그래서 그런 부분은 프로퍼티를 통해 수동으로 알려주기로 했다.
전방선언을 이용하면 컴파일러는 알 수가 없으니 프로퍼티에 명시적으로 "이 포인터는 SObject타입의 포인터다!" 라는 것을 알려주기 위한 옵션을 추가했다.
사실 이 부분은 조금 불편했다. 프로그래머가 실수하면 그대로 문제가 터지는 구조기 때문이다.
완벽히 해결하려면 언리얼의 UHT처럼 사전에 외부 툴이 파싱해서 메타 데이터를 가지게 하는 방식이어야 해결 할 수 있을 것 같다.
만들면서 왜 언리얼 엔진이나 상위 언어들이 GC기반으로 돌아가는지 심층적으로 이해할 수 있었다.
엔진을 통해 게임을 만들면서 가장 좋은점은 프로그래머가 복잡한 메모리 관리를 안해도 된다는 점이다.
GC를 만들기전에는 이러한 환상이 있었다.
"프로그래머가 메모리 관리를 철저히 하면 성능이 뛰어난 프로그램을 만들 수 있는 거 아닌가??"
맞는 말이다. 하지만 너무 실수하기가 쉽다. 특히 댕글링 포인터 문제가 치명적이고, 멀티스레딩 환경과 게임과 같이 얽힌 게 많은 시스템에선 모든 객체의 라이프 타임을 추적하기 힘들다.
결국 GC는 생산성도 높여주고 안정성도 높여주기 때문에 많은 언어와 게임 엔진이 채택하고 있던 것이었다.