들어가며
Moyo의 핵심 화면은 맵이다. 접속하면 가장 먼저 Phaser.js로 구동되는 2D 타일맵이 뜨고, 여기서 아바타를 움직이며 다른 사용자와 만난다.
그런데 이 맵이 열리기까지 10초 이상 빈 화면이 유지됐다. 콘텐츠나 피드백이 없어 사용자는 서비스가 느린 건지 연결이 끊긴 건지 알 수 없는 채로 기다려야 했다. 개발 과정에서 이 구간이 실제 이탈 지점이 될 수 있다고 생각했다.
네 가지 개선을 거친 결과, FCP 12.3s → 1.1s, LCP 24.3s → 1.2s, Performance 25 → 68점이 됐다. 네트워크 병목을 줄이자 실행 병목이 드러났고, 그것을 다시 분해했다. 이 글은 그 과정을 기록한다.
측정 환경 — 모든 수치는 개발 환경에서 Lighthouse Desktop 모드, 캐시를 제거(Clear site data)한 상태로 측정했다. 최종 성과(Performance 68점)만이 Production 빌드 기준이다. Lighthouse 특성상 측정마다 편차가 있고, 노트북 사양·네트워크 환경에 따라 실제 수치는 다를 수 있다.
병목이 어디 있는가
Lighthouse로 측정한 초기 성능 점수는 25점이었다.
| 지표 | 수치 |
|---|---|
| FCP | 12.3s |
| LCP | 24.3s |
| TBT | 1,440ms |
| Speed Index | 14.9s |
FCP(First Contentful Paint) 는 첫 텍스트나 이미지가 화면에 나타나기까지 걸리는 시간이다.
LCP(Largest Contentful Paint) 는 가장 큰 콘텐츠 요소가 표시되는 시간으로, 체감 로딩의 핵심 지표다.
TBT(Total Blocking Time) 는 메인 스레드가 50ms 이상 블로킹된 시간의 합으로, 높을수록 인터랙션이 지연된다.
rollup-plugin-visualizer와 Chrome DevTools의 Network·Performance 탭으로 원인을 분석했다.
병목은 두 레이어에 분산되어 있었다.
-
네트워크 —
world.tmj가 preload·create 단계에서 2회 요청됐고, PNG 타일셋(최대 683KB)이 최적화 없이 전달됐다. -
JS Execution — Phaser (~1.2MB), livekit-krisp (~5.9MB), easystarjs가 초기 번들에 포함되어 페이지 진입 시 evaluation 비용을 모두 부담했다. 타입 전용 모듈도 런타임 번들에 혼재했다.
JS Execution — 브라우저는 JS 파일을 다운로드한 후 파싱하고 실행한다. 이 실행 단계에서 모듈을 초기화하고 객체를 생성한다. 실행은 메인 스레드에서 동기로 수행되기 때문에, 시간이 길어질수록 TBT가 증가하고 사용자 입력이 차단된다. 다운로드가 빠르게 끝나도 실행이 오래 걸리면 사용자는 여전히 기다려야 한다.
네트워크 병목 제거
두 레이어 모두 손댈 부분이었지만 네트워크를 먼저 공략했다. 요청 수와 파일 크기는 수치로 바로 확인할 수 있었고, 개선 범위도 명확했다.
TMJ 중복 fetch 제거와 타일셋 병렬 프리로드
문제
Phaser 씬은 두 단계로 나뉜다. preload()는 에셋을 네트워크에서 가져오는 단계, create()는 가져온 에셋으로 씬을 구성하는 단계다.
기존 코드는 loadTilesets 함수가 create 단계에서 world.tmj를 다시 fetch했다. preload에서 이미 받아온 파일을 create에서 한 번 더 요청하는 구조였다. 타일셋 이미지 로딩도 create에서 수행됐다. preload와 병렬로 처리할 수 있는 작업이 직렬로 실행되고 있었다.
개선 전 — preload·create 직렬 + TMJ 중복 fetch
해결 방법
TMJ 내부 상대 경로를 직접 파싱해 타일셋 경로를 계산하는 방법도 있었지만, 경로 해석 로직을 직접 구현하면 유지보수 부담이 크다. Phaser가 제공하는 로딩 파이프라인을 활용하는 게 구조적으로 더 자연스럽다.
scene.cache.tilemap.get(mapKey).data— preload에서 파싱된 TMJ를 캐시에서 재사용filecomplete-tilemapJSON-{key}이벤트 — TMJ 파싱 완료 시점을 포착해 타일셋 이미지를 preload 큐에 등록
개선 후 — 캐시 재사용 + 타일셋 병렬 프리로드
// 변경 전: create()에서 TMJ 재fetch
async create() {
await loadTilesets(this, this.mapObj.tmjUrl);
}
// 변경 후: preload()에서 캐시 재사용 + 이벤트로 타일셋 병렬 등록
preload() {
this.load.tilemapTiledJSON(this.mapObj.name, this.mapObj.tmjUrl);
this.load.once(`filecomplete-tilemapJSON-${this.mapObj.name}`, () => {
const cached = this.cache.tilemap.get(this.mapObj.name);
if (!cached?.data?.tilesets) return;
for (const ts of cached.data.tilesets) {
const base = new URL(this.mapObj.tmjUrl, window.location.origin);
this.load.image(ts.name, new URL(ts.image, base).pathname);
}
});
}once()를 사용한 이유는 타일 로드 이벤트가 최초 한 번만 처리되면 충분하기 때문이다.
초기 구현에서는 on()으로 리스너를 등록했는데, 이 경우 씬이 재시작될 때마다 동일한 이벤트 리스너가 계속 추가로 등록된다. 그 결과 이벤트 핸들러가 중복 실행될 수 있고, 장기적으로는 메모리 누수로 이어질 가능성이 있다.
코드 리뷰 과정에서 이러한 구조적 문제를 지적받았고, 이벤트를 단발성으로 소비하도록 once()로 변경해 리스너 누적을 방지했다.
예상과 달랐던 결과: FCP, LCP 증가
| 단계 | FCP | LCP | TBT | Score |
|---|---|---|---|---|
| 초기 | 12.3s | 24.3s | 1,440ms | 25 |
| TMJ 캐시 + 타일셋 병렬 프리로드 | 24.2s ↑ | 46.1s ↑ | 890ms | 27 |
이 변경 직후 FCP와 LCP가 일시적으로 증가했다. 타일셋 이미지를 preload 단계로 이동하면서 초기 렌더링에 반드시 완료되어야 하는 네트워크 요청이 늘어났기 때문이다. 그 결과 critical path가 확장되었고, 첫 화면 표시 이전의 대기 시간이 길어졌다.
병렬화 자체는 성공했다. TBT는 감소했고 Score도 소폭 개선되었다. 그러나 preload 단계에 무거운 리소스가 포함되면서 네트워크 지연이 FCP와 LCP에 직접적으로 반영되는 구조가 되었다.
결국 문제는 “병렬화 여부”가 아니라 critical path 위에 올라간 리소스의 크기였다. 이를 줄이지 않는 한 초기 지표 개선은 어렵다고 판단했다.
PNG → WebP 전환
preload에 포함된 이미지 리소스의 용량을 줄이기 위해 포맷 전환을 검토했다.
| 파일 | 변환 전 | 변환 후 | 감소율 |
|---|---|---|---|
| world.png | 189KB | 42.8KB | 77% |
| Roof.png | 683KB | 160KB | 76% |
| Exterior Wall.png | 409KB | 114KB | 72% |
| lobby.png | 51.7KB | 14.6KB | 72% |
| Adam.png (아바타) | 16.4KB | 3.76KB | 77% |
AVIF는 WebP보다 압축 효율이 높지만 Safari 16 미만에서 지원되지 않고, Phaser 내부에서의 디코딩 안정성 또한 충분히 검증되지 않았다.
반면 WebP는 주요 브라우저에서 광범위하게 지원되며 Phaser에서도 문제없이 로드된다. 브라우저 호환성과 런타임 안정성을 우선 고려해 WebP를 선택했다.
Claude Code를 활용해 모든 타일셋, 스프라이트시트, 맵 미리보기 이미지를 WebP로 일괄 변환했고, world.tmj 내부의 이미지 경로 참조를 전부 교체했다. 또한 nginx 설정에 WebP MIME 타입(image/webp) 을 추가해 정적 리소스가 올바르게 서빙되도록 구성했다.
예상과 달랐던 결과: TBT 증가
| 단계 | FCP | LCP | TBT | Score |
|---|---|---|---|---|
| 초기 | 12.3s | 24.3s | 1,440ms | 25 |
| TMJ 캐시 + 타일셋 병렬 프리로드 | 24.2s ↑ | 46.1s ↑ | 890ms | 27 |
| PNG → WebP 전환 | 12.4s | 24.3s | 1,550ms ↑ | 25 |
WebP 전환 이후 TBT가 오히려 증가했다.
네트워크 전송량은 줄었지만 CPU 디코딩 비용이 증가했다. Chrome DevTools Performance 패널에서 이미지 디코딩 및 텍스처 업로드와 관련된 태스크가 메인 스레드를 추가로 점유하는 것을 확인했다.
왜 WebP 디코딩이 PNG보다 오래 걸리는가
PNG는 Deflate 기반 무손실 압축을 사용한다. 디코딩 과정은 비교적 단순한 스트림 복원과 허프만 디코딩으로 구성되어 CPU 부담이 크지 않다.
반면 WebP(손실 모드)는 VP8 기반 예측 인코딩을 사용한다. 블록 단위 예측, 엔트로피 디코딩, DCT 역변환, 색 공간 변환(YUV → RGB) 등의 연산이 필요하며, 복원 과정의 계산량이 더 많다. 이 과정이 메인 스레드와 동기화되면 Long Task를 유발할 수 있고, 그 결과 TBT에 영향을 줄 수 있다.
이는 실패라기보다 병목이 이동한 결과였다. 네트워크 비용이 완화되자 그동안 가려져 있던 실행 단계의 비용이 드러났다. 먼저 이미지 디코딩이 Long Task를 유발했고, 이어서 JS Execution이 주요 지연 요인으로 나타났다.
이를 검증하기 위해 동일한 프로덕션 설정에서 이미지를 PNG로 되돌려 재측정했다. 그 결과 TBT는 감소했지만 FCP와 LCP는 다시 악화되었다. 네트워크 비용과 디코딩 비용 사이에 명확한 트레이드오프가 존재함을 확인할 수 있었다.
JS Execution: 드러난 진짜 병목
네트워크 병목이 완화되자 Performance 패널에서 실행 단계의 비용이 명확하게 드러났다. 메인 스레드 점유 시간 대부분이 JS evaluation에서 발생하고 있었다. 특히 livekit-krisp는 초기 로드 구간에서 900ms 이상 메인 스레드를 점유했고, Phaser 초기화 구간에서도 Long Task가 연속적으로 발생했다. 이 구간 동안 사용자 입력은 사실상 차단된 상태였다.
실행 비용을 만든 원인은 세 가지였다.
- Phaser (~1.2MB): 맵이 열리기 전 반드시 실행되어야 하므로 초기 번들에서 분리하기 어렵다.
- livekit-krisp (~5.9MB): 마이크 트랙 생성 여부와 무관하게 페이지 진입 시 evaluation됐다.
- easystarjs: 경로 탐색 초기화 로직이 씬 로딩 흐름에 포함되어 있었다.
이 시점의 문제는 네트워크가 아니라 초기 JS 실행 비용이었다.
실행 비용 분해
각 라이브러리의 evaluation 시점을 재구성했다.
KrispNoiseFilter (~5.9MB)
livekit-krisp는 WebAudio Processor를 포함한 대형 모듈로, import 시점에 상당한 evaluation 비용이 발생한다. 초기 번들에 포함되어 있어 페이지 진입과 동시에 파싱·컴파일·실행이 이루어졌고, 이 과정이 메인 스레드를 장시간 점유하고 있었다.
노이즈 필터는 모든 사용자가 사용하는 기능이지만, 마이크 트랙이 생성되기 전까지는 실행될 필요가 없다.
이를 해결하기 위해 동적 import로 전환해 모듈의 evaluation 시점을 마이크 트랙 생성 시점 이후로 지연시켰다. 결과적으로 초기 진입 구간의 메인 스레드 점유 시간을 제거할 수 있었다.
// 변경 전: 모듈 상단에서 즉시 로드
import { KrispNoiseFilter } from "@livekit/krisp-noise-filter";
// 변경 후: 마이크 트랙 생성 시점에 로드
const { KrispNoiseFilter } = await import("@livekit/krisp-noise-filter");협업 도구 — lazy + 조건부 프리로드
tldraw(화이트보드) 와 monaco(코드 에디터) 는 초기 사이드바 렌더링 시점에 함께 로드되고 있었다. 두 라이브러리는 규모가 커 초기 JS evaluation 비용을 증가시키는 요인이었다.
React.lazy()로 완전 지연 로딩을 적용해 초기 번들에서 분리했지만, 모달을 처음 여는 시점에 네트워크 요청과 evaluation이 동시에 발생하면서 체감 지연이 크게 느껴졌다. 초기 성능은 개선됐지만 실제 사용 경험이 저하되는 문제가 있었다.
이에 조건부 프리로드(preload on intent) 전략을 적용했다. 협업 사이드바가 마운트되는 시점을 “곧 도구를 사용할 가능성이 높은 시점”으로 보고, 그때 모듈 로드를 시작하도록 변경했다. 그 결과 초기 진입 경로의 실행 비용은 줄이면서도, 모달을 열 때는 즉시 표시되는 구조를 만들 수 있었다.
// 모달 자체는 lazy
const LazyCodeEditorModalContent = lazy(
() => import("./CodeEditorModalContent"),
);
// 협업 사이드바가 마운트될 때 프리로드 시작
useEffect(() => {
import("@features/whiteboard-sidebar/ui/WhiteboardModalContent");
import("@features/code-editor-sidebar/ui/CodeEditorModalContent");
}, []);easystarjs
easystarjs는 맵 내 타일 정보를 기반으로 경로를 계산하는 라이브러리로, 이 프로젝트에서는 현재 위치 표시와 이정표 계산, 미니맵 시각화에 사용되었다. 이 기능들은 맵이 생성된 이후에만 필요하므로 초기 진입 시점에 실행될 이유는 없었다.
이를 해결하기 위해 setupPathFinding() 내부로 로딩을 이동해 동적 import를 적용하고, 타입은 import type으로 분리했다. 그 결과 실행 시점이 실제 사용 시점 이후로 지연되면서 초기 JS evaluation 비용을 제거할 수 있었다.
easystarjs는 CommonJS 모듈이기 때문에 동적 import 시 default로 접근해야 했다. ESM과 CJS 간 interop 특성상 await import()의 반환값은 { default: module.exports } 형태로 감싸지기 때문이다.
// 변경 전: 클래스 필드에서 즉시 초기화
private easystar = new EasyStar.js();
// 변경 후: 필요 시점에 동적 import
private easystar: EasyStar.js | null = null;
async setupPathFinding() {
const EasyStar = (await import("easystarjs")).default;
this.easystar = new EasyStar.js();
}잔여 번들 정리
GameScene은 맵 내부 대부분의 기능이 의존하는 중심 객체였다. 여러 모듈에서 타입으로만 참조했지만, 일반 import로 선언되어 런타임 번들에 포함되고 있었다.
이를 import type으로 전환해 트랜스파일 단계에서 제거하고, 번들러의 런타임 의존성 그래프에서 제외했다. 작은 수정이었지만 의도치 않은 의존성 전파를 차단했다.
import type { GameScene } from "@features/game";단계별 변화 요약
| 단계 | FCP | LCP | TBT | 해석 |
|---|---|---|---|---|
| 초기 | 12.3s | 24.3s | 1,440ms | 네트워크 + JS Execution 병목 |
| TMJ 캐시 + 타일셋 병렬 프리로드 | 24.2s ↑ | 46.1s ↑ | 890ms | critical path 집중 → FCP/LCP 증가 |
| PNG → WebP 전환 | 12.4s | 24.3s | 1,550ms ↑ | 네트워크 단축, 디코딩 비용 증가 |
| 대형 라이브러리 지연 로딩 | 8.4s | 16.1s | 1,210ms | JS Execution 병목 완화 |
| easystarjs + import type 정리 | 7.9s | 15.1s | 1,030ms | 실행 비용 소폭 감소 |
| 최종 (Production) | 1.1s | 1.2s | 580ms | FCP/LCP 목표 달성 |

