Windows Terminal에서 한글을 치다 보면 이상한 일이 생깁니다.
프롬프트에 안녕하세요를 치다가 오타를 발견하고 방향키로 커서를 옮기면,
조합 중이던 글자가 원래 자리에 남지 않고 커서가 옮겨간 자리에 찍힙니다.
안녕 다음에 하세요를 치던 중에 왼쪽 방향키를 누르면 안녕하요세 같은 게 나옵니다.
글자 하나 고치려고 방향키를 눌렀다가 문장이 더 망가지는 겁니다.
이 글은 그 버그의 원인을 찾아서 microsoft/terminal에 고침을 올리고, 리뷰를 거쳐 머지되기까지의 기록입니다.
메인테이너가 고쳐줄 때까지 기다릴 수도 있었습니다. 그런데 이건 제가 하루에 몇 시간씩 들여다보는 창이고, 한국어 사용자에게는 "프롬프트에서 방향키를 못 쓴다"는 뜻입니다. 게다가 이슈에는 이미 "누가 디버깅해주면 도움이 된다"고 적혀 있었습니다.
이미 고쳐졌다고 적혀 있던 버그
먼저 이슈 트래커를 뒤졌습니다. #20244에 정확히 같은 증상이 올라와 있었습니다.
- 한글 조합 중 확정 전에 방향키를 누르면 조합 문자가 커서 위치로 따라간다
- v1.24.11321.0 (stable)에서 재현
- 다른 사용자가 v1.25.1322.0도 같다고 확인
흥미로운 건 이슈 본문에 이미 "#20039, #20041에서 고쳐졌다고 하는데 여전히 재현된다"고 적혀 있었다는 점입니다.
메인테이너(lhecker)의 답변도 솔직했습니다.
성능 개선을 하면서 제가 이 동작을 깬 것 같습니다. 죄송합니다.
시간이 좀 걸릴 것 같으니, 누가 디버깅해주시면 큰 도움이 됩니다.
공개적으로 "디버깅 도와달라"고 열려 있는 자리였습니다. 들어가기에 좋은 조건입니다.
스레드가 헛돌던 지점
그런데 스레드는 이미 잘못된 방향으로 굳어 있었습니다.
한 사람이 "원인을 확인했다"며 이렇게 적었습니다.
#20039의
HasActiveComposition()가드가main에는 머지됐지만release-1.24로 체리픽되지 않았다.release-1.24의TermControl.cpp1795행 근처에 가드가 없어서 stable에서 재현되는 것이다.
배포 계보를 의심한 겁니다. 그럴듯하게 들립니다.
다른 메인테이너(DHowett)가 바로 잘랐습니다.
이건 틀렸고, 쓰인 시점에도 틀렸습니다. 그 PR은 4월 1일에 release-1.24로 서비싱됐고, 후속 수정은 4월 17일에 들어갔습니다. 두 수정 모두 11321, 1322 빌드에 들어 있습니다.
> 원인을 확인했다
did you
또 다른 사용자는 워크어라운드를 공유했습니다. 방향키를 누르기 전에 Tab을 눌러서 조합을 강제로 끊는 방식입니다. Claude Code에서는 통하지만 일반 프롬프트에서는 경로 자동완성이 튀어나와서 못 쓴다고 했습니다.
정리하면 이런 상태였습니다.
"고쳤다는 수정은 이미 들어가 있는데 증상은 그대로다." 즉 원인이 아직 밝혀지지 않은 겁니다.
여기서 배운 게 하나 있습니다. "이미 고쳤다"고 적힌 이슈가 재현되면, 배포 경로를 의심하기 전에 그 수정이 왜 이 경로를 못 덮는지를 봐야 합니다.
빌드부터가 일이었습니다
원인을 보려면 일단 빌드가 돼야 합니다. 여기서 반나절이 갔습니다.
이 저장소는 dep\vcpkg-overlay-triplets\x64-windows-static.cmake에 VCPKG_PLATFORM_TOOLSET v145를 못박아 두고 있습니다. v145는 VS 18 계열 툴셋인데, 제 머신은 VS 2022 17.14 (MSVC 14.44)입니다. 그래서 vcpkg manifest install이 이렇게 죽습니다.
error: in triplet x64-windows-static: Unable to find a valid Visual Studio instance with toolset version v145
우회로는 하나뿐이었습니다. 이미 설치가 끝난 obj\x64\vcpkg를 재사용하고, msbuild에 manifest install을 끄라고 알려주는 것입니다.
정석은 개발자 환경을 잡아주는 tools\razzle.cmd를 쓰는 것이지만, msbuild를 직접 부를 때는 플래그 세 개가 필수였습니다.
/p:SolutionDir=<저장소 경로>\ 없으면 vcpkg.props를 못 찾고 MSB4019
/p:VcpkgRoot=C:\Users\...\vcpkg 없으면 dep\vcpkg로 폴백해서 같은 에러
/p:VcpkgManifestInstall=false 없으면 위의 v145 에러
첫 풀빌드는 1시간 36분 걸렸습니다. 이 숫자는 나중에 중요해집니다. 리뷰 코멘트 한 줄에 이 비용이 다시 든다는 뜻이거든요.
원인: 확정된 글자가 아직 TSF 안에 있었습니다
Windows의 IME는 TSF(Text Services Framework)를 통해 앱과 대화합니다. 앱은 조합이 시작/변경/종료될 때 콜백을 받고, 실제 텍스트를 읽고 쓰려면 편집 세션(edit session) 을 열어야 합니다.
문제의 코드는 Implementation::OnEndComposition()입니다.
_compositions--;
if (_compositions == 0)
{
std::ignore = _request(_editSessionCompositionUpdate, TF_ES_READWRITE | TF_ES_ASYNC);
}
TF_ES_ASYNC가 눈에 걸립니다. 동기(TF_ES_SYNC)로 열면 안 되나요?
안 됩니다. OnEndComposition() 자체가 이미 편집 세션 안에서 호출되기 때문입니다. 세션 안에서 세션을 동기로 요청하는 건 TSF가 거부합니다. 그래서 비동기로 큐에 넣는 것 말고는 선택지가 없습니다.
그러면 실제로 텍스트를 넘기는 _doCompositionUpdate()는 그 세션이 메시지 루프에서 차례를 받아야 돕니다. 그때까지 확정된 글자는 TSF 컨텍스트 안에 그대로 남아 있습니다.
여기에 한국어 IME의 특성이 겹칩니다. 한국어 IME는 방향키나 Enter로 조합을 끝낼 때 그 키를 소비하지 않고 앱에 넘깁니다. 그래서 같은 메시지 디스패치 안에서 이런 순서가 만들어집니다.
- 왼쪽 방향키가 디스패치되고, IME가 조합을 종료하고, 비동기 세션이 큐에 들어갑니다
- 키는 소비되지 않았으니 그대로 셸로 전달되고, 셸이 커서를 옮깁니다
- 뒤늦게 비동기 세션이 돌면서
가가 새 커서 위치에 찍힙니다
이게 전부였습니다. 경합(race)이라기보다 순서 역전입니다.
그리고 #20039의 가드가 왜 무력했는지도 여기서 드러납니다. 그 수정은 키 핸들러에서 HasActiveComposition()을 검사합니다. 그런데 위 코드를 보면 _compositions--가 세션을 요청하기 전에 실행됩니다. 가드를 볼 시점에 HasActiveComposition()은 이미 false입니다.
가드는 "조합 중인가?"를 물었지만, 정작 문제는 "조합이 끝났는데 아직 배출되지 않았는가?"였습니다. 검사하는 상태값이 세션 요청보다 먼저 0이 되어버린 것입니다.
고치는 방향을 고르기
원인을 알고 나면 방법은 두 가지입니다.
첫 번째, 가드를 넓히기. 대기 중인 세션까지 포함해서 "확정이 아직 안 나갔으면 키를 처리하지 마라"로 바꾸는 방법입니다.
이러면 글자가 움직이는 건 막힙니다. 그런데 조합을 끝낸 키를 삼켜버립니다. Enter가 줄을 제출하지 못하고, 한국어 IME가 확정에 쓰는 스페이스가 먹혀서 안녕 하세요가 안녕하세요가 됩니다. 버그를 다른 버그로 바꾸는 셈입니다.
두 번째, 텍스트를 먼저 배출하기. 키 핸들러는 편집 세션 밖에서 돕니다. 그러니 여기서는 세션을 동기로 열 수 있습니다. OnEndComposition()이 못 하는 걸 키 핸들러는 할 수 있는 겁니다.
두 번째를 골랐습니다. 모든 키입력이 살아남기 때문입니다.
// OnEndComposition() can only request TF_ES_ASYNC, because it runs from within an active
// edit session. If the composition ended due to a relevant terminal key (arrow keys,
// Enter, etc.), the terminal will receive and handle that key input synchronously, before
// we got around to handling the completion asynchronously. This method allows you to
// synchronously flush it from inside the key handler. GH#20244
void Implementation::FlushPendingComposition() noexcept
호출 지점은 두 곳입니다.
TermControl::_KeyHandler()— Windows Terminal 쪽- conhost
HandleKeyEvent()최상단 — 콘솔 호스트 쪽
여기서 하나 더 신경 쓴 게 있습니다. TermControl에서는 플러시를 키바인딩 검사보다 앞에 두었습니다.
조합을 Ctrl+V로 끝내는 경우를 생각해보면 이유가 보입니다. Ctrl+V는 기본 바인딩이 붙어 있습니다. 플러시가 바인딩 검사 뒤에 있으면, 붙여넣기가 음절보다 먼저 연결에 도착합니다. 키를 PTY로 흘려보내는 것과 똑같은 순서 역전이 액션 경로에서도 일어나는 겁니다.
리뷰: "다른 IME로도 테스트했나요?"
PR을 올리고 몇 시간 뒤 리뷰가 왔습니다. 주석 표현에 대한 nit 네 건과 함께, 이런 질문이 붙어 있었습니다.
한국어 외의 다른 IME로도 변경을 테스트해봤나요? 아쉽지만 여러 IME로 테스트해야 합니다. 저는 보통 한국어, 베트남어, 일본어로 봅니다.
당연한 요구입니다. 키 처리 경로에 동기 편집 세션을 하나 끼워 넣은 변경이니, 다른 텍스트 서비스를 건드릴 가능성이 있습니다.
문제는 "IME로 타이핑해봤다"를 어떻게 증거로 만들 것인가였습니다. 스크린샷은 증거가 못 됩니다. 그래서 하네스를 만들었습니다.
ITfInputProcessorProfileMgr::ActivateProfile로 특정 TSF 프로파일을 활성화SendInput으로 raw 입력 레벨에서 타이핑 — 텍스트 서비스가 실제 키로 인식하게ReadConsoleOutputCharacterW로 conhost 화면 버퍼를 직접 읽어서 결과 비교- 패치 빌드 vs
FlushPendingComposition()호출만 주석 처리한 빌드를 나란히 비교
여기서 한 번 속았습니다. 초기 실행에서 일본어 nihongo가 ㅜㅑㅙㅜ해로 나왔습니다. 의도한 IME가 아예 활성화되지 않은 채로 한국어 자판이 그대로 먹은 것입니다.
그래서 매 실행 앞에 프로브 문자를 하나 치고, 기대한 문자(ㅁ / あ / â)가 돌아오는지 먼저 검사하는 게이트를 넣었습니다. 이 게이트가 표의 신뢰도를 만듭니다.
결과입니다.
대조군 (IME 없음) — 방향키가 원래 해야 하는 일:
| 키 | before | after |
|---|---|---|
ab ← cd |
acdb |
acdb |
한국어 (두벌식):
| 키 | before | after |
|---|---|---|
안녕 → 하세요 ← |
안녕하요세 |
안녕하세요 |
안녕 ← 하세요 |
실행마다 다름 | 안하세요녕 (6/6 동일) |
안녕 Enter |
안녕 |
안녕 |
첫 행이 가장 명확합니다. 줄 끝에서 →는 아무 일도 하지 않아야 하고, 유일하게 일어날 일은 녕의 확정입니다. 그다음 하세요를 치고 ←로 요 앞으로 돌아가면 평범하게 안녕하세요가 남아야 합니다. 수정 전에는 ←가 입력 버퍼에 먼저 도착해서 커서가 하와 세 사이로 가고, 거기에 요가 끼어들어 안녕하요세가 됩니다.
두 번째 행은 같은 실패가 덜 읽기 쉽게 나타난 경우인데, 실행마다 결과가 달랐습니다. 순서 역전이라는 진단과 정확히 맞는 증상입니다. 수정 후의 안하세요녕은 대조군의 acdb와 같은 모양입니다. 커서가 확정된 녕 앞으로 물러났고 다음 단어가 그 앞에 들어간 것이니 정상입니다.
일본어 (MS-IME, 로마자):
| 키 | before | after |
|---|---|---|
nihongo Space Enter Enter |
日本語 |
日本語 |
nihongo ← xy Enter Enter |
にほんxyご |
にほんxyご |
베트남어 (Telex):
| 키 | before | after |
|---|---|---|
tieengs Space |
tiếng |
tiếng |
tieengs ← xin |
tiếnxing |
tiếnxing |
일본어와 베트남어는 before/after가 바이트 단위로 동일했습니다.
여기서 재미있는 사실이 하나 나왔습니다. 이 두 IME도 방향키를 누르면 커서가 움직이고 다음 글자가 단어 중간에 들어갑니다. 그런데 확정을 컨텍스트에 남기지 않습니다. 남겼다면 수정 전 빌드가 한국어처럼 글자를 흩뜨렸을 텐데, 그러지 않았습니다.
즉 이 두 IME가 조합 중일 때 FlushPendingComposition()은 배출할 것이 없습니다. 한국어만 "방향키가 조합을 끝내고 그 키를 앱에 돌려주는" 케이스이고, 그게 정확히 이 수정의 대상입니다.
한계도 그대로 적었습니다. 이 표는 전부 conhost 호출 지점(HandleKeyEvent)을 검증한 것입니다. 결정론적으로 구동할 수 있는 쪽이 그쪽이고, TermControl이 부르는 것과 같은 FlushPendingComposition()을 타지만, TermControl 호출 지점 자체를 IME로 구동하지는 못했습니다.
리뷰어가 맞았던 지점
리뷰에는 이런 질문도 있었습니다.
EditSessionProxy는 참조 카운트가 제대로 되어 있지 않고_editSessionCompositionUpdate가 이미 사용 중일 수 있으니 별도 프록시 인스턴스가 필요하다 — 라고 쓰셨는데, 정말 필요한가요? 그냥 기존 멤버를 재사용해도 될 것 같은데요.
제 첫 커밋은 프록시를 하나 더 만들었고, 커밋 메시지에 "대기 중인 비동기 요청이 첫 번째 프록시의 참조를 잡고 있으므로 두 번째가 필요하다"고 적어두었습니다.
확인해보니 리뷰어가 맞았습니다. EditSessionProxyBase::AddRef()/Release()는 실제로 카운트를 유지합니다. 대기 중인 비동기 요청 안에 동기 세션이 중첩되면 카운트가 1 → 2 → 1로 돌 뿐입니다. 참조된 프록시의 재사용을 거부하는 건 _request()뿐이고, 이 호출은 RequestEditSession()을 직접 부릅니다.
멤버 하나와 헤더 한 줄을 지웠습니다. 최종 변경은 6개 파일에 42줄 추가입니다.
머지
수정을 반영하고 나서 승인이 왔습니다.
The PR looks good. Just nits about comment.
(nit 반영 후) Thank you for contributing this fix!
CI에서 x64 테스트 하나가 빨간불이었습니다. ConPtyTests::DiesOnClose#metadataSet0. 확인해보니 4개 데이터셋 중 ping localhost만 실패했고, 실패 지점은 pty 정리 후 3초 안에 conpty 프로세스가 죽었는지 확인하는 부분이었습니다. ping localhost는 그 데이터셋 중 유일하게 스스로 끝나지 않는 명령이라 3초 경합에 걸립니다. 같은 빌드의 arm64 잡에서는 동일 테스트가 통과했습니다.
이 PR은 키 입력 경로만 건드리는데, 그 테스트는 키 입력을 전혀 타지 않습니다. 무관한 플레이크였고, 메인테이너도 그렇게 보고 재실행 없이 그대로 병합했습니다.
cd89e8226 — upstream main에 squash 커밋 하나. 이슈 #20244는 자동으로 닫혔습니다.

