본문 바로가기

C#

C# 인터롭(Interop)으로 네이티브 코드 호출하기

.NET 애플리케이션에서 운영체제 API나 기존 C/C++ 라이브러리를 그대로 활용해야 할 때가 있습니다. 이때 C#은 P/Invoke(Platform Invocation)를 통해 네이티브 함수를 직접 호출할 수 있습니다. 핵심은 DllImport로 정확한 시그니처를 선언하고, 문자열/구조체/콜백/리소스 수명과 같은 마샬링 규칙을 올바르게 맞추는 것입니다. 아래 예제들은 Windows 기준입니다.

1. P/Invoke 핵심 요약

DllImport 특성으로 네이티브 함수를 선언합니다. 주요 옵션은 다음과 같습니다. CharSet(문자 인코딩), CallingConvention(호출 규약), EntryPoint(실제 함수명), SetLastError(Win32 오류 코드 보존)입니다. WinAPI는 보통 CallingConvention.Winapi(Windows에서 stdcall)와 Unicode를 사용합니다.

2. 첫 호출: MessageBoxW

가장 간단한 호출 예제입니다. user32.dll의 MessageBoxW를 호출합니다. CharSet.Unicode로 넓은 문자 버전을 사용합니다.

using System;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);

    static void Main()
    {
        MessageBoxW(IntPtr.Zero, "안녕하세요, Interop!", "MessageBox", 0);
    }
}

3. 문자열 In/Out 마샬링: GetComputerNameW

네이티브 함수가 출력 버퍼에 문자열을 써주는 패턴에서는 StringBuilder를 사용합니다. 버퍼 크기를 먼저 할당하고 길이 값을 전달합니다.

using System;
using System.Runtime.InteropServices;
using System.Text;

class InteropDemo
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool GetComputerNameW(StringBuilder lpBuffer, ref uint lpnSize);

    static void Main()
    {
        var sb = new StringBuilder(256);
        uint size = (uint)sb.Capacity;
        if (GetComputerNameW(sb, ref size))
        {
            Console.WriteLine($"ComputerName = {sb}");
        }
        else
        {
            Console.WriteLine($"Fail, error = {Marshal.GetLastWin32Error()}");
        }
    }
}

4. 구조체 마샬링: SYSTEMTIME과 GetLocalTime

구조체는 [StructLayout]으로 메모리 배치를 지정합니다. WinAPI 구조체는 대개 LayoutKind.Sequential이면 충분합니다. 네이티브의 필드 순서와 형식을 정확히 맞추세요.

using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
struct SYSTEMTIME
{
    public ushort Year;
    public ushort Month;
    public ushort DayOfWeek;
    public ushort Day;
    public ushort Hour;
    public ushort Minute;
    public ushort Second;
    public ushort Milliseconds;
}

class Program
{
    [DllImport("kernel32.dll")]
    private static extern void GetLocalTime(out SYSTEMTIME lpSystemTime);

    static void Main()
    {
        GetLocalTime(out var st);
        Console.WriteLine($"{st.Year}-{st.Month:00}-{st.Day:00} {st.Hour:00}:{st.Minute:00}:{st.Second:00}");
    }
}

만약 네이티브 구조체가 1바이트 정렬(#pragma pack(1))이라면 [StructLayout(LayoutKind.Sequential, Pack = 1)]처럼 Pack을 맞춰야 합니다.

5. 콜백(delegate) 전달: EnumWindows

네이티브가 콜백을 요구하면 C# delegate를 선언하고 UnmanagedFunctionPointer로 호출 규약을 지정합니다. GC가 콜백을 수거하지 않도록 호출 동안 참조를 유지하세요.

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;

class Program
{
    [UnmanagedFunctionPointer(CallingConvention.Winapi)]
    private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);

    [DllImport("user32.dll")]
    private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);

    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    private static extern int GetWindowTextW(IntPtr hWnd, StringBuilder lpString, int nMaxCount);

    static void Main()
    {
        var titles = new List<string>();
        EnumWindowsProc callback = (hWnd, lParam) =>
        {
            var sb = new StringBuilder(256);
            GetWindowTextW(hWnd, sb, sb.Capacity);
            var title = sb.ToString();
            if (!string.IsNullOrWhiteSpace(title))
                titles.Add(title);
            return true; // true면 계속 열거
        };

        EnumWindows(callback, IntPtr.Zero);
        GC.KeepAlive(callback); // 콜백 생명주기 보장

        foreach (var t in titles)
            Console.WriteLine(t);
    }
}

