포스트

MyKVM (4) 맥 키를 윈도우 키로 바꾸는 스캔 코드와 한/영 전환

이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.

3편에서 맥 키보드 입력을 이벤트 탭으로 받았다. 이제 그 키를 윈도우가 알아듣는 키로 바꿔 보내야 한다. 맥과 윈도우는 키에 붙이는 번호가 다르고, 수정 키(Shift, Command 등)를 알리는 방식도 다르다. 키보드는 해피해킹(HHKB)이라 한/영 키도 따로 없다. 이번 글은 이 차이를 메우는 방법이다.


키마다 붙은 번호

키보드의 키를 누르면 컴퓨터는 “몇 번 키가 눌렸다”를 받는다. 이 번호를 맥과 윈도우가 서로 다르게 쓴다.


가상 키코드와 스캔 코드란?

맥은 키마다 가상 키코드라는 번호를 붙인다. 윈도우는 키보드가 보내는 자리 번호인 스캔 코드를 받아 쓴다. 둘 다 “키보드의 어느 자리 키인지”를 나타내지만 번호 체계가 다르다. 같은 A 키가 맥에서는 0, 윈도우 스캔 코드로는 0x1E다.

키맥 가상 키코드윈도우 스캔 코드
A0x000x1E
Return0x240x1C
Space0x310x39
왼쪽 Command0x370x5B (확장)
왼쪽 Option0x3A0x38
오른쪽 Option0x3D0x38 (확장)
왼쪽 화살표0x7B0x4B (확장)

MyKVM은 이 대응표를 두고 키의 “위치” 그대로 보낸다. 맥의 Command 자리는 윈도우에서 같은 자리의 Windows 키가 되고, Option 자리는 Alt가 된다. 글자를 보내는 게 아니라 자리를 보내니, 윈도우가 지금 한글 자판이든 영어 자판이든 윈도우가 알아서 글자로 바꾼다.


확장 키란?

표에서 “(확장)”이 붙은 키가 있다. 옛날 키보드에는 없던 키(오른쪽 Ctrl, 오른쪽 Alt, 방향키 등)를 나중에 추가하면서, 이미 쓰던 번호 앞에 표시를 하나 붙여 구별한 것이다. 왼쪽 Alt와 오른쪽 Alt는 스캔 코드가 0x38로 같고, 확장 표시로만 갈린다. 그래서 윈도우로 보낼 때 스캔 코드와 확장 여부를 같이 보낸다.

1
{ "t": "key", "sc": 30, "ext": false, "down": true }

수정 키는 따로 온다

맥에서 A를 누르면 “키 누름”, 떼면 “키 뗌” 이벤트가 온다. 그런데 Shift, Control, Option, Command는 누름과 뗌이 아니라 “수정 키 상태가 바뀌었다”(flagsChanged)는 이벤트 하나로 온다. 눌렸는지 떼졌는지는 이벤트에 담긴 상태 비트를 보고 알아내야 한다.

1
2
3
4
5
6
7
8
9
10
// KeyRouter.swift (KVMCore)
/// 키코드별 장치 비트 (CGEventFlags의 하위 비트, 왼쪽과 오른쪽 구분)
static let modifierBits: [UInt16: UInt64] = [
    0x37: 0x08, 0x36: 0x10, 0x38: 0x02, 0x3C: 0x04, 0x3B: 0x01, 0x3E: 0x2000, 0x3A: 0x20, 0x3D: 0x40,
]
// 생략
guard let bit = Self.modifierBits[code] else { return KeyRouting(destination: current, messages: []) }
return flags & bit != 0
    ? keyDown(code, isRepeat: destinations[code] != nil, current: current, allowHangul: false)
    : keyUp(code, current: current)

상태 비트에는 “Command가 눌려 있다”는 비트와 별도로, 왼쪽과 오른쪽을 구분하는 장치 비트가 있다. 왼쪽 Command면 0x08, 오른쪽 Command면 0x10이 켜진다. 이벤트가 알려 준 키코드의 장치 비트가 켜져 있으면 누름, 꺼져 있으면 뗌으로 바꿔서, 윈도우에는 다른 키와 똑같이 누름과 뗌으로 보낸다.


뗌은 누름이 간 쪽으로

넘나드는 순간에는 키가 엉키기 쉽다. Shift를 누른 채 마우스를 밀어 윈도우로 넘어갔다고 하자. 누름은 맥에 갔는데, 뗌을 윈도우에 보내면 맥에는 Shift가 눌린 채 남는다. 그 뒤로 맥에서 치는 글자가 전부 대문자가 된다.

그래서 키마다 누름이 어디로 갔는지 기억하고, 뗌은 지금 커서가 어디 있든 누름이 간 쪽으로 보낸다.

1
2
3
4
5
6
7
8
9
// KeyRouter.swift (KVMCore)
private mutating func keyUp(_ code: UInt16, current: Destination) -> KeyRouting {
    // 생략: 한/영으로 바꿔 보낸 Space, 이미 뗀 것으로 처리한 키
    let destination = destinations.removeValue(forKey: code) ?? current
    guard destination == .windows, let key = KeyMap.winKey(forMacKeyCode: code) else {
        return KeyRouting(destination: destination, messages: [])
    }
    return KeyRouting(destination: .windows, messages: [key.message(down: false)])
}

반대로 맥으로 돌아올 때는 윈도우에 눌려 있다고 기억된 키를 전부 떼는 메시지를 보낸다. 연결이 끊기는 순간에도 마찬가지다. 윈도우 쪽도 자기가 누른 키를 기억했다가 연결이 끊기면 스스로 전부 뗀다. 양쪽에 같은 장치를 둔 이유는, 키가 눌린 채 남는 게 이런 앱에서 가장 성가신 고장이기 때문이다.

