← 염승준
02

LCP 5.24초에서 0.4초로

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

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

데스크톱

LCP
5.24s
0.4s
FCP
3.0s
0.4s
Lighthouse Performance
48
90
_app 청크 (Parsed)
1.25MB
207KB (-83%)
전체 인증 프로세스
1,508ms
601ms (-60%)
Lighthouse 데스크톱 1차 개선 전후 비교. Performance 48에서 85, FCP 3.0초에서 0.4초, LCP 3.0초에서 0.4초, Speed Index 8.2초에서 9.1초.
1차 개선: Lighthouse Performance 48 → 85 (데스크톱)
Lighthouse 데스크톱 최종 결과. Performance 90, FCP 0.4초, LCP 0.4초, Total Blocking Time 20ms, CLS 0.013, Speed Index 9.0초.
최종: Lighthouse Performance 90 (데스크톱)

모바일

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초였습니다. 껍데기만 일찍 그려지고 정작 사용자가 보러 온 내용은 한참 뒤에 채워지고 있었습니다.

병목은 넷이었습니다. 인증과 사용자 정보 조회 뒤에 줄지어 선 콘텐츠 조회, 무거운 초기 번들, 메인 스레드를 점유하는 비핵심 작업, 인증 초기화 자체의 비용. 지표가 좋아 보인다고 문제가 없는 게 아니라는 걸 확인한 뒤에야 어디를 파야 할지가 정해졌습니다.

직렬 호출: 서버로 옮겼다가, 다시 덜어내기

메인 페이지는 SSR로 되어 있었지만, 서버에서 하는 일은 없었습니다. 사용자가 콘텐츠를 보기까지 아래 순서가 차례로 이어졌고, 사용자 상세 정보가 있어야 그 정보를 바탕으로 메인 콘텐츠를 조회할 수 있었습니다. 콘텐츠를 조회하기까지만 최소 4.5초가 걸렸습니다.

Firebase 인증, 사용자 기본 정보 조회, 사용자 상세 정보 조회, 메인페이지 콘텐츠 조회가 차례로 이어지는 흐름도.
초기 콘텐츠가 뜨기까지의 직렬 호출

서버로 옮기기

getServerSideProps에 Firebase 인증과 메인 콘텐츠 조회를 넣고, 콘텐츠 조회에 필요한 사용자 상세 정보 일부는 쿠키에 저장해 요청에 활용했습니다. 클라이언트에서 네 번 왕복하던 일을 서버에서 한 번에 끝내고 콘텐츠가 담긴 HTML을 내려주는 방식입니다. LCP는 5.24초에서 약 1.0초가 됐습니다.

Chrome DevTools Performance 트레이스. 프로덕션 메인 페이지, FCP는 0.5초 근처, LCP 5.24초.
개선 전: 프로덕션, LCP 5.24s
Chrome DevTools Performance 트레이스. 로컬 프로덕션 빌드, 콘텐츠가 약 1초에 처음 렌더링된다.
서버로 옮긴 후: 로컬 프로덕션 빌드, LCP 약 1.0s

서버가 기다림의 자리가 되다

이번에는 서버 단계가 길어졌습니다. getServerSideProps에서 언어 처리, user-agent 판단, Firebase 로그인과 idToken 획득, 인기 커뮤니티 게시글 조회까지 하다 보니 이 단계에만 1.5~3.2초가 걸렸고(캐시 여부에 따라 차이), 그동안 사용자는 빈 화면을 봤습니다. SSR 페이지는 들어올 때마다 이 로직이 다시 돌아서, 다른 탭에 갔다가 홈으로 돌아올 때도 1~1.2초를 기다렸습니다. Lighthouse 진단에도 루트 문서 응답이 1,900ms로 잡혔습니다.

캐시로 풀 수 있을지도 검토했습니다. SSR 응답 데이터 캐시, CDN 캐시, ISR을 선택지로 정리해 백엔드·프론트엔드 팀에 공유했습니다. 메인에 ISR을 적용했다가 SSR로 되돌렸지만 해결되지 않았고, 진짜 원인이 폰의 브라우저 캐시였던 이야기는 페이지별 렌더링 전략 재배분에 따로 적었습니다.

다시 덜어내기

서버 단계에는 user-agent 판단만 남겼습니다. 사용자 기본 정보와 상세 정보를 차례로 조회해야 했던 구조도 백엔드 개발자분들과 논의해 한 번에 받을 수 있게 바꿨습니다. 인기 게시글 요청을 조금이라도 빨리 보내기 위해 남긴 로직입니다. 이 단계는 약 0.001초가 됐고, 인기 게시글은 클라이언트에서 불러오는 동안 스켈레톤 UI를 보여 줍니다.

메인 페이지 접근 흐름 비교. 이전 구조는 서버 1단계에서 언어 처리, user-agent 판단, Firebase 로그인, 인기 게시글 조회를 하느라 1.5~3.2초가 걸린다. 개선된 구조는 user-agent 판단만 남겨 0.001초.
서버 단계 비교: 1.5~3.2초에서 0.001초로

