본문 바로가기

C#

C# 동적 메서드 호출(DynamicMethod)으로 런타임 성능 개선

리플렉션의 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처럼 동일 멤버를 매우 많이 호출하는 시나리오입니다.

- 리플렉션만으로는 성능이 부족하고, 런타임에 멤버가 결정되어 정적 제네릭 호출로 풀기 어려운 경우입니다.

한 번 만들어 제대로 캐시하면, 리플렉션의 편의성과 정적 호출에 가까운 성능을 동시에 얻을 수 있습니다.