Microsoft MVP성태의 닷넷 이야기
닷넷: 2333. C# - (Console 유형의 프로젝트에서) Clipboard 연동 [링크 복사], [링크+제목 복사],
조회: 216
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일

C# - (Console 유형의 프로젝트에서) Clipboard 연동

ConsoleApp 유형에서 Clipboard를 쓰고 싶다면,

internal class Program
{
    static void Main(string[] args)
    {
        Clipboard.SetText("Hello, clipboard"); // How to copy data to clipboard in C#
    }
}

각각 Windows Forms와 WPF에서 제공하는 타입을 사용하면 됩니다.

Clipboard Class
; https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.clipboard

Clipboard Class
; https://learn.microsoft.com/en-us/dotnet/api/system.windows.clipboard

하지만, 위의 소스 코드 그대로 실행하면 이런 예외가 발생하는데요,

Unhandled exception. System.Threading.ThreadStateException: Current thread must be set to single thread apartment (STA) mode before OLE calls can be made. Ensure that your Main function has STAThreadAttribute marked on it.
   at System.Windows.Forms.Clipboard.SetDataObject(Object data, Boolean copy, Int32 retryTimes, Int32 retryDelay)
   at Program.Main(String[] args)

Clipboard가 OLE COM 호출을 하다 보니 STA 유형의 스레드에서만 사용할 수 있으므로 Main 함수에 해당 특성을 추가해야 합니다.

internal class Program
{
    [STAThread]
    static void Main(string[] args)
    {
        Clipboard.SetText("Hello, clipboard");
    }
}




그런데, 경우에 따라서는 저런 부가적인 라이브러리들이 붙는 것을 원치 않을 수도 있습니다. 그렇다면 Win32 API를 호출하면 되는데요, OpenClipboard에 윈도우 핸들을 전달하는 것으로 시작할 수 있습니다.

OpenClipboard function (winuser.h)
; https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard

그렇다면 윈도우를 생성하기 위해 또다시 Windows Forms/WPF를 라이브러리를 추가하거나, 혹은 윈도우 생성을 위한 Win32 API를 추가로 복잡하게 구성해야 할까요? 마침 ^^ 지난 글에 설명한 GetConsoleWindow가 윈도우 핸들을 반환하므로 다음과 같이 소스 코드를 구성하는 것도 가능할 것입니다.

using System.Runtime.InteropServices;

namespace ConsoleApp2;

internal class Program
{
    [DllImport("kernel32.dll")]
    static extern IntPtr GetConsoleWindow();

    [DllImport("user32.dll", SetLastError = true)]
    static extern bool CloseClipboard();

    [DllImport("user32.dll", SetLastError = true)]
    static extern bool OpenClipboard(IntPtr hWndNewOwner);

    [DllImport("user32.dll", SetLastError = true)]
    static extern bool EmptyClipboard();

    [DllImport("user32.dll", SetLastError = true)]
    static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);

    [STAThread]
    static void Main(string[] args)
    {
        IntPtr hwnd = GetConsoleWindow(); // 형식만 맞춘 것일 뿐, 의미 없는 윈도우 핸들에 불과합니다.
        if (hwnd == IntPtr.Zero)
        {
            Console.WriteLine("Failed to attach console");
            return;
        }

        if (OpenClipboard(hwnd) == false)
        {
            Console.WriteLine("Failed to open clipboard");
            return;
        }

        try
        {
            EmptyClipboard(); // empty 단계를 거치지 않으면 기존 데이터에 추가로 붙어버림
            SetClipboardData((int)ClipboardFormat.CF_UNICODETEXT, Marshal.StringToHGlobalUni($"{DateTime.Now} Hello, clipboard"));
        }
        finally
        {
            CloseClipboard();
        }
    }
}

// https://learn.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats
public enum ClipboardFormat
{
    CF_TEXT = 1,
    CF_BITMAP = 2,
    CF_METAFILEPICT = 3,
    CF_SYLK = 4,
    CF_DIF = 5,
    CF_TIFF = 6,
    CF_OEMTEXT = 7,
    CF_DIB = 8,
    CF_PALETTE = 9,
    CF_PENDATA = 10,
    CF_RIFF = 11,
    CF_WAVE = 12,
    CF_UNICODETEXT = 13,
}

