인스펙터는 내 private 필드를 어떻게 아는가
Esc

    인스펙터는 내 private 필드를 어떻게 아는가

    –C#

    앞에서 캡슐화를 다루면서 private 은 밖에서 못 본다고 했습니다.

    그런데 Unity 에서는 이렇게 씁니다.

    [SerializeField] private int 체력 = 100;

    private 인데 인스펙터 창에 나오고, 거기서 값을 고칠 수도 있습니다. 유니티는 우리 코드를 컴파일된 뒤에 받습니다. 우리 클래스를 본 적이 없습니다. 그런데 어떻게 체력 이라는 이름을 알까요.

    실행 중에 타입의 속을 들여다보는 기능이 있습니다. 리플렉션입니다.

    컴파일된 뒤에도 이름이 남는다

    먼저 오해 하나를 걷어야 합니다.

    C# 코드를 빌드하면 기계어가 나오는 것이 아니라 DLL 한 덩어리가 나옵니다. 그 안에는 실행할 명령만 있는 것이 아니라 타입의 목록도 같이 들어 있습니다.

    이 클래스에 어떤 필드가 있고, 이름이 무엇이고, 타입이 무엇이고, private 인지 public 인지, 어떤 메서드가 있고 매개변수가 무엇인지가 전부 표로 적혀 있습니다. 이 표를 메타데이터라고 부릅니다.

    앞에서 const 는 컴파일하면 사라진다고 했습니다. 그건 값이라 사라진 것이고, 타입의 구조는 사라지지 않습니다. 남아 있으니 실행 중에 읽을 수 있습니다.

    읽는 법

    typeof 나 GetType() 으로 그 표를 손에 넣습니다.

    Type t = typeof(캐릭터);
    
    foreach (var f in t.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
    {
        Console.WriteLine($"{f.FieldType.Name} {f.Name}");
    }

    private 필드까지 이름과 타입이 그대로 나옵니다. BindingFlags.NonPublic 이 “숨긴 것도 보여 달라” 는 뜻입니다.

    읽는 데서 그치지 않고 값을 넣을 수도 있습니다.

    var c = new 캐릭터();
    var f = typeof(캐릭터).GetField("체력", BindingFlags.NonPublic | BindingFlags.Instance);
    f.SetValue(c, 50);

    private 인데 밖에서 값이 바뀌었습니다. 캡슐화는 컴파일러가 지켜 주는 약속이지 자물쇠가 아니라는 것이 여기서 드러납니다.

    메서드도 이름으로 찾아 부를 수 있습니다.

    var m = typeof(캐릭터).GetMethod("공격");
    m.Invoke(c, null);

    Unity 의 Invoke("공격", 2f) 나 SendMessage("피격") 이 이것입니다. 문자열로 적은 메서드 이름이 실행되는 이유입니다.

    그래서 메서드 이름을 바꾸면 조용히 안 불립니다. 컴파일러는 문자열 안까지 안 봅니다. 이름 바꾸기가 안전하지 않은 자리가 여기입니다.

    특성은 그 표에 메모를 붙이는 것

    리플렉션이 타입의 구조를 읽는 것이라면, 특성은 그 구조에 표시를 남기는 것입니다.

    [SerializeField] private int 체력;
    [Range(0, 100)] public float 볼륨;
    [Obsolete("이제 쓰지 마세요")] public void 옛날메서드() { }

    대괄호로 적은 것들이 특성입니다. 이것도 컴파일하면 사라지지 않고 표에 같이 적힙니다.

    특성은 그 자체로는 아무 일도 안 합니다. 그냥 붙어 있는 메모입니다. 누군가 리플렉션으로 읽고 그에 맞춰 행동해야 뜻이 생깁니다.

    직접 만들 수도 있습니다. Attribute 를 상속하면 됩니다.

    class 설명Attribute : Attribute
    {
        public string 글 { get; }
        public 설명Attribute(string 글) { this.글 = 글; }
    }
    
    class 캐릭터
    {
        [설명("죽으면 0 이 됩니다")]
        public int 체력;
    }

    읽는 쪽은 이렇게 씁니다.

    var f = typeof(캐릭터).GetField("체력");
    var a = f.GetCustomAttribute<설명Attribute>();
    Console.WriteLine(a.글);       // 죽으면 0 이 됩니다

    이름 끝의 Attribute 는 붙일 때 생략할 수 있습니다. [설명(...)] 로 쓰는 이유입니다.

    그래서 인스펙터가 되는 것이다

    이제 처음 질문에 답할 수 있습니다.

    [Range(0, 100)] 을 붙이면 숫자 칸 대신 슬라이더가 나오는 것도 같은 이야기입니다. 2번에서 그 특성을 보고 3번에서 다른 칸을 그리는 것뿐입니다.

    같은 구조가 여기저기 쓰입니다.

    • JSON 직렬화. 필드 목록을 읽어 이름과 값을 짝지어 적고, 읽을 때는 반대로 넣습니다
    • 테스트 프레임워크. [Test] 가 붙은 메서드를 전부 찾아 하나씩 Invoke 합니다
    • 의존성 주입. 생성자의 매개변수 타입을 보고 맞는 객체를 만들어 넣습니다

    셋 다 “내 코드를 본 적 없는 라이브러리가 내 코드에 맞춰 동작한다” 는 점이 같습니다. 그게 리플렉션이 있는 이유입니다.

    그런데 느리다

    값을 하나 읽는 데 드는 일이 다릅니다.

    무슨 일이 일어나나
    c.체력 그 자리에서 바로 읽습니다. 위치가 컴파일할 때 정해졌습니다
    f.GetValue(c) 이름으로 표를 뒤지고, 접근 권한을 확인하고, 값을 꺼내 object 로 박싱합니다

    마지막의 박싱을 눈여겨볼 만합니다. GetValue 는 object 를 돌려줍니다. int 를 읽을 때마다 앞에서 본 그 박싱이 한 번씩 일어납니다. 프레임마다 도는 자리에서 리플렉션을 쓰면 쓰레기가 그만큼 쌓입니다.

    그래서 실무의 규칙은 이렇습니다.

    시작할 때 한 번만 하고 결과를 들고 있습니다.

    private static readonly FieldInfo _체력필드 =
        typeof(캐릭터).GetField("체력", BindingFlags.NonPublic | BindingFlags.Instance);

    static readonly 로 잡아 두면 찾는 일은 한 번뿐입니다. 앞에서 본 static 과 readonly 가 여기서 만납니다.

    매 프레임 도는 자리에는 안 씁니다. 유니티가 인스펙터에서 리플렉션을 쓰는 것은 에디터 쪽이고, 빌드된 게임이 도는 동안에는 그 코드가 안 돕니다.

    빌드에서 잘려 나갈 수 있습니다. IL2CPP 처럼 안 쓰는 코드를 지우는 빌드에서는 “문자열로만 부르는 메서드” 를 아무도 안 쓰는 것으로 보고 지워 버립니다. link.xml 로 남기라고 알려 줘야 합니다. 에디터에서는 되는데 빌드하면 안 되는 버그가 대개 이것입니다.

    정리

    • 컴파일해도 타입의 구조는 남습니다. 필드 이름, 타입, 접근 수준이 표로 들어 있습니다
    • 리플렉션은 그 표를 실행 중에 읽는 것입니다. private 도 읽고 쓸 수 있습니다
    • 특성은 그 표에 붙이는 메모입니다. 그 자체로는 아무 일도 안 합니다
    • 누군가 리플렉션으로 그 메모를 읽어야 뜻이 생깁니다. [SerializeField] 를 읽는 쪽이 유니티 에디터입니다
    • 느립니다. 이름을 뒤지고 권한을 확인하고 박싱합니다. 한 번만 찾아 들고 있습니다
    • 직접 쓸 일은 드뭅니다. 대신 매일 그 위에서 일합니다

    리플렉션은 남이 만든 타입의 속을 실행 중에 들여다보는 일이었습니다. 읽는 것까지는 됩니다.