대가가 있었습니다. 화면이 처음 그려지는 시점은 약 2.54초에서 0.43초로 당겨졌지만, 인기 게시글이 그려지는 시점은 2.54초에서 2.59초로 오히려 조금 늦어졌습니다. 전에는 게시글까지 한 번에 그려졌다면, 이제는 화면이 먼저 뜨고 게시글은 스켈레톤을 거쳐 채워집니다. 빈 화면을 보던 시간이 스켈레톤을 보는 시간으로 바뀌었고, 전체 기다림은 전보다 짧다고 판단했습니다.

서버 단계를 덜어내기 전후의 DevTools 트레이스 비교. 페이지가 처음 그려지는 시점 약 2,540ms에서 430ms, 인기 게시글이 그려지는 시점 약 2,540ms에서 2,589ms.
처음 그려지는 시점 2.54s → 0.43s, 인기 게시글 2.54s → 2.59s

번들: 두 단계

1차: 필요할 때만 불러오기

특정 기능에서만 쓰이는 Firebase 모듈(Database·Firestore·Storage)이 정적 import되어, 앱을 켜는 모든 사용자에게 다운로드를 강요하고 있었습니다. lib/firebase/loader.ts라는 중앙 로더를 만들어, 실제 함수가 호출되는 순간에만 await import()로 모듈을 불러오고 한 번 불러온 모듈은 재사용하게 했습니다. 이를 위해 21개 파일·150여 개 함수(runTransaction, onValue 등)를 조사해 async/await로 이관했습니다. 이 단계에서는 모든 페이지가 공통으로 받는 JS(First Load JS shared by all)도 498kB에서 334kB로 33% 줄었습니다.

Webpack Bundle Analyzer로 본 개선 전 _app 청크. firebase 영역에 firestore, auth, database, storage 모듈이 모두 들어 있다.
개선 전: firestore·database·storage가 _app에 포함
Webpack Bundle Analyzer로 본 1차 개선 후 _app 청크. firebase 영역에 auth만 남았다.
1차 개선 후: firebase는 auth만 남음
개선 전 _app 청크 크기. Stat 4.77MB, Parsed 1.25MB, Gzipped 397.59KB.
개선 전 크기: Parsed 1.25MB, Gzipped 397.59KB
1차 개선 후 _app 청크 크기. Stat 2.56MB, Parsed 791.83KB, Gzipped 248.24KB.
1차 개선 후 크기: Parsed 791.83KB, Gzipped 248.24KB

2차: 청크를 쪼개기

1차 후에도 _app 청크는 압축 기준 248KB였습니다. 참고로 삼은 기준은 Chrome 팀 Alex Russell이 제시한 "5초 내 TTI를 위한 JS 예산 약 170KB(압축 기준)"였고, 청크 하나만으로도 이를 넘고 있었습니다.

남은 문제는 구조였습니다. Firebase Auth·Sentry·MUI·TanStack Query처럼 거의 바뀌지 않는 벤더 라이브러리가 _app 단일 청크에 뭉쳐 있어, HTTP/2 병렬 다운로드를 못 쓰고 내 코드를 한 줄만 고쳐도 벤더까지 캐시가 무효화됐습니다. splitChunks로 Firebase·Sentry·MUI·TanStack Query를 각각 독립 청크로 분리하고, 해시 기반 파일명을 전제로 정적 리소스에는 1년 immutable 캐싱을, HTML 문서에는 캐시하지 않는 헤더를 적용했습니다. 배포 직후 옛 HTML이 사라진 청크를 찾는 경우에 대비해, ChunkLoadError가 나면 한 번 새로고침해 최신 리소스를 받아오는 전역 핸들러도 넣었습니다.

_app 청크시작1차 후2차 후
Parsed1.25 MB791.83 KB207.56 KB (-83%)
Gzipped397 KB248.24 KB66.73 KB (-83%)

_app 청크는 압축 기준 66.73KB가 됐습니다.

2차 개선 후 _app 청크 크기. Stat 575.89KB, Parsed 207.56KB, Gzipped 66.73KB.
2차 개선 후 크기: Parsed 207.56KB, Gzipped 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 완료와 하이드레이션을 둘 다 기다리기 때문입니다. 메인 스레드 작업이 줄어 하이드레이션도 함께 앞당겨졌습니다.

개선 후에도 LCP는 0.4~0.6초 사이를 유지하고 있습니다.

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

성능을 걷어낸 뒤 원래 제안했던 작업으로 돌아갔습니다. 커뮤니티 입구를 메인으로 올려, 경기 영상과 나란히 피드 형태로 노출되도록 다시 설계했습니다. 서비스에 들어오자마자 다른 사람들의 글을 자연스럽게 발견하도록 동선을 바꾼 것입니다.

주간 고착도 (WAU/MAU)
20%
40%
WAU
약 6천
약 1.2만
평균 체류
3:15
4:28
신규 커뮤니티 게시글 수
약 330% 증가

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