그렇긴 한데, 굳이 OpenClipboard에 윈도우 핸들을 전달하지 않아도 잘 동작합니다.

OpenClipboard(IntPtr.Zero);

재미있는 건 공식 문서에는,

If an application calls OpenClipboard with hwnd set to NULL, EmptyClipboard sets the clipboard owner to NULL; this causes SetClipboardData to fail.


이렇게 설명하고 있는데, 그렇다면 분명히 SetClipboardData 호출이 실패해야 하지만... ^^; 잘됩니다.




그나저나, Clipboard Owner로 반드시 윈도우가 필요한 경우는 언제일까요? 이에 대해 검색해 보면,

How ownership of the Windows clipboard is tracked in Win32
; https://devblogs.microsoft.com/oldnewthing/20210526-00/?p=105252

"Delayed Rendering" 기능을 제공할 수 있다고 나옵니다. 이를 위해 우선 WM_RENDERFORMAT 메시지가 하는 역할을 설명하는데요,

WM_RENDERFORMAT message
; https://learn.microsoft.com/en-us/windows/win32/dataxchg/wm-renderformat

클립보드에 데이터를 제공할 측에서 SetClipboardData를 호출했을 때 첫 번째 인자인 uFormat만 전달하고, 두 번째 인자인 hMem 값은 null로 설정해 바로 데이터를 제공하지 않게 만들 수 있다고 합니다. 그렇게 되면, 클립보드 데이터를 가져오려는 시도가 발생했을 때, 그제야 (hMem에 해당했던) 데이터를 요구하는 WM_RENDERFORMAT 메시지가 전달된다고 합니다.

// Window Procedure

case WM_RENDERFORMAT:
    CLIPFORMAT cf = (CLIPFORMAT)wParam;
    hData = GenerateFormat(cf); // 메시지를 받은 시점에 클립보드 데이터를 생성
    SetClipboardData(cf, hData);
    return 0;

그다음, (WM_RENDERFORMAT과 연관되는) WM_DESTROY­CLIPBOARD가 있는데요,

WM_DESTROYCLIPBOARD message
; https://learn.microsoft.com/he-il/windows/win32/dataxchg/wm-destroyclipboard

이것은 "Delayed Rendering"으로 생성해서 반환했던 클립보드 데이터를 이제 삭제해도 된다는 신호입니다. 이어서, 글의 마지막에 이런 설명이 있는데요,

There is also special handling of the case where somebody passes NULL to Open­Clipboard, indicating that “nobody” is opening the clipboard.


따라서 null 윈도우 핸들 전달이 (실패와는 관계없이) 소유자를 지정하지 않는 특별한 사용 예의 하나라고 소개하고 합니다. (이외에도 Owner는 WM_RENDERALLFORMATS 메시지를 수신할 수 있습니다.)




근래에 나온 또 하나의 클립보드 이야기를 볼까요? ^^

Why doesn’t Clipboard History capture rapid changes to clipboard contents?
; https://devblogs.microsoft.com/oldnewthing/20250508-00/?p=111162

첫 번째 글에서는 클립보드에 넣을 몇 개의 데이터를 미리 설정하기 위해 OpenClipboard/EmptyClipboard/SetClipboardData/CloseClipboard 쌍을 연속으로 호출하고 있는데요,

using System.Runtime.InteropServices;

namespace ConsoleApp2;

internal class Program
{
    // ...[생략]...

    [STAThread]
    static void Main(string[] args)
    {
        IntPtr hwnd = GetConsoleWindow();
        if (hwnd == IntPtr.Zero)
        {
            Console.WriteLine("Failed to attach console");
            return;
        }

        for (int i = 0; i < 3; i++)
        {
            if (OpenClipboard(hwnd) == false)
            {
                Console.WriteLine("Failed to open clipboard");
                return;
            }

            string text = $"{DateTime.Now} Hello, clipboard: {i}";

            try
            {
                EmptyClipboard();
                SetClipboardData((int)ClipboardFormat.CF_UNICODETEXT, Marshal.StringToHGlobalUni(text));
            }
            finally
            {
                CloseClipboard();
            }
        }
    }
}

