[.NET] Trigger Window Event Hook Message: C#에서 Windows 메시지와 이벤트 훅 다루기

Windows 데스크톱 애플리케이션을 만들다 보면 일반적인 버튼 클릭이나 텍스트 변경 이벤트만으로는 부족한 순간이 있습니다. 창 크기가 바뀌는 순간을 더 낮은 레벨에서 확인하고 싶거나, 다른 모듈에서 현재 창에 “새로고침하라”는 신호를 보내고 싶거나, WPF에서 Win32 메시지를 직접 받아야 하는 경우가 있습니다.

이때 등장하는 개념이 Window Message, WndProc, Hook, PostMessage, SendMessage입니다. 이름만 보면 어렵게 느껴지지만, 핵심은 단순합니다. Windows는 창에 여러 메시지를 보내고, 애플리케이션은 그 메시지를 처리하면서 화면을 갱신하거나 동작을 수행합니다.

이 글에서는 .NET에서 Windows 메시지를 다루는 실무적인 방법을 정리합니다. WinForms의 WndProc, WPF의 HwndSource.AddHook, 같은 앱 내부에서 메시지를 트리거하는 PostMessage, 그리고 자기 프로세스 범위에서 이벤트를 확인하는 SetWinEventHook 예제를 다룹니다.


핵심 요약

  • WinForms에서는 WndProc를 override해서 창으로 들어오는 Windows 메시지를 처리할 수 있습니다.
  • WPF에서는 HwndSource.AddHook을 사용해 Win32 메시지 처리 훅을 추가할 수 있습니다.
  • PostMessage는 메시지를 큐에 넣고 바로 반환하는 비동기 방식입니다.
  • SendMessage는 대상 창이 메시지를 처리할 때까지 기다리는 동기 방식입니다.
  • 사용자 정의 메시지는 WM_APP 범위 또는 RegisterWindowMessage를 사용하는 방식이 일반적입니다.
  • 다른 앱의 입력을 감시하거나 몰래 조작하는 코드는 법적 문제가 될 수 있으므로 자기 앱 또는 명시적으로 허용된 범위에서만 사용해야 합니다.

1. Window Message란 무엇인가?

Windows 데스크톱 앱은 내부적으로 메시지 기반으로 동작합니다. 창이 이동되면 WM_MOVE, 크기가 바뀌면 WM_SIZE, 닫히려 하면 WM_CLOSE 같은 메시지가 전달됩니다. 버튼 클릭이나 마우스 이동도 더 낮은 수준에서는 메시지로 표현됩니다.

.NET WinForms와 WPF는 이런 메시지 처리를 대부분 이벤트 모델로 감싸줍니다. 그래서 일반적인 개발에서는 Click, Loaded, SizeChanged 같은 이벤트만 사용해도 충분합니다. 하지만 Win32 메시지 자체를 처리해야 하는 경우에는 직접 메시지 훅을 다루게 됩니다.

2. WinForms에서 WndProc로 메시지 받기

WinForms에서는 Form이나 ControlWndProc 메서드를 override할 수 있습니다. 아래 예제는 창 크기 변경, 이동, 닫기 요청, 사용자 정의 메시지를 처리합니다.

using System;
using System.Windows.Forms;

public partial class MainForm : Form
{
    private const int WM_SIZE = 0x0005;
    private const int WM_MOVE = 0x0003;
    private const int WM_CLOSE = 0x0010;
    private const int WM_APP_REFRESH = 0x8000 + 1;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void WndProc(ref Message m)
    {
        switch (m.Msg)
        {
            case WM_SIZE:
                Console.WriteLine($"Window resized: {Width} x {Height}");
                break;

            case WM_MOVE:
                Console.WriteLine($"Window moved: {Location.X}, {Location.Y}");
                break;

            case WM_APP_REFRESH:
                Console.WriteLine("Custom refresh message received.");
                RefreshData();
                break;

            case WM_CLOSE:
                Console.WriteLine("Window close requested.");
                break;
        }

        base.WndProc(ref m);
    }

    private void RefreshData()
    {
        // 실제 업무 로직은 여기에서 처리합니다.
        // 예: 화면 데이터 다시 조회, 캐시 갱신, 상태 표시 업데이트
    }
}

