본문 바로가기

C#

C# Reflection으로 Private 멤버 접근 및 테스트 활용

Reflection은 런타임에 타입 정보를 읽고 조작할 수 있게 해주는 강력한 기능입니다. 테스트나 마이그레이션 상황에서 Private 멤버에 접근해야 할 때 유용하지만, 남용은 유지보수성과 성능을 해치므로 신중히 사용해야 합니다.

1. 예제 클래스

using System;
using System.Reflection;

public class User
{
    private int _age = 10;
    private string Secret { get; set; } = "token";
    private string Combine(string prefix) => $"{prefix}-{_age}-{Secret}";
}

2. Private 필드/속성 읽기·쓰기

var user = new User();
var type = typeof(User);

// 필드 접근
var field = type.GetField("_age", BindingFlags.Instance | BindingFlags.NonPublic);
int age = (int)field!.GetValue(user)!;             // 10
field.SetValue(user, 20);

// 속성 접근
var prop = type.GetProperty("Secret", BindingFlags.Instance | BindingFlags.NonPublic);
string secret = (string)prop!.GetValue(user)!;     // "token"
prop.SetValue(user, "api");

3. Private 메서드 호출

var method = type.GetMethod("Combine", BindingFlags.Instance | BindingFlags.NonPublic);
string result = (string)method!.Invoke(user, new object[] { "P" })!;  // "P-20-api"

Invoke는 박싱/언박싱과 매개변수 배열 생성이 발생하여 느립니다. 자주 호출한다면 델리게이트를 만들어 캐시하는 것이 좋습니다.

4. 성능 최적화: CreateDelegate 캐싱

// 오픈 인스턴스 델리게이트: 첫 매개변수로 대상 인스턴스를 받습니다.
var mi = typeof(User).GetMethod("Combine", BindingFlags.Instance | BindingFlags.NonPublic)!;
var combine = (Func<User, string, string>)mi.CreateDelegate(typeof(Func<User, string, string>));

var r1 = combine(user, "P"); // "P-20-api"

// 클로즈드 인스턴스 델리게이트: 대상 인스턴스를 고정합니다.
var combineForUser = (Func<string, string>)mi.CreateDelegate(typeof(Func<string, string>), user);
var r2 = combineForUser("Q"); // "Q-20-api"

5. 테스트에서의 활용

최선은 퍼블릭 API를 통해 테스트하는 것입니다. 다만, 레거시 코드나 리팩터링 전 과도기에 Private 로직 검증이 필요할 수 있습니다.

// xUnit 예제
using System;
using System.Reflection;
using Xunit;

public class UserTests
{
    [Fact]
    public void Combine_내부로직_검증()
    {
        var user = new User();
        var t = typeof(User);

        // 준비: 내부 상태 수정
        t.GetField("_age", BindingFlags.Instance | BindingFlags.NonPublic)!
         .SetValue(user, 42);
        t.GetProperty("Secret", BindingFlags.Instance | BindingFlags.NonPublic)!
         .SetValue(user, "key");

        // 빠른 호출을 위해 델리게이트 사용
        var mi = t.GetMethod("Combine", BindingFlags.Instance | BindingFlags.NonPublic)!;
        var combine = (Func<User, string, string>)mi.CreateDelegate(typeof(Func<User, string, string>));

        var result = combine(user, "T");
        Assert.Equal("T-42-key", result);
    }
}

MSTest를 사용한다면 PrivateObject로 간단히 호출할 수도 있습니다.

// MSTest 예제
using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class UserMsTests
{
    [TestMethod]
    public void Combine_PrivateObject_호출()
    {
        var user = new User();
        var po = new PrivateObject(user);
        var result = (string)po.Invoke("Combine", new object[] { "P" });
        Assert.IsTrue(result.StartsWith("P-"));
    }
}

6. 대안: InternalsVisibleTo로 테스트 용이성 확보

Private을 그대로 두고 반사로 접근하는 대신, 테스트 전용으로 internal로 낮추고 어셈블리 친구 관계를 맺는 방법이 더 안정적입니다.

// 프로젝트의 AssemblyInfo.cs (또는 소스 상단)
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MyProject.Tests")]

// 또는 해당 멤버를 internal로 바꾸고 테스트에서 직접 참조합니다.

7. 실패 원인과 디버그 팁

자주 겪는 문제와 해결 방법입니다.

- BindingFlags 누락: Instance/Static, NonPublic/Public 조합이 정확해야 합니다.
- 멤버명 오타: nameof를 활용해 리팩터링에 안전하게 유지합니다.
- null 반환: 계층 상위 타입의 멤버일 수 있습니다. type.BaseType을 순회하거나 FlattenHierarchy 옵션을 검토합니다(Static에만 영향).
- 접근 제한: 트리밍/AOT 환경에서 반사가 제거될 수 있습니다. 필요한 멤버에 보존 특성이나 DynamicDependency를 지정합니다.
- 성능: Invoke는 느립니다. CreateDelegate로 캐시하거나 한번만 반사로 찾아 델리게이트를 재사용합니다.

8. 보안과 운영 주의사항

- 캡슐화 위반: 테스트 외 운영 코드에서 Private 반사는 유지보수 비용을 키웁니다.
- 런타임 정책: .NET Core/5+는 기본적으로 Private 반사를 허용하지만, 샌드박스나 일부 호스팅 환경에 따라 제한될 수 있습니다.
- 트리밍/AOT: PublishTrimmed나 NativeAOT 사용 시 반사 대상 멤버가 제거될 수 있습니다.

9. 간단 유틸리티 헬퍼

public static class Reflect
{
    public static FieldInfo Field<T>(string name) =>
        typeof(T).GetField(name, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
        ?? throw new MissingFieldException(typeof(T).FullName, name);

    public static PropertyInfo Property<T>(string name) =>
        typeof(T).GetProperty(name, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
        ?? throw new MissingMemberException(typeof(T).FullName, name);

    public static MethodInfo Method<T>(string name, params Type[] types) =>
        typeof(T).GetMethod(name, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, null, types, null)
        ?? throw new MissingMethodException(typeof(T).FullName, name);
}

// 사용 예
var fAge = Reflect.Field<User>("_age");
fAge.SetValue(user, 33);

요약: 테스트에서는 일시적인 검증을 위해 Reflection으로 Private 멤버에 접근할 수 있습니다. 단, 장기적으로는 퍼블릭 계약을 통해 테스트하거나, internal + InternalsVisibleTo로 전환하는 것이 유지보수에 유리합니다.