남는 것
기술적인 것 하나와 태도에 관한 것 셋이 남았습니다.
비동기로만 요청할 수 있는 자리와 동기로 요청할 수 있는 자리를 구분하면 재진입 제약을 우회할 수 있습니다. OnEndComposition()은 세션 안이라 못 하지만 키 핸들러는 세션 밖이라 할 수 있었습니다. 같은 API인데 호출 문맥에 따라 가능한 것이 달라집니다.
"이미 고쳤다"는 이슈가 재현되면 수정이 왜 이 경로를 못 덮는지를 봐야 합니다. 여기서는 가드가 검사하는 상태값이 세션 요청보다 먼저 0이 되는 것이 전부였습니다. 배포 계보 추측은 헛다리였고, 심지어 그 추측이 "원인을 확인했다"는 문장과 함께 올라와서 스레드를 한동안 붙잡고 있었습니다.
IME 버그는 스크린샷으로 주장하면 안 됩니다. 프로파일 활성화 + SendInput + 화면 버퍼 읽기로 before/after 표를 만들면 리뷰 논쟁이 한 번에 끝납니다. 그리고 그 표의 신뢰도는 "의도한 IME가 실제로 살아 있는가"를 검사하는 프로브 게이트에서 나옵니다. 하네스에 한 번 속아본 뒤에 배웠습니다.
리뷰어의 "이거 정말 필요한가요?"는 대체로 맞습니다. 두 번째 프록시는 실제로 불필요했고, 확인해보니 참조 카운트는 제대로 동작하고 있었습니다. 제가 코드를 덜 읽고 방어적으로 하나 더 만들어둔 것이었습니다.
고친 코드는 42줄입니다. 원인을 찾아 고치는 데 하루, 그게 정말 맞는지 다른 IME로 증명하는 데 하루가 더 걸렸습니다.
이제 한국어 IME로 방향키를 눌러도 글자가 따라오지 않습니다. 제 머신에서만이 아니라, 다음 릴리스를 받는 모든 사람의 터미널에서요.
'Develop' 카테고리의 다른 글
| Vercel 보안 이슈 정리 (0) | 2026.04.20 |
|---|---|
| Zlack: Slack을 더 가볍게 쓰기 위한 Tauri 데스크톱 클라이언트 (0) | 2026.04.15 |
| 구너 영상 플레이어 15.11 (1) | 2018.05.30 |
| mWeb Mac버전 배포 (4) | 2016.11.22 |
| 개발자 후원하기. (0) | 2016.07.13 |