6.9 리플렉션

리플렉션 클래스는 BCL에서 제공해주는 메타데이터 정보를 얻는 클래스이다.

그림 6.41 프로세스 - AppDomain - 어셈블리의 관계

닷넷 프로세스는 운영체제에서 EXE 프로세스로 실행되고 그 내부에 CLR에 의해 응용 프로그램 도메인(AppDomaion)이라는 구획이 생긴다. AppDomain은 CLR이 구현한 내부 격리 공간으로써 응용 프로그램마다 단 1개의 AppDomain이 존재한다.

 

AppDomain이 만들어지면 그 내부 어셈블리들이 로드된다.

리플렉션을 이용하면 현재 AppDomain의 이름과 그 안에 로드된 어셈블리 목록을 구할 수 있다.

 

using System.Reflection;

AppDomain currentDomain = AppDomain.CurrentDomain;
Console.WriteLine("Current Domain Name: " + currentDomain.FriendlyName);
foreach(Assembly assembly in currentDomain.GetAssemblies())
{
    Console.WriteLine(assembly.FullName);
}

#출력
System.Private.CoreLib, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = 7cec85d7bea7798e
ConsoleApp4, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null
System.Runtime, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
Microsoft.Extensions.DotNetDeltaApplier, Version = 17.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.IO.Pipes, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Linq, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Collections, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Console, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Collections.Concurrent, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Threading, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Text.Encoding.Extensions, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Runtime.InteropServices, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
System.Threading.Overlapped, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a

 

어셈블리는 모듈의 집합으로 구성돼 있다. 어셈블리 내부의 메타데이터 정보는 그림 6.42에 나온 다이어그램에 따라 계층적으로 접근 가능하다.

 

그림 6.42 어셈블리 내의 계층 구조

 

using System.Reflection;
AppDomain currentDomain = AppDomain.CurrentDomain;
foreach (Assembly assembly in currentDomain.GetAssemblies())
{
    Console.WriteLine($"assembly: {assembly.FullName}");
    
    foreach(Module module in assembly.GetModules())
    {
        Console.WriteLine($"module: {module.FullyQualifiedName}");
    }
}
# 출력
assembly: System.Private.CoreLib, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = 7cec85d7bea7798e
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.Private.CoreLib.dll
assembly: ConsoleApp4, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null
module: C: \Users\asd57\source\repos\ConsoleApp4\ConsoleApp4\bin\Debug\net8.0\ConsoleApp4.dll
assembly: System.Runtime, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.Runtime.dll
assembly: Microsoft.Extensions.DotNetDeltaApplier, Version = 17.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
module: c:\program files\microsoft visual studio\2022\community\common7\ide\commonextensions\microsoft\hotreload\Microsoft.Extensions.DotNetDeltaApplier.dll
assembly: System.IO.Pipes, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.IO.Pipes.dll
assembly: System.Linq, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.Linq.dll
assembly: System.Collections, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.Collections.dll
assembly: System.Console, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = b03f5f7f11d50a3a
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.Console.dll

어셈블리에 포함된 모듈을 상기와 같이 열람가능하며, 

using System.Reflection;
using System.Text.RegularExpressions;
AppDomain currentDomain = AppDomain.CurrentDomain;
foreach (Assembly assembly in currentDomain.GetAssemblies())
{
    Console.WriteLine($"assembly: {assembly.FullName}");

    foreach (Module module in assembly.GetModules())
    {
        Console.WriteLine($"module: {module.FullyQualifiedName}");
        foreach (Type type in module.GetTypes()) 
        {
            Console.WriteLine($"type: {type.FullName}");
        }
    }
}

# 출력
assembly: System.Private.CoreLib, Version = 8.0.0.0, Culture = neutral, PublicKeyToken = 7cec85d7bea7798e
module: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.15\System.Private.CoreLib.dll
type: Interop
type: Interop + OleAut32
type: Interop + Globalization
type: Interop + Globalization + ResultCode
type: Interop + BOOL
type: Interop + Kernel32
type: Interop + Kernel32 + NlsVersionInfoEx
type: Interop + Kernel32 + OVERLAPPED_ENTRY
type: Interop + Kernel32 + CONDITION_VARIABLE
    ....

각 모듈에 구현된 타입을 GetTypes 메서드로 열람가능하다.

 

타입을 열것하는 것은 모듈단위 뿐만 아니라 어셈블리 레벨에서 열람 또한 가능하다. 그러나 이는 어셈블리에 포함된 모든 모듈의 타입을 조회하는 것과 같다. 이에 일반적으로 어셈블리 내에 모듈이 한개만 포함돼 있는 경우가 대부분이라 다음과 같이 어셈블리에서 직접 타입을 구하는 방법을 더 선호한다.

더보기

"모듈 단위로 조회하면 범위가 좁으니 더 빠르지 않을까?"라고 생각할 수 있지만, 실제로는 대부분의 .NET 어셈블리는 단 하나의 모듈만을 포함하기 때문에 어셈블리에서 직접 타입을 구하는 방식이 더 직관적이고 실용적입니다. 아래에서 이유를 정리해볼게요.


