서론: 유니티(Unity) 엔진 모딩의 동작 원리
유니티(Unity) 엔진은 현대 PC 인디 및 중견 게임 생태계의 상당수를 지탱하고 있습니다. 《발헤임(Valheim)》이나 《리설 컴퍼니(Lethal Company)》 같은 협동 생존 게임부터 《시티즈: 스카이라인 2(Cities: Skylines II)》, 《다이슨 스피어 프로그램(Dyson Sphere Program)》과 같은 복잡한 시뮬레이션에 이르기까지 수많은 명작들이 유니티로 제작되었습니다. 독자적인 바이너리 파일이나 베데스다 스타일의 선언적 플러그인 데이터 구조를 사용하는 다른 게임 엔진들과 달리, 유니티 프로젝트는 .NET 및 C# 소프트웨어 아키텍처를 기반으로 구축됩니다.
유니티 게임을 모딩할 때 디스크 상의 원본 애셋 아카이브를 직접 수정하거나 덮어쓰는 방식은 거의 사용되지 않습니다. 대신 현대 유니티 모딩은 런타임 메모리 상에서의 동적 어셈블리 주입 및 메서드 가로채기(인터셉션) 기법을 핵심으로 작동합니다. 이 생태계의 중심에는 오픈 소스 모드 인젝터이자 플러그인 부트스트래퍼인 BepInEx(Bepis Injector Extensible)와 안드레아스 파르데이케(Andreas Pardeike)가 개발한 Harmony 라이브러리가 자리 잡고 있습니다.
그러나 모드 개발자와 게이머들은 게임에 따라 모드가 전혀 실행되지 않거나 충돌하는 호환성 문제를 자주 겪습니다. 그 근본 원인은 유니티의 스크립트 컴파일 구조에 있습니다. 바로 전통적인 Mono 런타임 백엔드와 사전 컴파일 방식인 IL2CPP(Intermediate Language to C++) 백엔드 사이의 근본적인 아키텍처 격차 때문입니다. BepInEx와 Harmony가 이 두 백엔드를 처리하는 내부 메커니즘을 파악해야만 충돌, 비정상 후킹, 로드 순서 오류를 정확히 진단하고 해결할 수 있습니다.
스크립팅 백엔드의 양대 산맥: Mono와 IL2CPP 구조 비교
게임 개발자가 유니티 프로젝트를 빌드하여 배포할 때, 다음 두 가지 스크립팅 백엔드 중 하나를 반드시 선택해야 합니다.
[C# 소스 코드: 게임 로직 스크립트]
│
▼
[Roslyn / Unity C# 컴파일러] ---> 공통 중간 언어 (CIL 바이트코드)
│
├─────────────────────────────────────────┐
▼ ▼
[Mono 백엔드 경로] [IL2CPP 백엔드 경로]
- 관리형 어셈블리에 CIL 바이트코드 패키징 - IL2CPP 트랜스파일러가 CIL을 C++로 변환
- 대표 파일: Managed/Assembly-CSharp.dll - 플랫폼 네이티브 C++ 컴파일러가 GameAssembly.dll 생성
- Mono JIT 가상 머신에 의해 런타임 실행 - 메타데이터를 global-metadata.dat 파일로 분리
- 리플렉션 완전 지원, 소스 코드 역컴파일 용이 - 순수 기계어 바이너리, 런타임 CLR/JIT 부재
Mono 스크립팅 백엔드
Mono 방식으로 빌드된 게임에서는 모든 게임플레이 규칙, AI 제어 로직, 캐릭터 상태 머신이 관리형 .NET 어셈블리(주로 Managed/Assembly-CSharp.dll)로 패키징됩니다.
- 런타임 가상 머신: 게임이 실행되면 내장된 Mono JIT(Just-In-Time) 가상 머신이 CIL(Common Intermediate Language) 바이트코드를 메모리에 로드하고 즉시 컴파일하여 실행합니다.
- 풍부한 메타데이터 보존: 클래스 구조, 메서드 시그니처, 전용 필드, 변수 타입 등의 정보가 어셈블리 메타데이터 내에 원본 그대로 보존됩니다.
- 탁월한 모딩 접근성: dnSpy나 ILSpy 같은 역컴파일 도구를 사용하면
Assembly-CSharp.dll을 거의 완벽한 C# 원본 코드로 복원하여 분석할 수 있으며, .NET 표준 리플렉션을 통해 동적으로 코드를 가로채거나 수정하기가 매우 수월합니다.
IL2CPP 스크립팅 백엔드
반면 IL2CPP 방식으로 빌드된 게임은 완전히 다른 컴파일 파이프라인을 거칩니다.
- C++ 트랜스파일: C# 컴파일러가 생성한 CIL 바이트코드를 유니티의 IL2CPP 툴체인이 분석하여 수천 개의 네이티브 C++ 소스 코드로 변환(트랜스파일)합니다.
- 네이티브 기계어 바이너리: 변환된 C++ 소스는 C++ 컴파일러를 통해 플랫폼 네이티브 기계어 라이브러리인
GameAssembly.dll로 직접 컴파일됩니다. - 메타데이터 분리 및 스트리핑: 전통적인 공통 언어 런타임(CLR)이 바이너리에서 완전히 제거됩니다. 타입 이름과 심볼 정보는 암호화 및 난독화된 별도의
global-metadata.dat파일로 분리 추출됩니다. - 모딩 환경의 제약: 런타임에 로드할 관리형 C# 어셈블리가 존재하지 않습니다. 전통적인 C# 모드 DLL을 디렉터리에 넣더라도 이를 해석하고 구동할 가상 머신이 없으므로 모드가 전혀 작동하지 않습니다.
BepInEx 부트스트래퍼 아키텍처
외부 코드를 실행할 공식 진입점이 없는 닫힌 유니티 바이너리에 모드를 로드하기 위해, BepInEx는 동적 링크 라이브러리(DLL) 프록시 하이재킹 기법을 사용합니다.
Doorstop / 프록시 하이재킹 체인
윈도우 운영체제는 실행 파일이 필요로 하는 동적 라이브러리를 찾을 때, 시스템 디렉터리(C:\Windows\System32)를 조회하기 전에 먼저 실행 파일이 위치한 루트 디렉터리를 우선 탐색합니다. BepInEx는 이 순서를 활용합니다.
- 프록시 DLL 배치: 게임 실행 파일이 있는 폴더에 윈도우 기본 시스템 라이브러리와 이름이 같은 프록시 DLL(
winhttp.dll,version.dll등)을 배치합니다. - 실행 흐름 가로채기: 사용자가 게임을 실행하면 윈도우 로더는 시스템 폴더 대신 게임 폴더의
winhttp.dll을 우선적으로 메모리에 로드합니다. - 런타임 환경 부트스트랩: 로드된 프록시 DLL은 Unity Doorstop을 구동하고, 유니티 엔진의 본체 로직이 초기화되기 전에
BepInEx.Preloader.dll과BepInEx.Core.dll을 프로세스 메모리 공간에 신속하게 주입합니다. - 플러그인 탐색 및 마운트: 환경 구축이 완료되면 BepInEx는 디버그 콘솔을 활성화하고 로깅 시스템을 시작한 뒤,
BepInEx/plugins/디렉터리에 존재하는 모드 파일들을 검색하여 메모리에 마운트합니다.
BepInEx 5 대 BepInEx 6: IL2CPP 상호 운용 브리지
- BepInEx 5: 오랫동안 사용되어 온 안정적인 버전으로, Mono 백엔드 게임 전용입니다. 게임에 내장된 원본 Mono 런타임을 그대로 활용하여
BaseUnityPlugin기반의 C# 모드를 실행합니다. - BepInEx 6: Mono뿐만 아니라 IL2CPP 기반 게임까지 완벽하게 지원하도록 아키텍처를 전면 재설계한 버전입니다. IL2CPP 게임에서 BepInEx 6는 Il2CppInterop(구 Unhollower) 모듈을 사용합니다. 최초 실행 시
GameAssembly.dll과global-metadata.dat를 스캔하여 가상의 C# 래퍼 어셈블리를BepInEx/interop/폴더에 자동으로 생성합니다. 이를 통해 모드 개발자는 네이티브 C++ 메모리 구조를 직접 다루지 않고도 익숙한 C# 객체 지향 문법으로 모드를 제작할 수 있습니다.
Harmony: 메모리 상에서의 동적 메서드 패칭 기술
BepInEx가 모드 코드를 메모리에 올려놓으면, 실제 게임 기능 변경은 Harmony 라이브러리가 담당합니다. Harmony는 디스크 상의 원본 파일을 단 1바이트도 건드리지 않고, JIT 컴파일된 메모리 상의 메서드 프롤로그 코드를 동적으로 재작성하여 작동합니다.
Harmony의 4대 핵심 패치 유형
[호출되는 원본 메서드: 예: PlayerController.TakeDamage(float amount)]
│
▼
[Harmony Prefix 패치] ---> true 반환(계속 진행) 또는 false 반환(원본 스킵)
│
▼
[원본 게임 메서드 실행: 체력 차감 및 판정 연산]
│
▼
[Harmony Postfix 패치] ---> 반환값(__result) 검사 및 수정, 추가 로직 실행
│
▼
[Harmony Finalizer 패치] ---> 예외 처리(try/finally 역할), 게임 강제 종료 방지
- 전위 패치 (Prefix,
[HarmonyPrefix]):- 원본 메서드가 실행되기 직전에 가장 먼저 가로채 실행됩니다.
- 메서드로 전달되는 인자 값을 실시간으로 읽거나 수정할 수 있습니다(예: 피격 데미지
float amount수치 변경). - 실행 흐름을 완전히 차단할 수 있습니다. Prefix가
false를 반환하면 원본 게임 메서드의 실행 자체가 건너뛰어집니다.
- 후위 패치 (Postfix,
[HarmonyPostfix]):- 원본 메서드의 실행이 정상적으로 끝난 직후에 실행됩니다.
- 특수 매개변수인
ref __result를 통해 원본 메서드의 최종 반환값을 가로채거나 덮어쓸 수 있습니다. - 아이템 제작 완료 시 업적을 달성하게 하는 등 파생 이벤트를 추가할 때 가장 안정적인 선택지입니다.
- 트랜스파일러 패치 (Transpiler,
[HarmonyTranspiler]):- 가장 정밀하고 강력한 패치 방식입니다. 대상 메서드의 CIL 바이트코드를 JIT 컴파일 전에 명령어 열(
IEnumerable형태의CodeInstruction)로 가로챕니다. - 바이트코드 단계에서 특정 연산자(OpCode)를 삭제, 삽입, 치환할 수 있어 매우 미세한 로직 수정이 가능합니다.
- 가장 정밀하고 강력한 패치 방식입니다. 대상 메서드의 CIL 바이트코드를 JIT 컴파일 전에 명령어 열(
- 종결자 패치 (Finalizer,
[HarmonyFinalizer]):- 패치된 메서드를 감싸는 동적
try/finally블록 역할을 수행합니다. 메서드 내부에서 발생한 치명적인 예외를 안전하게 포착하고 무마하여, 모드 오류로 인해 게임이 바탕화면으로 튕기는 현상을 방지합니다.
- 패치된 메서드를 감싸는 동적
고장 진단 매트릭스: 유니티 모딩 오류 해결 가이드
유니티 모드가 정상 로드되지 않거나 실행 중 강제 종료될 경우, 아래 진단 매트릭스를 통해 아키텍처 수준의 결함을 점검할 수 있습니다.
| 문제 현상 | 근본적인 원인 | 진단 확인 방법 | 해결 조치 |
|---|---|---|---|
| 콘솔 미표시 / 모드 완전 무반응 | 프록시 DLL이 OS 로더에 의해 차단되거나 누락됨 | BepInEx/LogOutput.log 파일이 생성되지 않음 | 루트의 winhttp.dll 파일 확인 및 폴더 쓰기 권한 점검 |
| 게임 실행 즉시 바탕화면 튕김 (IL2CPP) | IL2CPP 게임에 구형 BepInEx 5를 설치함 | 루트에 GameAssembly.dll이 존재하나 BepInEx 5 구동 | 기존 BepInEx 삭제 후 BepInEx 6 x64 IL2CPP 전용 빌드 설치 |
| 상호 운용(Interop) 캐시 충돌 | 게임이 업데이트되었으나 interop 래퍼가 미갱신됨 | 로그에 TypeLoadException 또는 누락 메서드 에러 발생 | BepInEx/interop/ 폴더를 완전히 삭제하고 게임 재실행하여 재생성 |
| Prefix 후크 충돌 | 여러 모드가 동일 메서드에서 false를 반환함 | 특정 모드만 동작하고 다른 모드 기능이 먹통이 됨 | 플러그인 로드 우선순위 재조정 및 파괴적 Prefix 우회 자제 |
| 안티치트 부트스트랩 차단 | EAC 또는 배틀아이가 프록시 DLL 주입을 감지함 | 게임이 즉시 종료되거나 신뢰할 수 없는 시스템 파일 경고 | 공식 오프라인 모드로 실행하거나 개발자 전용 모딩 실행 플래그 적용 |
다양한 게임 엔진 전반의 모드 로드 순서 정렬, 어셈블리 충돌 디버깅 및 커스텀 C# 확장 도구 개발에 관한 심층적인 기술 분석은 365Finds의 전문 지식 베이스를 통해 확인할 수 있습니다. 메모리 상주 원리, 프록시 주입 및 비파괴적 바이트코드 패칭을 올바르게 이해하면 대규모 게임 업데이트 후에도 안정적인 모딩 환경을 유지할 수 있습니다.