---
title: "Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 생성에 있다"
description: "Reflex 의 조회는 컨테이너 깊이와 상관없이 쌉니다. 그 값을 부모 크기에 비례하는 스코프 생성과 IL2CPP 의 생성마다 부르는 리플렉션에서 치르고, 락이 없어 여러 스레드의 첫 조회는 싱글턴을 여럿 만들 수 있습니다."
author: "유니얼"
pubDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI", "최적화"]
canonical: https://blog.unialgames.com/posts/unity-reflex-perf/
---
```csharp
var enemyScope = sceneContainer.Scope(b => b.RegisterValue(enemyConfig));   // 적 하나마다 스코프?
var bullet = enemyScope.Resolve<Bullet>();                                  // 총알마다 Resolve?
```

컨테이너 없이 `new` 로 만들던 것을 컨테이너로 옮기면, 어느 호출이 비싼지부터 알아야 합니다.

Reflex 는 **조회를 깊이와 상관없이 싸게 만든 값을 스코프 생성과 IL2CPP 의 객체 생성에서 치릅니다.** 숫자는 이 Mac의 macOS 개발 빌드 플레이어(Mono, IL2CPP) 값이고, 로그 레벨은 `Warning` 입니다(워밍업 뒤 반복 평균, 3회 중앙값). 기준 판본은 14.3.1 입니다.

## 조회는 깊이와 무관하고 스코프 생성은 부모 크기를 따른다

자식 컨테이너는 빌드할 때 부모의 조회 표를 계약마다 목록째 복사해 둡니다. 그래서 조회는 자기 표를 한 번 찾고 끝나고, 복사 값은 스코프를 만들 때 한 번에 냅니다.

| 측정 | Mono | IL2CPP |
| --- | --- | --- |
| 싱글턴 조회, 깊이 0 | 163 ns | 243 ns |
| 싱글턴 조회, 깊이 8 | 163 ns | 243 ns |
| 스코프 생성 + 해제, 부모 계약 10개 | 24.2 µs | 18.1 µs |
| 스코프 생성 + 해제, 부모 계약 160개 | 234.8 µs | 193.8 µs |

깊이에 따른 차이는 2% 안쪽이고, 부모를 16배로 늘리자 스코프 생성이 Mono 9.7배, IL2CPP 10.7배가 됐습니다. 부모에 `Scoped + Eager` 가 있으면 자식마다 그것도 새로 만들고, 그 목록을 찾으려고 부모 표 전체를 LINQ 로 펼칩니다. 확정 단계 자체는 검증이 없어 쌉니다. 부모 없는 빌더에 20종을 등록하고 `Build()` 까지 Mono 42.7 µs, IL2CPP 34.5 µs 였습니다.

씬마다 한 번 자식을 만드는 게임이면 유리한 모양이고, 스코프를 짧은 주기로 많이 만드는 구조면 불리합니다.

## IL2CPP 는 객체를 만들 때마다 리플렉션을 부른다

새 인스턴스를 만드는 조회(처음 꺼내는 싱글턴, 매번 만드는 Transient)는 생성자 호출기를 거칩니다. 이 호출기가 백엔드마다 다릅니다.

