데스크톱
- LCP
- 5.24s
- 0.4s
- FCP
- 3.0s
- 0.4s
- Lighthouse Performance
- 48
- 90
- _app 청크 (Parsed)
- 1.25MB
- 207KB (-83%)
- 전체 인증 프로세스
- 1,508ms
- 601ms (-60%)
모바일
- LCP
- 19.2s
- 2.2s
- FCP
- 3.0s
- 0.4s
- Lighthouse Performance
- 43
- 76
- Total Blocking Time
- 1,620ms
- 700ms
- Speed Index
- 3.8s
- 3.1s
왜 성능부터였나
서비스에는 이미 커뮤니티가 있었지만, 깊은 단계에 묻혀 거의 쓰이지 않았습니다. 그래서 커뮤니티를 메인 피드로 끌어올리자고 제안했습니다.
다만 당시 메인은 콘텐츠가 그려지기까지 5.24초가 걸리는 상태였습니다. 이 상태에서는 노출을 늘려도 의미가 없다고 봤습니다. 성능을 먼저 걷어내고 노출은 그다음에 늘리는 순서를 PM·디자이너와 합의했고, 아래는 그 첫 단계의 기록입니다.
문제
DevTools 트레이스를 열고 나서야 문제가 정확히 보였습니다. 브라우저가 기록한 FCP는 0.5초 근처인데, LCP는 5.24초였습니다. 껍데기만 일찍 그려지고 정작 사용자가 보러 온 내용은 한참 뒤에 채워지고 있었습니다.
병목은 셋이었습니다. 무거운 초기 번들, 메인 스레드를 점유하는 비핵심 작업, 인증 완료를 기다리는 첫 렌더. 지표가 좋아 보인다고 문제가 없는 게 아니라는 걸 확인한 뒤에야 어디를 파야 할지가 정해졌습니다.
번들 — 두 단계
1차 — 필요할 때만 불러오기
특정 기능에서만 쓰이는 Firebase 모듈(Database·Firestore·Messaging·Storage)이 정적 import되어, 앱을 켜는 모든 사용자에게 다운로드를 강요하고 있었습니다. 중앙 로더를 만들어 실제 호출 시점에만 불러오도록 21개 파일·150여 개 함수를 async/await로 변경했습니다.
2차 — 청크를 쪼개기
1차 후에도 압축 기준 248KB로, Chrome 팀 Alex Russell이 제시한 "5초 내 TTI를 위한 JS 예산 약 170KB(압축 기준)"를 넘고 있었습니다.
남은 문제는 구조였습니다. Firebase Auth·Sentry·MUI·TanStack Query처럼 거의 바뀌지 않는 벤더 라이브러리가 _app 단일 청크에 뭉쳐 있어, HTTP/2 병렬 다운로드를 못 쓰고 내 코드를 한 줄만 고쳐도 벤더까지 캐시가 무효화됐습니다. splitChunks로 벤더를 독립 청크로 분리하고, 해시 기반 파일명을 전제로 정적 리소스에 1년 immutable 캐싱을 적용했습니다.
_app 청크 | 시작 | 1차 후 | 2차 후 |
|---|---|---|---|
| Parsed | 1.25 MB | 791.83 KB | 207.56 KB (-83%) |
| Gzipped | 397 KB | 248.24 KB | 66.73 KB (-83%) |
압축 기준 66.73KB로 예산 안에 들어왔습니다.
메인 스레드 — 언제 하느냐를 바꾸기
광고·지도·태그매니저와 비핵심 조회를 requestIdleCallback으로 유휴 시간에 실행하도록 미뤘습니다.
대가가 있었습니다. Speed Index는 8.2초에서 9.1초로 나빠졌습니다. 뒤로 미룬 광고와 지도가 늦게 그려졌기 때문입니다. 첫 콘텐츠 노출(LCP)과 화면 완성(SI)은 다른 것을 재고, 이 서비스에선 전자가 중요하다고 판단했습니다. 지표가 모두 좋아지는 최적화는 드뭅니다.
인증 — 두 번 파고들어 나온 것
1차 — 수년간 방치된 설정 한 줄
인증 초기화를 들여다보다 프로젝트 초기에 작성돼 아무도 건드리지 않은 inMemoryPersistence를 발견했습니다. 인증 상태를 메모리에만 두는 설정이라 새로고침하면 로그인이 날아갑니다.
단순한 설정 문제가 아니었습니다. "왜 매번 로그인해야 하죠?"는 대표와 경영진이 오랫동안 요청해 온 과제였고, 팀은 그 증상을 복잡한 재로그인 로직으로 덮고 있었습니다. 아무도 원인을 의심하지 않고 결과를 땜질하고 있었던 것입니다.
browserLocalPersistence로 바꾸자 새로고침마다 나가던 재인증 통신이 사라지고, 인증이 '서버와의 통신'에서 '로컬 저장소 읽기'로 바뀌었습니다. 덧대 놓았던 재로그인 로직도 함께 걷어냈습니다.
- 전체 인증 프로세스
- 1,508ms
- 601ms (-60%)
- 프로필 로드 완료
- 1,727ms
- 1,226ms (-29%)
2차 — 쓰지 않는 기능의 준비 비용
설정을 고친 뒤에도 인증 요청 앞에 대기가 남아 있었습니다. DevTools 트레이스로 순서대로 걸린 대기를 추적해 원인을 getAuth()의 기본 구성으로 특정했습니다. 거기엔 두 가지가 들어 있었습니다.
- OAuth popup/redirect resolver: apis.google.com의 gapi iframe을 로드합니다. 모바일·Safari·iOS 환경에서는 인증 복원 전에 이 초기화를 먼저 기다립니다.
- 3단 persistence 폴백 (IndexedDB → localStorage → sessionStorage): 초기화 후
setPersistence가 유저를 localStorage로 옮기고, 다음 로드 때 SDK가 다시 IndexedDB로 되옮겼습니다. 이 왕복 쓰기가 매 로드마다accounts:lookup앞에서 실행됐습니다.
이 서비스는 signInWithCustomToken만 씁니다. 소셜 로그인 팝업도, 3단 폴백도 필요가 없었습니다.
initializeAuth()로 필요한 persistence만 지정하고, 두 곳에 흩어져 있던 설정을 한 곳으로 합쳤습니다. 옮기는 과정에서 초기화가 늦어지는 회귀가 생기지 않도록 앱 시작 시점 초기화는 유지했고, 그 계약을 고정하는 정적 회귀 테스트 6건을 추가했습니다.
| 요청 시작 시점 | 개선 전 | 개선 후 | 단축 |
|---|---|---|---|
accounts:lookup (인증 API) | 880~900ms | 470~480ms | 약 47% (~410ms) |
| 인증 이후 콘텐츠 API (채널·이벤트·커뮤니티) | 1,550~1,600ms | 890~910ms | 약 43% (~670ms) |
후속 API가 더 크게 당겨진 것은 그 쿼리들이 accounts:lookup 완료와 하이드레이션을 둘 다 기다리기 때문입니다. 메인 스레드 작업이 줄어 하이드레이션도 함께 앞당겨졌습니다.
그다음 — 커뮤니티를 메인으로
성능을 걷어낸 뒤 원래 제안했던 작업으로 돌아갔습니다. 커뮤니티 입구를 메인으로 올려, 경기 영상과 나란히 피드 형태로 노출되도록 다시 설계했습니다.
- 주간 고착도 (WAU/MAU)
- 20%
- 40%
- WAU
- 약 6천
- 약 1.2만
- 평균 체류
- 3:15
- 4:28
MAU 약 3만 기준. 성능 개선과 피드 재설계를 순서대로 적용한 뒤의 결과로, 두 작업 각각의 몫을 나눈 수치는 아닙니다.