두 번째 모니터는 못 가는 게 아니라 갈 곳이 없었다
2026.08.05
배경화면 위에 투명한 레이어를 한 장 깔고, 그 위에서 작은 히어로가 논다. 거미줄이 걸리는 지점은 그림이 아니라 지금 실제로 열려 있는 창의 윗변이다. 크롬을 옮기면 매달릴 자리도 같이 움직이고, 창을 닫으면 그 발판은 사라진다.
이날 하루에 빌드를 다섯 번 새로 냈다. 맥은 그때마다 애플에 다시 공증을 맡겼다.
고친 것들을 늘어놓고 보니 전부 같은 착각이었다. 시간 축이 하나라고 믿었고, 화면이 한 장이라고 믿었고, 좌표계가 하나라고 믿었다.
클릭이 가끔 씹혔다
원인이 두 개였는데, 서로 아무 관계가 없었다.
하나는 쿨다운이었다. 더블클릭을 한 번으로 세려고 0.35초 쿨다운을 두었는데, 그게 위치를 안 보고 있었다. 그래서 서로 다른 자리를 연달아 클릭하면 두 번째가 통째로 버려졌다.
더블클릭은 제자리에 찍히고, 서로 다른 두 클릭은 그렇지 않다. 거르는 기준이 처음부터 시간이 아니라 위치였어야 했다. 이제 직전 클릭 반경 40 안에서 반복된 것만 삼킨다.
둘은 윈도우에만 있었다. GetAsyncKeyState의 「지금 눌려 있는가」 비트만 보고 프레임마다 비교하고 있었다. 그런데 그 폴링이 WPF 렌더 콜백에 얹혀 있어서, 프레임이 한 번 밀리면 그 사이에 눌렀다 뗀 클릭은 흔적이 남지 않는다.
상태를 샘플링하면 샘플과 샘플 사이가 빈다. 그 구간을 메우는 비트가 API에 이미 있었고, 나는 그걸 안 읽고 있었다.
두 번째 모니터는 갈 곳이 없었다
오버레이가 주 모니터의 작업 영역 한 장이었다. 두 번째 모니터는 시뮬레이션 입장에서 존재하지 않는 좌표라서, 히어로가 못 가는 게 아니라 갈 곳이 없었다.
그래서 모든 모니터의 작업 영역을 감싸는 한 장으로 폈다. 그리고 여기서 걸렸다.
바운딩 박스는 모니터들과 같지 않다.
높이가 다르거나 어긋나게 놓인 두 대는 어느 모니터도 덮지 않는 구석을 남긴다. 그 구석에 서 있는 히어로는 아무 데도 그려지지 않는다. 사용자 입장에서는 앱이 죽은 것이다.
그래서 박스는 오버레이 크기를 정하는 데만 쓴다. 바닥·천장·상단 앵커는 ScreenShape.MonitorAt으로 그가 지금 올라와 있는 화면에 묻는다. 서 있을 자리를 정하는 질문에 박스가 대답하면 안 된다.
되돌릴 손잡이도 같이 뒀다. 트레이의 Use All Monitors를 끄면 예전처럼 한 대만 쓴다(재시작 시 적용).
이유가 있다. WPF는 per-pixel 알파 창을 소프트웨어로 그리므로 면적이 늘어난 만큼 프레임 비용이 는다. 그리고 DPI 배율이 다른 모니터에 걸치면 한쪽 배율로 늘어난다. 둘 다 같은 배율의 두 대에서는 문제가 아니지만, 아닌 사람이 새 빌드를 기다리지 않고 되돌릴 수 있어야 한다.
다른 화면을 클릭하면 벽으로 돌진했다
모니터 스팬을 넣고 나서 나온 것이다. 오버레이를 보조 모니터에 두고 메인 모니터를 클릭하면 히어로가 그쪽 벽으로 계속 곤두박질쳤다.
전역 커서 좌표를 씬 안으로 접고(clamp) 있었다. 그래서 다른 화면의 클릭이 **「가장 가까운 벽을 클릭했다」**가 되고, 거기 건 거미줄이 그를 벽에 갈아 넣었다. 펫이 커서를 구경하러 가는 경로도 같은 이유로 벽을 목표로 잡았다.
씬 밖의 점을 씬 안의 점으로 접는 건 조용한 거짓말이다. 값은 유효해 보이고, 아무 데서도 오류가 안 나고, 그래서 결과만 이상하다.
클릭한 그 자리에 쏜다
같은 날 클릭의 의미 자체도 고쳤다.
클릭을 목적지로만 쓰고 있었다. 그래서 히어로는 한 번 뛰고, 그 방향에 있는 아무 창턱이나 골라 거기에 줄을 걸었다. 거미줄은 이쪽으로 가는데 누른 곳은 저쪽이니, 클릭이 무시된 것처럼 보였다.
이제 클릭한 지점 자체가 앵커다. 창턱이든 아무것도 없는 배경화면이든 그 자리에 줄이 붙고, 줄을 감으며 거기까지 올라간 뒤 도착하면 놓는다.
예외를 하나 남겼다. 히어로가 혼자 쏘는 줄은 예전 그대로 창의 윗변만 노린다. 걔는 설 자리를 찾는 중이라 허공에 걸어봐야 소용이 없다. 사람이 겨눈 것만 예외로 둔다.
거미줄은 위로만 당긴다. 그래서 히어로보다 아래를 클릭하면 줄이 아예 나가지 않고, 그쪽으로 건너가 떨어지는 것으로 대신한다.
이걸 넣으면서 커서 모드는 뺐다. 포인터 뒤를 따라다니다 클릭하면 그 지점으로 날아갔다 돌아오는 모드였는데, 클릭이 이미 그 일을 하게 되면서 남길 이유가 없어졌다.
숨기기는 네 번째 모드가 아니다

