완성한 Moonlight 한/영 키 패치를 버린 이유
노트북 내장 키보드로 Moonlight에 접속하면 한/영·한자 키가 잘 동작했다. 그런데 X-KEY 38BT Bluetooth 키보드를 연결하자 일반 키는 모두 들어가는데 두 키만 원격 컴퓨터에 전달되지 않았다.
X-KEY의 한/영·한자 키도 노트북 메모장에서는 정상으로 작동했다. 키보드도 되고 Moonlight도 되는데, 둘을 같이 쓸 때만 안 됐다.
처음에는 Moonlight가 한국어 전용 키를 지원하지 않는 문제라고 생각했다. 그래서 클라이언트의 입력 경로를 추적해 직접 고치기 시작했다.
왜 이 키보드에서만 안 될까
확인한 환경은 Windows Moonlight 클라이언트와 Windows·Sunshine 호스트, 한국어 키보드 레이아웃 KLID 00000412였다. X-KEY의 한/영·한자 키는 오른쪽 Alt와 Ctrl을 대신 쓰는 방식이 아니라 물리적으로 따로 존재한다.
직접 만든 Windows 입력 테스터에서 한/영 키를 누르자 이렇게 잡혔다.
X-KEY 한/영
Message: WM_KEYDOWN
Flags: RI_KEY_BREAK
Raw scan code: 0x72
Virtual-Key: 0x15 (VK_HANGUL/VK_KANA)
WH_KEYBOARD_LL: 이벤트 없음
한자 키도 스캔 코드 0x71, Virtual-Key 0x19 (VK_HANJA/VK_KANJI)로 같은 형태였다. 실제로는 키를 눌렀는데 Raw Input에는 키를 뗐다는 뜻의 RI_KEY_BREAK 플래그가 들어왔다.
일반 키보드는 누를 때 RI_KEY_MAKE와 WM_KEYDOWN, 뗄 때 RI_KEY_BREAK와 WM_KEYUP을 보낸다. X-KEY는 누른 순간부터 RI_KEY_BREAK를 설정하면서 Windows 상위 메시지에는 WM_KEYDOWN을 남겼다. 별도의 정상적인 KEYUP도 보내지 않았다. 누른 상태를 유지하는 일반 키보다 Windows가 순간 명령으로 해석하는 입력에 가까웠다.
노트북 내장 키보드는 달랐다. 한/영 키는 E0 38 → SDL_SCANCODE_RALT → 0xA5, 한자 키는 E0 1D → SDL_SCANCODE_RCTRL → 0xA3 경로였다. Moonlight가 이미 지원하는 오른쪽 Alt와 Ctrl 입력이었기 때문에 기존 버전에서도 작동했다.
왜 메모장에서는 되는데 Moonlight에서는 안 될까
메모장 같은 일반 Windows 프로그램은 Raw Input의 RI_KEY_BREAK만 보고 판단하지 않는다. Windows가 키보드 레이아웃과 한국어 입력기 처리를 거쳐 다음 상위 메시지를 전달한다.
WM_KEYDOWN + VK_HANGUL
WM_KEYDOWN + VK_HANJA
메모장과 .NET 프로그램은 0x15를 KanaMode/Hangul로, 0x19를 HanjaMode로 인식했다. 장치의 입력이 특이해도 Windows의 기존 한국어 키보드 호환 처리가 받아준 셈이다.
차이는 같은 키를 어느 단계에서 읽느냐에 있었다. 메모장은 Windows가 한 차례 해석한 결과를 받지만 Moonlight는 더 낮은 단계의 키보드 입력을 SDL을 통해 읽었다.
Moonlight 같은 게임 스트리밍 프로그램은 지연을 줄이고 물리 키를 구분하려고 SDL 또는 Raw Input을 직접 사용한다. SDL Windows scancode 테이블에서 0x72는 SDL_SCANCODE_LANG1, 0x71은 SDL_SCANCODE_LANG2다. 일반 Windows 메시지 경로는 0xF2, 0xF1에서 high break bit를 제거해 이 값들을 읽는다.
0xF2 & ~0x80 → 0x72 → LANG1
0xF1 & ~0x80 → 0x71 → LANG2
문제는 SDL3의 Raw Input 경로였다. 이 경로는 키의 누름 여부를 RI_KEY_BREAK만 보고 정한다.
bool down = !(rawkeyboard->Flags & RI_KEY_BREAK);
X-KEY는 실제 누름에도 RI_KEY_BREAK를 설정하므로 KEYUP으로 분류된다. 뒤따르는 정상적인 KEYUP도 없어서 안정적인 DOWN/UP 쌍을 만들기 어렵다.
Moonlight에 들어 있는 SDL2.dll은 실제 SDL2 구현이 아니라 SDL3 위에서 동작하는 sdl2-compat였다. 이 호환 계층은 LANG1/LANG2를 SDL2 값으로 그대로 전달했다. 메모장과 Moonlight의 차이는 호환 계층에서 키가 사라져서가 아니라 Windows 상위 메시지와 SDL3 Raw Input이 같은 장치 입력을 다르게 해석해서 생겼다.
Moonlight를 고치면 해결될까
Moonlight 6.1.0에는 SDL_SCANCODE_LANG1과 SDL_SCANCODE_LANG2 처리가 없었다. 당시 확인한 master에는 macOS 일본어 키보드를 위한 매핑이 추가되어 있었지만 Windows 한국어 키보드의 의미와 달랐다.
| 입력 | Moonlight 6.1.0 | 당시 master | Windows 한국어에 필요한 값 |
|---|---|---|---|
LANG1 |
처리 없음 | 0x1C VK_CONVERT |
0x15 VK_HANGUL |
LANG2 |
처리 없음 | 0x1D VK_NONCONVERT |
0x19 VK_HANJA |
패치에서는 X-KEY의 LANG 입력을 Windows 메시지 훅에서 잡았다. 스캔 코드 0x72/0xF2와 0x71/0xF1을 각각 한국어 VK_HANGUL 0x15와 VK_HANJA 0x19로 바꾼 뒤 별도 KEYUP이 없으므로 DOWN과 UP 한 쌍을 합성했다. Windows의 기존 LANG1/LANG2 이벤트는 중복 전송을 막으려고 건너뛰고 Windows 외 플랫폼의 매핑은 유지했다.
패치는 작동했다. Sunshine 디버그 로그에서 한/영 입력이 DOWN과 UP 한 쌍으로 들어왔다.
keyAction 3 keyCode 8015
keyAction 4 keyCode 8015
한자 키도 8019 DOWN과 UP으로 확인했다. 두 키는 원격 Windows에서 정상으로 동작했고 Windows x64 release 빌드와 배포 ZIP 무결성 검사도 통과했다. 여기까지만 보면 문제를 해결한 셈이었다.
그런데 이 패치는 최종적으로 버렸다.
완성한 패치를 버린 이유
가장 큰 이유는 검증 범위였다. 패치는 Windows에서 스캔 코드 0x71/0x72, 0xF1/0xF2를 받으면 항상 순간적인 DOWN/UP 탭으로 만들었다. 직접 확인한 장치는 X-KEY 38BT 한 대뿐이었다. 다른 한국어 전용 키보드는 정상적인 DOWN/UP을 보낼 수도 있었다.
영향 범위도 지나치게 넓었다. 한 장치의 특수 입력을 처리하려고 모든 Windows Moonlight 사용자에게 native message hook을 추가했다. X-KEY에서는 DOWN/UP 합성이 맞았지만 모든 LANG1/LANG2 장치를 같은 방식으로 다뤄야 한다는 근거는 없었다.
더 좁은 곳에서 해결할 방법이 있었다. X-KEY 입력을 Moonlight가 이미 안정적으로 처리하는 오른쪽 Alt와 Ctrl로 바꾸면 Moonlight 자체를 수정할 필요가 없었다. 패치는 개인 환경에서는 작동했지만 업스트림 전체에 적용하기에는 장치별 우회책에 가까웠다.
최종적으로 Moonlight 변경은 남기지 않았고 PR도 만들지 않았다. 공식 버전을 계속 사용하는 쪽을 택했다.
더 좁은 입력 경계
결국 X-KEY의 두 키만 Moonlight가 이미 잘 처리하는 오른쪽 Alt와 Ctrl로 바꾸기로 했다.
VK_HANGUL 0x15 → RAlt DOWN → RAlt UP
VK_HANJA 0x19 → RCtrl DOWN → RCtrl UP
AutoHotkey에서는 다음 두 줄이면 변환할 수 있었다.
#Requires AutoHotkey v2.0
#SingleInstance Force
vk15::SendInput "{RAlt down}{RAlt up}"
vk19::SendInput "{RCtrl down}{RCtrl up}"
X-KEY의 한/영·한자 키는 일반적인 저수준 키보드 훅에는 잡히지 않았다. WH_KEYBOARD_LL 이벤트 자체가 발생하지 않았기 때문에 $, *, ~, #UseHook처럼 키보드 훅을 사용하는 방식은 피했다.
반면 위처럼 단순한 hotkey로 등록하면 AutoHotkey가 RegisterHotKey() 경로를 사용할 수 있고, Windows가 만든 VK_HANGUL과 VK_HANJA 입력을 잡을 수 있었다.
이후 SendInput으로 표준 RAlt/RCtrl 입력을 만들면 Moonlight부터는 기존 입력 경로를 그대로 탄다.
X-KEY VK_HANGUL → RAlt 탭 → Moonlight 0xA5 → Sunshine → 호스트 한/영
X-KEY VK_HANJA → RCtrl 탭 → Moonlight 0xA3 → Sunshine → 호스트 한자
Sunshine 로그에서도 한/영 키는 오른쪽 Alt의 DOWN과 UP으로 들어왔다.
keyAction [00000003] keyCode [80A5] modifiers [04]
keyAction [00000004] keyCode [80A5] modifiers [00]
0xA5는 오른쪽 Alt인 VK_RMENU다. 한자 키 역시 오른쪽 Ctrl인 0xA3으로 들어왔다. 반복해서 눌러도 DOWN/UP이 한 쌍씩 전송됐고 Alt나 Ctrl이 눌린 채 남거나 입력이 두 번 토글되는 현상도 없었다.
Moonlight 안에서만 바꾸기
처음 스크립트는 전역 hotkey라서 Moonlight 밖에서도 동작했다. 메모장처럼 원래 한/영·한자 키를 정상적으로 처리하는 프로그램에서도 입력을 RAlt/RCtrl로 바꿔버릴 필요는 없었다.
그래서 최종적으로는 Moonlight가 활성 창일 때만 두 hotkey를 등록하도록 했다.
Moonlight에서만 동작하는 전체 스크립트
#Requires AutoHotkey v2.0
#SingleInstance Force
Hotkey "vk15", SendHangul, "Off"
Hotkey "vk19", SendHanja, "Off"
SetTimer UpdateMoonlightHotkeys, 100
UpdateMoonlightHotkeys()
UpdateMoonlightHotkeys() {
static enabled := false
shouldEnable := WinActive("ahk_exe Moonlight.exe") != 0
if (shouldEnable == enabled)
return
Hotkey "vk15", shouldEnable ? "On" : "Off"
Hotkey "vk19", shouldEnable ? "On" : "Off"
enabled := shouldEnable
}
SendHangul(*) {
SendInput "{RAlt down}{RAlt up}"
}
SendHanja(*) {
SendInput "{RCtrl down}{RCtrl up}"
}
100ms마다 활성 창을 확인하고 Moonlight에 들어왔을 때만 hotkey를 켠다. 다른 프로그램으로 전환하면 다시 꺼지므로 로컬에서는 X-KEY의 원래 한/영·한자 입력을 그대로 사용할 수 있다.
#HotIf로 조건을 거는 대신 hotkey 자체를 켜고 끄는 것도 같은 이유다. X-KEY의 두 키가 WH_KEYBOARD_LL에 나타나지 않았기 때문에 가능한 한 저수준 키보드 훅에 의존하지 않는 형태로 두었다.
실사용에서는 이 스크립트를 고정된 경로에 두고 shell:startup에 바로가기를 넣어 자동 실행하고 있다. #SingleInstance Force가 있으므로 중복 실행도 막을 수 있다.
Moonlight를 관리자 권한으로 실행한다면 일반 권한 AutoHotkey의 SendInput이 UIPI에 막힐 수 있다. 그런 경우에만 AutoHotkey도 작업 스케줄러 등을 통해 높은 권한으로 실행하면 된다.
고칠 수 있는 곳과 고쳐야 하는 곳
처음에는 Moonlight에서 입력이 사라지는 것을 보고 Moonlight 쪽 문제라고 생각했다. 실제로 Moonlight를 수정해서 동작하게 만들 수도 있었다.
하지만 원인을 따라가 보니 X-KEY가 한/영·한자 키를 일반적인 make/break 입력과 다르게 보내는 것이 시작점이었다. 이 한 장치 때문에 Moonlight의 Windows 입력 경로에 별도 예외 처리를 넣는 것보다는, X-KEY 입력 두 개만 이미 잘 지원되는 RAlt/RCtrl로 바꾸는 쪽이 훨씬 단순했다.
덕분에 Moonlight와 SDL 바이너리를 따로 관리할 필요도 없고 공식 업데이트도 그대로 받을 수 있다. Sunshine이나 드라이버를 건드릴 필요도 없다.
Moonlight 패치까지 만들어 보고 나서야, 고칠 수 있는 위치와 고쳐야 하는 위치가 항상 같지는 않다는 걸 알게 됐다.