✅ 어셈블리에서 직접 타입을 구하는 방식을 선호하는 이유

  1. 대부분의 어셈블리는 하나의 모듈만 가짐
    • C#, VB.NET 등 일반적인 .NET 언어로 작성된 코드는 컴파일 시 단일 모듈(single-module) 어셈블리를 생성합니다.
    • 따라서 assembly.GetTypes()는 내부적으로 해당 단 하나의 모듈에서 타입을 가져오기 때문에 사실상 성능 차이가 거의 없습니다.
  2. 코드 가독성과 간결함
    • assembly.GetTypes()는 한 줄로 모든 타입을 가져오므로 코드가 훨씬 간결하고 범용적입니다.
    • 반면 module.GetTypes()를 쓰려면 먼저 assembly.GetModules() 또는 assembly.ManifestModule을 호출해야 해서 코드가 길어지고 구조가 복잡해집니다.
    // 어셈블리에서 직접
    var types = assembly.GetTypes();
    
    // 모듈에서 가져오는 방식
    var types = assembly.ManifestModule.GetTypes();
     
  3. 어셈블리 내 모듈이 여러 개인 경우는 극히 드묾
    • 기술적으로는 여러 모듈을 포함한 멀티모듈 어셈블리를 만들 수 있지만,
    • 이는 고급 시나리오(ex: ILMerge, 커스텀 빌드 파이프라인)에서나 사용되고, 일반적인 애플리케이션에서는 거의 쓰이지 않습니다.
  4. 모듈 단위로 구해도 결국은 어셈블리로 통합해서 사용하는 경우가 많음
    • 어셈블리의 용도는 런타임 단위로 타입/리소스/메타데이터를 로드하는 것이기 때문에,
    • 전체 타입 목록을 가져오고자 할 때는 어셈블리를 기준으로 접근하는 것이 자연스럽습니다.

🔄 모듈로 타입을 구하는 게 유리한 경우?

  • 정말로 멀티모듈 어셈블리를 사용 중이고, 특정 모듈에만 있는 타입을 조회해야 할 때
  • 또는 IL 수준에서 정밀하게 제어하는 툴이나 디컴파일러를 만들 때

이런 경우에는 모듈 단위 접근이 더 적절할 수 있습니다.


✅ 결론

단일 모듈 어셈블리가 일반적이므로 Assembly.GetTypes() 방식이 더 직관적이고 선호됩니다. 성능 차이는 거의 없으며, 코드의 간결성과 사용성 측면에서 이점이 있습니다.

 

 

예제 6.55 어셈블리에 포함된 모든 타입을 열거

using System.Reflection;

AppDomain currentDomain = AppDomain.CurrentDomain;
foreach (Assembly asm in currentDomain.GetAssemblies())
{
    Console.WriteLine($"assembly full name: {asm.FullName}");

    foreach(Type type in asm.GetTypes())
    {
        Console.WriteLine($"assembly type: {type.FullName}");
    }
}

 

타입은 각종 멤버(메서드, 프로퍼티, 이벤트, 필드...)를 가지므로 Type.GetMembers 메서드를 이용해 열람 가능하다.

using System.Reflection;
using System.Runtime.InteropServices;

AppDomain currentDomain = AppDomain.CurrentDomain;
foreach (Assembly asm in currentDomain.GetAssemblies())
{
    Console.WriteLine($"assembly full name: {asm.FullName}");

    foreach(Type type in asm.GetTypes())
    {
        Console.WriteLine($"assembly type: {type.FullName}");
        foreach(MemberInfo memberInfo in type.GetMembers())
        {
            Console.WriteLine($"memberInfo: {memberInfo.Name}");
        }
    }
}


#출력
assembly type: System.IO.SyncTextReader
memberInfo: GetSynchronizedTextReader
memberInfo: Peek
memberInfo: Read
memberInfo: Read
memberInfo: ReadBlock
memberInfo: ReadLine
memberInfo: ReadToEnd
memberInfo: ReadLineAsync
memberInfo: ReadLineAsync
memberInfo: ReadToEndAsync
memberInfo: ReadToEndAsync
memberInfo: ReadBlockAsync
memberInfo: ReadAsync
memberInfo: Close
memberInfo: Dispose
memberInfo: Read
...

 

 

멤버를 유형별로 구하는 것도 가능하다.

using System.Reflection;
using System.Runtime.InteropServices;

AppDomain currentDomain = AppDomain.CurrentDomain;
foreach (Assembly asm in currentDomain.GetAssemblies())
{
    Console.WriteLine($"assembly full name: {asm.FullName}");

    foreach (Type type in asm.GetTypes())
    {
        Console.WriteLine($"assembly type: {type.FullName}");

        //클래스에 정의된 생성자를 열거
        foreach (ConstructorInfo ctorInfo in type.GetConstructors())
        {
            Console.WriteLine($"ctor: {ctorInfo.Name}");
        }

        //클래스에 정의된 이벤트 열거
        foreach (EventInfo eventInfo in type.GetEvents())
        {
            Console.WriteLine($"Event: {eventInfo.Name}");
        }

        //클래스에 정의된 필드 열거
        foreach (FieldInfo fieldInfo in type.GetFields())
        {
            Console.WriteLine($"Field: {fieldInfo.Name}");
        }

        //클래스에 정의된 메서드 열거
        foreach(MethodInfo methodInfo in type.GetMethods())
        {
            Console.WriteLine($"Method: {methodInfo.Name}");
        }

        //클래스에 정의된 프로퍼티 열거
        foreach(PropertyInfo propertyInfo in type.GetProperties())
        {
            Console.WriteLine($"Property: {propertyInfo.Name}");
        }
    }
}
#출력
일부 출력 예제.
assembly type: System.Diagnostics.Tracing.PollingCounter
ctor: .ctor
Method: ToString
Method: Dispose
Method: AddMetadata
Method: get_DisplayName
Method: set_DisplayName
Method: get_DisplayUnits
Method: set_DisplayUnits
Method: get_Name
Method: get_EventSource
Method: GetType
Method: Equals
Method: GetHashCode
Property: DisplayName
Property: DisplayUnits

 

