2026 . 오은

~~~~~~><>

멀티가능 미니 FPS 만들기 2편

2026.05.31

멀티플레이어 미니 FPS 만들기 2편(운영편)

전체 흐름

코드 정리(상수 중앙화) → 멀티룸(로비) → EC2 배포 → 맵 권위화 → 자연 지형 → 아레나 확장 → 밤하늘 → rubber-banding 진단

진행 방식이 기본편과 달랐다. 기본편 단계에서는 한 단계씩 내가 직접 검증하며 구현했고, 여기는 클로드코드로 에이전트 파이프라인(architect → implementer → reviewer → qa)으로 굵직한 작업 단위를 처리했다.


Part 1 - 상수/매직넘버 중앙화

무엇 / 왜

서버 전반에 흩어진 매직넘버를 단일 소스로 모았다. 핵심은 숨은 결합을 드러내는 것. 예를 들어 틱 레이트 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처럼 파생관계로 적으면 둘이 어긋날 수 없다.


Part 2 - 로비: 방 목록/생성/입장 (멀티룸)

무엇 / 왜

접속 즉시 단일 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이 그 경계를 맡으면서 컴포넌트 책임이 명확해졌다.


Part 3 - game-server를 EC2에 통합 배포

무엇 / 왜

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).
  • Nginx 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 프록시 설정 그대로 두면 핸드셰이크는 되는데 연결이 금방 끊긴다.


Part 4 - 인프라 안전장치 (terraform)

배포하면서 지뢰 두 개 밟을 뻔했다.

ECR 라이프사이클

기존 정책이 태그 prefix v 기준이라 git-sha 태그 이미지가 만료가 안 됐다. 계속 쌓여서 디스크 누적. → tagStatus="any"로 최신 N개(기본 10)만 유지 + untagged 1일 후 만료.

EC2 강제 교체 방지

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 같은 "항상 최신" 옵션이 멱등을 깨는 함정이 됨.


Part 5 - 맵을 서버 권위 데이터로 (가장 영향 큰 리팩토링)

무엇 / 왜

프론트가 맵 지오메트리를 3겹으로 복붙 보유하던 구조를 제거했다.

  1. 렌더용 메시(Arena.tsx)
  2. 예측용 Rapier 충돌
  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배 확장)도 서버 한 곳만 수정하면 됐다. 권위 데이터를 일찍 분리한 효과.


Part 6 - 자연 지형 (heightfield)

무엇 / 왜

평지를 부드러운 굴곡 지형으로. 시각만이 아니라 충돌도 지형을 따른다.

핵심 변경

  • 서버 map.ts: 저주파 사인 합 + 가장자리 falloff로 지형 생성 → MapDefinition.terrain(정점 그리드). buildWorldColliders가 Rapier heightfield 콜라이더 생성. terrainHeightAt(양선형 샘플러) + spawnYAt로 스폰/리스폰/장애물을 지형 표면에 안착.
  • 프론트: 예측 월드에 동일 heightfield, BufferGeometry 메시 렌더(콜라이더와 동일 인덱스→월드 매핑).

검증 - probe로 API 실측

Rapier heightfield API를 추측하지 않고 실측 probe로 확정했다: 배열 길이 (rows+1)*(cols+1), 인덱스 ix*(cols+1)+iz, 원점 중심. 그 다음 서버 충돌 raycast ↔ 샘플러를 비교해 diff 0.00 확인 → 시각 메시와 물리가 정렬됨을 보장.

교훈

문서가 모호한 API는 추측 말고 probe heightfield의 정점 배열 레이아웃은 라이브러리마다 다르고, 한 칸만 어긋나도 시각과 충돌이 따로 논다. 작은 probe 스크립트로 수 시간을 아꼈다.


Part 7 - 아레나 3배 확장 + 장애물 군집

무엇 / 왜

테스트용 좁은 아레나를 실전 크기로. Part 5의 데이터 기반 구조 덕에 서버 값만 바꾸면 렌더·충돌·지형·스폰이 자동 반영(프론트 무변경).

핵심 변경

  • ARENA_SIZE 50 → 87(면적 ≈3.03배).
  • 스폰 링 12–20 → 20–36.
  • 장애물: 3개 라인 → 4개 군집 11개 박스(지형 표면 안착).
  • 지형 해상도를 맵 크기에 비례 산출.

교훈

