2026.05.31
코드 정리(상수 중앙화) → 멀티룸(로비) → EC2 배포 → 맵 권위화 → 자연 지형 → 아레나 확장 → 밤하늘 → rubber-banding 진단
진행 방식이 기본편과 달랐다. 기본편 단계에서는 한 단계씩 내가 직접 검증하며 구현했고, 여기는 클로드코드로 에이전트 파이프라인(architect → implementer → reviewer → qa)으로 굵직한 작업 단위를 처리했다.
서버 전반에 흩어진 매직넘버를 단일 소스로 모았다. 핵심은 숨은 결합을 드러내는 것.
예를 들어 틱 레이트 TICK_HZ=30과 물리 timestep=1/30은 반드시 같아야 하는데 따로 떨어져 있었다.
스폰 높이 0.9도 여기저기 퍼져 있었다.
src/config.ts 신설(도메인별 섹션). 8개 모듈이 여기서 import.TICK_INTERVAL_S 파생, PLAYER_SPAWN_Y = 캡슐반높이 + 반지름.ARENA_SIZE 기반 계산으로.상수 중앙화의 진짜 가치는 "한 곳에서 바꾸기"보다 암묵적 결합을 코드로 명시하는 것.
timestep = TICK_INTERVAL_S처럼 파생관계로 적으면 둘이 어긋날 수 없다.
접속 즉시 단일 default 룸 자동 입장이던 걸 로비 단계로 바꿨다. 방 목록 확인 → 생성(최대 3개) 또는 입장 → 게임 진입. 목록은 서버 push로 실시간 갱신.
hello를 신원 등록만으로 축소(자동 입장 제거). C→S list_rooms/create_room/join_room/leave_room, S→C room_list/room_joined/room_left 추가. welcome에서 peers 제거(룸 입장 시점으로 이동).Hub가 로비/방 라우팅, MAX_ROOMS=3, 빈 방 자동 폐기 + 방 번호 재사용, 변동 시 로비 클라이언트에 room_list push.Session 컴포넌트(연결·물리초기화·phase 라우팅)가 기존 NetBridge를 대체. useSyncExternalStore 로비 스토어. Lobby UI(실시간 N/3, 가득참/생성제한 처리).phase(로비 ↔ 게임)가 생기면서 연결 라이프사이클과 게임 라이프사이클을 분리해야 했다.
Session이 그 경계를 맡으면서 컴포넌트 책임이 명확해졌다.
game-server(WS)를 EC2에 컨테이너로 띄우고 wss://domain/fps로 노출.
(EC2에 이미 떠있는 리소스와 독립 배포)
기본편 초반의 결정("EC2를 재활용하되 Nest는 아님")이 여기서 결실. 같은 박스, 다른 프로세스/컨테이너.
game-server/Dockerfile(arm64 멀티스테이지, ESM type:module, EXPOSE 4000).deploy/ec2/compose.yml에 game-server 서비스 추가(127.0.0.1:4000, 독립 ECR/IMAGE_TAG).location /fps WebSocket 프록시(Upgrade 헤더 + 긴 타임아웃).terraform에 game 전용 ECR 레포 + 출력.scripts/deploy-game-ec2.sh 신설, 기존 deploy-ec2.sh를 서비스 한정으로 공존 처리.NEXT_PUBLIC_GAME_WSS_URL=wss://domain/fps.WebSocket은 Nginx에서 Upgrade 헤더 처리 + 타임아웃 연장이 필수. 일반 HTTP 프록시 설정 그대로 두면 핸드셰이크는 되는데 연결이 금방 끊긴다.
배포하면서 지뢰 두 개 밟을 뻔했다.
기존 정책이 태그 prefix v 기준이라 git-sha 태그 이미지가 만료가 안 됐다.
계속 쌓여서 디스크 누적. → tagStatus="any"로 최신 N개(기본 10)만 유지 + untagged 1일 후 만료.
data "aws_ami" most_recent=true + aws_instance.ami 조합은 새 AMI 출시 시 인스턴스를 destroy+recreate 한다.
모르고 apply하면 운영 인스턴스가 날아간다... 이거 놓쳐서 실제로 날아갔다.. 개인 프로젝트였기에 망정이지..
→ lifecycle { ignore_changes = [ami, user_data] }
terraform apply전에 항상terraform plan으로0 to destroy확인.
IaC의 무서운 점은 의도치 않은 destroy. ㅜㅜㅜㅜ
most_recent=true 같은 "항상 최신" 옵션이 멱등을 깨는 함정이 됨.
프론트가 맵 지오메트리를 3겹으로 복붙 보유하던 구조를 제거했다.
세 곳이 각각 맵을 알고 있어서, 맵을 바꾸려면 세 곳을 동시에 수정해야 했다(기본편 내내 "양쪽 동시 수정" 했었다...)
→ 서버가 맵을 단일 권위로 정의해 JSON 전송. 프론트는 그 데이터로 렌더 메시와 예측 Rapier 월드를 모두 구성
src/map.ts(MapDefinition + DEFAULT_MAP + buildWorldColliders), physics.ts가 맵 데이터로 월드 구성(하드코딩 제거), protocol.ts에 MapDefinition/room_joined.map, 입장 시 map 동봉.createClientWorld(map)/prediction.setup(map), arena.tsx가 map으로 렌더, config/arena.ts 삭제, 진입 시 MapLoading 가드.version 검증, terrain 슬롯 전방호환 예약.서버에서 맵 위치 한 곳만 바꾸면 클라이언트는 무수정으로 렌더+충돌이 둘 다 따라옴 = SSOT 전환 성공.
이 리팩토링이 이후 모든 것을 가능하게 했다. Part 6(자연 지형)도 Part 8(아레나 3배 확장)도 서버 한 곳만 수정하면 됐다. 권위 데이터를 일찍 분리한 효과.
평지를 부드러운 굴곡 지형으로. 시각만이 아니라 충돌도 지형을 따른다.
map.ts: 저주파 사인 합 + 가장자리 falloff로 지형 생성 → MapDefinition.terrain(정점 그리드). buildWorldColliders가 Rapier heightfield 콜라이더 생성. terrainHeightAt(양선형 샘플러) + spawnYAt로 스폰/리스폰/장애물을 지형 표면에 안착.BufferGeometry 메시 렌더(콜라이더와 동일 인덱스→월드 매핑).Rapier heightfield API를 추측하지 않고 실측 probe로 확정했다:
배열 길이 (rows+1)*(cols+1), 인덱스 ix*(cols+1)+iz, 원점 중심.
그 다음 서버 충돌 raycast ↔ 샘플러를 비교해 diff 0.00 확인 → 시각 메시와 물리가 정렬됨을 보장.
문서가 모호한 API는 추측 말고 probe heightfield의 정점 배열 레이아웃은 라이브러리마다 다르고, 한 칸만 어긋나도 시각과 충돌이 따로 논다. 작은 probe 스크립트로 수 시간을 아꼈다.
테스트용 좁은 아레나를 실전 크기로. Part 5의 데이터 기반 구조 덕에 서버 값만 바꾸면 렌더·충돌·지형·스폰이 자동 반영(프론트 무변경).
ARENA_SIZE 50 → 87(면적 ≈3.03배).Part 5의 투자 회수.
클라이언트 변경 0줄로 맵이 3배가 됐다.
권위 데이터 분리가 없었다면 이 한 줄(ARENA_SIZE) 변경에 세 곳을 고쳐야 했다.
손전등 컨셉의 어둠에 분위기를 더했다. 이뿌게 ㅎㅎ
config/sky.ts 신설.Stars(fog 무시) + 달(meshBasicMaterial fog=false toneMapped=false → bloom 빛무리).FOG_FAR 40 → 65(확장 맵 가시거리).게임플레이에 영향 없는 장식(하늘·별)은 프론트에서만 처리해 네트워크 비용 0.
배포 환경에서 점프·이동 시 화면이 "살짝 되돌아갔다 복귀"를 빠르게 반복(rubber-banding). 지형 도입 후 증상 발생. 로컬(저지연)에선 무증상 → 지연이 트리거 였다!
시각 회귀로 디버깅하면 답이 없다... 서버 truth 시뮬(연속)과 클라이언트식 시뮬(지연 + reconcile 재시뮬)을 나란히 돌려 발산 크기를 숫자로 측정하는 harness를 만들었다.
reconcile이 위치만 복원하고 vy/grounded(물리 동역학 상태)는 복원 안 함.
그래서 재시뮬 시작 상태가 매 스냅샷 달라져 궤적이 발산.
배포(실지연)에서만 남는 미세 점프. SSM 로그로 서버 틱 드리프트 0 확인(EC2 부하 아님, 처음엔 EC2 부하인줄로만 알았다...) 코드는 지연 무관 결정적임을 확인하고, 점프 발동 시 grounded 경계 판정이 클라이언트/서버 간 위상차 날 때만 발생함을 격리.
yourVy/yourGrounded 추가 → 클라이언트 reconcile에서 복원(구버전 서버 하위호환 폴백 포함).COYOTE_TIME_S=0.08): 지면 떠난 직후 짧게 점프 허용 → grounded 경계 위상차 감소. 클라이언트·서버 동일 로직 미러(예측 일치 필수).errorOffset에 담아 렌더에서 80ms 지수 감쇠로 흡수. 2m 초과(리스폰)는 즉시 스냅./→3000, /fps→4000 WS). 두 서비스 독립 ECR·독립 배포.