public enum ClipboardFormat
{
    // ...[생략]...
    CF_UNICODETEXT = 13,
}

의도한 바는 차례대로 모두 등록하기를 원했지만 실제로는 마지막 한 문장만 등록됩니다. 실제로 등록된 이력은 Clipboard Viewer를 이용해 확인할 수 있는데요, Window + V 단축키로 실행할 수 있습니다. (그리고, 그에 앞서 클립보드 이력은 설정이 필요합니다.)

이유는, 클립보드 이력이 내부적으로 비동기로 구현됐기 때문입니다. 작동 방식을 보면, 우선 클립보드 이력 서비스는 클립보드로부터 내용이 바뀌었다는 WM_CLIPBOARDUPDATE 메시지를 받기 위해 AddClipboardFormatListener 함수로 자신을 등록해 둔다고 합니다.

AddClipboardFormatListener
; https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-addclipboardformatlistener

즉, 클립보드 이력 서비스가 WM_CLIPBOARDUPDATE를 받아 처리하려고 하는 시점에는 이미 (위의 예제 프로그램 같은 경우) 마지막 3번째 SetClipboardText까지 호출을 완료했기 때문에 클립보드 이력 서비스는 마지막 메시지만 구할 수 있게 되는 것입니다.

그렇다면, 만약 위의 코드를 의도한 대로 동작하게 만들려면 어떻게 해야 할까요? 이에 대해서도 다음과 같은 글에서 설명하고 있습니다. ^^

How can I wait for Clipboard History to recognize a clipboard change before I change it again?
; https://devblogs.microsoft.com/oldnewthing/20250509-00/?p=111169

하지만 아쉽게도 WinRT API를 이용해야 하며 운영체제도 Windows 10 1809 이상에서만 동작하는데요,

Clipboard.HistoryChanged Event
; https://learn.microsoft.com/en-us/uwp/api/windows.applicationmodel.datatransfer.clipboard.historychanged?view=winrt-26100

그래도 C#에서는 쉽게 사용할 수 있습니다. 이를 위해 우선 csproj에 WinRT API를 사용하기 위한 설정을 추가하고,

<TargetFramework>net5.0-windows10.0.17763.0</TargetFramework>

이렇게 구현하면 됩니다. ^^

using Windows.ApplicationModel.DataTransfer;

internal class Program
{
    // ...[생략]...

    [STAThread]
    static void Main(string[] args)
    {
        // ...[생략]...

        if (Clipboard.IsHistoryEnabled() == false)
        {
            Console.WriteLine("Clipboard history is not enabled");
            Console.WriteLine("Please enable clipboard history in Windows settings.");
            Console.WriteLine("Settings > System > Clipboard, Turn on \"Clipboard history\" option.");
            return;
        }

        Clipboard.HistoryChanged += Clipboard_HistoryChanged;

        for (int i = 0; i < 3; i++)
        {
            // ...[생략]...

            _historyChanged.WaitOne();
            _historyChanged.Reset();
        }

        Clipboard.HistoryChanged -= Clipboard_HistoryChanged;
    }

    private static void Clipboard_HistoryChanged(object? sender, ClipboardHistoryChangedEventArgs e)
    {
        _historyChanged.Set();
    }
}

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




참고로, .NET Core/5+의 경우 Clipboard 관련 타입이 기본적인 ConsoleApp을 위한 어셈블리 Set에는 포함돼 있지 않으므로 그와 연관된 옵션을 csproj에 추가해야 합니다.

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <UseWindowsForms>true</UseWindowsForms>
        <!-- WPF인 경우라면 -->
        <!--
        <UseWPF>true</UseWPF>
        -->

    </PropertyGroup>

</Project>

또한, 이렇게 추가하고 빌드하면 이어서 NETSDK1136 컴파일 오류가 발생합니다.

error NETSDK1136: The target platform must be set to Windows (usually by including '-windows' in the TargetFramework property) when using Windows Forms or WPF, or referencing projects or packages that do so.


