프로그램 본체는 그대로 두고 기능만 DLL로 갈아 끼우고 싶을 때가 있습니다. 거래처마다 계산 방식이 조금씩 다르다거나, 플러그인처럼 폴더에 DLL을 넣으면 메뉴가 늘어나게 만들고 싶을 때죠. 이럴 때 프로젝트 참조로 DLL을 박아 넣으면 DLL이 바뀔 때마다 본체도 다시 빌드해야 합니다. 실행 중에 DLL 파일을 직접 읽어서 쓰면 그 묶임이 풀립니다.

VB.NET에서 "DLL을 코드에서 불러 쓴다"고 할 때는 사실 두 갈래가 섞여서 이야기됩니다. C/C++ 로 만든 네이티브 DLL을 부르는 DllImport 방식과, .NET으로 만든 어셈블리 DLL을 System.Reflection 으로 읽는 방식입니다. 둘은 대상이 완전히 다르니 구분해서 봐야 합니다.

광고

네이티브 DLL은 DllImport 로

Windows API 를 호출할 때 쓰는 그 방식입니다. 함수 모양(이름, 인수, 반환형, 호출 규약)만 선언해 두면 처음 호출할 때 DLL이 로드되고 함수가 연결됩니다.

Imports System.Runtime.InteropServices

Module MainModule
    ' DLL 함수의 프로토타입 정의
    <DllImport("example.dll", CallingConvention:=CallingConvention.Cdecl)>
    Public Function Add(ByVal a As Integer, ByVal b As Integer) As Integer
    End Function

    Sub Main()
        ' DLL 함수 호출
        Dim result As Integer = Add(5, 3)
        Console.WriteLine("결과: " & result)
    End Sub
End Module

여기서 example.dll 은 Add 함수를 내보내기(export)한 네이티브 DLL이어야 합니다. 아래에서 만드는 .NET DLL을 이 방식으로 부르면 "진입점을 찾을 수 없다"는 오류가 납니다. 호출 규약도 DLL을 만든 쪽과 맞아야 하는데, C 로 작성하고 따로 지정하지 않았다면 보통 Cdecl, Windows API 류는 StdCall 입니다. 이게 어긋나면 당장은 값이 나오는 것 같다가 스택이 꼬여 엉뚱한 곳에서 터지기 때문에, 원인 찾기가 꽤 괴롭습니다.

.NET DLL은 Reflection 으로

.NET으로 만든 DLL은 클래스와 메서드 정보가 메타데이터로 들어 있어서, 파일을 읽은 다음 이름으로 클래스를 찾고 메서드를 찾아 호출할 수 있습니다. 먼저 불러다 쓸 DLL 쪽 코드입니다.

Imports System

Namespace ExampleNamespace
    Public Class ExampleClass
        Public Function Add(ByVal a As Integer, ByVal b As Integer) As Integer
            Return a + b
        End Function
    End Class
End Namespace

이 코드를 ExampleClass.vb 로 저장하고 클래스 라이브러리로 빌드하면 됩니다. Visual Studio 에서 "클래스 라이브러리" 프로젝트로 만들어도 되고, 명령줄이라면 .NET Framework 의 VB 컴파일러로 이렇게 만듭니다.

vbc /target:library /out:example.dll ExampleClass.vb

이제 본체 쪽에서 이 DLL을 읽어 Add 를 부릅니다.

Imports System.Reflection

Module MainModule
    Sub Main()
        ' DLL 파일 경로 설정
        Dim dllPath As String = "C:\path\to\your\example.dll"

        ' DLL 파일 로드
        Dim assembly As Assembly = Assembly.LoadFrom(dllPath)

        ' DLL 내의 클래스 및 메서드 정보 가져오기
        Dim className As String = "ExampleNamespace.ExampleClass"
        Dim methodName As String = "Add"

        Dim type As Type = assembly.GetType(className)
        Dim methodInfo As MethodInfo = type.GetMethod(methodName)

        ' 메서드 호출
        Dim instance As Object = Activator.CreateInstance(type)
        Dim result As Integer = CType(methodInfo.Invoke(instance, New Object() {5, 3}), Integer)

        ' 결과 출력
        Console.WriteLine("결과: " & result)
    End Sub
End Module