C#코드가 빌드되어 어셈블리에 포함되는 경우 그에 대한 모든 정보를 조회할 수 있는 기술을 리플렉션이라한다.

 

6.9.1 AppDomain과 Assembly
AppDomain은 EXE 프로세스 내에서 CLR에 의해 구현된 격리 공간이다. 현재 스레드가 실행 중인 어셈블리가 속한 AppDomain 인스턴스는 CurrentDomain 정적 속성을 이용해 접근 가능하다.

 

AppDomain 내 로드되는 어셈블리들은 참조한 라이브러리로 구성되지만 직접 로드하는 것도 가능하다. CreateInstanceFrom 메서드를 이용해 AppDomain 내 어셈블리를 직접 로드해보자. 이때 어셈블리 파일의 경로와 최소 생성될 객체의 타입명을 지정할 것이다.

실습 목적으로 콘솔 응용프로그램 외 별도 DLL 라이브러리 프로젝트를 만들고 다음과 같은 클래스를 정의하자.

namespace ClassLibrary1
{
    public class Class1
    {
        public Class1()
        {
            Console.WriteLine(typeof(Class1).FullName + " : Created");
        }
    }
}

클래스라이브러리 프로젝트 생성

 

DLL을 빌드하고 해당 DLL 파일의 경로명과 클래스의 완전한 이름(FQDN : 네임스페이스 경로까지 포함한 이름)을 알아야한다.

DLL 경로: C:\Users\asd57\source\repos\ConsoleApp4\ClassLibrary1\bin\Debug\net8.0\ClassLibrary1.dll

Class1의 FQDN : ClassLibrary1.Class1

 

콘솔 EXE 프로젝트 내의 코드에서 CreateInstanceFrom 메서드를 사용해 이 값들을 전달하면 AppDomain에 어셈블리가 로드됨과 함께 클래스의 인스턴스가 하나 생성된다.

 

예제 6.56 AppDomain에 어셈블리 로드 방법

using System.Runtime.Remoting;

AppDomain currentAppDomain = AppDomain.CurrentDomain;
string dllPath = @"C:\Users\asd57\source\repos\ConsoleApp4\ClassLibrary1\bin\Debug\net8.0\ClassLibrary1.dll";

ObjectHandle objHandle = currentAppDomain.CreateInstanceFrom(dllPath, "ClassLibrary1.Class1");


#출력
ClassLibrary1.Class1 : Created

 

별도로 만든 ClassLibrary1.Class1 의 생성자가 실행됬다.

 

c/c++ 윈도우 프로그램에서는 DLL 파일을 LoadLibrary API를 이용해 프로세스에 로드했다가 FreeLibrary API를 이용해 메모리로부터 해제할 수 있다. 하지만, 닷넷 응용 프로그램의 경우 한번 로드된 어셈블리는 절대로 다시 내릴 수 없다.

 

6.9.2 Type과 리플렉션

리플렉션은 메타데이터 조회 뿐만 아니라 타입을 생성 할 수 있고, 그 타입에 정의된 메서드를 호출 할 수 있으며 필드/프로퍼티의 값을 바꿀 수 있다.

 

예제 6.57 리플렉션 실습용 코드

SystemInfo sysInfo = new();
sysInfo.WriteInfo();
public class SystemInfo
{
    bool _is64Bit;

    public SystemInfo()
    {
        _is64Bit = Environment.Is64BitOperatingSystem;
        Console.WriteLine("SystemInfo Created.");
    }

    public void WriteInfo()
    {
        Console.WriteLine($"OS == {(_is64Bit == true ? "64" : "32")}");

    }
}

 

예제 6.57에서 수행되는 메인 작업

SystemInfo sysInfo = new();
sysInfo.WriteInfo();

을 리플렉션을 이용해 동일하게 수행할 수 있다.

using System.Reflection;

//
// 현재 어셈블리에서 타입 찾기
//Type systemInfoType = Assembly.GetExecutingAssembly().GetType("ConsoleApp4.SystemInfo");

Type systemInfoType = typeof(SystemInfo);  // 정적으로 타입 참조

//Activator 타입의 CreateInstance 정적 메서드는 Type 정보만 가지고 해당 객체를 생성할 수 있게 만들어준다.
object objInstance = Activator.CreateInstance(systemInfoType);

//타입의 생성자를 리플렉션으로 구해서 직접 호출하는 것도 가능하다.
//GetConstructor 메서드는 Type.EmptyTypes 인자를 받는 경우 지정된 타입의 기본 생성자를 반환한다.
//이어서 ConstructorInfo 타입은 Invoke 메서드를 제공하는데 이를 사용해 생성자를 호출(invocation)함으로써 객체를 만든다.
ConstructorInfo ctorInfo = systemInfoType.GetConstructor(Type.EmptyTypes);
objInstance = ctorInfo.Invoke(null);

//생성자를 호출했던과 같은 방식을 이용하면 WriteInfo 메서드를 실행하는 것도 가능하다.
//타입에 정의된 생성자 정보를 가져오기 위해 GetConstructor 메서드를 이용했듯이 메서드 정보를 가져오기 위해
//GetMethod에 메서드의 이름을 전달하면 MethodInfo 객체가 반환된다.
//Invoke 메서드의 첫번째 인자에서는 호출될 객체의 인스턴스를 전달, 두번 째 인자에는 해당 메서드에 필요한 인자 목록을 전달한다.
//WriteInfo 메서드의 경우 인자가 하나도 없기에 null을 전달한다.
MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");
methodInfo.Invoke(objInstance, null);