WPF와 Windows Forms가 윈도우 전용이다 보니, 아예 TargetFramework도 윈도우 전용으로 설정해야 하는 것입니다.

<TargetFramework>net8.0-windows</TargetFramework>




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 5/12/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




1  2  3  4  5  6  7  8  9  10  11  [12]  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13649정성태6/18/20248288오류 유형: 910. windbg - !py 확장 명령어 실행 시 "failed to find python interpreter" (2)
13648정성태6/17/20248723오류 유형: 909. C# - DynamicMethod 사용 시 System.TypeAccessException
13647정성태6/16/20249780개발 환경 구성: 712. Windows - WSL 2의 네트워크 통신 방법 - 세 번째 이야기 (같은 IP를 공유하는 WSL 2 인스턴스) [1]
13646정성태6/14/20248165오류 유형: 908. Process Explorer - "Error configuring dump resources: The system cannot find the file specified."
13645정성태6/13/20248541개발 환경 구성: 711. Visual Studio로 개발 시 기본 등록하는 dev tag 이미지로 Docker Desktop k8s에서 실행하는 방법
13644정성태6/12/20249323닷넷: 2265. C# - System.Text.Json의 기본적인 (한글 등에서의) escape 처리 [1]
13643정성태6/12/20248656오류 유형: 907. MySqlConnector 사용 시 System.IO.FileLoadException 오류
13642정성태6/11/20248543스크립트: 65. 파이썬 - asgi 버전(2, 3)에 따라 달라지는 uvicorn 호스팅
13641정성태6/11/20249125Linux: 71. Ubuntu 20.04를 22.04로 업데이트
13640정성태6/10/20249302Phone: 21. C# MAUI - Android 환경에서의 파일 다운로드(DownloadManager)
13639정성태6/8/20248912오류 유형: 906. C# MAUI - Android Emulator에서 "Waiting For Debugger"로 무한 대기
13638정성태6/8/20249012오류 유형: 905. C# MAUI - 추가한 layout XML 파일이 Resource.Layout 멤버로 나오지 않는 문제
13637정성태6/6/20248776Phone: 20. C# MAUI - 유튜브 동영상을 MediaElement로 재생하는 방법
13636정성태5/30/20248412닷넷: 2264. C# - 형식 인자로 인터페이스를 갖는 제네릭 타입으로의 형변환파일 다운로드1
13635정성태5/29/20249530Phone: 19. C# MAUI - 안드로이드 "Share" 대상으로 등록하는 방법
13634정성태5/24/20249988Phone: 18. C# MAUI - 안드로이드 플랫폼에서의 Activity 제어 [1]
13633정성태5/22/20249420스크립트: 64. 파이썬 - ASGI를 만족하는 최소한의 구현 코드
13632정성태5/20/20248934Phone: 17. C# MAUI - Android 내에 Web 서비스 호스팅
13631정성태5/19/20249704Phone: 16. C# MAUI - /Download 등의 공용 디렉터리에 접근하는 방법 [1]
13630정성태5/19/20249214닷넷: 2263. C# - Thread가 Task보다 더 빠르다는 어떤 예제(?)
13629정성태5/18/20249502개발 환경 구성: 710. Android - adb.exe를 이용한 파일 전송
13628정성태5/17/20248877개발 환경 구성: 709. Windows - WHPX(Windows Hypervisor Platform)를 이용한 Android Emulator 가속
13627정성태5/17/20248936오류 유형: 904. 파이썬 - UnicodeEncodeError: 'ascii' codec can't encode character '...' in position ...: ordinal not in range(128)
13626정성태5/15/20249482Phone: 15. C# MAUI - MediaElement Source 경로 지정 방법파일 다운로드1
13625정성태5/14/20249347닷넷: 2262. C# - Exception Filter 조건(when)을 갖는 catch 절의 IL 구조
13624정성태5/12/20249042Phone: 14. C# - MAUI에서 MediaElement 사용파일 다운로드1
1  2  3  4  5  6  7  8  9  10  11  [12]  13  14  15  ...