Wintersalmon | Blog

게스트 모드 + 뷰포트 인지 화면 + 와이어프레임 기반 UI

5 min read

이전 포스트에서 만든 플랫폼은 동작했지만, games-as-vehicle 가설을 한꺼번에 가로막는 세 가지 공백이 있었습니다: 친구가 계정 없이 참여할 수 없었고, 모바일은 스크롤할 때마다 다시 렌더링되었으며, UI는 자체 와이어프레임에서 한 달이나 멀어져 있었습니다. 새로운 게임을 추가하기 전에, 3월의 세 task-log가 그 공백들을 메웠습니다.

TL;DR

  • 게스트 share-link: POST /api/v2/games/:roomId/share-link는 raw secret이 포함된 URL을 반환하고, 서버는 secretHash만 저장합니다. TTL 5분은 명시적 검사 + TTL index로 적용됩니다.
  • 게스트 UUID는 secret + normalizedUsername + gameType + roomNumber에서 파생되어, 새로고침해도 같은 자리를 유지합니다.
  • 뷰포트 모드는 화면 마운트 시점에 한 번 DESKTOP_MIN_WIDTH = 1025로 결정됩니다. 실시간 resize 전환 없음. 모든 공유 화면은 *Mobile*Desktop 형제 컴포넌트로 분리됩니다.
  • 와이어프레임(docs/wireframes/board-game-client/)이 단일 진실 공급원이며, 35개 테스트로 구성된 wireframe-sync.spec.ts Playwright 스위트가 라벨과 구조를 검증합니다.
  • 두 수정 모두 in-memory E2E(e2e/_shared/, mongodb-memory-server)에 의존했습니다.

친구에게 계정이 필요했고, share-link가 그 단계를 제거했습니다

POST /api/v1/auth/guest는 이미 존재했습니다. 빠진 것은 6자리 코드를 입력하지 않고도 특정 방으로 바로 진입하는 방법이었습니다. v2 엔드포인트 세 개:

POST /api/v2/games/:roomId/share-link    // host creates, auth required
GET  /api/v2/share-links/resolve         // public validate + expiry
POST /api/v2/share-links/join            // post-guest-login
  • 호스트 호출은 gameType, roomNumber, raw secret이 포함된 URL을 반환합니다. 서버는 secretHash(sha256)만 저장합니다.
  • TTL 5분 — 핸들러의 expiresAt > now 검사와 정리용 TTL index로 적용됩니다.
  • resolve는 public이라 게스트 화면이 로그인을 강요하기 전에 “이 링크는 만료되었습니다”를 보여줄 수 있습니다.

게스트 신원이 가장 오래 걸렸습니다. 클라이언트는 secret + normalizedUsername + gameType + roomNumber에서 게스트 UUID를 파생하고, POST /api/v1/auth/guest를 호출한 후, cloudnest_guest_share_v1:{gameType}:{roomNumber}에 저장합니다. 같은 브라우저, 같은 UUID, 서버 측 같은 자리. 사용자는 isGuest: trueguestDisplayName을 받기 때문에, 나중에 player stats에서 게스트를 필터링할 수 있습니다.

e2e/chess-v2-inmemory/guest-share-link.spec.ts는 네 가지 시나리오를 다룹니다: 호스트 복사, 게스트 입장, 새로고침 시 자리 유지, 만료 링크 차단. 만료 케이스가 가장 중요했습니다 — 사용자가 실수로 마주치는 유일한 경우이기 때문입니다.

모바일이 깨졌고, 해법은 마운트 시점에 한 번 모드를 결정하는 것이었습니다

이 모바일 버그는 Tailwind와 저의 합작이었습니다. 공유 화면은 sm:* 클래스를 사용했고, alkagi의 MultiplayerGamewindow.resizeuseCanvasSize를 걸어두었습니다. 모바일 Safari에서 주소창이 접히면 innerHeight가 점프하고, resize가 발화하고, 캔버스가 다시 측정되며, 보드가 다시 렌더링됩니다. 스크롤마다 가시적인 깜빡임.

선택지는 모든 resize 핸들러를 디바운스하거나, 컴포넌트를 형태별로 분리하는 것이었습니다. 후자를 선택했습니다:

const DESKTOP_MIN_WIDTH = 1025;

function resolveScreenMode(): "mobile" | "desktop" {
  if (typeof window === "undefined") return "mobile";
  return window.innerWidth >= DESKTOP_MIN_WIDTH ? "desktop" : "mobile";
}
  • 화면 마운트 시점에 한 번 결정합니다. 새로고침 시 재평가. 실시간 resize는 모드를 전환하지 않습니다.
  • 모든 공유 화면은 형제 컴포넌트로 분리됩니다 — HomeScreenMobile/HomeScreenDesktop, LobbyScreen, GameRoomScreen, auth, 게임들도 모두 동일합니다. Router 레벨 컴포넌트는 얇은 selector입니다.