흐름은 네 단계입니다. Assembly.LoadFrom 으로 파일을 읽고, GetType 에 "네임스페이스.클래스명" 전체 이름을 넘겨 형식을 찾고, GetMethod 로 메서드 정보를 얻은 뒤, Invoke 로 호출합니다. Add 가 인스턴스 메서드라서 Activator.CreateInstance 로 객체를 먼저 만들고, 그 객체를 Invoke 의 첫 번째 인수로 넘깁니다. 인수는 Object 배열로 묶어서 넘기고, 반환값도 Object 로 오니 원하는 형식으로 바꿔 받습니다.

공유(Shared) 메서드라면 객체가 필요 없다

메서드를 Shared 로 만들면 인스턴스를 만들 필요가 없습니다. 이번에는 호출됐는지 눈으로 확인할 수 있게 메시지 박스를 띄우는 버전입니다.

Public Class ExampleClass
    Public Shared Function Add(ByVal a As Integer, ByVal b As Integer) As Integer
        MsgBox(a.ToString() & " + " & b.ToString() & " = " & (a + b).ToString())
        Return a + b
    End Function
End Class

부르는 쪽은 Invoke 의 첫 번째 인수에 Nothing 을 넘기면 됩니다.

Imports System.Reflection

Module MainModule
    Sub Main()
        ' DLL 파일 경로 설정
        Dim dllPath As String = "DLL의_경로\example.dll"

        ' DLL 파일 로드
        Dim assembly As Assembly = Assembly.LoadFile(dllPath)

        ' DLL 내의 클래스 및 메서드 정보 가져오기
        Dim className As String = "루트네임스페이스.ExampleClass"
        Dim methodName As String = "Add"

        Dim type As Type = assembly.GetType(className)
        Dim methodInfo As MethodInfo = type.GetMethod(methodName)

        ' 메서드 호출
        Dim returnValue As Integer = DirectCast(methodInfo.Invoke(Nothing, New Object() {5, 3}), Integer)

        ' 결과 출력
        Console.WriteLine("결과: " & returnValue.ToString())
    End Sub
End Module

이 DLL 코드에는 Namespace 가 없는데도 클래스 이름 앞에 "루트네임스페이스." 를 붙인 이유가 있습니다. VB.NET 프로젝트는 프로젝트 속성에 "루트 네임스페이스"(기본값은 프로젝트 이름)가 있어서, 코드에 네임스페이스를 안 써도 모든 클래스가 그 아래로 들어갑니다. 프로젝트 이름이 MyLib 이면 "MyLib.ExampleClass" 로 찾아야 합니다. GetType 이 Nothing 을 돌려줄 때 열에 아홉은 이 이름 문제입니다.

막히기 쉬운 지점

LoadFrom 과 LoadFile 은 비슷해 보여도 다릅니다. LoadFile 은 절대 경로를 줘야 하고, 그 DLL이 다른 DLL을 참조하고 있으면 같은 폴더에 있어도 자동으로 찾아 주지 않습니다. LoadFrom 은 상대 경로도 받고, 같은 폴더의 의존 DLL도 찾아 줍니다. 플러그인 폴더에서 읽는 용도라면 대개 LoadFrom 쪽이 덜 귀찮습니다.

한 번 로드한 어셈블리는 .NET Framework 에서는 프로그램이 끝날 때까지 내려지지 않습니다. 실행 중에 DLL 파일을 새 버전으로 덮어쓰려 해도 파일이 잠겨 있다고 나올 수 있으니, "프로그램을 끄지 않고 DLL만 교체"까지 바란다면 파일을 바이트로 읽어 Assembly.Load(바이트배열) 로 올리는 방법을 검토해 보세요.

메서드 이름을 문자열로 다루다 보니 오타가 나도 컴파일러가 잡아 주지 않습니다. GetMethod 결과가 Nothing 인지, 같은 이름의 오버로드가 여러 개라 모호하다는 예외가 나는지 확인하는 코드는 꼭 넣어 두는 게 좋습니다. 플러그인을 여러 개 만들 계획이면 공통 인터페이스를 별도 DLL로 정의해 두고, 읽어 온 객체를 그 인터페이스로 캐스팅해서 쓰는 방식이 훨씬 다루기 편합니다. 그러면 Invoke 대신 평범한 메서드 호출로 쓸 수 있고 인텔리센스도 됩니다.