핫-리로드 구현 과정

Shell·2026년 3월 20일

해당 글은 ShellEngine의 핫-리로드를 만들 때의 기억을 되살려 작성한 글입니다.

개요

에디터가 어느 정도 완성되고 나서 실제 게임을 만들어 보려고 할 때, 컴포넌트 하나를 조금 고쳤는데 확인하려면 에디터를 끄고 다시 켜야 한다. 처음 한두 번은 그냥 넘어가도 열 번쯤 반복되면 슬슬 참기 어려워진다.

사용자 코드가 엔진 바깥 DLL로 분리되기 시작하면서 그 불편함이 더 다가왔다.
그래서 핫 리로드를 만들기 시작했다.
이론적으로는 간단해 보였다. DLL을 언로드하고, 새 DLL을 로드하면 되지 않나?
그런데 막상 만들기 시작하니 몇몇 문제에 부딪혔다.

첫번째 문제

Windows는 로드 중인 DLL을 건드리지 못하게 한다.

핫리로드를 구현하는 가장 첫 번째 문제는 OS 파일 잠금이었다.

Windows는 프로세스가 로드 중인 DLL 파일을 덮어쓰는 것을 허용하지 않는다.
빌드 시스템이 새 DLL을 써 넣으려 해도 파일이 잠겨 있으면 실패한다.
그래서 이를 해결 하기 위해 원본 파일을 직접 읽지 않고, temp.dll처럼 임시 파일로 복사한 뒤 그 복사본을 로드했다.

두번째 문제

리로드를 할 때 컴포넌트 타입을 교체할 때 사용자 타입은 월드 안에만 있지 않는 문제 때문에 골치였다.

  • 월드에는 사용자 컴포넌트 인스턴스가 살아 있다.
  • AssetDatabase에는 사용자 타입을 담은 ScriptableObject가 들어올 수 있다.
  • 에디터가 로드한 에셋 목록도 그 객체들을 참조하고 있다.

그래서 리로드 로직은 월드 저장과 복원을 넘어서야 했다.

onAssetImportedListener.SetCallback(
	[this](core::SObject* objPtr)
	{
		if (objPtr == nullptr)
			return;
		if (objPtr->GetType().IsChildOf(game::ScriptableObject::GetStaticType()))
			loadedScriptableObjects.insert(objPtr);
		loadedAssets.push_back(objPtr);
	}
);

이를 해결하기 위해 컴포넌트는 ComponentModule클래스에서 사용자 컴포넌트의 목록을, Project클래스에서는 사용자 ScriptableObject의 목록을 따로 보존했다가 언로드 전에 객체를 실제로 파괴하고, 새 모듈 로드 후 다시 import해서 복원하는 흐름이 붙었다.

초기 구조

여기까지 구현한 리로드 흐름은 대략 이랬다.

  • 1.월드 상태를 임시 저장
  • 2.사용자 컴포넌트 인스턴스를 찾아서 파괴
  • 3.GC를 돌려 정리
  • 4.컴포넌트 등록을 지우고, 언로드하고, 다시 로드
  • 5.저장해 둔 상태를 복원

문제는 이 모든 과정이 Project 하나에 들어 있었는데, 25년 중반에 구현한 Project 클래스는 그 시기에 파일 탐색기 역할도 하고, AssetDatabase와 연결되어 있고, 월드 저장과 복원도 알고, 모듈 로드 방식도 알고, 컴포넌트 등록 해제 방법까지 직접 처리하는 빅 클래스였다. 일단 돌아가긴 했지만 리로드 흐름을 고치려면 거의 모든 것을 다 알아야 했다.

또한 언제 리로드 할지도 중요했다. 엔진 중간에 요청하는 즉시 리로드를 해버리면 엔진이 터져버렸다.
엔진은 멀티스레드로 작동하고 있었고 얽힌 요소들이 많았기 때문에 적절할 때 리로드 해야 했다.
그래서 한 프레임이 끝난 시점인, 모든 월드 루프가 끝나고 스레드간 동기화가 이뤄지는 시점으로 리로드 요청을 미뤘다.

언로드를 명시적으로

