UnsafeAccessor를 사용한 최신 .NET Reflection | NDepend


#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의 순환 의존성 문제:

  1. System.Net.Http가 System.Security.Cryptography 위에 위치 (X509Certificate 참조)
  2. System.Security.Cryptography는 OCSP 정보 다운로드를 위해 HTTP 요청 필요
  3. 순환 참조 불가능 → 기존에는 Reflection 사용
  4. NET 10에서는 UnsafeAccessor로 개선하여 성능 향상

9. 핵심 정리

언제 사용할 것인가

  • 성능이 중요한 코드에서 private 멤버 접근이 필수적일 때
  • Dictionary 내부 레이아웃 접근으로 성능 개선
  • 캡슐화된 private 타입에 접근해야 할 때
  • 프레임워크 내부 성능 최적화

주의사항

  • Private 멤버 접근은 일반적으로 권장하지 않음
  • 성능상 정당한 이유가 있을 때만 사용
  • 런타임 타입 안전성 보장 안 됨 (컴파일 타임만 보장)
  • NET 8 이상 필수
  • 코드 유지보수성 감소 가능

성능 이득

  • 기존 Reflection 대비 최대 3.7배 성능 향상
  • Direct access와 동일한 성능 수준
  • 메모리 효율성 개선 (Boxing 제거)