public class SystemInfo
{
    bool _is64Bit;

    public SystemInfo()
    {
        _is64Bit = Environment.Is64BitOperatingSystem;
        Console.WriteLine("SystemInfo Created.");
    }

    public void WriteInfo()
    {
        Console.WriteLine($"OS == {(_is64Bit == true ? "64" : "32")}");

    }
}

#출력
SystemInfo Created.
SystemInfo Created.
OS == 64

 

 

[강력하게 결합된 코드(tightly coupling)]
SystemInfo sysInfo = new();
sysInfo.WriteInfo();


[리플렉션을 사용해 느슨하게 결합된 코드(loosely coupling)]
Type systemInfoType = Type.GetType("ConsoleApp4.SystemInfo");
object objInstance = Activator.CreateInstance(SystemInfoType);

MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");
methodInfo.Invoke(objInstance, null);

상기 두 코드는 완전히 동일한 역할을 수행한다.

더보기

상기 코드에서 리플렉션을 굳이 사용하는 이유는 뭘까? 

리플렉션 사용이유

1. 컴파일 시점에 타입을 몰라도 될 때

  • SystemInfo라는 클래스가 런타임에 결정될 수 있는 경우
  • 예: 플러그인 시스템, 설정 파일 기반 객체 생성, 스크립트 엔진, AOP 프레임워크
#JSON

{
  "TypeToLoad": "ConsoleApp4.SystemInfo"
}

 

# Cshapr

// 설정값으로부터 타입 이름 읽어와 동적으로 로드
string typeName = config["TypeToLoad"];
Type t = Type.GetType(typeName);
object obj = Activator.CreateInstance(t);

✔ 이런 경우는 미리 코드에서 타입을 알 수 없기 때문에 리플렉션이 필수적입니다.

2. 모듈 간 결합도를 낮추기 위해 (느슨한 결합)

  • SystemInfo 클래스가 **별도 어셈블리(.dll)**에 있을 수 있고,
  • Main 프로젝트는 그 어셈블리에 의존하지 않아도 되게끔 만드는 것이 목표입니다.
# Csharp

// DLL 파일 이름만 알고 있다면
Assembly pluginAssembly = Assembly.LoadFrom("Plugin.dll");
Type pluginType = pluginAssembly.GetType("Plugin.SystemInfo");

✔ 이 방식은 컴파일 타임 종속성 제거 = "느슨한 결합"을 달성합니다.

 

3. 테스트, 프레임워크, IoC 컨테이너, AOP 구현 시

  • DI 프레임워크, 테스트 프레임워크, 런타임 코드 생성기 등은 내부적으로 리플렉션을 씁니다.
  • 예를 들어 NUnit이나 xUnit은 메서드 이름만 보고 테스트 메서드를 찾아 실행합니다.

✅ 왜 "느슨한 결합"인가?

"강하게 결합된 코드"란?

SystemInfo s = new SystemInfo();

 

  • 컴파일러는 SystemInfo가 존재함을 알아야 함
  • 소스 코드, 프로젝트, 네임스페이스 등 서로 직접 연결되어 있음

"느슨하게 결합된 코드"란?

Type t = Type.GetType("ConsoleApp4.SystemInfo");
  • 이름만 알고 있어도 객체 생성 가능
  • 컴파일러는 해당 타입의 존재 여부를 몰라도 됨
  • 의존성이 런타임에만 존재

✔ 즉, "직접 참조하지 않음" = 느슨한 결합입니다.

✅ 단점도 있다

항목리플렉션 사용 시 단점
성능 일반 코드보다 느림 (캐싱 필요)
가독성 덜 직관적임
타입 안정성 컴파일 시 타입 검사 불가 → 런타임 에러 가능
유지보수 리팩터링 시 타입 이름 변경 누락 위험

✅ 요약

항목강하게 결합된 코드느슨하게 결합된 코드 (리플렉션 기반)
컴파일 시 타입 정보 필요 필요 불필요 (이름만 알면 됨)
유지보수 쉽고 안정적 위험하지만 유연함
사용 시점 일반적인 경우, 성능이 중요할 때 플러그인, 설정 기반 로딩, 프레임워크 개발 등

🔚 결론

"리플렉션은 느슨한 결합을 원할 때 사용하는 유연한 도구지만, 비용(가독성/성능/안정성)도 크다"
따라서 일반적인 앱 로직에는 불필요, 하지만 플러그인 시스템, 프레임워크 설계, 동적 로딩 같은 특수 목적엔 아주 강력한 도구입니다.


IoC 컨테이너에서 리플렉션이 어떻게 사용되는지 간단한 예제

🧠 상황: 인터페이스 기반 의존성 주입

우리가 만드는 앱에서는 IMessageSender 인터페이스만 알고 있고,
그 구현체가 뭔지는 런타임에 결정된다고 해볼게요.

public interface IMessageSender
{
    void Send(string message);
}

public class EmailSender : IMessageSender
{
    public void Send(string message)
    {
        Console.WriteLine($"[EMAIL] {message}");
    }
}

 

✅ 리플렉션 기반 IoC 컨테이너 (초간단 버전)

public class MyContainer
{
    private Dictionary<Type, Type> _map = new();

    public void Register<TInterface, TConcrete>()
    {
        _map[typeof(TInterface)] = typeof(TConcrete);
    }