Part 5의 투자 회수. 클라이언트 변경 0줄로 맵이 3배가 됐다. 권위 데이터 분리가 없었다면 이 한 줄(ARENA_SIZE) 변경에 세 곳을 고쳐야 했다.


Part 8 - 밤하늘 (달·별)

무엇 / 왜

손전등 컨셉의 어둠에 분위기를 더했다. 이뿌게 ㅎㅎ

핵심 변경 (프론트만)

  • config/sky.ts 신설.
  • drei Stars(fog 무시) + 달(meshBasicMaterial fog=false toneMapped=false → bloom 빛무리).
  • 달빛 방향광(ambient 0.07 + 차가운 0.26).
  • 배경/안개 밤톤, FOG_FAR 40 → 65(확장 맵 가시거리).

교훈

게임플레이에 영향 없는 장식(하늘·별)은 프론트에서만 처리해 네트워크 비용 0.


Part 9 - Rubber-banding 진단 (가장 배운 게 많은 디버깅)

증상

배포 환경에서 점프·이동 시 화면이 "살짝 되돌아갔다 복귀"를 빠르게 반복(rubber-banding). 지형 도입 후 증상 발생. 로컬(저지연)에선 무증상 → 지연이 트리거 였다!

진단 방법론 — differential harness

시각 회귀로 디버깅하면 답이 없다... 서버 truth 시뮬(연속)과 클라이언트식 시뮬(지연 + reconcile 재시뮬)을 나란히 돌려 발산 크기를 숫자로 측정하는 harness를 만들었다.

근본 원인

reconcile이 위치만 복원하고 vy/grounded(물리 동역학 상태)는 복원 안 함. 그래서 재시뮬 시작 상태가 매 스냅샷 달라져 궤적이 발산.

  • 평지에선 vy가 −1로 수렴해 무해.
  • 지형 경사/점프에서 발산.
  • vy까지 복원하니 harness 발산이 2.8m → 0.000으로 떨어져 원인 확정.
  • (heightfield 비결정성 가설은 이 과정에서 기각.)

잔여 점프 증상

배포(실지연)에서만 남는 미세 점프. SSM 로그로 서버 틱 드리프트 0 확인(EC2 부하 아님, 처음엔 EC2 부하인줄로만 알았다...) 코드는 지연 무관 결정적임을 확인하고, 점프 발동 시 grounded 경계 판정이 클라이언트/서버 간 위상차 날 때만 발생함을 격리.

수정 (3종 세트)

  1. vy/grounded 동기화: 서버 스냅샷에 본인 전용 yourVy/yourGrounded 추가 → 클라이언트 reconcile에서 복원(구버전 서버 하위호환 폴백 포함).
  2. coyote-time (COYOTE_TIME_S=0.08): 지면 떠난 직후 짧게 점프 허용 → grounded 경계 위상차 감소. 클라이언트·서버 동일 로직 미러(예측 일치 필수).
  3. reconciliation smoothing: 보정을 즉시 스냅 대신 errorOffset에 담아 렌더에서 80ms 지수 감쇠로 흡수. 2m 초과(리스폰)는 즉시 스냅.

교훈

  • 결정론 시뮬에선 위치 외의 동역학 상태(vy·grounded)도 reconcile 대상이다. 기본편에서 "vy/grounded는 자연 수렴하니 둔다"고 했던 게, 평지에선 맞았지만 지형에선 틀렸다.
  • 시각이 아니라 숫자로 보는 디버깅(differential harness). 가설 검증과 기각이 명확히 분리될 때 좋은 진단이 된다.
  • 로컬 무증상 / 배포 유증상 → 지연이 트리거인 결정론 버그를 의심.

핵심 아키텍처 개념 (재정리)

  • 서버 권위 + 클라이언트 예측: 서버 30Hz 시뮬(진실), 클라이언트 입력 즉시 예측 + 서버 스냅샷(15Hz) reconcile. 예측·재시뮬 일치를 위해 시뮬 로직(이동·중력·점프·coyote)과 충돌 지오메트리(Rapier 동일 버전·동일 맵)가 결정론적으로 동일해야.
  • 단일 권위 데이터(MapDefinition): 맵을 서버가 정의·전송 → 렌더·충돌·예측이 한 소스에서 파생. 복붙 제거가 이후 모든 월드 변경을 서버 1곳 수정으로 가능하게 한 핵심.
  • 배포: 단일 EC2 + docker compose(backend/game-server) + 호스트 Nginx 경로 분기(/→3000, /fps→4000 WS). 두 서비스 독립 ECR·독립 배포.