ShellEngine은 자동 등록 기반 구조로 가고 있었다. 사용자 컴포넌트는 COMPONENT 매크로로 정적 등록되고, 인스펙터는 INSPECTOR로, 에셋은 SASSET으로 등록된다. 편리한 구조지만 리로딩 앞에서는 질문이 생겼다.

'이 등록은 언제 사라지는가?'

DLL이 내려가도 팩토리에는 여전히 죽은 타입의 생성 함수가 등록되어 있었다. 그걸 부르면 크래시다. 그래서 DLL이 언로드될 때 타입 등록도 함께 제거되도록 COMPONENT 매크로가 내부 정적 빌더의 소멸자에서 ComponentModule::DestroyComponent()를 호출하도록 바꿨다.

이전에는 리로드 코드가 등록 해제를 밖에서 수동으로 처리해야 했다. 이후에는 타입이 자기 자신을 정리하며 내려가게 됐다.
DLL을 언로드하면 해당 DLL영역에 있는 정적 클래스의 소멸자가 호출된다는 것을 직접 눈으로 보았다.

ComponentLoader - 책임 분리

리로딩 로직이 Project와 GameManager에 흩어져 있던 상태에서, 모듈 관련 책임을 ComponentLoader로 떼어내는 리팩토링을 했다.

ComponentLoader의 책임은 정확히 세 가지다. 플러그인 경로를 OS별 실제 바이너리 경로로 변환한다. 모듈을 로드하고 언로드한다. 로드 직후 ComponentModule의 대기 목록을 읽어 컴포넌트 등록을 확정한다.

분리 이후 Project::ReloadModule()은 전체 순서를 결정하되, 모듈을 직접 처리하는 방식은 더 이상 알지 않는다. 게임 런타임과 에디터 리로드의 경계도 조금 더 명확해졌다. 책임이 나뉘고 나서야 리로드 흐름 전체가 한눈에 읽히기 시작했다.

중복 등록 문제

모듈이 다시 올라왔을 때 정적 등록이 중복되는 경우가 생겼다.
SASSET이 DLL마다 같은 타입을 팩토리에 두 번 등록하는 일이 실제로 있었다.

수정은 간단했다. 무조건 등록하는 대신 키 존재 여부를 먼저 확인하고 없을 때만 등록하게 바꿨다.

완성된 리로드 순서

최종 구조에서 리로드는 즉시 실행되지 않는다. 현재 프레임이 끝난 뒤로 미뤄진다.
렌더링이나 업데이트가 진행 중인 프레임 안에서 타입 정의가 사라지면 참조 무효화가 훨씬 쉽게 터지기 때문이다.

  1. 각 월드의 현재 상태를 임시 world point로 저장한다.
  2. 월드 안의 사용자 컴포넌트 인스턴스를 파괴한다.
  3. 사용자 ScriptableObject를 추적해서 모두 파괴한다.
  4. GC를 두 번 돌며 pending kill 객체와 GCObject까지 정리한다.
  5. 플러그인을 언로드한다.
  6. 새 플러그인을 다시 로드한다.
  7. ScriptableObject를 AssetDatabase에서 다시 import한다.
  8. 각 월드의 임시 상태를 다시 로드한다.
  9. 임시 world point를 삭제한다.

처음의 목표는 에디터를 끄지 않는 것이었지만, 끝에 가서는 엔진이 동적 코드 교체를 견딜 수 있게 여러 층을 같이 다듬은 작업이 됐다.


배운점

  • 핫리로드는 로더 구현 하나로 끝나지 않는다. 월드, 에셋, GC, 자동 등록이 모두 함께 움직여야 한다.
  • 언로드는 로드보다 어렵다. 새 DLL을 여는 건 쉽지만 기존 인스턴스와 등록 정보를 안전하게 비우는 건 훨씬 어렵다.
  • 상태를 버리지 않고 복원하려면 직렬화가 중요했다.
  • 책임 분리는 늦더라도 결국 필요하다. Project가 다 알고 있던 구조에서 ComponentLoader를 떼어내고 나서야 흐름이 쉽게 읽혔다.
profile
개발하며 배웠던 것들 기록용 블로그

0개의 댓글