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

비밀번호

댓글 작성자
 




... 76  77  78  79  80  81  82  83  84  85  86  87  88  89  [90]  ...
NoWriterDateCnt.TitleFile(s)
11691정성태9/13/201820257VS.NET IDE: 127. Visual C++ / x64 환경에서 inline-assembly를 매크로 어셈블리로 대체하는 방법 - 두 번째 이야기
11690정성태9/13/201823342사물인터넷: 43. 555 타이머의 단안정 모드파일 다운로드1
11689정성태9/13/201822509VS.NET IDE: 126. 디컴파일된 소스에 탐색을 사용하도록 설정(Enable navigation to decompiled sources)
11688정성태9/11/201817862오류 유형: 485. iisreset - The data is invalid. (2147942413, 8007000d) 오류 발생
11687정성태9/11/201819972사물인터넷: 42. 사물인터넷 - 트랜지스터 다중 전압 테스트파일 다운로드1
11686정성태9/8/201819015사물인터넷: 41. 다중 전원의 소스를 가진 회로파일 다운로드1
11685정성태9/6/201819010사물인터넷: 40. 이어폰 소리를 capacitor로 필터링파일 다운로드1
11684정성태9/6/201821620개발 환경 구성: 396. pagefile.sys를 비활성화시켰는데도 working set 메모리가 줄어드는 이유파일 다운로드1
11683정성태9/5/201819214개발 환경 구성: 395. Azure Web App의 이벤트 로그를 확인하는 방법
11682정성태9/5/201818126오류 유형: 484. Fakes를 포함한 단위 테스트 프로젝트를 빌드 시 CS1729 관련 오류 발생
11681정성태9/5/201820818Windows: 149. 다른 컴퓨터의 윈도우 이벤트 로그를 구독하는 방법 [2]
11680정성태9/2/201822967Graphics: 21. shader - _Time 내장 변수를 이용한 UV 변동 효과파일 다운로드1
11679정성태8/30/201821028.NET Framework: 792. C# COM 서버가 제공하는 COM 이벤트를 C++에서 받는 방법 [1]파일 다운로드1
11678정성태8/29/201819370오류 유형: 483. 닷넷 - System.InvalidProgramException [1]
11677정성태8/29/201817209오류 유형: 482. TFS - Could not find a part of the path '...\packages\Microsoft.AspNet.WebApi.5.2.5\.signature.p7s'.
11676정성태8/29/201828071.NET Framework: 791. C# - ElasticSearch를 위한 Client 라이브러리 제작 [1]파일 다운로드1
11675정성태8/29/201818244오류 유형: 481. The located assembly's manifest definition does not match the assembly reference.
11674정성태8/29/201819984Phone: 12. Xamarin - 기존 리모컨 기능을 핸드폰의 적외선 송신으로 구현파일 다운로드1
11673정성태8/28/201817621오류 유형: 480. Fritzing 실행 시 Ordinal Not Found 오류
11672정성태8/28/201817735오류 유형: 479. 윈도우 - 시스템 설정에서 도메인 참가를 위한 "Change" 버튼이 비활성화된 경우
11671정성태8/28/201824183사물인터넷: 39. 아두이노에서 적외선 송신기 기본 사용법파일 다운로드1
11670정성태8/28/201822500사물인터넷: 38. 아두이노에서 적외선 수신기 기본 사용법 [1]파일 다운로드1
11669정성태8/24/201821288개발 환경 구성: 394. 윈도우 환경에서 elasticsearch의 한글 블로그 검색 인덱스 구성
11668정성태8/24/201832330오류 유형: 478. 윈도우 업데이트(KB4458842) 이후 SQL Server 서비스 시작 오류
11667정성태8/24/201818932오류 유형: 477. "Use Unicode UTF-8 for worldwide language support" 옵션 설정 시 SQL Server 2016 설치 오류 [1]
11666정성태8/22/201818746사물인터넷: 37. 아두이노 - 코딩으로 대신하는 오실레이터 회로의 소리 출력파일 다운로드1
... 76  77  78  79  80  81  82  83  84  85  86  87  88  89  [90]  ...