채택하지 않은 대안
이번 병목의 핵심은 네트워크 전송이 아니라 JS execution이었다. 대안 검토에서도 이 우선순위 판단을 기준으로 삼았다.
modulepreload
Vite는 주요 청크에 modulepreload를 이미 자동 주입한다. 수동으로 추가해도 다운로드와 파싱을 앞당길 뿐, 모듈 실행은 여전히 import 시점에 메인 스레드에서 동기로 수행된다. TBT 개선 효과가 없어 채택하지 않았다.
Phaser 커스텀 빌드
Phaser 3는 공식 트리셰이킹을 지원하지 않는다. 커스텀 빌드로 불필요한 모듈을 제거할 수 있지만, Phaser 내부 결합이 강해 유지보수 비용 대비 효과가 낮다고 판단했다.
Brotli 압축
리뷰에서 gzip 대비 15~20% 추가 효율의 Brotli 도입 제안이 나왔다. 검토해보니 nginx:alpine 기본 이미지에는 ngx_brotli 모듈이 없어 Dockerfile 멀티스테이지 구조 변경이 필요하고, 빌드 시간 증가와 공급망 보안 리스크가 따랐다. 에셋은 대부분 캐시되므로 재방문 시 효과도 없다. 현재 방식을 유지하기로 했다.
성과 및 구조적 한계
성과
10초 이상 이어지던 빈 화면을 1.1초 수준으로 단축했다. 사용자가 아무 반응 없이 기다려야 하던 시간이 90% 이상 감소했다.
| 지표 | 개선 전 | 개선 후 | 변화 |
|---|---|---|---|
| Performance | 25 | 68 | +43 |
| FCP | 12.3s | 1.1s | -91% |
| LCP | 24.3s | 1.2s | -95% |
| TBT | 1,440ms | 580ms | -60% |
구조적 한계
TBT는 580ms까지 감소했지만, Phaser(~1.2MB)의 모듈 evaluation 비용이 하한선으로 남아 있다.
Phaser는 import 시 모듈 evaluation이 메인 스레드에서 동기적으로 실행된다. 게임 씬이 시작되기 전에 반드시 로드되어야 하므로, 다른 라이브러리처럼 실행 시점을 지연시키기 어렵다. 이로 인해 초기 구간에 일정 수준의 Long Task가 불가피하다.
근본적인 TBT 개선을 위해서는 엔진 경량화, Web Worker 기반 분리, 렌더링 파이프라인 재설계 등 구조적 변경이 필요하다. 이번 단계에서는 ROI를 고려해 적용 범위를 제한했다.
마치며
이번 최적화는 첫 로딩 시간이 길어 사용자 경험을 개선하기 위해 시작했다.
초기에는 네트워크 비용을 줄이는 데 집중했지만, 이를 완화하자 실행 단계의 비용이 드러났다. WebP 전환 이후 TBT가 증가한 것과 타일셋 프리로드 이후 FCP가 상승한 현상 역시 같은 맥락이었다.
결국 초기 렌더링에 포함되는 작업을 다시 정의했다. 반드시 선행되어야 하는 리소스만 남기고, 디코딩과 모듈 evaluation은 가능한 한 실제 사용 시점 이후로 지연시켰다.
그 결과 빈 화면 구간을 1초대로 단축했다. 동시에, 성능 최적화는 수치를 낮추는 일이 아니라 병목이 형성되는 구조를 이해하고 재배치하는 작업이라는 점을 깨달았다.
참고
PR
Wiki
공식 문서