롤아웃: resolver + 테스트, 공유 화면, 게임(PR당 하나씩), 그다음 desktop과 mobile Playwright 프로젝트로 회귀를 CI에서 잡습니다. 플레이그라운드는 desktop-only로 정규화했습니다 — 어차피 제 책상 위, 저 혼자 쓰는 것이니까요. HomeScreen.tsx는 세 개의 파일이 되었습니다. 영원히 clamp() Tailwind를 쓰는 것보다 낫습니다.

와이어프레임이 표류했고, 해법은 코드가 아니라 습관이었습니다

docs/wireframes/board-game-client/는 이미 존재했습니다 — index.html, design-system.html, user-flows.html. 한 달 동안 동기화되지 않았습니다. 수정: 모든 UI 변경의 첫 단계로 와이어프레임을 격상시키기. 한 차례의 재동기화 라운드로 화면들이 따라잡았습니다:

  • RegisterScreenconfirmPassword를 잃고 name(이름)을 얻었습니다.
  • LoginScreen은 일반적인 플랫폼 헤더에서 실제 목적에 맞는 제목으로 바뀌었습니다.
  • GuestJoinScreen은 두 개의 버튼을 하나로 합쳤습니다 — saved-credential 듀얼 버튼 UX는 자동 감지가 동작한 후로는 과잉이었습니다.
  • HomeScreen은 게임별 카드 아이콘을 얻었고(GameTypeConfigicon/iconBg 추가), 카드+버튼 쌍 대신 클릭 가능한 카드로 바뀌었습니다.
  • LobbyScreen은 host UUID를 노출하는 대신 deriveRoomDisplayName()을 통해 room ID의 마지막 네 글자로 방 #XXXX를 렌더링합니다.
  • GameRoomScreen은 호스트/참가자 아바타, 빈자리에 대한 ”?” placeholder, 그리고 와이어프레임이 지정한 위치에 share-link 버튼을 얻었습니다.

35개 테스트의 wireframe-sync.spec.ts Playwright 스위트가 라벨과 구조를 검증합니다. 그 전 주에 E2E를 어떻게 깔아두었는지 덕분에 실행 비용이 저렴했습니다 — Sidebar 1 참고.

e2e/_shared/는 함수 핸들러 그래프를 in-process로 연결하고(Docker compose가 아닌) mongodb-memory-server를 사용합니다 — Playwright worker당 임시 mongod 하나, 구조적으로 격리됩니다. board-game-inmemory 프로젝트 전체가 1분도 안 되어 끝납니다. Docker 기반 multiplayer-chess 스위트는 ./test-functions.sh start와 healthcheck 대기가 필요했습니다 — 한 번 돌리기엔 괜찮지만 35번 돌리기엔 고통스럽습니다.

4월 중순: ErrorBoundary.tsx<App />과 각 lazy 게임 <Screen />을 감쌉니다 — throw가 발생하면 nav shell을 죽이는 대신 새로고침 CTA가 있는 오류가 발생했습니다 카드를 보여줍니다. use-ws-reconnect-timeout.tsconnectionStatus === "reconnecting"이 30초 지속되면 true를 반환합니다. GameRoomScreen은 그러면 “새로고침”과 “로비로 돌아가기”가 있는 모달을 띄웁니다. Boundary는 JS crash를 잡고, 타임아웃은 WS hang을 잡습니다.

앞으로 무엇이 바뀌는가

와이어프레임을 단일 진실 공급원으로 삼는 습관은 4월 28일에 재사용되었습니다: docs/wireframes/games-design-system.html이 일곱 개 게임 전체의 단일 비주얼 스펙으로 출시되었습니다. 이제 모든 remodel 세션은 정규 render<Game>Board() IIFE를 HTML에서 React 컴포넌트로 포팅하는 것으로 시작합니다. 같은 습관, 더 큰 표면.

#ux #wireframes

AI 워크플로우 메모

Claude는 와이어프레임 우선 습관을 강제했습니다. 모든 UI 변경에서 와이어프레임 HTML과 React 컴포넌트를 나란히 열고, 먼저 HTML에서 변경을 스케치한 뒤(Claude가 인라인으로 편집), 업데이트된 와이어프레임에 대한 컴포넌트 diff를 요청했습니다. responsive 대 explicit-split 결정은 architect 에이전트를 거쳤습니다 — 양쪽 모두를 변호해 달라고 요청했고, resize-thrash와 canvas-flash 포인트가 결정적 요인으로 돌아왔습니다. 제가 먼저 유도한 내용이 아니었습니다. 재사용 가능한 습관: 화면을 변경할 때, Claude에게 먼저 그것을 import하는 모든 컴포넌트를 나열하게 한 뒤 그 목록에 맞춰 변경을 제안하게 합니다. 예전에 PR 리뷰에서 발견되던 “이 호출처를 잊었네” 부류의 버그를 잡아냅니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.