Public

[Unity/C#] 리플렉션은 왜 느리고, 왜 IL2CPP에서 위험한가

김치킨. 2026. 1. 31. 16:22

유니티 프로젝트에서 이름(문자열)으로 메서드/필드/프로퍼티를 찾거나 호출하는 순간, 대부분 “리플렉션(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회 탐색 후 캐싱/델리게이트