    public TInterface Resolve<TInterface>()
    {
        Type implType = _map[typeof(TInterface)];

        // 기본 생성자 기반 객체 생성 (리플렉션 사용)
        object instance = Activator.CreateInstance(implType);
        return (TInterface)instance;
    }
}

✅ 사용 예시

class Program
{
    static void Main()
    {
        MyContainer container = new MyContainer();

        // 컴파일 타임에선 EmailSender를 몰라도 됨 (느슨한 결합)
        container.Register<IMessageSender, EmailSender>();

        IMessageSender sender = container.Resolve<IMessageSender>();
        sender.Send("Hello Dependency Injection!");
    }
}

💬 여기서 리플렉션의 역할

  • Activator.CreateInstance(implType) → 구현 타입의 객체를 동적으로 생성
  • 인터페이스만 알면 되고, 실제 구현은 바뀌어도 코드 수정 불필요
  • 이를 통해 느슨한 결합 + 유연한 구조가 가능해짐

🔚 결론

IoC 컨테이너, DI 프레임워크, AOP, 테스트 프레임워크, ORM 등
내부적으로 대부분 리플렉션을 사용해 동적 객체 생성, 메서드 실행, 속성 접근을 처리합니다.

 
 
 

 

리플렉션을 이용한 타입 접근은 OOP의 캡슐화를 무시할 수 있는 위력을 발휘한다.

예를 들어, 일반적인 C# 코드로는 SystemInfo 타입에 정의된 private타입인 _is64Bit 필드에 접근할 수 없지만 리플렉션을 이용하면 가능하다. 

 

예제 6.58 private 속성 접근

using System.Reflection;
Type systemInfoType = typeof(SystemInfo);  // 정적으로 타입 참조
object objInstance = Activator.CreateInstance(systemInfoType);
MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");

FieldInfo fieldInfo = systemInfoType.GetField("_is64Bit", BindingFlags.NonPublic | BindingFlags.Instance);

//기존 값을 구하고,
object oldValue = fieldInfo.GetValue(objInstance);

//새로운 값을 쓴다.
fieldInfo.SetValue(objInstance, !Environment.Is64BitProcess);

//확인을 위해 WriteInfo 메서드 호출
methodInfo.Invoke(objInstance, null);

public class SystemInfo
{
    bool _is64Bit;

    public SystemInfo()
    {
        _is64Bit = Environment.Is64BitOperatingSystem;
        Console.WriteLine("SystemInfo Created.");
    }

    public void WriteInfo()
    {
        Console.WriteLine($"OS == {(_is64Bit == true ? "64" : "32")}");

    }
}

#출력
SystemInfo Created.
OS == 32

_is64Bit 필드는 private 접근자가 지정된 인스턴스 멤버이므로 GetField 메서드의 두 번째 인자에 BindingFlags 열거형으로 NonPublic과 Instance를 함께 전달한다. 일단 FieldInfo 객체를 구할 경우 GetValue 메서드와 SetValue 메서드를 이용해 필드의 값을 구하거나 가져올 수 있다. 물론, OOP의 캡슐화를 무너뜨리는 식의 이러한 사용법은 관례상 권장하지 않지만, 이는 리플렉션의 강력한 특징이다.

 

리플렉션을 이용해 타입을 다루는 코드에서 해당 타입이 가진 코드의 멤버를 C#코드에서 직접 접근하지 않는다.

[리플렉션을 사용하지 않은 코드 접근 - 멤버에 직접적으로 접근]

SystemInfo sysInfo = new();
sysInfo.WriteInfo();


[리플렉션을 사용한 코드 접근 - 멤버에 간접 접근]

Type systemInfoType = Type.GetType("ConsoleApp4.SystemInfo");
object objInstance = Activator.CreateInstance(systemInfoType);
methodInfo.Invoke(objInstance, null);

 

GetType, GetMethod, GetField의 인자에는 모두 문자열 이름이 사용됐기 때문에 리플렉션을 사용한 c#코드는 컴파일러 입장에서 보았을 때 SystemInfo 타입을 몰라도 된다. 

 

- ConsoleApp4: SystemInfo 클래스를 제외시킨 C# EXE 콘솔 프로젝트

- ClassLibrary1: SystemInfo.cs 파일을 추가하고 예제 6.57의 SystemInfo 클래스를 포함하는 C# DLL 라이브러리 프로젝트

 

ConsoleApp4 프로젝트의 Main은 ClassLibrary1 DLL파일을 Assembly 타입을 이용해 직접 로드해 리플렉션으로 사용하는 것이 가능하다.

 

예제 6.59 어셈블리를 참조하지 않고 다른 dll의 클래스 사용

 

ConsoleApp4\Program.cs

using ClassLibrary1;
using System.Reflection;

string dllPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,  "ClassLibrary1.dll");
Assembly asm = Assembly.LoadFrom(dllPath);

Type systemInfoType = asm.GetType("ClassLibrary1.SystemInfo");

ConstructorInfo ctorInfo = systemInfoType.GetConstructor(Type.EmptyTypes);
object objInstance = ctorInfo.Invoke(null);

MethodInfo methodInfo = systemInfoType.GetMethod("WriteInfo");
methodInfo.Invoke(objInstance, null);

FieldInfo fieldInfo = systemInfoType.GetField("_is64Bit", BindingFlags.NonPublic | BindingFlags.Instance);
object oldValue = fieldInfo.GetValue(objInstance);
fieldInfo.SetValue(objInstance, !Environment.Is64BitOperatingSystem);

methodInfo.Invoke(objInstance, null);


#출력
SystemInfo Created.
OS == 64
OS == 32

 