여기서 중요한 점은 마지막에 base.WndProc(ref m)를 호출하는 것입니다. 특별히 메시지를 완전히 소비해야 하는 경우가 아니라면 기본 처리를 호출해 Windows와 WinForms의 정상적인 메시지 흐름을 유지하는 것이 좋습니다.

3. PostMessage로 자기 창에 사용자 정의 메시지 보내기

같은 애플리케이션 안에서 특정 창에 “새로고침하라”, “상태를 갱신하라” 같은 신호를 보내고 싶을 수 있습니다. 이럴 때 WM_APP 범위의 사용자 정의 메시지와 PostMessage를 사용할 수 있습니다.

using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;

public partial class MainForm : Form
{
    private const int WM_APP_REFRESH = 0x8000 + 1;

    [DllImport("user32.dll", SetLastError = true)]
    private static extern bool PostMessage(
        IntPtr hWnd,
        int msg,
        IntPtr wParam,
        IntPtr lParam);

    private void btnRefresh_Click(object sender, EventArgs e)
    {
        // 자기 Form의 Handle로 사용자 정의 메시지를 보냅니다.
        // PostMessage는 큐에 넣고 즉시 반환합니다.
        PostMessage(this.Handle, WM_APP_REFRESH, IntPtr.Zero, IntPtr.Zero);
    }
}

이 예제는 자기 Form의 Handle로만 메시지를 보냅니다. 다른 프로세스의 창 핸들을 찾아 메시지를 보내는 방식은 권한, 보안, 안정성 문제가 생길 수 있으므로 신중해야 합니다. 내부 모듈 간 신호 전달에는 .NET 이벤트, 메시지 버스, MVVM Messenger 같은 대안도 함께 고려하는 것이 좋습니다.

4. SendMessage와 PostMessage의 차이

SendMessagePostMessage는 모두 창에 메시지를 전달하지만 실행 방식이 다릅니다. 이 차이를 모르고 쓰면 UI가 멈추거나, 처리 순서가 예상과 달라질 수 있습니다.

// SendMessage와 PostMessage의 차이를 단순화하면 다음과 같습니다.
//
// SendMessage:
// - 대상 Window Procedure가 메시지를 처리할 때까지 기다립니다.
// - 동기 방식입니다.
// - 잘못 사용하면 UI 멈춤이나 교착 상태를 만들 수 있습니다.
//
// PostMessage:
// - 메시지 큐에 넣고 바로 반환합니다.
// - 비동기 방식입니다.
// - 화면 갱신 요청, 내부 작업 트리거처럼 가벼운 신호에 적합합니다.

단순한 내부 알림이나 화면 갱신 요청이라면 PostMessage가 더 안전한 경우가 많습니다. 반대로 반드시 즉시 결과가 필요하고 대상 창이 같은 스레드에서 안전하게 처리된다는 확신이 있을 때만 SendMessage를 고려하는 편이 좋습니다.

5. WinForms IMessageFilter로 앱 내부 메시지 미리 보기

WinForms에서는 IMessageFilter를 사용해 메시지가 컨트롤에 전달되기 전에 한 번 확인할 수 있습니다. 이 기능은 애플리케이션 내부 단축키 처리, 디버깅, 특정 UI 메시지 관찰에 사용할 수 있습니다.

using System;
using System.Windows.Forms;

public class AppMessageFilter : IMessageFilter
{
    private const int WM_MOUSEMOVE = 0x0200;

    public bool PreFilterMessage(ref Message m)
    {
        if (m.Msg == WM_MOUSEMOVE)
        {
            // 같은 WinForms 애플리케이션 안에서 전달되는 메시지만 관찰합니다.
            // 전역 마우스 감시가 아닙니다.
            Console.WriteLine("Mouse moved inside this application.");
        }

        // true를 반환하면 메시지를 여기서 막습니다.
        // 대부분의 경우 false를 반환해 정상 흐름으로 보냅니다.
        return false;
    }
}