6. 오류 처리: SetLastError와 Marshal.GetLastWin32Error

WinAPI 다수는 실패 시 GetLastError로 오류 코드를 제공합니다. DllImport에 SetLastError = true를 지정하고, 실패 후 Marshal.GetLastWin32Error로 읽습니다.

using System;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool DeleteFileW(string lpFileName);

    static void Main()
    {
        if (!DeleteFileW(@"C:\\does_not_exist.txt"))
        {
            int err = Marshal.GetLastWin32Error();
            Console.WriteLine($"Delete failed, GetLastError = {err}");
        }
    }
}

7. 리소스 수명 관리: SafeHandle로 누수 방지

네이티브 핸들은 반드시 닫아야 합니다. SafeHandle을 사용하면 예외가 발생해도 안전하게 해제됩니다. 반환 형식을 SafeHandle로 선언하고 ReleaseHandle에서 CloseHandle을 호출합니다.

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

class Program
{
    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool CloseHandle(IntPtr hObject);

    sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid
    {
        private SafeKernelHandle() : base(ownsHandle: true) { }
        protected override bool ReleaseHandle() => CloseHandle(handle);
    }

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateEventW")]
    private static extern SafeKernelHandle CreateEvent(IntPtr lpEventAttributes, bool bManualReset, bool bInitialState, string lpName);

    static void Main()
    {
        using (SafeKernelHandle h = CreateEvent(IntPtr.Zero, false, false, "MyEvent.InteropDemo"))
        {
            if (h.IsInvalid)
                throw new Win32Exception(Marshal.GetLastWin32Error());

            Console.WriteLine($"Event handle = 0x{h.DangerousGetHandle().ToInt64():X}");
        } // 여기서 자동 해제
    }
}

8. 자주 겪는 함정과 팁

문자 집합을 정확히 지정하세요. WinAPI는 보통 W(유니코드) 버전을 사용합니다(CharSet.Unicode, EntryPoint로 ...W 지정).

호출 규약이 다르면 스택이 망가집니다. WinAPI는 일반적으로 CallingConvention.Winapi를 사용합니다.

32/64비트 차이를 확인하세요. 포인터 크기(IntPtr)와 구조체 필드 크기가 일치해야 합니다. AnyCPU 빌드 시 네이티브 DLL의 비트수와 프로세스 비트수가 맞아야 로드됩니다.

구조체 정렬(Pack)과 필드 순서를 네이티브와 동일하게 유지하세요. 잘못 맞추면 데이터가 틀어집니다.

네이티브가 버퍼를 채우는 경우 StringBuilder나 적절한 배열을 준비하고 길이를 전달하세요. 포인터를 직접 넘겨야 하면 고정(fixed) 또는 GCHandle로 핀 고정이 필요합니다.

콜백 delegate는 GC로 수거되지 않도록 참조를 유지하거나 GC.KeepAlive를 사용하세요.

9. 마무리

P/Invoke는 강력하지만 정확성이 중요합니다. DllImport 시그니처, CharSet, 호출 규약, 구조체 레이아웃, 오류 처리, 핸들 수명만 제대로 지키면 안정적으로 네이티브 라이브러리를 활용할 수 있습니다. 단계적으로 작은 예제부터 검증하면서 적용해 보시길 권장합니다.