ClassLibrary1\Class1.cs

using System.Threading.Channels;

namespace ClassLibrary1
{
    public class Class1
    {
        public Class1()
        {
            Console.WriteLine(typeof(Class1).FullName + " : Created");
        }
    }

    public class SystemInfo
    {
        bool _is64Bit;

        public SystemInfo()
        {
            _is64Bit = Environment.Is64BitOperatingSystem;
            Console.WriteLine("SystemInfo Created.");
        }

        public void WriteInfo()
        {
            Console.WriteLine($"OS == {(_is64Bit == true ? "64" : "32")}");

        }
    }
}

 

여기서 어셈블리를 로드하는 Assembly asm = Assembly.LoadFrom(dllPath);  이 부분이 두 파일이 다른 경로로 되어있어 dllPath를 찾을 수 없는 에러가 난다. dll 파일 이름만 전달했으므로 ConsoleApp4.exe 파일과 같은 폴더에 DLL 파일이 있어야하는데, 프로젝트를 참조하지 않았으므로 VS에서는 ClassLibrary1.dll 파일을 ConsoleApp4.exe 가 있는 폴더에 자동 복사해주지 않는다는 점을 주의 하자. 

 

 

이를 해결하는 방법은 다음과 같다.

더보기

✅ 해결 방법

✅ 방법 1. 올바른 DLL 경로 직접 지정

csharp
string dllPath = @"C:\Users\asd57\source\repos\ConsoleApp4\ClassLibrary1\bin\Debug\net8.0\ClassLibrary1.dll";
Assembly asm = Assembly.LoadFrom(dllPath);​
 
 

✔ 가장 확실한 방법입니다.


✅ 방법 2. 빌드 후 DLL 자동 복사 설정 (Visual Studio에서 깔끔하게)

1. ConsoleApp4 실행 프로젝트에 ClassLibrary1.dll을 복사해오려면,
실행 프로젝트 (ConsoleApp4)의 .csproj 파일을 다음과 같이 수정하세요:

<Target Name="CopyClassLibraryDll" AfterTargets="Build">
  <Copy SourceFiles="..\ClassLibrary1\bin\Debug\net8.0\ClassLibrary1.dll"
        DestinationFolder="$(OutputPath)" />
</Target>

2. 또는 Visual Studio에서:

  • ConsoleApp4 프로젝트 오른쪽 클릭 > 종속성 추가 > ClassLibrary1 프로젝트 추가
  • 이렇게 하면 빌드시 자동 복사됨

단, 이렇게 하면 리플렉션으로 로드할 필요 없이 참조만 해도 접근 가능해집니다.


✅ 방법 3. 빌드 출력 폴더 통일시키기

각 프로젝트의 .csproj에서 출력 디렉터리를 아래처럼 통일할 수 있어요:

[각 프로젝트 더블 클릭]

<PropertyGroup>
  <OutputPath>..\BuildOutput\</OutputPath>
</PropertyGroup>

그러면 모든 프로젝트의 DLL이 BuildOutput\ 폴더로 모이게 됩니다.

 

클래스 라이브러리 참조를 선택했다.

ClassLibrary1.dll 이 ConsoleApp4.exe의 경로에 복사됬다는 것을 확인할 수 있다.

 

리플렉션의 특징을 이용해 응용 프로그램에 확장 모듈(Plug-in, Add-in)을 구현하는 것이 가능하다.

 

6.9.3 리플렉션을 이용한 확장 모듈 구현

소프트웨어 개발업체가 아닌 다른 개발사에서 기능 확장을 할 수 있게 '확장 인터페이스'를 제공하는 경우가 있는데 이번에는 개발한 프로그램에 플러그인(plug in)을 구현하는 법을 알아보자.

 

플러그인 구현한 sw 동작방식

1. EXE 프로그램이 실행 경로 아래에 확장 모듈을 담도록 약속된 폴더가 있는 지 확인한다.

2. 해당 폴더 존재 시, 그 안에 DLL 파일 여부를 검사하고 로드한다.

3. DLL이 로드됐으면 사전에 약속된 조건을 만족하는 타입이 있는지 살펴본다.

4. 조건에 부합하는 타입이 있다면 생성하고, 역시 사전에 약속된 메서드를 실행한다.

 

플러그인을 이용할 수 있는 응용 프로그램을 만드는 개발자는 위의 순서에 나열된 몇몇 조건을 확정 짓고 그 규칙을 공개하면 된다.

 

플러그인을 이용할 수 있는 응용 프로그램을 만든 개발자는 위 순서에 나열된 몇몇 조건을 확정 짓고, 그 규칙을 공개하면 된다.

예) 

1. 확장 모듈이 담길 폴더명: EXE 하위의 plugin

2. 플러그인 타입 조건: 타입에 "PluginAttribute"라는 이름의 Attribute(특성)이 부여돼 있어야 한다.

3. 호출될 메서드: 메서드에는 "StartupAttribute"라는 이름의 특성(Attribute)이 부여돼 있어야 한다. 또한 입력 인자도 없고 반환값도 없어야 한다.

 

1단계를 구현한 코드 

예제 6.60 확장 모듈로드

using System.Reflection;

string pluginFolder = @".\plugin";
if(Directory.Exists(pluginFolder) == true)
{
    ProcessPlugIn(pluginFolder);
}

void ProcessPlugIn(string rootPath)
{
    foreach(string dllPath in Directory.EnumerateFiles(rootPath, "*.dll"))
    {
        //확장 모듈을 현재의 AppDomain에 로드
        Assembly pluginDll = Assembly.LoadFrom(dllPath);
    }
}

 

