#NET-Reflection unsafeaccessor #성능 #NET8 #NET10
한 눈에 보기: .NET Reflection의 혁신
기존 Reflection의 문제점: 성능 저하, Boxing/Unboxing, 복잡한 API 호출 방식
UnsafeAccessor의 해법: .NET 8 이후 도입된 새로운 속성으로 기존 방식 대비 3.7배 빠른 성능(0.9290ns vs 3.4947ns)
주요 이점: Private 멤버에 대한 제로-오버헤드 접근, 컴파일 타임 타입 안전성, 직접 필드 수정 지원
확장성: NET 9에서는 제네릭 타입 지원 개선, .NET 10에서는 UnsafeAccessorType으로 비공개 중첩 타입 접근
상세 요약: 현대적 .NET Reflection의 완벽 가이드
1. 기존 Reflection 방식의 한계
성능 문제점
- 런타임 오버헤드: Reflection API 호출 시 동적 타입 해석에 따른 성능 저하
- Boxing/Unboxing: 값 타입 접근 시 메모리 낭비 및 성능 감소
- 복잡한 API 호출: GetField() → GetValue() 등 여러 단계의 메서드 호출 필요
- JIT 컴파일러 최적화 어려움: 동적 특성으로 인한 컴파일러 최적화 제약
전형적인 기존 코드 예시
using System.Reflection;
var employee = new Employee();
var field = typeof(Employee).GetField("_ssn",
BindingFlags.NonPublic | BindingFlags.Instance);
var ssn = (string)field.GetValue(employee); // Boxing, 느림
2. NET 8: UnsafeAccessor 혁명
핵심 개념
System.Runtime.CompilerServices.UnsafeAccessor 속성은 extern 메서드를 선언하여 런타임이 직접 접근을 구현하는 방식으로 작동합니다.
주요 특징
- 제로 오버헤드: 직접 접근과 동일한 성능 수준
- 타입 안전성: 컴파일 타임에 타입 검증
- 참조 기반 반환: ref 키워드를 통해 필드 직접 수정 가능
- 간결한 구문: 단일 메서드 호출로 접근
개선된 코드 패턴
using System.Runtime.CompilerServices;
// 사용 - 빠르고, 간결하며, 타입 안전
var employee = new Employee();
ref string ssn = ref EmployeeAccessor.GetSSN(employee);
Console.WriteLine(ssn); // "123-45-6789"
ssn = "999-99-9999"; // 필드 직접 수정
EmployeeAccessor.CallCalculateBonus(employee, 0.15m);
// Accessor 클래스 정의
public static class EmployeeAccessor
{
// Private 필드 접근
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_ssn")]
public static extern ref string GetSSN(Employee employee);
// Private 메서드 호출
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "CalculateBonus")]
public static extern void CallCalculateBonus(Employee employee, decimal percentage);
}
public class Employee
{
private string _ssn = "123-45-6789";
private int _salary = 50000;
private void CalculateBonus(decimal percentage)
{
Console.WriteLine($"Bonus: {_salary * percentage}");
}
}
UnsafeAccessorKind 종류
public enum UnsafeAccessorKind
{
Field, // Private 필드 접근
Method, // Private 메서드 호출
Constructor, // Private 생성자 접근
StaticField, // Private static 필드 접근
StaticMethod // Private static 메서드 호출
}
Top-level Statement 형태로도 선언 가능
Accessor 메서드를 별도 클래스 대신 Program 클래스 또는 메서드 내부에 로컬 메서드로 선언할 수 있습니다.
3. 반환 타입의 의미
ref string 반환의 이해:
- 문자열 자체의 참조가 아니라 직관 포인터(managed pointer)를 반환
- 직관 포인터를 통해 employee 객체 내 실제 필드에 접근
- 현재 값 읽기와 새 문자열을 기존 필드에 직접 할당 가능
4. 런타임 안전성
타입 검증 시점:
- Name 매개변수로 지정된 멤버가 존재하지 않으면 런타임에 MissingFieldException 또는 MissingMethodException 발생
- 기존 Reflection과 동일하게 컴파일 타임 타입 안전성 없음
- 멤버 이름은 문자열로 지정되므로 런타임 검증 필수
5. NET 9: 제네릭 지원 강화
개선 사항
NET 9는 제네릭 타입 처리를 개선하여 더 유연한 멤버 접근 가능
제네릭 클래스 예시
using System.Runtime.CompilerServices;
var cache = new Cache<int>();
ref var items = ref CacheAccessor<int>.GetItems(cache);
items["key1"] = 100;
CacheAccessor<int>.CallEvict(cache, "key1");
public class Cache<T>
{
private Dictionary<string, T> _items = new();
private void Evict(string key)
{
_items.Remove(key);
}
}
public static class CacheAccessor<T>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
public static extern ref Dictionary<string, T> GetItems(Cache<T> cache);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Evict")]
public static extern void CallEvict(Cache<T> cache, string key);
}
제네릭 메서드 선언 불가
- Accessor 메서드 자체는 제네릭으로 선언할 수 없음
- 대신 제네릭 클래스 CacheAccessor가 타입 매개변수 T를 가져야 함
- Accessor 메서드가 제네릭이면 컴파일되지만 런타임에 실패
제네릭 컬렉션 내부 접근 예시
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
var list = new List<int>(Enumerable.Range(0, 691));
// 방법 1: UnsafeAccessor 사용
int[] items = Accessors<int>.GetItems(list);
// 방법 2: CollectionsMarshal 사용
Span<int> span = CollectionsMarshal.AsSpan(list);
Debug.Assert(span.Length == items.Length);
Debug.Assert(items[0] == 0);
span[0] = 42;
Debug.Assert(items[0] == 42);
static class Accessors<T>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
public static extern ref T[] GetItems(List<T> list);
}
주의사항: List를 지원하는 배열은 의도적으로 private입니다. 요소 추가/삭제 시 재할당이 발생하므로 직접 조작하면 복잡한 버그 발생 가능
6. 성능 벤치마크: UnsafeAccessor vs 기존 Reflection
벤치마크 결과
| 메서드 | 평균 시간 |
|---|---|
| WithReflection | 3.4947 ns |
| WithUnsafeAccessor | 0.9290 ns |
결론: UnsafeAccessor는 기존 Reflection 대비 약 3.7배 더 빠름
벤치마크 코드
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Reflection;
using System.Runtime.CompilerServices;
BenchmarkSwitcher.FromAssembly(typeof(Tests).Assembly).Run(args);
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public partial class Tests
{
private List<int> _list = new List<int>(16);
private FieldInfo _itemsField = typeof(List<int>).GetField("_items",
BindingFlags.NonPublic | BindingFlags.Instance)!;
private static class Accessors<T>
{
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")]
public static extern ref T[] GetItems(List<T> list);
}
[Benchmark]
public int[] WithReflection() => (int[])_itemsField.GetValue(_list)!;
[Benchmark]
public int[] WithUnsafeAccessor() => Accessors<int>.GetItems(_list);
}
7. NET 10.0: UnsafeAccessorType 속성 추가
문제: 비공개 중첩 타입 접근 불가
NET 10.0 이전에는 컴파일 타임에 타입을 확인할 수 없는 경우 런타임 실패 발생:
- Private 중첩 클래스
- 외부 어셈블리에 보이지 않는 internal 타입
해결: UnsafeAccessorType 속성
숨겨진 타입의 완전한 이름을 문자열로 제공하여 런타임 타입 해석 가능
구현 예시
using System.Diagnostics;
using System.Runtime.CompilerServices;
using MyNamespace;
var visibleObject = new VisibleClass();
object hiddenObject = ReadVisibleClassField(visibleObject);
string value = ReadHiddenClassProperty(hiddenObject);
Debug.Assert(value == "Hello world");
// "MyNamespace.VisibleClass+HiddenClass"는 private 중첩 클래스의 완전한 이름
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetField")]
[return: UnsafeAccessorType("MyNamespace.VisibleClass+HiddenClass")]
static extern object ReadVisibleClassField(VisibleClass instance);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Prop")]
static extern string ReadHiddenClassProperty(
[UnsafeAccessorType("MyNamespace.VisibleClass+HiddenClass")] object instance);
namespace MyNamespace
{
public class VisibleClass
{
private readonly HiddenClass _field = new("Hello world");
private HiddenClass GetField() => _field;
private class HiddenClass(string prop)
{
internal string Prop { get; } = prop;
}
}
}
중요: UnsafeAccessorType 생략 시
타입을 제대로 지정하지 않으면 런타임에 예외 발생
8. 실제 사용 사례
.NET 내부에서의 활용
Stephen Toub가 발표한 “.NET 10 성능 개선” 문서에서 언급한 사례:
System.Net.Http와 System.Security.Cryptography의 순환 의존성 문제:
- System.Net.Http가 System.Security.Cryptography 위에 위치 (X509Certificate 참조)
- System.Security.Cryptography는 OCSP 정보 다운로드를 위해 HTTP 요청 필요
- 순환 참조 불가능 → 기존에는 Reflection 사용
- NET 10에서는 UnsafeAccessor로 개선하여 성능 향상
9. 핵심 정리
언제 사용할 것인가
- 성능이 중요한 코드에서 private 멤버 접근이 필수적일 때
- Dictionary 내부 레이아웃 접근으로 성능 개선
- 캡슐화된 private 타입에 접근해야 할 때
- 프레임워크 내부 성능 최적화
주의사항
- Private 멤버 접근은 일반적으로 권장하지 않음
- 성능상 정당한 이유가 있을 때만 사용
- 런타임 타입 안전성 보장 안 됨 (컴파일 타임만 보장)
- NET 8 이상 필수
- 코드 유지보수성 감소 가능
성능 이득
- 기존 Reflection 대비 최대 3.7배 성능 향상
- Direct access와 동일한 성능 수준
- 메모리 효율성 개선 (Boxing 제거)