Microsoft MVP성태의 닷넷 이야기
닷넷: 2333. C# - (Console 유형의 프로젝트에서) Clipboard 연동 [링크 복사], [링크+제목 복사],
조회: 1265
글쓴 사람
정성태 (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

비밀번호

댓글 작성자
 




... 46  47  48  49  50  [51]  52  53  54  55  56  57  58  59  60  ...
NoWriterDateCnt.TitleFile(s)
12672정성태6/15/202119147오류 유형: 725. IntelliJ에서 Java webapp 실행 시 "Address localhost:1099 is already in use" 오류
12671정성태6/15/202127831오류 유형: 724. Tomcat 실행 시 Failed to initialize connector [Connector[HTTP/1.1-8080]] 오류
12670정성태6/13/202117765.NET Framework: 1071. DLL Surrogate를 이용한 Out-of-process COM 개체에서의 CoInitializeSecurity 문제파일 다운로드1
12669정성태6/11/202117928.NET Framework: 1070. 사용자 정의 GetHashCode 메서드 구현은 C# 9.0의 record 또는 리팩터링에 맡기세요.
12668정성태6/11/202120457.NET Framework: 1069. C# - DLL Surrogate를 이용한 Out-of-process COM 개체 제작파일 다운로드2
12667정성태6/10/202118298.NET Framework: 1068. COM+ 서버 응용 프로그램을 이용해 CoInitializeSecurity 제약 해결파일 다운로드1
12666정성태6/10/202115829.NET Framework: 1067. 별도 DLL에 포함된 타입을 STAThread Main 메서드에서 사용하는 경우 CoInitializeSecurity 자동 호출파일 다운로드1
12665정성태6/9/202118013.NET Framework: 1066. Wslhub.Sdk 사용으로 알아보는 CoInitializeSecurity 사용 제약파일 다운로드1
12664정성태6/9/202115728오류 유형: 723. COM+ PIA 참조 시 "This operation failed because the QueryInterface call on the COM component" 오류
12663정성태6/9/202118226.NET Framework: 1065. Windows Forms - 속성 창의 디자인 설정 지원: 문자열 목록 내에서 항목을 선택하는 TypeConverter 제작파일 다운로드1
12662정성태6/8/202115738.NET Framework: 1064. C# COM 개체를 PIA(Primary Interop Assembly)로써 "Embed Interop Types" 참조하는 방법파일 다운로드1
12661정성태6/4/202127864.NET Framework: 1063. C# - MQTT를 이용한 클라이언트/서버(Broker) 통신 예제 [4]파일 다운로드1
12660정성태6/3/202118609.NET Framework: 1062. Windows Forms - 폼 내에서 발생하는 마우스 이벤트를 자식 컨트롤 영역에 상관없이 수신하는 방법 [1]파일 다운로드1
12659정성태6/2/202119286Linux: 40. 우분투 설치 후 MBR 디스크 드라이브 여유 공간이 인식되지 않은 경우 - Logical Volume Management
12658정성태6/2/202117319Windows: 194. Microsoft Store에 있는 구글의 공식 Youtube App
12657정성태6/2/202117939Windows: 193. 윈도우 패키지 관리자 - winget 설치
12656정성태6/1/202116467.NET Framework: 1061. 서버 유형의 COM+에 적용할 수 없는 Server GC
12655정성태6/1/202114921오류 유형: 722. windbg/sos - savemodule - Fail to read memory
12654정성태5/31/202115809오류 유형: 721. Hyper-V - Saved 상태의 VM을 시작 시 오류 발생
12653정성태5/31/202118952.NET Framework: 1060. 닷넷 GC에 새롭게 구현되는 DPAD(Dynamic Promotion And Demotion for GC)
12652정성태5/31/202116429VS.NET IDE: 164. Visual Studio - Web Deploy로 Publish 시 암호창이 매번 뜨는 문제
12651정성태5/31/202116676오류 유형: 720. PostgreSQL - ERROR: 22P02: malformed array literal: "..."
12650정성태5/17/202116011기타: 82. OpenTabletDriver의 버튼에 더블 클릭을 매핑 및 게임에서의 지원 방법
12649정성태5/16/202118167.NET Framework: 1059. 세대 별 GC(Garbage Collection) 방식에서 Card table의 사용 의미 [1]
12648정성태5/16/202117041사물인터넷: 66. PC -> FTDI -> NodeMCU v1 ESP8266 기기를 UART 핀을 연결해 직렬 통신하는 방법파일 다운로드1
12647정성태5/15/202117047.NET Framework: 1058. C# - C++과의 연동을 위한 구조체의 fixed 배열 필드 사용파일 다운로드1
... 46  47  48  49  50  [51]  52  53  54  55  56  57  58  59  60  ...