어셈블리를 성공적으로 로드했을 경우 예제 6.55 방법을 이용해 DLL에 포함된 모든 타입을 열거 해야 한다.

이 때 확장 모듈 개발자가 구현한 클래스 중 어떤 타입이 진입점에 해당하는지 결정하고자 사전에 "PluginAttribute"  라는 이름의 특성이 적용돼 있어야 한다.

따라서 다음과 같이 리플렉션을 이용해 해당 특성이 정의된 타입을 찾아내자.

using System.Reflection;

string pluginFolder = @".\plugin";
if(Directory.Exists(pluginFolder) == true)
{
    ProcessPlugIn(pluginFolder);
}

void ProcessPlugIn(string rootPath)
{
    foreach(string dllPath in Directory.EnumerateFiles(rootPath, "*.dll"))
    {
        //확장 모듈을 현재의 AppDomain에 로드
        Assembly pluginDll = Assembly.LoadFrom(dllPath);

        Type entryType = FindEntryType(pluginDll);
    }
}

Type FindEntryType(Assembly pluginDll)
{
    foreach(Type type in pluginDll.GetTypes())
    {
        foreach(object objAttr in type.GetCustomAttributes(false))
        {
            if (objAttr.GetType().Name == "PluginAttribute")
            {
                return type;
            }
        }
    }
    return null;
}

 

Type 클래스는 GetCustomAttributes메서드를 이용해 클래스에 부여된 Attribute(특성) 정보를 얻을 수 있다.

특성은 다중으로 적용될 수 있으므로 그중에서도 "PluginAttribute" 특성이 지정된 타입을 Type.Name 속성을 이용해 알아 낼 수 있다. 이 방식을 비슷하게 도입해서 해당 클래스에 포함된 메서드 가운데 "StartupAttribute"가 지정된 메서드를 결정하는 것도 가능하다.

using System.Reflection;

string pluginFolder = @".\plugin";
if(Directory.Exists(pluginFolder) == true)
{
    ProcessPlugIn(pluginFolder);
}

void ProcessPlugIn(string rootPath)
{
    foreach(string dllPath in Directory.EnumerateFiles(rootPath, "*.dll"))
    {
        //확장 모듈을 현재의 AppDomain에 로드
        Assembly pluginDll = Assembly.LoadFrom(dllPath);

        Type entryType = FindEntryType(pluginDll);
        if(entryType == null)
        {
            continue;
        }

        //타입에 해당하는 객체 생성
        object instance = Activator.CreateInstance(entryType);

        //약속된 메서드를 구함.
        MethodInfo entryMethod = FindStartupMethod(entryType);
        if( entryMethod == null)
        {
            continue;
        }

        //메서드 호출
        entryMethod.Invoke(instance, null);
    }
}

MethodInfo FindStartupMethod(Type entryType)
{
    foreach(MethodInfo methodInfo in entryType.GetMethods())
    {
        foreach(object objAttribute in methodInfo.GetCustomAttributes(false))
        {
            if(objAttribute.GetType().Name == "StartupAttribute")
            {
                return methodInfo;
            }
        }
    }
    return null;
}

Type FindEntryType(Assembly pluginDll)
{
    foreach(Type type in pluginDll.GetTypes())
    {
        foreach(object objAttr in type.GetCustomAttributes(false))
        {
            if (objAttr.GetType().Name == "PluginAttribute")
            {
                return type;
            }
        }
    }
    return null;
}

 

여기까지가 플러그인 기능을 제공하는 응용 앱 개발자가 구현해야할 코드이다.

 

이제 반대로 확장 모듈을 개발하는 측에서 어떤 식으로 코드를 구성해야할지알아보자.

DLL 프로젝트를 만들고 확장 모듈이 갖춰야할 조건에 따라 하나 씩 코드를 작성하자.

 

예제 6.61 확장 모듈 제작

using System.Threading.Channels;

namespace ClassLibrary1
{
    [PluginAttribute]
    public class SystemInfo
    {
        bool _is64Bit;

        public SystemInfo()
        {
            _is64Bit = Environment.Is64BitOperatingSystem;
            Console.WriteLine("SystemInfo Created.");
        }

        [StartupAttribute]
        public void WriteInfo()
        {
            Console.WriteLine($"OS == {(_is64Bit == true ? "64" : "32")}");

        }
    }

    public class PluginAttribute : Attribute { }
    public class StartupAttribute : Attribute { }
}

 

확장 모듈의 조건을 만족 시키고자 PluginAttribute와 StartupAttribute 클래스를 정의해 특성을 만들고 SystemInfo 클래스에 그러한 특성을 적용했다.

 

예제 6.6 ConsoleApp4.exe 만 빌드해서 실행 시 화면에 아무것도 안나온다. ConsoleApp4.exe 파일의 경로 아래에 plugin 폴더를 만들고 예제 6.61 ClassLibrary1을 빌드한 Dll 파일을 plugin에 넣고 다시 ConsoleApp4.exe 를 실행하면 SystemInfo 클래스의 WriteInfo메서드가 수행되는 것을 확인 할 수 있다.

#출력
SystemInfo Created.
OS == 64

 

플러그인은 해당 코드가 '컴파일 시점'에 서로에 대한 코드 정보가 없어도 만들 수 있는 장점이 있다. 이렇게 개발되는 유형을 '느슨한 결합'이라고 하며 반대로 예제 6.57처럼 직접 대상을 참조해 사용하는 것을 '강력한 결합'이라 한다.

 

