---
title: "인스펙터는 내 private 필드를 어떻게 아는가"
description: "감춘 필드가 화면에 나오고, 문자열로 적은 메서드 이름이 실행됩니다. 실행 중에 코드가 코드를 읽기 때문입니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#"]
canonical: https://blog.unialgames.com/posts/csharp-attribute-reflection/
---
앞에서 캡슐화를 다루면서 `private` 은 밖에서 못 본다고 했습니다.

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

```csharp
[SerializeField] private int 체력 = 100;
```

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

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

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

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

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

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

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

## 읽는 법

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

```csharp
Type t = typeof(캐릭터);

foreach (var f in t.GetFields(BindingFlags.NonPublic | BindingFlags.Instance))
{
    Console.WriteLine($"{f.FieldType.Name} {f.Name}");
}
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```csharp
class 설명Attribute : Attribute
{
    public string 글 { get; }
    public 설명Attribute(string 글) { this.글 = 글; }
}

class 캐릭터
{
    [설명("죽으면 0 이 됩니다")]
    public int 체력;
}
```

읽는 쪽은 이렇게 씁니다.

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

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

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

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

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "1", "title": "타입의 필드 목록을 읽는다", "text": "리플렉션으로 이 MonoBehaviour 에 어떤 필드가 있는지 훑습니다. private 까지 전부 봅니다." },
    { "tag": "2", "title": "무엇을 보여줄지 고른다", "text": "public 이거나 [SerializeField] 가 붙은 것만 남깁니다. [HideInInspector] 가 붙었으면 뺍니다." },
    { "tag": "3", "title": "타입에 맞는 칸을 그린다", "text": "int 면 숫자 칸, bool 이면 체크박스, Color 면 색 고르개. 필드 타입을 알고 있으니 정할 수 있습니다." },
    { "tag": "4", "title": "고친 값을 다시 넣는다", "text": "SetValue 로 그 필드에 값을 씁니다. private 이어도 들어갑니다." }
  ]
}
</script>
</x-steps>

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

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

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

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

## 그런데 느리다

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

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

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

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

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

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

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

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

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

## 정리

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

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