멀티플레이어 미니 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→Slist_rooms/create_room/join_room/leave_room, S→Croom_list/room_joined/room_left추가.welcome에서 peers 제거(룸 입장 시점으로 이동). - 서버:
Hub가 로비/방 라우팅,MAX_ROOMS=3, 빈 방 자동 폐기 + 방 번호 재사용, 변동 시 로비 클라이언트에room_listpush. - 프론트:
Session컴포넌트(연결·물리초기화·phase 라우팅)가 기존NetBridge를 대체.useSyncExternalStore로비 스토어.LobbyUI(실시간 N/3, 가득참/생성제한 처리).
교훈
phase(로비 ↔ 게임)가 생기면서 연결 라이프사이클과 게임 라이프사이클을 분리해야 했다.Session이 그 경계를 맡으면서 컴포넌트 책임이 명확해졌다.
Part 3 - game-server를 EC2에 통합 배포
무엇 / 왜
game-server(WS)를 EC2에 컨테이너로 띄우고 wss://domain/fps로 노출.
(EC2에 이미 떠있는 리소스와 독립 배포)
기본편 초반의 결정("EC2를 재활용하되 Nest는 아님")이 여기서 결실.
같은 박스, 다른 프로세스/컨테이너.
핵심 변경
game-server/Dockerfile(arm64 멀티스테이지, ESMtype:module,EXPOSE 4000).deploy/ec2/compose.yml에game-server서비스 추가(127.0.0.1:4000, 독립 ECR/IMAGE_TAG).- Nginx
location /fpsWebSocket 프록시(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겹으로 복붙 보유하던 구조를 제거했다.
- 렌더용 메시(Arena.tsx)
- 예측용 Rapier 충돌
- 물리 상수
세 곳이 각각 맵을 알고 있어서, 맵을 바꾸려면 세 곳을 동시에 수정해야 했다(기본편 내내 "양쪽 동시 수정" 했었다...)
→ 서버가 맵을 단일 권위로 정의해 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가 Rapierheightfield콜라이더 생성.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_SIZE50 → 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_FAR40 → 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종 세트)
- vy/grounded 동기화: 서버 스냅샷에 본인 전용
yourVy/yourGrounded추가 → 클라이언트 reconcile에서 복원(구버전 서버 하위호환 폴백 포함). - coyote-time (
COYOTE_TIME_S=0.08): 지면 떠난 직후 짧게 점프 허용 → grounded 경계 위상차 감소. 클라이언트·서버 동일 로직 미러(예측 일치 필수). - 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·독립 배포.
'TIL' 카테고리의 다른 글
| [260531 TIL] 멀티가능 미니 FPS 만들기 1편(기본구현) (0) | 2026.05.31 |
|---|---|
| [260528 TIL] Next.js Vercel → AWS 마이그레이션 (0) | 2026.05.28 |
| [260527 TIL] 비개발자에게 Claude로 RDS 조회할 수 있도록(안전히...) (0) | 2026.05.27 |
| [260508 TIL] Personas - 임베딩 검색 구현 - overview (1) | 2026.05.09 |
| [260508 TIL] Personas - 임베딩 검색 구현 - observability (0) | 2026.05.09 |