Rocket Workshop — 조립과 비행을 한 Canvas 로 왕복하기
조립 UI 와 실제 비행 시뮬레이션을 한 Canvas 안에서 왕복 전환하는 구조. AudioContext 생명주기와 ResizeObserver redraw 함정까지.
시작 — Canvas 두 개 쓰기 vs 한 개로 왕복하기
Rocket Workshop 은 두 모드로 구성됩니다.
- Assembly (조립) — 엔진·연료탱크·페이로드·부스터를 고르고 예산·스펙을 확인
- Flight (비행) — 실제 발사·상승·궤도 진입 시뮬레이션
자연스러운 설계 선택: Canvas 를 두 개 따로 두고 각 모드 전환 시 display 속성만 바꾸는 것. 하지만 이렇게 하면 DOM 에 항상 큰 Canvas 2개가 떠 있고, 배경 이미지·스케일링 로직도 중복됩니다.
그래서 단일 Canvas 로 왕복 전환 하는 설계를 택했습니다. 이 포스트는 그 과정에서 만난 몇 가지 함정을 남깁니다.
모드 전환 구조
React state 하나로 전체 모드를 관리:
const [mode, setMode] = useState<'assembly' | 'flight'>('assembly');
const phaseRef = useRef<WorkshopPhase>('assembly');
- Assembly 모드: Canvas 에 종이 패널 UI + 로켓 프리뷰 + HUD 프리뷰 를 그림. RAF 루프 없이 정적 렌더 + ResizeObserver 로 리사이즈 대응.
- Flight 모드: 전체화면 Canvas + RAF 루프 (60fps 캡) + phase 기반 시뮬레이션.
즉 같은 Canvas 를 "정적 UI 캔버스" 와 "게임 캔버스" 로 번갈아 사용합니다.
함정 1 — AudioContext 생명주기
처음 구현 시 useEffect([phase]) 의 cleanup 에서 AudioContext 를 close() 했습니다.
// ❌ 문제 코드
useEffect(() => {
const ctx = new AudioContext();
// ... engine rumble start
return () => ctx.close(); // cleanup 시 닫기
}, [phase]);
증상: 발사 도중 phase 가 바뀌면 (countdown → launch → ascent) 엔진 사운드가 뚝뚝 끊겼습니다.
원인: useEffect cleanup 은 매 phase 전환마다 실행됩니다. 언마운트만이 아닙니다. phase 가 바뀔 때마다 AudioContext 가 파괴되고 재생성되니, 엔진음이 이어지지 않았던 것.
해결: AudioContext 생명주기를 명시적 경로 에서만 다룸.
// ✅ 수정
// phase cleanup 에서는 닫지 않음
useEffect(() => {
// phase 별 효과 처리만, close() 호출 안 함
}, [phase]);
// 언마운트 전용 effect
useEffect(() => {
return () => stopEngineRumble(); // 컴포넌트 언마운트 시에만 close
}, []);
// Assembly 복귀 시에도 명시적 close
function returnToAssembly() {
stopEngineRumble();
setMode('assembly');
}
원칙: 전역 리소스의 close 는 명시적 "해제 시점" 에서만. useEffect deps 가 바뀔 때 자동 호출되게 두면 안 됨.
함정 2 — ResizeObserver 와 정적 렌더
Assembly 는 RAF 루프가 없습니다. 마운트 시점에 한 번 drawPreview() 로 Canvas 에 그림을 그리고 끝. 그런데 창 크기 변경 시 화면이 갱신되지 않았습니다.
ResizeObserver 도입 필요
useEffect(() => {
if (mode !== 'assembly') return;
const canvas = canvasRef.current;
if (!canvas) return;
// 초기 draw
drawPreview(canvas);
// 창 크기 변경 대응
const ro = new ResizeObserver(() => {
drawPreview(canvas);
});
ro.observe(canvas.parentElement!);
return () => ro.disconnect();
}, [mode]);
canvas.width 재설정 = 클리어
여기서 한 가지 더 주의. canvas.width = X 로 크기를 재설정하면 Canvas 내용이 전부 클리어 됩니다. 정적 렌더에서 이걸 모르면 "창 크기 바꿨더니 화면이 비었다" 버그가 생깁니다.
RAF 보호
ResizeObserver 콜백이 매우 빠르게 연속 호출될 수 있어, 매 콜백마다 drawPreview 호출은 낭비입니다. RAF 로 감싸기:
let rafId: number;
const ro = new ResizeObserver(() => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => drawPreview(canvas));
});
함정 3 — 모드 전환 타이밍
Assembly → Flight 로 전환할 때, Canvas 의 내용을 즉시 클리어 + 물리 상태 초기화 + RAF 시작 이 한 프레임에 일어나야 합니다.
function startFlight() {
// 1. 물리 상태 초기화
stateRef.current = createInitialState();
// 2. 모드 전환
setMode('flight');
// ↑ 이후 useEffect 가 RAF 시작
}
// flight 모드용 useEffect
useEffect(() => {
if (mode !== 'flight') return;
let rafId: number;
function loop() {
tick(stateRef.current);
drawFlight(canvasRef.current, stateRef.current);
rafId = requestAnimationFrame(loop);
}
loop();
return () => cancelAnimationFrame(rafId);
}, [mode]);
핵심은 state 초기화를 setMode 보다 먼저 하는 것. 그래야 모드 전환 이후 첫 RAF 프레임부터 깨끗한 상태로 시작합니다.
함정 4 — 비행 리셋 시 ref 초기화 누락
Flight 모드에서 "재도전" 버튼을 눌러 새 비행을 시작할 때, 신규 ref 를 도입할 때마다 3곳을 동시에 리셋 하는 것을 잊지 말아야 합니다.
returnToAssembly()— Assembly 로 돌아갈 때resetFlight()— 재도전 시createInitialState()— 초기화 팩토리
예를 들어 peakAltM 같은 "비행 중 최대값" ref 를 추가했을 때, 그중 하나라도 리셋을 빠뜨리면 두 번째 비행에서 이전 기록이 잔류 합니다.
매 신규 상태 필드 추가할 때 체크리스트를 확인하는 버릇이 중요했습니다.
HUD 접기/펴기 애니메이션
비행 중에는 작은 HUD 를 보고 싶고, 결과 화면에선 크게 보고 싶습니다. hudFold 값 (0=펼침, 1=접힘) 을 lerp 로 부드럽게 보간:
// 매 프레임
state.hudFold = lerp(state.hudFold, state.hudFoldTarget, 0.12);
Phase 별 기본 fold 상태
- assembly: 접힘 (1)
- standby / countdown: 펼침 (0, 자동 슬라이드)
- launch / ascent / coast: 접힘
- orbit / result / failed: 현재 상태 유지 — 사용자가 수동 토글 가능
phase 가 바뀌면 기본값으로 target 이 바뀌지만, 결과 phase 에서는 사용자 의사 존중.
고정 영역 vs 접히는 영역
HUD 를 "고정 영역 (레이더 + 비행 데이터 + 토글 바)" + "접히는 영역 (로켓 스펙 + 버튼)" 으로 분리. 접는 쪽만 높이가 줄어 애니메이션 자연스러움.
회고
단일 Canvas 로 두 모드를 왕복하는 설계는 DOM 복잡도는 줄였지만, 생명주기와 상태 관리 난이도는 올라갔습니다. 특히 AudioContext 와 ResizeObserver 는 각각 "언제 닫고 언제 열어야 하는가" 를 명확히 정의해야 했습니다.
만약 다시 설계한다면, 두 가지 패턴 중 어느 것이 더 나은지는 프로젝트 규모에 따라 다를 것 같습니다.
- 단일 Canvas 왕복: DOM 경량, 생명주기 복잡 (이 프로젝트 선택)
- 별도 Canvas 2개: DOM 무거움, 생명주기 단순
이번엔 캔버스 공간이 서로 비슷했기에 단일로 가는 게 합리적이었습니다. 그 덕분에 "전체 화면을 한 번에 전환" 같은 과감한 연출도 가능해졌죠.
복잡한 모드 전환 게임을 만드시는 분이 계시다면, 명시적 생명주기 관리 + ref 리셋 체크리스트 두 가지를 초반에 도입하시길 권합니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...