유니티 프로젝트에서 이름(문자열)으로 메서드/필드/프로퍼티를 찾거나 호출하는 순간, 대부분 “리플렉션(Reflection) 계열” 문제(성능/안정성/스트리핑)를 만나게 됩니다.
이 글은 Unity + IL2CPP(AOT) 환경을 기준으로, “언제 써도 되는지/언제 피해야 하는지/피할 수 없으면 어떻게 안전장치를 거는지”를 정리합니다.
0) 한 줄 요약
link.xml/[Preserve]는 코드가 빌드에서 삭제(스트리핑)되는 문제를 막는 장치입니다.- 하지만 리플렉션 호출이 느린 것(탐색/메타데이터 접근 비용) 자체를 빠르게 만들진 못합니다.
- 런타임(특히 매 프레임) 에서 리플렉션을 쓰면 성능이 무너질 수 있고, IL2CPP(AOT) 에서는 “에디터에서는 OK, 기기에서는 실패” 패턴이 쉽게 나옵니다.
1) 유니티에서 흔히 등장하는 “리플렉션/문자열 호출” 패턴들
아래는 유니티 개발에서 많이 보이는 대표적인 문자열 기반 호출입니다. (직접 Reflection API를 쓰지 않더라도 결과적으로 비슷한 위험을 가집니다.)
1-1. SendMessage / BroadcastMessage
gameObject.SendMessage("OnDamage", 10f, SendMessageOptions.DontRequireReceiver);
gameObject.BroadcastMessage("OnReset");
- 문자열로 메서드 이름을 찾아 호출합니다.
- 컴파일 타임 체크가 없어서 오타가 런타임에야 터집니다.
- 성능상 비추천(특히 빈번 호출).
1-2. Invoke / InvokeRepeating
Invoke("SpawnEnemy", 2f);
InvokeRepeating("Tick", 1f, 0.2f);
- 내부적으로 문자열 기반으로 메서드를 찾습니다.
- IL2CPP/스트리핑 상황에서 “찾을 메서드가 없어짐” 류 문제가 날 수 있습니다.
1-3. StartCoroutine("MethodName") / StopCoroutine("MethodName")
StartCoroutine("MyRoutine");
StopCoroutine("MyRoutine");
- 문자열 기반 코루틴은 직접 참조 방식보다 안전성과 유지보수성이 떨어집니다.
- 가능하면 아래처럼 바꾸는 게 정석입니다:
StartCoroutine(MyRoutine());
1-4. GetComponent("TypeName")
var rb = GetComponent("Rigidbody"); // 문자열로 타입 탐색
GetComponent<Rigidbody>()가 가능하면 항상 그쪽이 낫습니다.
2) “정통 리플렉션” API: GetField/GetProperty/Invoke
유니티에서 직접 System.Reflection을 써야 하는 경우도 있습니다(툴, 디버깅, 플러그인). 가장 흔한 형태는 아래입니다.
2-1. 필드(Field) 접근
using System.Reflection;
var flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
FieldInfo fi = target.GetType().GetField("hp", flags);
int hp = (int)fi.GetValue(target);
fi.SetValue(target, hp + 10);
2-2. 프로퍼티(Property) 접근
using System.Reflection;
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
PropertyInfo pi = target.GetType().GetProperty("Speed", flags);
float speed = (float)pi.GetValue(target);
pi.SetValue(target, speed * 1.1f);
2-3. 메서드(Method) 호출
using System.Reflection;
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
MethodInfo mi = target.GetType().GetMethod("Recalculate", flags);
mi.Invoke(target, null);
3) 왜 IL2CPP(AOT)에서 문제가 더 자주 터지나?
3-1. 코드 스트리핑(Managed Stripping / Linker)
IL2CPP 빌드(특히 iOS)는 용량/로딩/보안을 위해 사용되지 않는 관리 코드를 제거합니다.
문제는 “문자열로만 호출되는 코드”는 정적 분석에서 사용된다고 확신할 수 없어서 제거될 수 있다는 점입니다.
- 에디터/Mono에서는 잘 되는데
- 기기(IL2CPP)에서만
MissingMethodException, 호출 실패, 동작 누락 같은 현상이 나옵니다.
3-2. 제네릭(generic)과 AOT 제약
IL2CPP는 런타임에 새 제네릭 인스턴스를 JIT로 만들 수 없습니다.
리플렉션으로 예측 불가능한 제네릭 조합을 만들거나 호출하려 하면, 특정 케이스에서 런타임 실패를 유발할 수 있습니다.
4) link.xml / [Preserve]는 “성능 최적화”가 아니라 “생존 장치”다
4-1. link.xml이 하는 일
link.xml은 빌드 단계에서 링커에게 “이 타입/메서드는 삭제하지 말라”고 지시합니다.
예시(Assets/link.xml):
<linker>
<assembly fullname="Assembly-CSharp">
<type fullname="MyNamespace.MyBehaviour" preserve="all"/>
</assembly>
</linker>
- ✅ 해결: 스트리핑으로 인해 “메서드를 못 찾는” 문제
- ❌ 미해결: 리플렉션 호출이 느린 문제(탐색/메타데이터 접근 비용은 그대로)
4-2. [Preserve]가 하는 일
코드에 직접 보존 표시를 할 수 있습니다.
using UnityEngine.Scripting;
public class MyBehaviour : MonoBehaviour
{
[Preserve]
private void SpawnEnemy()
{
// Invoke("SpawnEnemy") 같은 문자열 호출을 쓴다면 보존 표시가 도움이 됩니다.
}
}
5) 성능 관점: 리플렉션을 “안 느리게” 쓰는 방법(캐싱/델리게이트)
리플렉션이 느린 핵심은 보통 이렇습니다.
- 타입/멤버 탐색(
GetMethod,GetProperty등) - 접근 제한/메타데이터 확인
- 박싱/언박싱
Invoke의 간접 호출 오버헤드
5-1. 멤버 탐색 결과를 캐싱하기
using System;
using System.Collections.Generic;
using System.Reflection;
public static class ReflectionCache
{
private static readonly Dictionary<(Type, string), MethodInfo> _methodCache = new();
public static MethodInfo GetMethodCached(Type t, string name, BindingFlags flags)
{
var key = (t, name);
if (_methodCache.TryGetValue(key, out var mi)) return mi;
mi = t.GetMethod(name, flags);
_methodCache[key] = mi;
return mi;
}
}
- “매번 찾는 비용”을 한 번으로 줄입니다.
- 그래도
Invoke자체는 느립니다.
5-2. Delegate로 변환(가능하면 가장 효과적)
리플렉션으로 한 번 찾고, 이후부터는 델리게이트로 호출하면 오버헤드가 크게 줄어듭니다.
using System;
using System.Reflection;
public static class DelegateFactory
{
public static Action CreateAction(object target, string methodName)
{
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
MethodInfo mi = target.GetType().GetMethod(methodName, flags);
return (Action)Delegate.CreateDelegate(typeof(Action), target, mi);
}
}
사용:
private Action _tick;
void Awake()
{
_tick = DelegateFactory.CreateAction(this, "Tick");
}
void Update()
{
_tick?.Invoke(); // 리플렉션 Invoke보다 훨씬 유리
}
private void Tick()
{
// ...
}
주의: IL2CPP/AOT 환경에서는
CreateDelegate조합이나 시그니처가 복잡해질수록 보존/제네릭 문제가 얽힐 수 있습니다.
“정말 매 프레임 호출해야 한다”면 구조적으로 리플렉션을 제거하는 쪽이 최선입니다.
6) 실무 규칙(추천)
6-1. 절대(에 가까운) 금지 구역
Update()/LateUpdate()/FixedUpdate()에서의 리플렉션/문자열 호출- 대량 반복(수백~수천 회) 루프 안에서의
GetMethod/GetProperty/Invoke SendMessage/BroadcastMessage남발
6-2. 조건부 허용 구역(“가끔”이면 가능)
- UI 버튼 클릭, 메뉴 열기, 설정 적용 같은 낮은 빈도 이벤트
- 디버그/치트/툴용 코드(릴리스에서 제외하거나 빈도 제한)
- 에디터 전용 도구(런타임이 아니라면 비교적 안전)
6-3. 모바일(IL2CPP)에서 안전장치 체크리스트
- 문자열/리플렉션으로만 호출되는 메서드/타입이 있나?
- 있다면
link.xml또는[Preserve]적용
- 있다면
- Managed Stripping Level이 Medium/High인가?
- 실제 기기에서 반드시 테스트
- 제네릭/런타임 타입 생성이 있나?
- IL2CPP 제약을 고려해 설계 변경 검토
7) 결론
link.xml/[Preserve]는 “코드가 사라지는 문제”를 막는 안전망입니다.- 성능을 지키려면 리플렉션 자체를 빈도 높은 경로에서 제거하거나,
- 최소한 캐싱 + 델리게이트로 “탐색/Invoke 비용”을 낮추는 전략이 필요합니다.
- “에디터 OK / 모바일만 Fail”은 대부분 스트리핑/IL2CPP 제약부터 의심하는 게 정석입니다.
부록) 빠른 치환 표
| 문제 코드 | 추천 대안 |
|---|---|
StartCoroutine("MyRoutine") |
StartCoroutine(MyRoutine()) |
Invoke("Spawn", 1f) |
StartCoroutine(SpawnAfter(1f)) 또는 타이머/스케줄러 |
GetComponent("Rigidbody") |
GetComponent<Rigidbody>() |
SendMessage("OnDamage") |
인터페이스/이벤트/델리게이트 |
반복적으로 GetMethod(...).Invoke(...) |
1회 탐색 후 캐싱/델리게이트 |
'Public' 카테고리의 다른 글
| [Unity\] On-Screen Stick 으로 모바일 조이스틱 구현하기 (0) | 2025.12.20 |
|---|---|
| [Unity] while 문 그만! Coroutine에서 '우아하게' 기다리기 : WaitUntil, WaitWhile (1) | 2025.12.20 |
| [Unity] Prefab Variant vs Nested Prefab (1) | 2025.12.18 |
| [Unity] 프리팹 안의 프리팹 : Nested Prefab (중첩 프리팹) (0) | 2025.12.18 |
| [Unity] 프리팹 간에 상속이 가능하다고? Prefab Variant 활용법 (0) | 2025.12.11 |