리플렉션의 MethodInfo.Invoke는 편리하지만, 호출마다 타입 검사, 접근성 확인, 박싱/언박싱이 반복되어 비용이 큽니다. DynamicMethod로 IL을 직접 생성해 Delegate로 캐시하면 동일 작업을 수백만 번 호출하는 상황에서 큰 폭의 성능 개선을 얻을 수 있습니다.
1. 왜 DynamicMethod인가?
- 반복되는 리플렉션 호출의 오버헤드를 줄이고자 합니다.
- ILGenerator로 최소한의 스택 조작만 수행하는 호출 경로를 만들 수 있습니다.
- Delegate로 캐시하여 이후 호출은 정적 메서드 호출과 거의 유사한 비용으로 처리합니다.
2. 핵심 아이디어: 한 번 생성, 계속 재사용
- 대상 멤버(MethodInfo/Getter)를 입력 받아 DynamicMethod를 생성합니다.
- 반환/인자 타입을 박싱/캐스팅 처리해 범용 Delegate(Func<object,...>)로 노출합니다.
- Dictionary<MethodInfo, Delegate> 등으로 캐시해 핫패스에서 재사용합니다.
3. 실전 코드: 속성 getter/메서드 호출 DynamicMethod
using System;
using System.Diagnostics;
using System.Reflection;
using System.Reflection.Emit;
public class User
{
public int Id { get; set; }
public string FullName(string sep) => $"User#{Id}{sep}";
}
public static class DynamicInvoker
{
// object instance -> object return
public static Func<object, object> CreateGetter(MethodInfo getter)
{
if (getter == null) throw new ArgumentNullException(nameof(getter));
var dm = new DynamicMethod(
name: "Get_" + getter.Name,
returnType: typeof(object),
parameterTypes: new[] { typeof(object) },
m: getter.DeclaringType!.Module,
skipVisibility: true);
var il = dm.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // this
il.Emit(OpCodes.Castclass, getter.DeclaringType!); // (User)
il.Emit(OpCodes.Callvirt, getter); // call get_Id
if (getter.ReturnType.IsValueType)
il.Emit(OpCodes.Box, getter.ReturnType); // box int
il.Emit(OpCodes.Ret);
return (Func<object, object>)dm.CreateDelegate(typeof(Func<object, object>));
}
// object instance + object arg -> object return (데모: 1개 인자 메서드)
public static Func<object, object, object> CreateMethodCaller(MethodInfo method)
{
if (method == null) throw new ArgumentNullException(nameof(method));
var ps = method.GetParameters();
if (ps.Length != 1) throw new NotSupportedException("데모는 1개 인자 메서드만 지원합니다.");
var dm = new DynamicMethod(
name: method.Name + "_Call",
returnType: typeof(object),
parameterTypes: new[] { typeof(object), typeof(object) },
m: method.DeclaringType!.Module,
skipVisibility: true);
var il = dm.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // this
il.Emit(OpCodes.Castclass, method.DeclaringType!); // (User)
il.Emit(OpCodes.Ldarg_1); // arg0
if (ps[0].ParameterType.IsValueType)
il.Emit(OpCodes.Unbox_Any, ps[0].ParameterType);
else
il.Emit(OpCodes.Castclass, ps[0].ParameterType);
il.Emit(method.IsVirtual ? OpCodes.Callvirt : OpCodes.Call, method);
if (method.ReturnType == typeof(void))
{
il.Emit(OpCodes.Ldnull); // void -> null
}
else if (method.ReturnType.IsValueType)
{
il.Emit(OpCodes.Box, method.ReturnType);
}
il.Emit(OpCodes.Ret);
return (Func<object, object, object>)dm.CreateDelegate(typeof(Func<object, object, object>));
}
}
public class Program
{
public static void Main()
{
var user = new User { Id = 42 };
var getIdMI = typeof(User).GetProperty(nameof(User.Id))!.GetGetMethod();
var fullNameMI = typeof(User).GetMethod(nameof(User.FullName))!;
var getIdDM = DynamicInvoker.CreateGetter(getIdMI!);
var callFullNameDM = DynamicInvoker.CreateMethodCaller(fullNameMI!);
// 워밍업
_ = getIdMI!.Invoke(user, null);
_ = fullNameMI!.Invoke(user, new object[] { "-" });
_ = getIdDM(user);
_ = callFullNameDM(user, "-");
int loops = 2_000_000;
var sw = Stopwatch.StartNew();
for (int i = 0; i < loops; i++) _ = getIdMI!.Invoke(user, null);
sw.Stop();
Console.WriteLine($"Reflection get_Id: {sw.ElapsedMilliseconds} ms");
sw.Restart();
for (int i = 0; i < loops; i++) _ = getIdDM(user);
sw.Stop();
Console.WriteLine($"DynamicMethod get_Id: {sw.ElapsedMilliseconds} ms");
sw.Restart();
for (int i = 0; i < loops; i++) _ = fullNameMI!.Invoke(user, new object[] { "-" });
sw.Stop();
Console.WriteLine($"Reflection FullName: {sw.ElapsedMilliseconds} ms");
sw.Restart();
for (int i = 0; i < loops; i++) _ = callFullNameDM(user, "-");
sw.Stop();
Console.WriteLine($"DynamicMethod FullName: {sw.ElapsedMilliseconds} ms");
}
}
4. 벤치마크 해설
- 환경에 따라 수치는 달라지지만, DynamicMethod 호출은 보통 리플렉션 Invoke 대비 수배 이상의 개선을 보입니다.
- 초기 Delegate 생성(IL 생성 + JIT)은 비용이 있으므로 핫패스에서 재사용할 때 효과가 큽니다.
- 값 형식 반환/인자에는 박싱/언박싱을 정확히 처리해야 합니다.
5. 적용 팁과 주의사항
- 캐싱 필수: Dictionary/ConcurrentDictionary에 MethodInfo/PropertyInfo 기반 키로 Delegate를 저장합니다.
- 접근성: skipVisibility=true는 비공개 멤버 접근에 필요할 수 있습니다. .NET Core/5+에서는 제한이 있으므로 공개 멤버 위주로 사용합니다.
- 호출 규약: 가상 메서드는 Callvirt, 비가상은 Call을 사용합니다. 값 형식 처리, void 반환 처리 등을 IL에서 직접 다룹니다.
- 대안 검토: 미리 아는 멤버는 Delegate.CreateDelegate가 더 간단하고 빠를 수 있습니다. 복잡한 시그니처는 Expression<T> 컴파일도 고려합니다.
- 유지보수: IL은 저수준입니다. 공용 유틸리티로 감싸고 단위 테스트를 통해 회귀를 방지합니다.
6. 언제 DynamicMethod를 쓰면 좋은가?
- 대량 직렬화/매핑/ORM처럼 동일 멤버를 매우 많이 호출하는 시나리오입니다.
- 리플렉션만으로는 성능이 부족하고, 런타임에 멤버가 결정되어 정적 제네릭 호출로 풀기 어려운 경우입니다.
한 번 만들어 제대로 캐시하면, 리플렉션의 편의성과 정적 호출에 가까운 성능을 동시에 얻을 수 있습니다.
'C#' 카테고리의 다른 글
| C# 고정(Fixed) 키워드로 네이티브 메모리 접근 (0) | 2026.07.15 |
|---|---|
| C# Environment 클래스 활용해 시스템 정보 가져오기 (1) | 2026.07.14 |
| C# 유니코드 문자열 처리와 인코딩 변환 (0) | 2026.07.13 |
| C# CollectionView를 이용한 데이터 필터링 및 정렬 (0) | 2026.07.13 |
| C# Stopwatch로 함수 호출 빈도 측정하기 (0) | 2026.07.12 |