과거 DLL 간의 느슨한 결합은 속도 저하라는 이유로 기피했으나 근래 하드웨어의 발전과 함께 flexible architecture(유연한 구조)를 지향하는 분위기로인해 권장되는 추세다.

프레임워크 제작시 필수적으로 리플렉션을 알아야한다.

 

6.10 기타

6.10.1 윈도우 레지스트리

레지스트리에 접근 시 Win32 API를 직접 사용하기 보단 Microsoft.Win32 네임스페이스에서 제공되는 Registry, RegistryKey 타입을 사용하자.

 

윈도우의 registry 편집기(regedit)에서 컴퓨터의 메인보드에 장착된 바이오스의 날짜와 제조사를 구할 수 있는 경로는 다음과 같다.

 

위의 ...\System\BIOS 경로에 접근하는 코드는 다음과 같다.

 

예제 6.62 레지스트리 값 읽기

using Microsoft.Win32;

string regPath = @"HARDWARE\DESCRIPTION\System\BIOS";

using (RegistryKey systemKey = Registry.LocalMachine.OpenSubKey(regPath))
{
    string biosDate = (string)systemKey.GetValue("BIOSReleaseDate");
    string biosMaker = (string)systemKey.GetValue("BIOSVendor");

    Console.WriteLine("BIOS 날짜: " + biosDate);
    Console.WriteLine("BIOS 제조사: " + biosMaker);
}

#출력
BIOS 날짜: 09 / 10 / 2024
BIOS 제조사: American Megatrends International, LLC.

 

'HKEY_LOCAL_MACHINE'은 별도 분리되어 Registry 타입의 정적 속성인 LocalMachine과 연결된다.

이러한 경로 관계를 다음과 같이 정리한다.

표 6.26 Registry 루트 경로에 대응되는 정적 속성

Registry 정적속성 대응되는 레지스트리 루트 경로
ClassesRoot HKEY_CLASSES_ROOT
CurrentUser HKEY_CURRENT_USER
LocalMachine HKEY_LOCAL_MACHINE
Users HKEY_USERS
CurrentConfig HKEY_CURRENT_CONFIG

 

RegistryKey 인스턴스의 Get-Value 메서드의 반환값은 object 타입으로 통합해 반환한다. 따라서 다음과 같은 기준으로 적절한 타입으로 변환해서 사용 가능하다.

 

표 6.27 레지스트리 값의 타입과 대응되는 c# 타입

Registry 값 타입 대응되는 C# 타입
REG_SZ string
REG_BINARY byte[]
REG_DWORD int
REG_QWORD long
REG_MULTI_SZ string[]

 

 

레지스트리에 값 쓰는 방식은 다음과 같다.

OpenSubKey 메서드는 기본으로 읽기 전용 RegistryKey 인스턴스를 반환하지만 쓰기작업을 위해 OpenSubKey의 두번째 인자에 true 값을 지정하면 된다. 

using Microsoft.Win32;

//string regPath = @"HARDWARE\DESCRIPTION\System\BIOS";

string regPath = @"TEST";

//using (RegistryKey regKey = Registry.LocalMachine.OpenSubKey(regPath, true))
using (RegistryKey regKey = Registry.CurrentUser.OpenSubKey(regPath, true))
{
    regKey.SetValue("TestValue1", 5); //REG_dWORD로 기록됨
    regKey.SetValue("TestValue2", "Test"); //REG_SZ로 기록됨.
}

 

 

HKEY_LOCAL_sYSTEM 영역의 레지스트리는 관리자 권한에서만 쓸수 있기에 '관리자 권한'으로 프로그램 자체를 실행하거나 상기 코드처럼 사용자 계정으로도 쓰기가 가능한 HKEY_CURRENT_USER 영역에 쓰도록 키 경로를 변경하면 된다.

 

 

6.10.2 BigInteger

c#에서 표현 가능한 최대 정수형인 8바이트 (2^ 64) long형의 범위를 넘어서서 사용할 수 있는 구조체 타입. (BCL에서 제공)

 

6.10.3 IntPtr

정수형 포인터를 의미하는 값형식의 타입.

 

포인터는 메모리 주솟값을 보관하는 곳으로 32 비트 프로그램은 2^32 주소 영역을 지정할 수 있어야하고, 64 비트 프로그램에선 2^64 주소 영역을 지정할 수 있어야 한다. 이러한 이유로, IntPtr자료형은 32비트 프로그램에서는 4바이트, 64비트 프로그램에서는 8바이트로 동작하는 특징을 갖는다.

Console.WriteLine(IntPtr.Size);

#출력
//32비트 프로그램인 경우: 4
//64                   : 8

 

IntPtr 타입은 메모리 주소를 가리키는것 외에 윈도우 운영체제의 핸들(HANDLE)값을 보관하는 용도로도 쓰인다.

핸들은 윈도우 OS가 특정 자원에 대한 식별자(identifier)로서 보관하는 값인데, 일례로 파일이 좋은 예다.

닷넷 BCL에서도 FileStream에서 핸들 값을 알 수 있는 속성이 제공된다. 

using (FileStream fs = new("test.dat", FileMode.Create))
{
    Console.WriteLine(fs.Handle);
}

#출력
816

 

IntPtr 타입을 사용하는 경우는 Win32 API를 호출하거나 기존 C/C++ 로 작성된 프로그램과 상호 연동해야 할 때 IntPtr을 사용한다.

그 외 순수 닷넷 응용 앱에서는 사용하는 경우가 거의 없다.

+ Recent posts