<x-compare>
<script type="application/json">
{
  "label": "타입 하나의 생성 함수를 만드는 두 방식",
  "tabs": [
    {
      "label": "Mono (에디터 포함)",
      "code": "var newExpression = Expression.New(constructor, argumentsExpressions);\nvar lambda = Expression.Lambda<ObjectActivator>(\n    Expression.Convert(newExpression, typeof(object)), param);\nreturn lambda.Compile();   // 타입마다 처음 한 번",
      "text": "타입을 처음 만들 때 식 트리를 컴파일해 캐시에 넣고, 그 뒤로는 컴파일된 델리게이트를 부릅니다. 실행 중에 코드를 만든다는 뜻이라 AOT 환경에서는 쓸 수 없는 경로입니다.",
      "stats": [["싱글턴 조회", "171 ns (에디터 165 ns)"], ["Transient 조회", "489 ns (에디터 465 ns)"], ["생성 한 번", "컴파일된 new"]]
    },
    {
      "label": "IL2CPP",
      "code": "return args =>\n{\n    var instance = FormatterServices.GetUninitializedObject(type);\n    constructor.Invoke(instance, args);   // 매번 리플렉션 호출\n    return instance;\n};",
      "text": "실행 중에 코드를 만들지 않습니다. 대신 생성할 때마다 빈 객체를 만들고 ConstructorInfo.Invoke 로 생성자를 부릅니다.",
      "stats": [["싱글턴 조회", "245 ns"], ["Transient 조회", "1,101 ns"], ["생성 한 번", "리플렉션 호출"]],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

손으로 쓴 `new` 와 같은 루프에서 견줬습니다.

| 타입 | Mono `Resolve` | Mono `new` | IL2CPP `Resolve` | IL2CPP `new` |
| --- | --- | --- | --- | --- |
| 인자 0개 `T()` | 484 ns | 136 ns | 1,144 ns | 98 ns |
| 인자 1개 `T(U)` | 714 ns | 172 ns | 1,393 ns | 131 ns |

`new` 대비 Mono 는 3.6~4.2배, IL2CPP 는 10.7~11.7배입니다. 첫 생성은 반대로 Mono 가 무겁습니다. 캐시에 없는 타입의 첫 `Resolve` 가 Mono 174 µs, IL2CPP 14.8 µs 였는데, Mono 는 여기서 식 트리를 컴파일하기 때문입니다. README 의 "런타임 Emit 없음" 은 IL2CPP 경로에서 맞고, 그 대가가 생성마다의 `ConstructorInfo.Invoke` 입니다.

할당은 조회 자체에서는 거의 없습니다. README 벤치마크와 같은 모양으로 Transient 다섯 개가 이어진 체인을 꺼내면 1회당 Mono 112 B, IL2CPP 144 B 로, 손 `new` 다섯 개(112 B)와 같거나 32 B 더 붙었습니다. 할당이 보이는 곳은 스코프 생성(빈 스코프 한 쌍에 Mono 1,380 B, IL2CPP 1,432 B), `All<T>()` 의 결과 배열, 컬렉션 주입입니다.

## 락이 없어서 첫 조회가 겹치면 싱글턴이 여럿 생긴다

README 의 "Immutable Container: 락 없는 스레드 안전" 에서 불변인 것은 등록 표입니다. 조회가 건드리는 싱글턴 리졸버의 `_instance` 와 정적 타입 캐시 `Dictionary` 는 락 없이 검사하고 씁니다.

<x-flow>
<script type="application/json">
{
  "label": "SingletonTypeResolver.Resolve 를 스레드 둘이 동시에 처음 부를 때",
  "lines": [
    "if (_instance == null)",
    "{",
    "    _instance = DeclaringContainer.Construct(_concreteType);   // 생성자 안에서 20 ms",
    "    DeclaringContainer.Disposables.TryAdd(_instance);",
    "}",
    "return _instance;"
  ],
  "cases": [
    {
      "label": "두 스레드가 겹칠 때",
      "state": "_instance = null",
      "path": [
        { "line": 0, "verdict": "true", "why": "스레드 1: 비어 있습니다." },
        { "line": 0, "verdict": "true", "why": "스레드 2: 스레드 1 이 아직 생성 중이라 여전히 비어 있습니다." },
        { "line": 2, "why": "스레드 1 의 생성이 끝나 대입합니다.", "state": "_instance = 객체 1" },
        { "line": 3, "why": "스레드 1: 해제 목록에 객체 1." },
        { "line": 5, "out": "스레드 1 → 객체 1" },
        { "line": 2, "why": "스레드 2 의 생성도 끝나 덮어씁니다.", "state": "_instance = 객체 2" },
        { "line": 3, "why": "스레드 2: 해제 목록에 객체 2. 목록에는 둘 다 있습니다." },
        { "line": 5, "out": "스레드 2 → 객체 2" }
      ]
    },
    {
      "label": "한 스레드씩",
      "state": "_instance = null",
      "path": [
        { "line": 0, "verdict": "true", "why": "스레드 1: 비어 있습니다." },
        { "line": 2, "state": "_instance = 객체 1" },
        { "line": 3 },
        { "line": 5, "out": "스레드 1 → 객체 1" },
        { "line": 0, "verdict": "false", "why": "스레드 2: 이미 있습니다." },
        { "line": 5, "out": "스레드 2 → 객체 1" }
      ]
    }
  ],
  "note": "검사와 대입 사이가 생성자 실행 시간만큼 벌어져 있습니다. 그 사이에 들어온 스레드는 모두 자기 인스턴스를 만듭니다."
}
</script>
</x-flow>

생성자에 20 ms 대기를 넣은 싱글턴을 스레드 8개가 동시에 처음 `Resolve` 하게 했습니다. 칸은 생성자 호출 / 받은 서로 다른 인스턴스 / 예외 수입니다.

| 시도 | 에디터 | Mono 플레이어 | IL2CPP 플레이어 |
| --- | --- | --- | --- |
| 1 | 1 / 1 / 7 | **8 / 8 / 0** | 1 / 1 / 7 |
| 2 | 1 / 1 / 7 | 1 / 1 / 7 | **7 / 4 / 4** |
| 3 | 1 / 1 / 7 | 1 / 1 / 7 | 1 / 1 / 7 |
| 4 | 1 / 1 / 7 | 1 / 1 / 7 | **5 / 4 / 4** |
| 5 | 1 / 1 / 7 | 1 / 1 / 7 | 5 / 1 / 7 |

예외는 모두 타입 캐시에 같은 키를 동시에 `Add` 한 `ArgumentException` 이고, 캐시를 무사히 지난 스레드끼리는 싱글턴을 각자 만들었습니다. 같은 코드가 실행마다 다르게 나옵니다.

## 주의할 점

### 1. Resolve 는 메인 스레드에서만 한다

**안 지키면 생기는 문제.** `Task.Run` 이나 비동기 로딩 스레드에서 처음 조회하면 `ArgumentException` 으로 빈손이 되거나, 서로 다른 "싱글턴" 을 받습니다. 이 Mac 에서 8개까지 나왔습니다.

**이렇게 쓴다.** 조회는 메인 스레드에서 끝내고 백그라운드에는 꺼낸 객체를 넘깁니다. 백그라운드에서 쓸 싱글턴은 `Resolution.Eager` 로 등록해 빌드 때 만들어 둡니다.

### 2. 자식 스코프를 짧은 주기로 만들지 않는다

**안 지키면 생기는 문제.** 부모 등록 160개면 스코프 하나를 만들고 해제하는 데 Mono 235 µs, IL2CPP 194 µs 가 듭니다. 60 fps 한 프레임의 1% 를 넘고, 적이나 총알마다 만들면 그만큼이 매번 붙습니다.

**이렇게 쓴다.** 스코프는 씬이나 게임 모드처럼 드물게 바뀌는 경계에만 둡니다. 개체마다 달라지는 값은 팩토리 메서드의 인자로 넘깁니다.

### 3. IL2CPP 의 핫 경로에서 Transient 를 Resolve 로 만들지 않는다

**안 지키면 생기는 문제.** IL2CPP 의 Transient 조회는 `new` 의 약 11배입니다. 에디터(Mono)의 같은 조회는 컴파일된 식 트리라 465 ns 여서, 에디터에서 잰 값으로 IL2CPP 를 짐작하면 두 배 넘게 틀립니다.

**이렇게 쓴다.** 프레임마다 여러 개 만드는 것은 컨테이너에서 한 번 꺼낸 풀이나 팩토리가 `new` 로 만듭니다. 성능 판단은 IL2CPP 플레이어의 프로파일러로 합니다.

## 정리

Reflex 의 비용은 **조회가 아니라 스코프 생성과 IL2CPP 의 생성**에 있습니다.

- 조회는 깊이 0 에서 8 까지 Mono 163 ns, IL2CPP 243 ns 로 같고, 스코프 생성은 부모 계약 수에 거의 비례합니다.
- IL2CPP 는 생성마다 `ConstructorInfo.Invoke` 를 불러 Transient 조회가 `new` 의 약 11배입니다. Mono 는 첫 생성에서 식 트리를 컴파일합니다.
- 등록 표는 불변이지만 싱글턴 생성과 타입 캐시에는 락이 없어, 여러 스레드의 첫 조회가 싱글턴을 여럿 만들 수 있습니다.