YORR
휴대폰을 컨트롤러로 쓰는 모바일 실시간 멀티플레이 게임 플랫폼. 6인 팀에서 프론트엔드를 혼자 맡았다.
- React · Three.js
- 휴대폰이 컨트롤러가 되는 실시간 멀티플레이
- 프론트엔드 단독 개발
- WebSocket · WebRTC 음성
개요
- 무엇
- 휴대폰을 컨트롤러로 쓰는 모바일 실시간 멀티플레이 게임 플랫폼 (야추 · 탁구 · 듀얼)
- 기간
- 2026-07-21 ~ 2026-08-13 (약 3.5주)
- 팀
- 6명 — BE 3 · AI 1 · Infra 1 · FE 1(본인 단독)
- 본인 커밋
- 81건(비-머지) + 103건(develop 통합 머지)
- 프론트 코드량
- 약 52,000 LOC (.ts 249파일 + .tsx 198파일)
- 스택 (본인 담당)
- React 19 · Vite 8 · TypeScript 7 · TanStack Router · Zustand · Tailwind 4 · motion · Three.js 0.180 + Rapier3D · WebSocket · WebRTC(음성)
- 품질 도구
- Biome · Vitest 4 + v8 coverage · Playwright · MSW · dpdm
세 가지 설계 판단
본인이 쓴 frontend/docs/architecture.md에 남긴 세 원칙이다. 화면 하나를 그리기까지 이 순서로 통과한다.
서버 상태를 화면으로
상태의 최종 권위자는 서버다. 클라이언트는 서버가 보낸 상태를 그리는 쪽이고, 서버의 응답과 어긋난 로컬 상태를 오래 들고 있지 않는다.
센서를 게임 입력으로
휴대폰 센서가 만드는 원시값을 그대로 서버로 보내지 않는다. 클라이언트가 먼저 판정하고, 판정된 이벤트만 전송한다 — 스윙인지 아닌지, 방향이 어느 쪽인지를 결정한 뒤의 결과다.
연출은 로컬에서
3D 물리 · 진동 · 소리는 전부 클라이언트가 맡는다. 서버는 판정에만 관여하고, 그 판정을 느끼게 만드는 연출은 네트워크를 왕복하지 않는다.
커버리지 래칫
frontend/vitest.config.ts에 건 하한이다. 통과 여부가 아니라 왜 이 숫자로 정했고 어디서 흔들리는지가 이 절의 요점이다.
- statements
- 96.33%/ 96% 하한
- branches
- 91.94%/ 91% 하한
- functions
- 96.63%/ 96% 하한
- lines
- 98.40%/ 98% 하한
분모는 `include: ['src/**/*.{ts,tsx}']` 로 설계했다 — v8 커버리지는 테스트가 실제로 import 한 파일만 센다. 그래서 어떤 테스트도 한 번도 불러오지 않은 소스 파일은 분자에서 빠지는 대신 분모에서도 함께 빠진다. 실행되지 않은 코드가 성적을 깎지 않는 대신, 그 파일이 실제로 안전한지는 이 숫자가 보증하지 않는다.
`World.ts` 의 렌더 루프는 `performance.now()` 로 실제 프레임 간격을 재고, 그 값이 accumulator 반복 횟수와 clamp 분기를 좌우한다. 그래서 이 파일 하나의 branches 커버리지가 실행할 때마다 71.71%에서 84.84% 사이를 오간다 — 코드가 바뀌지 않아도 측정값이 흔들린다.
흔들림의 원인을 특정한 방법은 같은 테스트를 두 번 실행해 비교하는 것이었다 — 전역 branches 가 한 번은 90.48%, 다른 한 번은 91.43%로 나왔다. 코드가 아니라 시계가 흔들림의 원인이라는 뜻이다.
먼저 검토한 대안은 파일별 glob 하한을 따로 거는 것이었다. 그러나 그 방식은 전역 분모에서 해당 파일을 빼주지 않는다는 것을 확인하고 기각했다 — 흔들리는 파일 하나 때문에 전역 하한이 계속 실패하는 구조는 그대로 남는다.
선택한 조치는 이 파일을 측정에서만 제외하는 것이다. 검증에서 빼는 것이 아니다 — `World.ts` 를 다루는 테스트 48개는 그대로 돌며 물리 거동을 계속 검증한다. 빠지는 것은 흔들리는 숫자뿐이다.
근본 해결책은 `World.ts` 가 시간을 인자로 주입받고, 테스트가 그 자리에 가짜 시계를 넣는 것이다. 이것은 렌더 루프 자체를 고치는 일이라 3.5주 안에 끝내지 못했고, 별도 작업으로 유예했다.
이 하한은 로컬 게이트다. Jenkins 파이프라인은 커버리지 계측을 끈 `npm test` 만 돌린다 — 하한을 넘기지 못한 커밋이 파이프라인에서 저절로 막히지는 않는다.
E2E 두 벌
18개 스펙 — mock 14개 + real 4개.
두 벌이 필요한 이유는 프로덕션 빌드에서 MSW 가 컴파일 아웃되기 때문이다. 소스 코드를 검증하는 벌은 MSW 로 API 를 흉내 내고, 빌드 산출물을 검증하는 벌은 Playwright 라우트 페이크를 쓴다 — 둘 중 하나만으로는 다른 한쪽이 무엇을 실제로 실행하는지 확인할 수 없다.
두 벌 사이의 어긋남을 막는 것은 `contract.ts` 다. 와이어 계약이 바뀌면 두 하네스가 같은 파일을 참조하고 있으므로 테스트 인프라 쪽이 먼저 깨진다.
실행 대상은 Playwright 프로젝트 4종이다 — mobile-chrome(Pixel 7) · mobile-safari(iPhone 15) · mobile-320 · desktop-chrome.
`narrow-width.spec.ts` 는 스크린샷을 비교하지 않는다. 요소의 실제 위치와 크기를 읽어 넘침을 판정하고, 넘친 요소의 이름을 짚어 보고한다.
도메인 우선 구조 재편
236개 파일을 옮긴 재편이다.
이전 구조는 레이어 우선이었다. 야추 하나를 이해하려면 7개 폴더를 동시에 뒤져야 했다. `components/` 폴더 하나에는 랜딩 카드와 게임판과 로비 패널과 공용 버튼이 평평하게 섞여 있었다.
재편 이후로는 게임 하나를 추가하는 일이 폴더 하나를 만드는 일이 되었다.
도메인 사이의 의존이 한 방향으로만 흐르는지는 `dpdm --circular`(`npm run check:cycles`)로 기계 검사한다.
`check:cycles` 도 Jenkinsfile 에는 없다. 로컬에서 실행하는 검사다.
커밋 91b3363
롤백 판단
본인이 작성한 404파일 규모 리팩터링이 배포 회귀를 일으켰다. 원인을 되짚어 롤백을 판단하고 직접 실행했다 — 리팩터링을 밀어붙이는 대신 되돌리는 쪽을 선택했다.
한계
과장하지 않는다. 아래는 각주가 아니라 위 절들과 같은 무게로 읽는 사실이다.
3.5주짜리 프로젝트다. 장기 운영 경험이 아니다.
백엔드(Spring Boot, .java 267파일)는 팀원 담당이다. 본인의 프론트·백엔드 동시 수정은 6건이고, 전부 프론트와 맞물린 부분이다.
본인의 비-머지 커밋 81건 중 16건에 Co-Authored-By: Claude 트레일러가 있다.
커버리지 96%는 본인이 정한 분모 기준 위에서 나온 숫자다. 위 커버리지 절에서 그 분모 설계를 함께 설명한 이유가 이것이다 — 분모를 빼고 숫자만 읽으면 의미가 없다.
`s15-Yorr` 조직 저장소는 비어 있다. 공개된 근거는 이 페이지가 링크하는 개인 미러뿐이다.

