[회고] FPS Aim Test — 완성 이후에 다시 만든 프로젝트

Ethan·2026년 1월 26일

FPS Aim Test는 처음부터 “빠르고 정밀한 렌더링을 다뤄보는 프로젝트”를 목표로 시작했다.
단순한 웹 페이지가 아니라, 화면이 끊임없이 변하고 사용자의 입력에 즉각 반응하는 구조를 직접 만들어보고 싶었다.

그래서 흔히 볼 수 있는 에임 테스트 게임이 아니라,
마우스로 시점을 이동하는 1인칭 FPS 방식을 웹에서 구현해보는 것을 목표로 잡았다.


1. 1차 완성과 공백, 그리고 다시 시작

이 프로젝트는 사실 한 번 완성했었다.
타겟 생성, 점수 계산, 랭킹보드 백엔드까지 목표로 했던 기능은 모두 구현했고,
배포까지 마친 뒤 이력서 작성과 입사 지원도 했었다.

만족스런 성과 없이 몇 달이 지나 새 프로젝트 시작을 고민하며 Aim Test를 다시 살펴봤다.
그런데 이게 웬 걸. 구조적으로 상당히 마음에 들지 않았다.

  • 로직이 한 컴포넌트에 과도하게 몰려 있었고
  • “아직은 이해하지만, 나중엔 유지하기 힘들겠다”는 느낌이 강했다

새 프로젝트를 시작할까도 고민했지만,
이미 구현한 게임의 형태는 분명했고
이걸 그냥 두고 넘어가는 게 더 아쉬웠다.

그래서 다시 붙잡았다.
‘하나를 하더라도 제대로 해보자’고 마음먹었다.
이번에는 기능 추가가 아니라 구조와 완성도를 기준으로.


2. 첫 관문부터 난제 — 렌더링과 시간 제어를 다시 생각하다

리팩토링의 출발점은 타겟 생성 로직이었다.

기존 구조에서는 화면 렌더링은 requestAnimationFrame(이하 rAF)에 맡기고,
타겟 스폰과 같은 시간 제어는 setInterval 기반 타이머에 의존하고 있었다.

겉보기에는 문제없이 동작했지만,
렌더링과 게임 로직이 서로 다른 시간축을 기준으로 흐르고 있는 상태였다.

이 지점에서 처음으로 명확하게 문제를 인식했다.

화면을 그리는 시간축과
게임 로직이 흐르는 시간축 기준이 다르다는 점

결국 타겟 스폰 로직을 포함한 모든 시간 제어를 하나의 rAF 루프 기준으로 재설계했고,
프레임마다 누적 시간을 직접 계산하는 구조로 변경했다.

이 작업은 단순한 최적화라기보다,
의미 없이 나뉘어진 두 시간축을 하나로 정리하는 첫 구조적 리팩토링이었다.


3. God Component를 허브로 재정의하다

렌더 루프 통합을 마치고 나서 뿌듯하긴 했지만, 여전히 걸리는 점이 있었다.
GameWorld 컴포넌트가 너무 많은 책임을 가지고 있다는 점이었다.

  • 캔버스 렌더링
  • 입력 처리
  • 게임 상태
  • 타겟 관리
  • 사이드 이펙트

이 밖에도 주요 stateref 등 대부분이 한 파일에 모여 있었고,
때문에 수정 하나가 다른 영역에 영향을 주기 쉬운 구조였다.
라인 수는 무려 600라인에 육박했다.

1차 작업 당시에는 "중요한 컴포넌트니까 이 정도는 괜찮지 않나" 하고 넘어갔었다.

이건 아니다 싶어 정신을 차리고 방향을 정했다.

GameWorld
무언가를 “처리하는 곳”이 아니라,
각 역할을 가진 훅들을 연결하는 허브
로만 남기기로 했다.

렌더링 루프, 입력 제어, 상태 동기화, 게임 런타임 로직 등을
모두 각각의 커스텀 훅으로 분리했고,
컴포넌트는 결과만 받아 UI와 Canvas에 연결하는 역할만 수행하게 만들었다.

그 결과 라인 수보다 더 큰 변화는,
코드를 읽을 때 구조가 한눈에 들어오기 시작했다는 점이었다.

완성된 구조를 보며, 앞으로의 작업에서도 이 방식을 잊지 않고 기준으로 삼고 싶다는 생각이 들었다.


4. React와 Canvas의 경계 정리

이 프로젝트에서 계속 마주친 문제는
React의 선언적 상태Canvas의 명령형 루프를 어떻게 공존시키느냐였다.

모든 걸 state로 관리하면 성능이 깨지고,
모든 걸 ref로 관리하면 흐름이 보이지 않는다.

결국 선택한 방식은 단순했다.

  • UI에 필요한 값은 React state
  • 프레임 단위로 읽히는 값은 ref
  • 두 세계를 연결하는 동기화 지점을 명확하게 한 곳에 둔다

이 역할을 useGameRuntime 훅으로 모으면서,
렌더 루프는 “그저 그리기만 하는 구조”로 만들 수 있었다.


5. 보이는 완성도까지 책임지기

여러 책임을 커스텀 훅으로 분리하고,
안정성과 컨벤션까지 정리하고 나니 이제 정말 끝난 줄 알았다.

적어도 내부 구조만 놓고 보면, 만족할 만한 탄탄한 아키텍쳐를 가진 웹 게임이었다.
마지막 점검차 게임을 몇 번 플레이해보니, 이번에는 눈에 보이는 것들이 모두 다 아쉽게 느껴졌다.

  • 고성능 자동차 엔진을 마련했지만
  • 차 프레임의 페인트는 군데군데 벗겨지고 녹슨 것만 같았다.

내부 구조만 강화했지 UI 디자인은 1차 작업 이후 전혀 나아지지 않았던 것이다.
속만 아니라 겉보기에도 완성도 높은 사이트를 만들어야 스스로도 잘 만들었다고 생각할 수 있었다.

메인 페이지를 시작으로 UI를 전반적으로 리디자인했고,
게임의 분위기와 어울리도록 톤을 통일했다.
디자인을 찾아보는 과정에서, Glassmorphism이라는 그래픽 스타일이 맘에 들어 채택했다.

이 과정에서 기능은 거의 바뀌지 않았지만,
프로젝트의 인상은 크게 달라졌다고 느꼈다.

작업을 마치고, 다시 한 번 전체 코드를 하나씩 확인한 뒤,
나는 이제는 정말 할 건 다 했구나 하고 마지막 커밋을 푸쉬했다.


6. 돌아보며

가볍게 기획했던 나의 프로젝트는
처음 생각했던 것보단 훨씬 완성도 있게 마무리되었다.

오랜 기간이 걸리긴 했지만, 이 프로젝트는 결과적으로
기능 구현 → 구조 재설계 → 완성도 보완의 과정을 모두 거쳤다.

처음부터 더 잘 설계했으면 고생을 덜 했겠으나
사서 만든 고생이 복잡한 리팩토링 경험과 좋은 아키텍쳐 설계 방법을 엿보게 했다.

웹에서 FPS 에임 테스트 게임을 만든다는 선택이 정답이었는지는 모르겠다.
다만 이 프로젝트를 통해 렌더링, 상태 관리, 구조 설계를 끝까지 고민해볼 수 있었다는 점만큼은 분명하다.

허술하게 시작한 것을 끝까지 고쳐 정돈했고,
지금 기준으로는 충분히 납득할 수 있는 형태로 마무리했다.

이거면 이 프로젝트는 역할을 다했다고 생각한다.

profile
"Actions speak louder than words"

0개의 댓글