// Program.cs 예시
internal static class Program
{
    [STAThread]
    private static void Main()
    {
        ApplicationConfiguration.Initialize();

        var filter = new AppMessageFilter();
        Application.AddMessageFilter(filter);

        Application.Run(new MainForm());

        Application.RemoveMessageFilter(filter);
    }
}

IMessageFilter는 같은 WinForms 애플리케이션의 메시지 흐름을 대상으로 합니다. 전역 마우스 감시나 다른 앱의 입력 수집 기능이 아닙니다. 메시지를 막을 필요가 없다면 대부분 false를 반환하는 것이 안전합니다.

6. WPF에서 HwndSource.AddHook 사용하기

WPF는 WinForms처럼 WndProc를 바로 override하는 구조가 아닙니다. 대신 Window의 HWND가 만들어진 뒤 HwndSource.AddHook을 사용해 Win32 메시지를 받을 수 있습니다.

using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;

public partial class MainWindow : Window
{
    private const int WM_SIZE = 0x0005;
    private const int WM_APP_REFRESH = 0x8000 + 1;

    public MainWindow()
    {
        InitializeComponent();
        SourceInitialized += MainWindow_SourceInitialized;
    }

    private void MainWindow_SourceInitialized(object? sender, EventArgs e)
    {
        var source = (HwndSource?)PresentationSource.FromVisual(this);

        if (source is not null)
        {
            source.AddHook(WndProc);
        }
    }

    private IntPtr WndProc(
        IntPtr hwnd,
        int msg,
        IntPtr wParam,
        IntPtr lParam,
        ref bool handled)
    {
        if (msg == WM_SIZE)
        {
            Console.WriteLine($"WPF window resized: {ActualWidth} x {ActualHeight}");
        }

        if (msg == WM_APP_REFRESH)
        {
            Console.WriteLine("WPF custom refresh message received.");
            handled = true;
        }

        return IntPtr.Zero;
    }
}

HwndSource.AddHook은 WPF 이벤트로 충분하지 않은 Win32 메시지를 처리할 때 사용합니다. 일반적인 크기 변경, 로드, 키 입력은 WPF 이벤트로 처리하는 편이 더 자연스럽습니다. 꼭 필요한 메시지만 Hook에서 처리하는 것이 유지보수에 좋습니다.

7. WPF 창에 PostMessage 보내기

WPF에서도 WindowInteropHelper를 사용하면 현재 Window의 HWND를 얻을 수 있습니다. 이 핸들로 자기 창에 사용자 정의 메시지를 보낼 수 있습니다.

using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;

public partial class MainWindow : Window
{
    private const int WM_APP_REFRESH = 0x8000 + 1;

    [DllImport("user32.dll", SetLastError = true)]
    private static extern bool PostMessage(
        IntPtr hWnd,
        int msg,
        IntPtr wParam,
        IntPtr lParam);

    private void RequestRefresh()
    {
        var hwnd = new WindowInteropHelper(this).Handle;

        if (hwnd != IntPtr.Zero)
        {
            PostMessage(hwnd, WM_APP_REFRESH, IntPtr.Zero, IntPtr.Zero);
        }
    }
}

이 방식은 백그라운드 작업 완료 후 UI 창에 갱신 신호를 주거나, 네이티브 모듈과 WPF 화면 사이에서 간단한 신호를 주고받을 때 사용할 수 있습니다. 다만 WPF에서는 Dispatcher를 이용한 UI 스레드 호출이 더 자연스러운 경우도 많습니다.

8. Hook 제거와 리소스 정리

메시지 Hook을 추가했다면 제거도 신경 써야 합니다. 창이 닫힌 뒤에도 Hook이 남아 있거나 콜백이 살아 있으면 예외나 메모리 누수의 원인이 될 수 있습니다.

public partial class MainWindow : Window
{
    private HwndSource? _source;

    private void MainWindow_SourceInitialized(object? sender, EventArgs e)
    {
        _source = (HwndSource?)PresentationSource.FromVisual(this);
        _source?.AddHook(WndProc);
    }

    protected override void OnClosed(EventArgs e)
    {
        if (_source is not null)
        {
            _source.RemoveHook(WndProc);
            _source = null;
        }

        base.OnClosed(e);
    }
}

