Microsoft MVP성태의 닷넷 이야기
닷넷: 2333. C# - (Console 유형의 프로젝트에서) Clipboard 연동 [링크 복사], [링크+제목 복사],
조회: 213
글쓴 사람
정성태 (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)
13751정성태10/2/20245735C/C++: 176. C/C++ - ARM64로 포팅할 때 유의할 점
13750정성태10/1/20245610C/C++: 175. C++ - WinMain/wWinMain 호출 전의 CRT 초기화 단계
13749정성태9/30/20245792닷넷: 2302. C# - ssh-keygen으로 생성한 Private Key와 Public Key 연동파일 다운로드1
13748정성태9/29/20246083닷넷: 2301. C# - BigInteger 타입이 byte 배열로 직렬화하는 방식
13747정성태9/28/20246379닷넷: 2300. C# - OpenSSH의 공개키 파일에 대한 "BEGIN OPENSSH PUBLIC KEY" / "END OPENSSH PUBLIC KEY" PEM 포맷파일 다운로드1
13746정성태9/28/20246021오류 유형: 924. Python - LocalProtocolError("Illegal header value ...")
13745정성태9/28/20245853Linux: 80. 리눅스 - 실행 중인 프로세스 내부의 환경변수 설정을 구하는 방법 (lldb)
13744정성태9/27/20246332닷넷: 2299. C# - Windows Hello 사용자 인증 다이얼로그 표시하기파일 다운로드1
13743정성태9/26/20246823닷넷: 2298. C# - Console 프로젝트에서의 await 대상으로 Main 스레드 활용하는 방법 [1]
13742정성태9/26/20247061닷넷: 2297. C# - ssh-keygen으로 생성한 ecdsa 유형의 Public Key 파일 해석 [1]파일 다운로드1
13741정성태9/25/20246257디버깅 기술: 202. windbg - ASP.NET MVC Web Application (.NET Framework) 응용 프로그램의 덤프 분석 시 요령
13740정성태9/24/20246113기타: 86. RSA 공개키 등의 modulus 값에 0x00 선행 바이트가 있는 이유(ASN.1 인코딩)
13739정성태9/24/20246258닷넷: 2297. C# - ssh-keygen으로 생성한 Public Key 파일 해석과 fingerprint 값(md5, sha256) 생성 [1]파일 다운로드1
13738정성태9/22/20245984C/C++: 174. C/C++ - 윈도우 운영체제에서의 file descriptor, FILE*파일 다운로드1
13737정성태9/21/20246408개발 환경 구성: 727. Visual C++ - 리눅스 프로젝트를 위한 빌드 서버의 msbuild 구성
13736정성태9/20/20246380오류 유형: 923. Visual Studio Code - Could not establish connection to "...": Port forwarding is disabled.
13735정성태9/20/20246374개발 환경 구성: 726. ARM 플랫폼용 Visual C++ 리눅스 프로젝트 빌드
13734정성태9/19/20246093개발 환경 구성: 725. ssh를 이용한 원격 docker 서비스 사용
13733정성태9/19/20246451VS.NET IDE: 194. Visual Studio - Cross Platform / "Authentication Type: Private Key"로 접속하는 방법
13732정성태9/17/20246536개발 환경 구성: 724. ARM + docker 환경에서 .NET 8 설치
13731정성태9/15/20247009개발 환경 구성: 723. C# / Visual C++ - Control Flow Guard (CFG) 활성화 [1]파일 다운로드2
13730정성태9/10/20246991오류 유형: 922. docker - RULE_APPEND failed (No such file or directory): rule in chain DOCKER
13729정성태9/9/20247790C/C++: 173. Windows / C++ - AllocConsole로 할당한 콘솔과 CRT 함수 연동 [1]파일 다운로드1
13728정성태9/7/20247574C/C++: 172. Windows - C 런타임에서 STARTUPINFO의 cbReserved2, lpReserved2 멤버를 사용하는 이유파일 다운로드1
13727정성태9/6/20248113개발 환경 구성: 722. ARM 플랫폼 빌드를 위한 미니 PC(?) - Khadas VIM4 [1]
13726정성태9/5/20247760C/C++: 171. C/C++ - 윈도우 운영체제에서의 file descriptor와 HANDLE파일 다운로드1
1  2  3  4  5  6  7  [8]  9  10  11  12  13  14  15  ...