배경
UE5(5.4)로 개발 중인 모바일 게임에 Google AdMob 보상형 광고를 연동하고 있었다. Android에서는 정상적으로 동작했지만, iOS Shipping 빌드에서 앱 시작 약 1.8초 후 100% 확률로 크래시가 발생했다.
Exception Type: EXC_CRASH (SIGABRT)
___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED
std::__1::basic_string::~basic_string()
GADOnDeviceFeaturesManager → GADPersistentStateMonitor → GADPostNotificationFromMainQueue
핵심 에러 메시지는 POINTER_BEING_FREED_WAS_NOT_ALLOCATED. 시스템의 free()가 자신이 할당하지 않은 포인터를 해제하려다 실패한 것이다.
원인: UE5 메모리 할당자 충돌
UE5의 전역 operator new/delete 오버라이드
UE5는 ModuleBoilerplate.h에서 전역 operator new와 operator delete를 자체 메모리 할당자(FMallocBinned2 또는 FMallocBinned3)로 오버라이드한다.
// ModuleBoilerplate.h (간략화)
#define REPLACEMENT_OPERATOR_NEW_AND_DELETE \
void* operator new(size_t Size) { return FMemory::Malloc(Size); } \
void operator delete(void* Ptr) { FMemory::Free(Ptr); }
일반적인 iOS 앱에서는 문제가 없다. 하지만 UE5처럼 전역 할당자를 교체한 환경에서 정적 라이브러리(AdMob SDK의 GoogleMobileAds.xcframework)를 같은 바이너리에 링크하면 상황이 달라진다.
충돌 메커니즘
- AdMob SDK 내부에서 Objective-C/CoreFoundation을 통해 시스템 malloc으로 메모리 할당
- SDK의 C++ 코드(
std::string등)가 소멸될 때operator delete호출 - 이
operator delete는 UE5가 오버라이드한 버전 →FMallocBinned::Free()실행 - FMallocBinned는 자신이 할당하지 않은 포인터를 받아 SIGABRT
정리하면, 할당은 시스템 malloc으로, 해제는 UE5의 FMallocBinned로 가는 불일치(mismatch)가 크래시의 근본 원인이다.
왜 iOS에서만 발생하는가
- iOS Shipping 빌드에서
FMallocBinned가 기본 할당자로 사용된다 - Windows/Mac Editor에서는
FMallocTBB또는 ANSI 할당자를 사용하므로 문제가 드러나지 않는다 - Android에서도 동일한 문제가 잠재적으로 존재하지만, AdMob Android SDK는 Java/JNI 기반이라 C++ 할당자 충돌이 발생하지 않는다
첫 번째 해결: FORCE_ANSI_ALLOCATOR
원인을 확인하기 위해 UE5의 커스텀 할당자를 비활성화하고 시스템 malloc을 사용하도록 설정했다.
// SuperPlatM.Target.cs
if (Target.Platform == UnrealTargetPlatform.IOS)
{
GlobalDefinitions.Add("FORCE_ANSI_ALLOCATOR=1");
}
이 한 줄로 크래시가 사라졌다. 모든 코드가 동일한 시스템 malloc/free를 사용하게 되면서 할당자 불일치가 해소된 것이다.
이 방식은 다른 서드파티 라이브러리(whisper.cpp 등)에서도 동일한 문제의 공식 해결법으로 제시되고 있다. UE 5.6 이상에서는 아예 정식 API가 추가되었다:
// UE 5.6+
bOverrideBuildEnvironment = true;
StaticAllocator = StaticAllocatorType.Ansi;
FORCE_ANSI_ALLOCATOR의 단점
- UE5의 최적화된
FMallocBinned를 포기하고 시스템 malloc을 사용 - 이론적으로 메모리 할당 속도 저하, 단편화 증가
- iOS 전체 빌드에 영향 (Android는 해당 없음)
실제로 2D 모바일 게임 수준에서는 체감 차이가 거의 없었지만, 근본적인 해결은 아니었다.
실패한 시도: Dynamic Framework 래퍼
FORCE_ANSI_ALLOCATOR=1의 성능 영향을 피하기 위해 AdMob SDK를 동적 프레임워크로 감싸는 방법을 시도했다.
이론: 동적 라이브러리는 자체 심볼 공간을 가지므로, 내부에서 사용하는 operator new/delete가 UE5의 오버라이드 버전이 아닌 시스템 기본 버전을 사용하게 된다.
구현: AdMobBridge.framework라는 동적 프레임워크를 만들어 GoogleMobileAds SDK를 정적 링크하고, Objective-C 인터페이스만 외부에 노출했다.
# clang으로 동적 프레임워크 빌드
clang -arch arm64 -dynamiclib -all_load \
-framework GoogleMobileAds \
-fvisibility=hidden \
AdMobBridge.o -o AdMobBridge.framework/AdMobBridge
결과: 실패. 여러 문제가 연쇄적으로 발생했다.
- UE5가
.m파일을 자동 컴파일 — Bridge 소스가 UE5 빌드에 포함되면서-stdlib=libc++플래그 충돌 @import구문 비호환 — UE5는 C++ modules를 비활성화하므로@import Foundation사용 불가- 심볼 미노출 —
-fvisibility=hidden이 ObjC 클래스 심볼까지 숨김 - UE5 빌드 시스템의 동적 프레임워크 미지원 —
PublicAdditionalFrameworks가 정적 프레임워크 전제로 설계되어-F(framework search path) 플래그가 제대로 생성되지 않음
결국 UE5의 iOS 빌드 파이프라인이 커스텀 동적 프레임워크를 제대로 지원하지 않는다는 결론에 도달했다.
근본 해결: 엔진 소스 수정
이후 조사 과정에서 UE5 커뮤니티의 두 가지 분석 문서를 발견했다. 두 문서 모두 동일한 근본 원인을 지적하고 있었다.
진짜 원인: strip이 operator new/delete 심볼을 제거한다
iOS Shipping 빌드 과정에서 UE5의 빌드 툴이 바이너리를 strip하는데, 이때 operator new/delete의 전역 심볼이 제거된다.
// IOSToolChain.cs (line 1112)
string StripArguments = BinaryLinkEnvironment.bIsBuildingDLL ? "-x" : "";
// DLL이 아닌 경우 → 빈 문자열 → 모든 심볼 strip
심볼이 제거되면:
- 게임 바이너리 내부 코드 → UE5의 커스텀 할당자 사용
- 바이너리 외부 코드(libc++의
std::string등) → 심볼을 찾지 못해 시스템 기본 할당자로 fallback - 할당자 불일치 발생 → 크래시
문서에서는 3가지 mismatch 유형을 정리하고 있었다:
- 파라미터 mismatch — C++17/20에서 추가된 operator 서명이 누락
- 최적화 mismatch — 컴파일러가 미사용으로 판단하여 operator를 삭제
- strip mismatch — 패키징 시 전역 심볼이 제거됨
수정 1: strip 파라미터 변경
// IOSToolChain.cs (수정 후)
string StripArguments = "-x"; // 전역 심볼(operator new/delete) 보존
-x 옵션은 로컬/디버그 심볼만 제거하고, 전역 공개 심볼은 보존한다.
수정 2: operator new/delete에 visibility 속성 추가
// ModuleBoilerplate.h에 추가
#if PLATFORM_ANDROID || PLATFORM_IOS
#define MEMORY_P __attribute__((used, visibility("default")))
#else
#define MEMORY_P
#endif
MEMORY_P를 모든 operator new/delete 선언에 적용:
#define REPLACEMENT_OPERATOR_NEW_AND_DELETE \
OPERATOR_NEW_MSVC_PRAGMA MEMORY_P void* operator new(size_t Size) ... \
MEMORY_P void operator delete(void* Ptr) ...
used: 컴파일러가 미사용으로 판단하여 최적화 삭제하는 것을 방지visibility("default"): strip 시 전역 심볼로 보존
검증 방법
수정 후 빌드된 바이너리에서 operator delete 심볼이 살아있는지 확인:
nm -gU SuperPlatM-IOS-Shipping | c++filt | grep 'operator delete'
심볼이 출력되면 성공이다.
정리
| 해결 방법 | 방식 | 성능 | 난이도 |
|---|---|---|---|
FORCE_ANSI_ALLOCATOR=1 |
UE5 할당자 비활성화 | 시스템 malloc (느림) | Target.cs 1줄 |
| Dynamic Framework 래퍼 | SDK를 동적 라이브러리로 격리 | 영향 없음 | UE5 빌드 시스템 미지원으로 실패 |
| 엔진 소스 수정 | strip/visibility 수정 | FMallocBinned 유지 (최적) | 엔진 파일 2개 수정 |
핵심 포인트
- UE5는 전역
operator new/delete를 자체 할당자로 오버라이드하지만, iOS Shipping 빌드의 strip 과정에서 이 심볼이 제거되어 서드파티 정적 라이브러리와 할당자 불일치가 발생한다 FORCE_ANSI_ALLOCATOR=1은 빠른 우회책이지만 최적화된 할당자를 포기해야 한다- UE 5.6 이상에서는
StaticAllocator = StaticAllocatorType.AnsiAPI가 추가되었다 - 근본 해결은
IOSToolChain.cs의 strip 파라미터를-x로 변경하고,ModuleBoilerplate.h에서 operator에__attribute__((used, visibility("default")))를 적용하는 것이다 - 이 문제는 AdMob뿐 아니라 C++ 코드를 포함하는 모든 iOS 정적 라이브러리(Firebase, whisper.cpp 등)에서 동일하게 발생할 수 있다