목차11개 항목
출처와 참고
토스 「모닥불」 EP.9 「프론트엔드 서비스 최적화? 토스에서는 '이렇게' 합니다!」(2024.12, 박서진·박건영·조유성)를 바탕으로 재구성한 노트다. 각 기법의 자세한 방법은 연결한 노트에 있다. 측정 조건과 성능 예산은 마이리얼트립의 「웹사이트 성능 측정 및 최적화 Part 1. 리소스 로딩」으로 보강했다. 예산의 지표 종류는 web.dev의 「Performance budgets 101」로 보강했다. 로딩 기법 모음 표는 ByteByteGo의 System Design 101 가이드 「Frontend Performance Optimization」을 바탕으로 보강했다.
언제 최적화하나
최적화하면 좋겠다 싶을 땐 하지 마라. 그래도 해야 할 것 같으면 한 번 더 생각해라. 정말 해야겠다면 프로파일링부터 하고 시작해라.
최적화는 측정에서 출발한다. 다만 "정말 해야 할 때"를 늦게 알아채는 것이 흔한 실패다.
- 개발 환경은 사용자보다 빠르다. 가장 좋은 컴퓨터, 와이파이, 휴대폰으로 테스트하면 실제 사용자가 겪는 느림이 보이지 않는다. 네트워크·CPU 스로틀링과 저사양 기기로 확인한다.
- 평균·중간값(p50)이 아니라 p95·p99를 본다. 사용자는 평범하게 빠를 때는 성능을 기억하지 않고, 아주 느렸던 순간을 기억한다. 엘리베이터 안이나 해외 로밍처럼 최악의 상황에서 한 번 크게 느리면 "이 서비스는 느리다"는 낙인이 찍힌다.
- 최적화는 트레이드오프다. 사용자의 시간을 줄였다면 무엇인가를 희생한 것이다. 메모리, 코드 양, 복잡도, 개발자의 시간 중 무엇을 내주고 무엇을 얻는지 따져 고른다.
측정 조건부터 정한다
「3초 안에 뜬다」 같은 말은 네트워크와 캐시 상태를 빼면 의미가 없다. 서비스에서 가장 답답할 사용자를 정하고 그 조건으로 잰다.
| 정할 것 | 예 |
|---|---|
| 대상 환경 | 모바일 + 제한된 대역폭(해외 로밍 등). DevTools 네트워크 스로틀링(예: Fast 4G·3G 프리셋)과 CPU 스로틀링 |
| 캐시 상태 | 첫 방문자 기준이면 Network 패널의 Disable cache |
| 핵심 영역(hero element) | 방문 목적을 이루는 영역(검색창, 상품 정보)이 언제 보이나. 아래쪽 추천 영역은 나중에 불러와도 된다 |
핵심 영역이 아닌 데이터는 서버 렌더링에서 빼고 클라이언트에서 필요할 때 요청하며, 그동안 스켈레톤으로 자리를 채운다(로딩 UI).
성능 예산
성능 예산(performance budget) 은 넘지 않기로 팀이 합의한 한도다. 번들 크기, 이미지 총량, 특정 네트워크에서의 LCP 같은 값으로 정한다. 한 번 줄인 성능은 기능이 붙으며 다시 늘어나므로, CI에서 자동으로 확인해야 지켜진다.
| 예산의 종류 | 예 | 성격 |
|---|---|---|
| 양(quantity) | 이미지 최대 크기, 웹 폰트 개수, 스크립트 총량(프레임워크 포함), 서드파티 요청 수 | 디자이너·개발자 모두 알아듣기 쉬워 초기에 좋다. 다만 같은 무게라도 요청 순서에 따라 체감이 달라 경험을 직접 말해 주지 않는다 |
| 시점(milestone) | LCP, INP, CLS 같은 사용자 중심 지표 | 경험을 잰다. 개발 초기부터 추적한다(웹 성능 지표) |
| 규칙(rule-based) | Lighthouse 성능 점수 | 모범 사례 점검표. 다른 두 종류의 보조로 쓴다 |
예산은 페이지 종류마다 따로 둔다. 「상품 페이지는 모바일에서 JS 170KB 미만」, 「검색 페이지의 이미지는 데스크톱에서 2MB 미만」, 「블로그는 Lighthouse 성능 80점 이상」처럼 대상 페이지·기기·한도를 함께 적는다. 예산을 넘으면 선택지는 셋이다: 기존 기능·자원을 최적화하거나, 빼거나, 새것을 넣지 않는다. 과거에는 「느린 3G의 Moto G4에서 5초 안에 TTI」가 대표 기준이었지만, TTI는 Lighthouse 10에서 빠졌고 지금은 Core Web Vitals 기준을 쓴다.
- 번들 크기:
size-limit같은 도구로 파일별 상한을 두고 초과하면 빌드를 실패시킨다. 번들 구성은rollup-plugin-visualizer·webpack-bundle-analyzer로 본다. 이미지·폰트를 base64로 번들에 넣는 인라인 기준을 너무 크게 잡으면 번들이 부푼다. - 지표: Lighthouse CI의
budget.json·assertion으로 PR마다 검사한다(웹 성능 지표와 Lighthouse).
느림을 둘로 나눈다
| 구분 | 질문 | 대표 지표 |
|---|---|---|
| 초기 로딩 | 처음 화면이 얼마나 빨리 뜨나 | LCP, FCP |
| 런타임 | 뜬 뒤 입력에 얼마나 빠릿하게 반응하나 | INP, 프레임률(FPS) |
지표의 정의와 기준값은 웹 성능 지표와 Lighthouse에 있다.
초기 로딩: 세 가지 원인
로딩을 쌀가마니를 트럭으로 옮기는 일에 비유하면 원인이 셋으로 갈린다.
| 원인 | 비유 | 줄이는 법 |
|---|---|---|
| 대역폭(bandwidth) | 트럭의 수. 3G → LTE → 5G로 갈수록 늘어난다 | 옮길 쌀가마니(JS 번들·이미지)를 줄인다 |
| 지연 시간(latency) | 트럭의 속도. 쌀 한 톨만 옮겨도 걸리는 시간. 네트워크 품질에 좌우된다 | 왕복 횟수를 줄이고, 아예 옮기지 않아도 되게 미리 가까이 둔다 |
| JS 실행 시간 | 도착한 쌀을 손질하는 시간 | 시작할 때 파싱·실행할 코드를 줄인다 |
번들을 아무리 줄여도 지연 시간은 남는다. 사용자에게 빠른 인터넷을 쓰라고 할 수는 없으므로 옮기지 않는 방법을 쓴다.
- 캐싱: 쌀을 미리 집 앞에 둔다. 여러 서비스가 함께 쓰는 공통 컴포넌트(예: 날짜 선택기)는 캐시 효율이 크다.
- 프리패치: 다음에 갈 페이지가 예상되면 미리 받아 둔다(리소스 힌트).
- 앱에 내장: 네트워크가 아니라 기기 안에서 불러오게 한다. React Native 같은 앱 셸을 도입하는 이유 중 하나다.
다만 모든 것을 집에 쟁여 둘 수는 없다. 매일 마시는 물은 늘 집에 있어야 하지만, 가끔 먹는 짜장 소스까지 쌓아 둘 필요는 없다. 자주 쓰는 것은 캐시하고 드문 것은 그때 받는 전략을 세운다.
한 번에 다 가져올 필요도 없다. 배고픈 10명 몫을 먼저 가져오고 기다릴 수 있는 90명 몫은 나중에 가져오듯, 첫 화면에 꼭 필요한 번들을 먼저 받고 상호작용 뒤에 나오거나 당장 보이지 않는 부분은 동적 import()로 미룬다(자바스크립트 코드 분할, 지연 로딩). 단 잘못 쪼개면 첫 화면에 필요한 리소스가 늦게 와서 오히려 어색한 화면이 된다. 어디서 쪼갤지 기준을 알아야 한다.
지연이 어디서 얼마나 생기는지는 프론트엔드 개발자가 알아야 할 지연 시간 숫자에 있다.
로딩 기법 한눈에 보기
세 원인 중 무엇을 줄이느냐로 기법을 묶으면 다음과 같다.
| 기법 | 하는 일 | 줄이는 것 | 자세히 |
|---|---|---|---|
| 압축 | 전송 전에 파일을 줄인다(gzip·Brotli, minify) | 대역폭 | 이미지 압축 |
| 트리 셰이킹 | 쓰이지 않는 코드를 번들에서 뺀다 | 대역폭, JS 실행 | 번들러와 Vite |
| 코드 분할 | 큰 번들을 작은 조각으로 나눠 필요한 것만 받는다 | 대역폭, JS 실행 | 자바스크립트 코드 분할 |
| 동적 import | 사용자 행동이 있을 때 모듈을 불러온다 | 초기 대역폭 | 자바스크립트 코드 분할 |
| 우선순위 로딩 | 첫 화면(above the fold)과 핵심 리소스를 먼저 받는다 | 체감 지연 | LCP 최적화 |
| 프리로드 | 이 페이지에서 곧 쓸 리소스를 일찍 받기 시작한다 | 지연 | 리소스 힌트 |
| 프리패치 | 다음 페이지에서 쓸 법한 리소스를 한가할 때 받아 둔다 | 다음 이동의 지연 | 리소스 힌트 |
| 선택적 렌더링(윈도잉) | 긴 목록에서 화면에 보이는 항목만 DOM으로 그린다 | 렌더링·메모리 | 지연 로딩 |
런타임: 실제 성능과 체감 성능
디자인 편집기처럼 사용자가 1초에도 여러 번 단축키와 드래그를 하는 도구에서는 런타임 성능이 제품의 질이다.
측정
- 브라우저 프로파일러를 켜고 같은 동작(여러 요소 선택 → 드래그 앤 드롭)을 재현한다. 프레임이 떨어지는 구간에 메인 스레드를 막는 긴 작업(노란 막대)이 보인다. 클릭해 들어가며 어떤 함수가 시간을 쓰는지 찾는다(DevTools로 성능·메모리 분석하기).
- 브라우저 내장 FPS 미터는 실제보다 후하게 나올 수 있다. 성능이 중요한 앱은 앱 안에 직접 FPS 미터를 두고 개발 중에 상시 확인한다(예: 드래그 중 60fps 유지).
두 가지 성능
| 구분 | 뜻 | 긴 작업에 하는 일 |
|---|---|---|
| 실제 성능 | 작업 시작부터 끝까지 걸리는 시간 | 막대의 길이를 줄인다(알고리즘·불필요한 일 제거) |
| 체감 성능(반응성) | 작업 중에도 인터랙션이 처리되고 있다고 느끼는 감각 | 막대를 잘게 쪼개 사이사이에 입력·렌더링이 끼어들게 한다 |
실제 시간이 조금 더 걸려도 반응성이 좋으면 사용자는 더 빠르다고 느낀다. 예를 들어 수천~수만 개 요소를 붙여 넣는 작업은 꼭 필요한 일만 남겨도 오래 걸린다. 이때 노드를 적당한 단위로 청크로 나눠 비동기로 처리하고, 그 사이에 "붙여 넣는 중" 같은 진행 상태를 보여 준다. 구현 방법(메인 스레드에 양보, scheduler.yield())은 INP 최적화: 긴 작업 쪼개기와 양보, 메인 스레드 밖으로 빼는 방법은 웹 워커에 있다.
React 리렌더링
화면이 많은 대시보드형 서비스를 프로파일링하면 자주 나오는 원인은 두 가지다.
| 유형 | 증상 | 처방 |
|---|---|---|
| 연쇄 상태 업데이트 | useEffect 안에서 setState → 바뀐 상태에 또 다른 useEffect가 반응해 setState… 하나가 바뀌는데 커밋이 여러 번 일어난다 | 다른 상태에서 파생되는 값은 상태로 두지 않는다. 원본 상태 하나에서 렌더링 중에 계산하고, 계산이 비싸면 useMemo로 감싼다 |
| 렌더링 범위가 너무 넓음 | 각 컴포넌트를 최적화해도, 한 번 리렌더링될 때 실행할 컴포넌트가 너무 많아 늘어진다 | 실제로 바뀌어야 하는 부분만 다시 실행되게 범위를 좁힌다. 상태를 쓰는 곳 가까이로 내리고, 바뀌지 않는 큰 하위 트리는 memo로 끊는다 |
원칙은 업데이트되어야 하는 영역만, 한 번의 리렌더링으로 갱신하는 것이다. 원인 추적 순서는 리렌더링 원인과 추적에 있다. React Compiler를 쓰는 프로젝트는 메모이제이션 상당 부분을 컴파일러가 넣어 주지만, 파생 상태를 상태로 만드는 설계 문제는 직접 고쳐야 한다.
정리
- 최적화는 프로파일링한 뒤에, 최악의 사용자 경험(p95·p99)을 기준으로, 무엇을 희생하는지 알고 한다.
- 초기 로딩은 대역폭(줄이기), 지연 시간(캐싱·프리패치·내장), JS 실행 시간(코드 분할)으로 나눠 원인에 맞는 처방을 고른다.
- 런타임은 긴 작업을 줄여 실제 성능을, 쪼개 체감 성능을 올린다.
- React에서는 파생 값을 상태로 두지 말고, 리렌더링 범위를 바뀌는 곳으로 좁힌다.
관련 노트: 웹 성능 지표와 Lighthouse, INP 최적화: 긴 작업 쪼개기와 양보, 자바스크립트 코드 분할, 프론트엔드 개발자가 알아야 할 지연 시간 숫자, 리렌더링 원인과 추적, DevTools로 성능·메모리 분석하기, AMP