⌘⇧H를 누르면 화면 아래로 내려가 눈만 남는다. 다시 누르거나 눈을 클릭하면 하던 모드를 그대로 이어서 한다.
여기가 설계에서 한 줄로 정리되는 자리다. 숨기기를 네 번째 모드로 만들지 않았다. 잠깐 멈춤이라서, 돌아올 때 무슨 모드였는지 사용자가 기억할 필요가 없다.
모드가 넷이 되면 「숨기기에서 나가면 어디로 가는가」라는 질문이 생긴다. 멈춤에는 그 질문이 없다.
잠금을 없앴더니 세 가지가 같이 없어졌다
윈도우 버전에는 타이핑으로 여는 가림막이 있었다. 실제 윈도우 PC에서 한 번도 돌려보지 못한 빌드를 받아가게 하고 싶지 않아서 둔 것이었다. 확인이 끝났으니 없앴다.
잠금 상태를 기억하던 localStorage도 같이 지웠다. 상태를 남겨 두면 이미 열어 둔 사람과 처음 온 사람이 서로 다른 화면을 본다. 잠금이 사라진 마당에 그 차이는 설명할 수 없는 차이다.
WindowsPanel의 dynamic import도 걷어냈다. 그건 성능 장치가 아니라 잠겨 있는 동안 다운로드 주소를 페이지 소스에서 빼두려던 장치였다. 감출 것이 없어졌으니 정적 HTML에 그냥 들어간다.
그리고 접속한 OS에 맞는 탭을 먼저 연다. 윈도우에서 온 사람에게 맥 설치법을 먼저 보여줄 이유가 없다. 탭을 직접 누르면 그 선택이 이긴다.
파일 이름을 바꾸면 평판이 0으로 돌아간다
윈도우 zip 파일 이름에 빌드 해시가 붙어 있었다. WebSwing-win-x64-preview-384287.zip 같은 식으로, 릴리스마다 주소가 바뀌었다.
크롬과 SmartScreen은 다운로드 평판을 URL과 파일 해시 단위로 쌓는다. 이름이 바뀌면 세상에 처음 등장한 파일이 되고, 조금씩 쌓이던 신뢰가 매번 0으로 돌아간다. 받는 사람이 계속 「의심스러운 파일」 경고를 보는 이유가 여기 있었다.
캐시는 배포마다 무효화되니 이름을 고정해도 잃을 게 없다. 맥 zip은 원래부터 WebSwing.zip 하나였다.
그 뒤로는 새 빌드를 올릴 때 주소를 그대로 두는 것이 규칙이 됐다. 지난 버전은 평판을 쌓을 일이 없으니 버전 폴더로 내려보낸다.
같은 날 .NET 런타임 링크도 고쳤다. 목록 페이지가 아니라 x64 설치 파일로 바로 보낸다. 그 페이지에는 SDK · ASP.NET · Desktop이 아키텍처별로 늘어서 있어서, 필요한 하나를 고르는 것이 설치에서 사람이 가장 많이 틀리는 지점이었다. aka.ms 별칭이라 최신 패치로는 마이크로소프트가 알아서 넘겨준다.
맥은 실행할 때마다 다른 화면에 떴다
곁다리로 하나 더 나왔다. 맥에서 오버레이가 실행할 때마다 다른 디스플레이에 뜨고 있었다.
NSScreen.main은 주 화면이 아니다. 키 윈도우가 있는 화면이다. 그러니 앱을 켤 때 어느 창이 앞에 있었는지에 따라 결과가 달라졌다.
맥에는 아직 모니터 스팬이 없어서, 대신 어느 디스플레이에 있을지 고르게 했다. 재시작 없이 바로 옮겨간다.
크기 조절도 같이 넣었다. Tiny(0.5×)부터 Large(1.5×)까지. 그림만 키우면 큰 히어로는 창턱에 발이 박히고 작은 히어로는 떠 있게 되므로, 스프라이트와 물리 반경을 한 숫자로 같이 움직인다.
남은 것
코어 테스트가 43개에서 55개로, 다시 72개로 늘었다. 그날 하루에 늘어난 것이다.
고친 것을 다시 늘어놓아 본다.
- 클릭 쿨다운 — 시간 축이 하나라고 믿었다. 서로 다른 두 클릭이 같은 클릭으로 접혔다.
GetAsyncKeyState— 샘플과 샘플 사이에는 아무 일도 없다고 믿었다.- 오버레이 — 화면이 한 장이라고 믿었다. 두 번째 모니터가 없는 셈이 됐다.
- 커서 접기 — 좌표계가 하나라고 믿었다. 씬 밖의 점을 씬 안으로 접어 넣었다.
넷 다 「하나뿐이겠지」였다. 그리고 넷 다 버그가 아니라 조용한 오답으로 나타났다. 예외도 로그도 없이 결과만 이상했다.
데스크톱 앱은 이런 게 유난히 많다. 웹은 창 하나가 세계 전부지만, 데스크톱은 화면이 몇 장인지, 배율이 몇인지, 지금 앞에 있는 게 뭔지를 앱이 물어봐야 안다. 안 물어보면 기본값이 대답하고, 기본값은 늘 「하나」다.