특히 WPF에서 AddHook을 사용했다면 창 종료 시 RemoveHook을 호출하는 습관을 들이는 것이 좋습니다. Win32 API로 Hook을 등록한 경우에도 대응되는 해제 함수를 반드시 호출해야 합니다.

9. SetWinEventHook으로 자기 프로세스 이벤트 확인하기

SetWinEventHook은 Windows 접근성 이벤트나 UI 변화 이벤트를 받을 수 있는 API입니다. 이 예제에서는 현재 프로세스 범위에서 위치 변경 이벤트를 관찰하는 형태로만 작성했습니다. 다른 앱 감시나 사용자 입력 수집 목적이 아닙니다.

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;

public sealed class OwnProcessWinEventHook : IDisposable
{
    private const uint EVENT_OBJECT_LOCATIONCHANGE = 0x800B;
    private const uint WINEVENT_OUTOFCONTEXT = 0x0000;
    private const uint WINEVENT_SKIPOWNPROCESS = 0x0002;

    private readonly WinEventDelegate _callback;
    private IntPtr _hook;

    public OwnProcessWinEventHook()
    {
        _callback = OnWinEvent;
    }

    public void Start()
    {
        uint currentProcessId = (uint)Environment.ProcessId;

        // 예제는 현재 프로세스의 위치 변경 이벤트만 대상으로 합니다.
        // 다른 앱 감시용이 아니라 자기 앱 디버깅/접근성 이벤트 확인용입니다.
        _hook = SetWinEventHook(
            EVENT_OBJECT_LOCATIONCHANGE,
            EVENT_OBJECT_LOCATIONCHANGE,
            IntPtr.Zero,
            _callback,
            currentProcessId,
            0,
            WINEVENT_OUTOFCONTEXT);
    }

    private static void OnWinEvent(
        IntPtr hWinEventHook,
        uint eventType,
        IntPtr hwnd,
        int idObject,
        int idChild,
        uint eventThread,
        uint eventTime)
    {
        Console.WriteLine(
            $"WinEvent: event={eventType}, hwnd={hwnd}, object={idObject}, child={idChild}");
    }

    public void Dispose()
    {
        if (_hook != IntPtr.Zero)
        {
            UnhookWinEvent(_hook);
            _hook = IntPtr.Zero;
        }
    }

    private delegate void WinEventDelegate(
        IntPtr hWinEventHook,
        uint eventType,
        IntPtr hwnd,
        int idObject,
        int idChild,
        uint eventThread,
        uint eventTime);

    [DllImport("user32.dll")]
    private static extern IntPtr SetWinEventHook(
        uint eventMin,
        uint eventMax,
        IntPtr hmodWinEventProc,
        WinEventDelegate lpfnWinEventProc,
        uint idProcess,
        uint idThread,
        uint dwFlags);

    [DllImport("user32.dll")]
    private static extern bool UnhookWinEvent(IntPtr hWinEventHook);
}

SetWinEventHook은 강력한 API이므로 범위를 좁혀 사용하는 것이 좋습니다. 프로세스 ID나 스레드 ID를 지정하지 않고 전체 시스템 이벤트를 받는 방식은 개인정보와 보안 이슈가 생길 수 있습니다. 디버깅, 접근성 지원, 자기 앱의 UI 상태 확인처럼 명확한 목적이 있을 때만 사용해야 합니다.

10. 사용자 정의 메시지 설계 팁

  • 앱 내부 전용 메시지는 WM_APP + n 범위를 사용합니다.
  • 여러 프로그램 간 공통 메시지가 필요하면 RegisterWindowMessage를 검토합니다.
  • wParam, lParam에는 단순한 숫자나 포인터 값을 넣을 수 있지만, 수명 관리에 주의해야 합니다.
  • 복잡한 데이터를 전달해야 한다면 Windows 메시지보다 IPC, Named Pipe, gRPC, 파일, 데이터베이스, 메시지 큐가 더 안전할 수 있습니다.
  • UI 업데이트는 가능한 한 UI 스레드에서 처리해야 합니다.

11. 피해야 할 사용 방식

