SetWindowText·GetWindowText: 버퍼 크기, 유니코드, 다른 프로세스 창
GetWindowTextLength로 버퍼 크기 정하기, 2021년 예제의 sizeof 버그, 다른 프로세스 컨트롤은 WM_GETTEXT로 읽는 이유, C# LibraryImport 선언까지.
2026년 11월 보강: 2021년에 쓴 글의 C++ 예제 두 개를 MinGW-w64 14.0.0(GCC 16.2.0)으로 Windows용으로 컴파일했습니다. 이 글을 쓴 Mac에서는 실행하지 않았습니다. 그래서 실행 결과 화면은 싣지 않고, 컴파일러가 실제로 낸 오류와 Microsoft Learn 문서에 적힌 동작만 적었습니다. 2021년 예제에는 유니코드 빌드에서 버퍼를 넘칠 수 있는
sizeof(buffer)실수와, 빌드 설정에 따라 컴파일되지 않는 문자열 리터럴이 있었습니다. 고친 코드, 버퍼 크기 계산, 다른 프로세스 창을 읽는 방법, C#LibraryImport선언을 더했습니다.
이 글은 Win32에서 창 제목이나 컨트롤 글자를 바꾸고 읽어야 하는 분을 위한 글입니다. 결론부터 말하면, 내 창이라면 GetWindowTextLengthW로 길이를 얻고 +1 크기의 wchar_t 버퍼로 GetWindowTextW를 부르면 됩니다. 다른 프로그램의 컨트롤이라면 GetWindowText로는 읽히지 않으므로 WM_GETTEXT를 직접 보내야 합니다.
함수 원형과 반환값
BOOL WINAPI SetWindowTextW(HWND hWnd, LPCWSTR lpString);
int WINAPI GetWindowTextW(HWND hWnd, LPWSTR lpString, int nMaxCount);
int WINAPI GetWindowTextLengthW(HWND hWnd);
SetWindowText와 GetWindowText는 매크로이고, 프로젝트에 UNICODE가 정의돼 있으면 ...W, 아니면 ...A 함수로 바뀝니다. 2026년에 새로 쓰는 코드라면 처음부터 W 함수를 직접 부르는 편이 헷갈리지 않습니다.
SetWindowTextW는 성공하면 0이 아닌 값, 실패하면 0을 돌려줍니다. 실패 이유는GetLastError로 봅니다.GetWindowTextW는 복사한 문자 수(NUL 제외)를 돌려줍니다.nMaxCount는 NUL을 포함한 버퍼의 문자 수이고, 글자가 더 길면 잘립니다.- 2021년 글은 "실패하면 0"이라고만 썼는데, 제목이 비어 있어도 0입니다. 둘을 구분하려면 호출 전에
SetLastError(0)을 하고 0이 나왔을 때GetLastError()를 확인합니다.
2021년 예제를 다시 컴파일해 보니
2021년 글의 콘솔 예제는 TCHAR 버퍼에 일반 문자열 "notepad"를 섞어 썼습니다. 멀티바이트(ANSI) 설정에서는 오류 없이 컴파일됐지만, UNICODE를 정의하자 이렇게 나왔습니다.
$ x86_64-w64-mingw32-g++ -Wall -Wextra -DUNICODE -D_UNICODE orig_console.cpp -luser32
orig_console.cpp:7:28: error: cannot convert 'const char*' to 'LPCWSTR' {aka 'const wchar_t*'}
orig_console.cpp:10:25: warning: format '%s' expects argument of type 'char*', but argument 2 has type 'TCHAR*' {aka 'wchar_t*'} [-Wformat=]
orig_console.cpp:11:25: error: cannot convert 'const char*' to 'LPCWSTR' {aka 'const wchar_t*'}
반대로 창 프로시저 예제는 L"notepad"를 써서 유니코드 빌드에서는 통과했고, ANSI 빌드에서는 cannot convert 'const wchar_t*' to 'LPCSTR' 오류 두 개가 났습니다. 같은 글 안의 두 예제가 서로 다른 빌드 설정을 전제로 하고 있었던 셈입니다. Visual Studio의 새 프로젝트는 기본이 유니코드이므로 콘솔 예제는 그대로 붙여 넣으면 컴파일되지 않습니다.
더 위험한 것은 컴파일러가 잡아 주지 않는 부분입니다.
TCHAR buffer[256];
GetWindowText(hWnd, buffer, sizeof(buffer)); // 2021년 코드
sizeof(buffer)는 바이트 수입니다. ANSI 빌드에서는 256이라 우연히 맞지만, 유니코드 빌드에서는 TCHAR가 2바이트라 512가 됩니다. 256칸짜리 버퍼에 "512칸까지 써도 된다"고 알려 주는 셈이라, 제목이 256자를 넘으면 스택 버퍼가 넘칩니다. 문자 수를 넘겨야 하므로 _countof(buffer)(MSVC) 또는 ARRAYSIZE(buffer)가 맞고, 아래처럼 길이를 먼저 묻는 방식이 더 낫습니다. 리터럴의 인코딩도 함정입니다. GCC는 소스의 한글을 UTF-8 바이트로 넣기 때문에, ANSI 빌드에서 SetWindowTextA에 그대로 넘기면 시스템 코드 페이지(한국어 Windows라면 CP949)로 해석되어 글자가 깨질 수 있습니다. W 함수와 L"..." 리터럴을 쓰면 이 문제가 사라집니다.
그 밖에 2021년 코드에서 고친 점은 다음과 같습니다.
FindWindow가NULL을 돌려줄 때(메모장이 안 떠 있을 때)를 확인하지 않았습니다.WM_PAINT안에서SetWindowText를 불렀습니다. 그리기 메시지는 창이 가려졌다 나타날 때마다 오므로, 다른 프로그램 창에 매번WM_SETTEXT를 보내게 됩니다. 상태를 바꾸는 코드는 버튼 클릭 같은 이벤트에서 한 번만 실행하는 것이 맞습니다.- 2021년에는 Windows 8 메모장 화면을 실었지만, Windows 11 메모장은 탭이 있는 앱으로 바뀌었고 이번에는 확인하지 못했습니다. 그래서 예제는 직접 만든 창을 대상으로 바꿨습니다.
길이를 먼저 묻는 2026년 버전
// x86_64-w64-mingw32-g++ -municode -Wall -Wextra text2026.cpp -o text2026.exe -luser32
#include <windows.h>
#include <cstdio>
#include <string>
std::wstring GetText(HWND hwnd) // 내 창, 내 컨트롤
{
int len = GetWindowTextLengthW(hwnd); // 실제보다 클 수는 있어도 작지는 않다
if (len <= 0) return {};
std::wstring s(len + 1, L'\0'); // +1 = 끝의 NUL
int copied = GetWindowTextW(hwnd, s.data(), len + 1);
s.resize(copied); // 실제로 복사된 만큼만 남긴다
return s;
}
std::wstring GetRemoteText(HWND hwnd) // 다른 프로세스의 컨트롤
{
DWORD_PTR len = 0;
if (!SendMessageTimeoutW(hwnd, WM_GETTEXTLENGTH, 0, 0,
SMTO_ABORTIFHUNG, 1000, &len) || len == 0)
return {};
std::wstring s(len + 1, L'\0');
DWORD_PTR copied = 0;
if (!SendMessageTimeoutW(hwnd, WM_GETTEXT, len + 1, (LPARAM)s.data(),
SMTO_ABORTIFHUNG, 1000, &copied))
return {};
s.resize(copied);
return s;
}
int wmain()
{
HWND top = CreateWindowExW(0, L"STATIC", L"Before", WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, 300, 120,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
HWND edit = CreateWindowExW(0, L"EDIT", L"", WS_CHILD | WS_VISIBLE,
0, 0, 280, 24, top, nullptr, nullptr, nullptr);
if (!top || !edit) { std::printf("CreateWindowEx failed: %lu\n", GetLastError()); return 1; }
std::wprintf(L"before: %ls\n", GetText(top).c_str());
if (!SetWindowTextW(top, L"After — 日本語 한국어"))
std::printf("SetWindowText failed: %lu\n", GetLastError());
std::wprintf(L"after: %zu UTF-16 units\n", GetText(top).size());
SetWindowTextW(edit, std::wstring(1000, L'x').c_str()); // 256자보다 길게
std::wprintf(L"edit: %zu chars\n", GetText(edit).size());
HWND other = FindWindowW(L"Notepad", nullptr); // 없으면 nullptr
if (other) std::wprintf(L"notepad: %ls\n", GetRemoteText(other).c_str());
DestroyWindow(top);
return 0;
}
-Wall -Wextra로 경고 없이 컴파일됐고, 결과물은 PE32+ executable (console) x86-64, for MS Windows였습니다. Mac에서는 실행할 수 없어 출력은 싣지 않습니다. wmain을 쓰므로 MinGW에서는 -municode가 필요합니다.
GetWindowTextLengthW에는 문서에 적힌 특징이 하나 있습니다. ANSI와 유니코드가 섞인 경우 실제보다 큰 값을 돌려줄 수 있다는 것입니다. 작게 돌려주는 일은 없으므로 버퍼 크기로는 안전하고, 대신 GetWindowTextW의 반환값으로 문자열을 잘라야 합니다. 위 코드의 s.resize(copied)가 그 역할입니다. 또 std::wstring의 길이는 UTF-16 단위 수라서, 이모지처럼 서로게이트 쌍으로 표현되는 글자는 2로 셉니다.
같은 파일을 배포용으로 빌드할 때 크기를 재 보니 차이가 컸습니다(MinGW-w64 14.0.0, GCC 16.2.0, -O2).
| 빌드 | 크기 | 함께 배포해야 하는 DLL |
|---|---|---|
| 기본(동적 링크) | 333,405 바이트 (strip 후 59,392) | libstdc++-6.dll, libgcc_s_seh-1.dll |
-static |
3,015,335 바이트 (strip 후 257,536) | 없음 |
objdump -p로 확인한 의존 DLL입니다. 기본 빌드를 다른 PC에 exe 하나만 복사하면 DLL을 찾지 못해 실행되지 않으므로, 단일 파일로 넘길 때는 -static이 편합니다.
다른 프로세스의 창: GetWindowText가 하는 일과 안 하는 일
Microsoft Learn의 GetWindowText 문서에 따르면 동작은 대상 창의 주인에 따라 다릅니다.
- 내 프로세스의 창:
WM_GETTEXT메시지를 보내고, 창 프로시저가 돌려준 글자를 받습니다. - 다른 프로세스의 창이고 캡션이 있음: 메시지를 보내지 않고 시스템이 보관해 둔 캡션을 돌려줍니다. 그래서 상대 프로그램이 응답 없음 상태여도 호출이 묶이지 않습니다.
- 다른 프로세스의 컨트롤(캡션 없음): 빈 문자열을 돌려줍니다. 문서도 "다른 응용 프로그램의 컨트롤 글자는 GetWindowText로 가져올 수 없다"고 적고 있습니다.
3번이 필요하면 WM_GETTEXT를 직접 보내야 합니다. 이때 그냥 SendMessage를 쓰면 상대가 멈춰 있을 때 내 프로그램까지 멈추므로, 위 코드처럼 SendMessageTimeoutW에 SMTO_ABORTIFHUNG과 제한 시간을 줍니다. WM_GETTEXT는 시스템이 프로세스 사이에서 버퍼를 복사해 주는 메시지라 포인터를 넘겨도 됩니다.
2021년 글의 "다른 응용 프로그램의 컨트롤 캡션은 변경할 수 없다"는 설명은 SetWindowText 문서와 같은 내용으로 맞습니다. 다만 최상위 창의 제목은 바뀌므로 2021년 예제가 메모장 제목을 바꿀 수 있었던 것입니다. 관리자 권한으로 실행된 창은 UIPI(사용자 인터페이스 권한 격리) 때문에 일반 권한 프로그램에서 보낸 메시지가 막힐 수 있다는 점도 기억해 두면 좋습니다.
C#에서 부르기: LibraryImport
.NET 7부터는 DllImport 대신 소스 생성기가 마샬링 코드를 만들어 주는 LibraryImport가 권장됩니다. net10.0-windows로 dotnet build -p:EnableWindowsTargeting=true를 해서 Mac에서 컴파일했습니다(.NET SDK 10.0.403, 런타임 10.0.14).
using System.Runtime.InteropServices;
[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
nint hwnd = Native.FindWindow("Notepad", null);
Console.WriteLine(hwnd == 0 ? "Notepad not found" : Native.GetText(hwnd));
static partial class Native
{
[LibraryImport("user32.dll", EntryPoint = "FindWindowW", StringMarshalling = StringMarshalling.Utf16)]
internal static partial nint FindWindow(string? className, string? windowName);
[LibraryImport("user32.dll", EntryPoint = "SetWindowTextW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetWindowText(nint hWnd, string text);
[LibraryImport("user32.dll", EntryPoint = "GetWindowTextLengthW", SetLastError = true)]
internal static partial int GetWindowTextLength(nint hWnd);
[LibraryImport("user32.dll", EntryPoint = "GetWindowTextW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
internal static partial int GetWindowText(nint hWnd, Span<char> buffer, int maxCount);
internal static string GetText(nint hWnd)
{
int len = GetWindowTextLength(hWnd);
if (len <= 0) return "";
Span<char> buf = len < 256 ? stackalloc char[len + 1] : new char[len + 1];
int copied = GetWindowText(hWnd, buf, buf.Length); // +1 = NUL
return new string(buf[..copied]);
}
}
처음부터 이렇게 되지는 않았습니다. 실제로 만난 오류와 경고는 다음과 같습니다.
error SYSLIB1062: LibraryImportAttribute requires unsafe code. Project must be updated with '<AllowUnsafeBlocks>true</AllowUnsafeBlocks>'.
Program.cs(19,69): error SYSLIB1051: Runtime marshalling must be disabled in this project by applying the 'System.Runtime.CompilerServices.DisableRuntimeMarshallingAttribute' to the assembly to enable marshalling this type. The generated source will not handle marshalling of parameter 'buffer'.
- SYSLIB1062: csproj에
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>를 넣어 해결했습니다. - SYSLIB1051:
Span<char>(그리고char[]도) 매개변수에StringMarshalling이 없으면char를 몇 바이트로 넘길지 정해지지 않아 생깁니다.StringMarshalling = StringMarshalling.Utf16을 붙이자 사라졌습니다.
이 상태에서 -p:AnalysisMode=All로 분석기를 모두 켜면 CA5392(The method ... didn't use DefaultDllImportSearchPaths attribute for P/Invokes) 경고가 함수마다 하나씩, 4개 나왔습니다. DLL을 현재 폴더에서 먼저 찾는 DLL 하이재킹을 막으라는 경고라서, 위처럼 어셈블리에 DefaultDllImportSearchPaths(DllImportSearchPath.System32)를 붙이자 경고 0개가 됐습니다. 비교용으로 예전 방식인 [DllImport(..., CharSet = CharSet.Auto)]와 StringBuilder를 쓴 선언도 빌드해 보니 CA2101, CA5392, CA1838(Avoid 'StringBuilder' parameters for P/Invokes) 세 경고가 나왔습니다.
참고로 이 프로그램을 Mac에서 그대로 실행하면 컴파일은 통과했어도 첫 호출에서 System.DllNotFoundException: Unable to load shared library 'user32.dll'이 납니다. net10.0-windows 대상은 Mac에서 빌드는 되지만 동작 확인은 Windows에서 해야 합니다.
정리
- 버퍼 크기는
GetWindowTextLengthW + 1, 단위는 문자 수.sizeof는 바이트라 유니코드 빌드에서 두 배가 됩니다. - 반환값 0은 "실패"와 "빈 제목" 둘 다입니다.
GetLastError로 구분합니다. - 다른 프로세스의 컨트롤은
GetWindowText로 읽히지 않습니다.SendMessageTimeoutW+WM_GETTEXT를 씁니다. - 새 코드는
W함수와L"..."리터럴로, C#은LibraryImport+StringMarshalling.Utf16으로 씁니다.
키 입력 상태를 읽는 API는 GetKeyState와 GetAsyncKeyState, 실행 파일 위치를 얻는 API는 GetCurrentDirectory와 GetModuleFileName 글에서 같은 방식으로 다시 검증했습니다.
자주 묻는 질문
GetWindowText에 넘길 버퍼 크기는 어떻게 정하나요?
GetWindowTextLengthW로 길이를 먼저 얻고, 끝의 NUL 문자 몫으로 1을 더한 크기의 버퍼를 만듭니다. 세 번째 인수 nMaxCount는 바이트가 아니라 문자 수입니다. 2021년 예제처럼 sizeof(buffer)를 넘기면 유니코드 빌드에서 실제 크기의 두 배를 알려 주게 되어 버퍼 넘침이 생길 수 있습니다.
다른 프로그램의 에디트 박스 글자가 GetWindowText로 읽히지 않는 이유는?
GetWindowText는 대상 창이 다른 프로세스 것이면 WM_GETTEXT를 보내지 않고 시스템이 보관한 캡션만 돌려줍니다. 캡션이 없는 컨트롤은 빈 문자열이 됩니다. 다른 프로세스의 컨트롤은 SendMessageTimeoutW로 WM_GETTEXTLENGTH와 WM_GETTEXT를 직접 보내서 읽습니다.
C#에서는 DllImport와 LibraryImport 중 무엇을 쓰나요?
.NET 7 이후로는 소스 생성기 기반의 LibraryImport가 권장됩니다. 이번에 .NET 10으로 빌드해 보니 AllowUnsafeBlocks가 없으면 error SYSLIB1062, char 버퍼에 StringMarshalling을 지정하지 않으면 error SYSLIB1051이 났고, 둘 다 고치자 경고 0개로 빌드됐습니다.