← 염승준
02

LCP 5.24초에서 0.4초로

세 갈래 병목을 나눠 줄이고, 그다음에야 묻혀 있던 커뮤니티를 메인으로 올렸습니다.

문제
  • 묻혀 있던 커뮤니티를 메인으로 올리자고 제안했지만, 콘텐츠가 그려지기까지 5.24초라 노출을 늘려도 의미가 없는 상태
  • FCP는 0.5초 근처인데 LCP는 5.24초. 병목은 셋: 무거운 초기 번들, 메인 스레드를 점유하는 비핵심 작업, 인증 완료를 기다리는 첫 렌더
해결
  • 성능을 먼저 걷어내고 노출은 그다음이라는 순서를 PM·디자이너와 합의
  • Firebase 모듈을 호출 시점에만 불러오도록 21개 파일·150여 개 함수 전환, 벤더 라이브러리를 독립 청크로 분리
  • 광고·지도·태그매니저를 유휴 시간으로 이관. Speed Index가 나빠지는 대가는 알고 감수
  • 인증 persistence 설정을 바로잡고, initializeAuth()로 쓰지 않는 OAuth resolver와 3단 폴백 제거
  • 이후 커뮤니티 입구를 메인으로 올려 경기 영상과 나란히 피드 형태로 재설계
결과
  • LCP 5.24s → 0.4s, Lighthouse Performance 48 → 90 (데스크톱), _app 청크 -83%
  • 전체 인증 프로세스 1,508ms → 601ms (-60%)
  • 주간 고착도(WAU/MAU) 20% → 40%, 평균 체류 3:15 → 4:28

데스크톱

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차 후
Parsed1.25 MB791.83 KB207.56 KB (-83%)
Gzipped397 KB248.24 KB66.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()의 기본 구성으로 특정했습니다. 거기엔 두 가지가 들어 있었습니다.

이 서비스는 signInWithCustomToken만 씁니다. 소셜 로그인 팝업도, 3단 폴백도 필요가 없었습니다.

initializeAuth()로 필요한 persistence만 지정하고, 두 곳에 흩어져 있던 설정을 한 곳으로 합쳤습니다. 옮기는 과정에서 초기화가 늦어지는 회귀가 생기지 않도록 앱 시작 시점 초기화는 유지했고, 그 계약을 고정하는 정적 회귀 테스트 6건을 추가했습니다.

요청 시작 시점개선 전개선 후단축
accounts:lookup (인증 API)880~900ms470~480ms약 47% (~410ms)
인증 이후 콘텐츠 API (채널·이벤트·커뮤니티)1,550~1,600ms890~910ms약 43% (~670ms)

후속 API가 더 크게 당겨진 것은 그 쿼리들이 accounts:lookup 완료와 하이드레이션을 둘 다 기다리기 때문입니다. 메인 스레드 작업이 줄어 하이드레이션도 함께 앞당겨졌습니다.

그다음 — 커뮤니티를 메인으로

성능을 걷어낸 뒤 원래 제안했던 작업으로 돌아갔습니다. 커뮤니티 입구를 메인으로 올려, 경기 영상과 나란히 피드 형태로 노출되도록 다시 설계했습니다.

주간 고착도 (WAU/MAU)
20%
40%
WAU
약 6천
약 1.2만
평균 체류
3:15
4:28

MAU 약 3만 기준. 성능 개선과 피드 재설계를 순서대로 적용한 뒤의 결과로, 두 작업 각각의 몫을 나눈 수치는 아닙니다.