Windows 메시지와 Hook은 강력하지만, 잘못 쓰면 보안 문제로 이어질 수 있습니다. 특히 아래 방식은 일반적인 블로그 예제나 사내 도구에서도 매우 조심해야 합니다.

  • 사용자 동의 없이 키보드 입력을 수집하는 전역 Hook
  • 다른 앱의 창을 무단으로 제어하는 메시지 전송
  • 비밀번호, 채팅 내용, 개인 정보 입력을 가로채는 기능
  • 사용자에게 알리지 않고 백그라운드에서 UI 활동을 기록하는 기능
  • 보안 프로그램이나 운영체제 보호 기능을 우회하려는 시도

정당한 자동화나 접근성 도구를 만들더라도 사용자 고지, 권한, 로그 저장 범위, 개인정보 처리 기준을 명확히 해야 합니다. 기술적으로 가능하다는 것과 서비스에 넣어도 된다는 것은 다른 문제입니다.

12. 실무에서 언제 써야 할까?

상황 추천 방식 이유
WinForms 창 메시지 처리 WndProc override Form/Control 메시지를 직접 처리하기 가장 단순함
WPF에서 Win32 메시지 처리 HwndSource.AddHook WPF Window의 HWND 메시지를 받을 수 있음
같은 앱의 창에 내부 신호 보내기 PostMessage 또는 .NET 이벤트 비동기 메시지 트리거에 적합
앱 내부 메시지 디버깅 IMessageFilter WinForms 메시지 흐름을 미리 관찰 가능
자기 앱의 접근성 이벤트 확인 SetWinEventHook 범위 제한 사용 프로세스 범위를 좁혀 UI 이벤트 확인 가능

13. 마무리

.NET에서 Windows 메시지와 Hook을 다루는 일은 자주 있는 작업은 아니지만, 데스크톱 앱을 오래 개발하다 보면 한 번쯤 필요해집니다. WinForms에서는 WndProc, WPF에서는 HwndSource.AddHook을 기억해두면 대부분의 기본적인 메시지 처리는 해결할 수 있습니다.

메시지를 직접 트리거해야 할 때는 PostMessageSendMessage의 차이를 이해해야 합니다. 그리고 Hook을 사용할 때는 반드시 범위를 좁히고, 정리 코드를 넣고, 사용자 권한과 개인정보 문제를 먼저 확인해야 합니다.

실무에서는 “가능한 한 .NET 이벤트와 Dispatcher를 먼저 사용하고, 꼭 필요한 경우에만 Win32 메시지와 Hook을 사용한다”는 기준이 가장 안전합니다.


FAQ

Q1. WinForms에서는 왜 WndProc를 override하나요?

WinForms의 WndProc는 창으로 들어오는 Windows 메시지를 처리하는 지점입니다. 일반 이벤트로 처리하기 어려운 WM_* 메시지를 직접 다뤄야 할 때 override합니다.

Q2. WPF에는 WndProc가 없나요?

WinForms처럼 직접 override하는 WndProc는 없습니다. 대신 HwndSource.AddHook을 사용해 WPF Window의 HWND로 들어오는 Win32 메시지를 받을 수 있습니다.

Q3. PostMessage와 SendMessage 중 무엇을 써야 하나요?

내부 신호나 UI 갱신 요청처럼 즉시 결과가 필요 없는 경우에는 PostMessage가 더 안전한 경우가 많습니다. SendMessage는 메시지가 처리될 때까지 기다리므로 UI 멈춤이나 교착 상태에 주의해야 합니다.

Q4. SetWinEventHook은 전역 감시 도구인가요?

그렇게 사용할 수도 있지만, 개인정보와 보안 문제가 생길 수 있습니다. 이 글에서는 자기 프로세스 범위의 UI 이벤트 확인처럼 제한된 용도로만 설명했습니다.

Q5. 전역 키보드 Hook 예제도 같이 넣어도 되나요?

일반 블로그 예제로는 권장하지 않습니다. 키 입력 수집은 악용 가능성이 높고 법적 문제가 생길 수 있습니다. 단축키가 목적이라면 운영체제의 공식 HotKey 등록 방식이나 애플리케이션 내부 단축키 처리를 먼저 검토하는 것이 좋습니다.


참고자료