하나 더 있다. 키를 꾹 누르고 있으면 같은 키의 누름이 계속 다시 온다(자동 반복). 윈도우에서 Backspace를 꾹 누른 채 맥으로 돌아오면, 돌아온 뒤에 오는 반복 누름이 맥 앱에 들어가 맥에서 글자가 지워질 수 있다. 그래서 이미 뗀 것으로 처리한 키의 반복은 버린다.


Control+Space를 윈도우 한/영 키로

맥에서는 Control+Space로 한/영을 바꾸고, 윈도우에서는 한/영 키를 쓴다. HHKB에는 한/영 키가 없다. 그래서 입력이 윈도우로 가 있을 때 Control+Space를 누르면 윈도우에는 한/영 키를 보낸다.

한/영 키는 키보드마다 스캔 코드가 달라서, 자리 번호 대신 윈도우가 정한 키 이름 번호인 가상 키(VK_HANGUL, 0x15)로 보낸다.

1
2
3
4
5
6
7
8
9
10
11
// KeyRouter.swift (KVMCore)
let heldControls = Self.controlKeys.filter { destinations[$0] == .windows }
if allowHangul, code == Self.spaceKey, !isRepeat, !heldControls.isEmpty {
    // Control이 눌린 채면 한/영 키가 안 먹어서 잠깐 뗐다가 다시 누른다
    swallowed.insert(code)
    let controls = heldControls.compactMap { KeyMap.winKey(forMacKeyCode: $0) }
    let hangul = WinKey(sc: nil, ext: false, vk: Self.hangulVK)
    return KeyRouting(destination: .windows, messages: controls.map { $0.message(down: false) }
        + [hangul.message(down: true), hangul.message(down: false)]
        + controls.map { $0.message(down: true) })
}

윈도우에 Control이 눌린 채로 한/영 키를 보내면 한/영이 바뀌지 않는다. 그래서 Control을 잠깐 떼고, 한/영 키를 누르고 떼고, Control을 다시 누른다. 이 Space의 반복과 뗌은 아무것도 보내지 않는다(swallowed). 이벤트 탭이 HID 단계에 있어서 macOS가 이 조합으로 맥 입력 소스를 바꾸기 전에 가로채고 삼키니, 맥의 입력 소스는 그대로다.

실제로 써 보니 우측 Option도 윈도우에서 한/영으로 잘 바뀌었다. 우측 Option을 “오른쪽 Alt 자리(0x38, 확장)”로 그대로 보내는데, 윈도우의 한국어 자판 설정이 그 자리를 한/영 키로 받아 주기 때문이다. 손버릇에 따라 둘 중 아무거나 쓰면 된다.


단축키 고르기

키보드가 맥에 꽂혀 있을 때 쓰는 단축키는 두 개다.

단축키하는 일
⌥⌘XLG를 맥 ↔ 윈도우로 바꾼다(스왑). LG가 윈도우가 되면 커서가 윈도우 LG 가운데로 간다
⌥⌘Z비상 복귀. 무슨 일이 있어도 입력을 맥으로 되찾는다

처음에는 Control+Option+Command+S와 M이었다가, 누르기 편하게 Option+Command 둘에 글자 하나로 줄였다. 이 조합은 MyKVM이 어디서든 가로채기 때문에, 이미 쓰이는 조합과 겹치면 원래 기능을 못 쓰게 된다. 그래서 겹치는 글자를 피했다.

피한 조합원래 쓰임
⌥⌘SXcode 모두 저장
⌥⌘M앱의 창 모두 최소화
⌥⌘DDock 가리기
⌥⌘H다른 앱 가리기
⌥⌘I, ⌥⌘J크롬 개발자 도구
Win+Alt+G, R, B윈도우 게임 바

X와 Z는 맥과 윈도우 양쪽에서 비어 있고, HHKB에서 Option과 Command 바로 옆이라 왼손으로 누르기 편하다. Control이나 Shift가 같이 눌려 있으면 단축키로 보지 않는다. 다른 앱의 ⌃⌥⌘ 단축키를 가로채지 않으려는 것이다.


윈도우 시작 메뉴가 열리지 않게

커서가 윈도우에 가 있을 때 ⌥⌘X를 누르면, Option과 Command 누름은 이미 윈도우로 가서 윈도우에서는 Alt와 Windows 키가 눌린 상태가 된다. X는 MyKVM이 삼킨다. 그 상태로 손을 떼면 윈도우 입장에서는 “Windows 키를 눌렀다 뗐다”가 되어 시작 메뉴가 열릴 수 있다.

1
2
3
4
5
6
7
// KeyRouter.swift (KVMCore)
private mutating func maskIfNeeded(_ code: UInt16) -> [Message] {
    guard maskWin, Self.winKeys.contains(code) else { return [] }
    maskWin = false
    let blank = WinKey(sc: nil, ext: false, vk: Self.unassignedVK)
    return [blank.message(down: true), blank.message(down: false)]
}

그래서 단축키를 처리한 뒤의 Windows 키 뗌 앞에, 윈도우에 아무 뜻이 없는 가상 키(0xE8)를 한 번 눌렀다 뗀다. 그러면 Windows 키만 눌렀다 뗀 것이 아니게 되어 시작 메뉴가 열리지 않는다. 평소 Command만 눌렀다 떼서 시작 메뉴를 여는 동작은 건드리지 않는다. 단축키를 쓴 직후에만 이 빈 키를 끼운다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.