프론트엔드 성능 최적화에 대해 공부하다 보면 자주 등장하는 말들이 있다.
requestAnimationFrame을 활용하자.분명 다 중요한 이야기다. 그런데 막상 실제 프로젝트에 적용하려고 하면 갑자기 막막해진다.
이미지가 문제인지, JavaScript가 문제인지, 렌더링이 문제인지 어떻게 알지?
Lighthouse에서 LCP가 느리다고 하는데, 그래서 어느 코드를 수정해야 하지?
Performance 탭을 열기는 했는데, 복잡한 그래프 중에서 무엇을 봐야 하지?
나 역시 합동세미나에서 직접 성능 최적화를 적용해보려고 하면서 같은 문제를 겪었다.
네트워크 탭은 그나마 익숙했다. 어떤 이미지가 큰지, 어떤 요청이 늦는지는 비교적 쉽게 확인할 수 있었다. 하지만 그것만으로는 부족했다. 화면에 리소스가 늦게 도착하는 문제는 찾을 수 있어도, 리소스가 이미 도착했는데 왜 화면이 늦게 그려지는지는 알기 어려웠다.
그래서 이번에는 최적화 기법을 하나씩 정리하기보다, 개발이 끝난 서비스에서 실제로 병목을 찾고 수정 방향을 정하는 흐름을 정리해보려고 한다.
처음에는 성능 최적화를 “좋다고 알려진 기법을 프로젝트에 적용하는 일”이라고 생각했다.
이미지를 WebP로 바꾸고, React.memo를 붙이고, lazy loading을 적용하면 성능이 좋아질 것 같았다. 물론 실제로 도움이 될 수도 있다. 하지만 측정 없이 적용하면 무엇이 효과가 있었는지 알 수 없고, 심지어 현재 문제와 전혀 상관없는 작업을 하고 있을 수도 있다.
성능 최적화는 결국 다음 과정을 반복하는 일에 가깝다.
느린 순간을 정한다
→ 동일한 조건에서 측정한다
→ 병목이 발생한 구간을 찾는다
→ 원인을 추측한다
→ 하나를 수정한다
→ 같은 조건에서 다시 측정한다
중요한 것은 무작정 많이 고치는 것이 아니다.
어떤 문제를 해결하기 위해 무엇을 수정했고, 실제로 어떤 변화가 있었는지 확인하는 것이다.
Performance 탭을 열자마자 복잡한 그래프를 읽으려고 하면 어렵다.
그 전에 먼저 사용자가 느끼는 문제를 구체적으로 정해야 한다.
성능 문제는 크게 두 종류로 나눌 수 있다.
페이지에 처음 들어갔을 때 주요 콘텐츠가 늦게 보이는 문제다.
예를 들면 다음과 같다.
이때는 주로 다음을 확인한다.
페이지 로딩은 끝났는데 클릭, 검색, 필터링, 스크롤, 애니메이션에서 문제가 발생하는 경우다.
예를 들면 다음과 같다.
이때는 주로 다음을 확인한다.
처음부터 모든 문제를 한 번에 해결하려고 하면 안 된다.
느린 장면 하나를 정하고, 그 장면만 기록하는 것이 중요하다.
성능을 비교하려면 수정 전과 수정 후의 조건이 최대한 같아야 한다.
예를 들어 초기 로딩을 확인하고 싶다면 다음과 같은 시나리오를 고정할 수 있다.
1. 배포된 페이지를 연다.
2. DevTools를 켠다.
3. 캐시를 비운다.
4. 새로고침과 함께 Performance 기록을 시작한다.
5. 첫 화면의 주요 콘텐츠가 나타나면 기록을 종료한다.
사용자 인터랙션을 확인하고 싶다면 다음처럼 기록한다.
1. 페이지를 먼저 로딩한다.
2. Performance 기록을 시작한다.
3. 느리다고 느낀 행동을 한 번만 수행한다.
4. 바로 기록을 종료한다.
여기서 중요한 점은 행동을 너무 많이 하지 않는 것이다.
검색도 하고, 스크롤도 하고, 필터도 누르고, 모달도 열면 Performance 탭에 너무 많은 정보가 쌓인다. 그러면 어느 구간이 문제인지 판단하기가 더 어려워진다.
처음에는 정말 단순하게 기록하는 편이 좋다.
검색어 한 글자 입력하기
필터 버튼 한 번 누르기
스크롤 한 번 내리기
페이지 한 번 새로고침하기
이 정도면 충분하다.
성능 최적화를 시작할 때 가장 먼저 Lighthouse를 돌리는 경우가 많다.
Lighthouse는 현재 페이지의 성능 상태를 빠르게 확인하기 좋다.
FCP, LCP, CLS 등의 지표를 보여주고, 개선이 필요해 보이는 항목도 알려준다.
하지만 Lighthouse만 보고 바로 코드를 수정하기는 어렵다.
예를 들어 다음과 같은 결과를 받았다고 해보자.
LCP가 느립니다.
이것만으로는 아직 아무것도 알 수 없다.
모두 가능하다.
그래서 Lighthouse는 정답을 알려주는 도구라기보다, 어느 방향을 먼저 조사해야 하는지 알려주는 출발점에 가깝다.
나는 이렇게 생각하면 가장 이해가 쉬웠다.
Lighthouse는 범인을 잡아주는 도구가 아니라,
어느 사건부터 수사해야 하는지 알려주는 도구다.
초기 로딩 문제를 살펴볼 때 가장 먼저 확인하기 좋은 지표는 LCP다.
LCP는 화면 안에서 가장 큰 콘텐츠가 실제로 표시되는 시점을 의미한다.
사용자가 페이지에 들어왔을 때 가장 중요하게 인식하는 콘텐츠가 언제 보였는지를 확인할 수 있다.
예를 들면 다음과 같은 요소가 LCP가 될 수 있다.
1. Performance 탭을 연다.
2. 새로고침과 함께 기록한다.
3. 기록 결과에서 LCP 시점을 찾는다.
4. LCP 항목을 클릭한다.
5. 어떤 요소가 LCP element인지 확인한다.
가장 먼저 해야 하는 일은 단순하다.
무엇이 늦게 보였는지 확인하기
이미지가 문제인지, 텍스트가 문제인지도 모르는 상태에서 무작정 이미지 최적화를 시작하면 안 된다.
LCP element를 찾았다면, 이제 LCP가 늦어진 이유를 조금 더 구체적으로 나눠볼 수 있다.
LCP는 크게 다음 네 구간으로 나누어 생각할 수 있다.
| 구간 | 의미 | 확인할 방향 |
|---|---|---|
| TTFB | HTML의 첫 응답을 받기까지 걸린 시간 | 서버 응답, 캐시, CDN |
| Resource load delay | HTML을 받은 뒤 LCP 리소스 요청이 시작되기까지 걸린 시간 | 요청 발견 시점, 우선순위 |
| Resource load duration | 리소스를 실제로 다운로드하는 시간 | 이미지 크기, 포맷, 네트워크 |
| Element render delay | 리소스를 다 받았지만 화면에 그려지기까지 기다린 시간 | JavaScript, CSS, 렌더링 작업 |
이렇게 나누면 막연했던 LCP 문제가 조금씩 구체적으로 보이기 시작한다.
LCP element가 큰 이미지라고 해보자.
Network 탭에서 해당 이미지를 찾아보니 파일 크기가 크고 다운로드 시간도 길다.
이 경우에는 리소스 크기 자체를 줄이는 방향을 생각할 수 있다.
예를 들면 다음과 같은 최적화가 후보가 된다.
중요한 것은 “이미지 최적화는 무조건 해야 한다”가 아니다.
LCP 리소스의 다운로드 시간이 길다는 근거를 확인했기 때문에
이미지 최적화를 선택하는 것이다.
이번에는 이미지 파일이 그렇게 크지 않은데도 LCP가 늦다고 해보자.
Network 탭의 waterfall을 확인해보니, 대표 이미지 요청이 HTML을 받은 직후가 아니라 한참 뒤에 시작됐다.
이 경우에는 다운로드 속도보다 요청 시작 시점이 문제다.
다음과 같은 상황을 의심할 수 있다.
이때는 다음과 같은 방향을 검토할 수 있다.
여기서 Network 탭은 굉장히 유용하다.
수정 전: JavaScript 실행 이후 이미지 요청 시작
수정 후: HTML을 받은 직후 이미지 요청 시작
이런 식으로 waterfall을 비교하면 수정 효과를 눈으로 확인할 수 있다.
이 경우부터는 Network 탭만으로 부족하다.
이미지는 이미 다운로드가 끝났는데, 실제 LCP 시점은 훨씬 뒤에 있다.
즉, 리소스는 도착했지만 화면에 나타나지 못하고 기다린 것이다.
이때 확인해야 하는 것이 Element render delay다.
가능한 원인은 다음과 같다.
이 경우에는 Performance 탭의 Main Thread를 봐야 한다.
Network 탭은 리소스가 언제 도착했는지 알려준다.
Performance 탭은 리소스가 도착했는데도 왜 화면에 나오지 못했는지 알려준다.
이 차이를 이해하고 나니 Performance 탭을 왜 봐야 하는지 조금 감이 왔다.
LCP가 주요 콘텐츠가 나타나는 시점이라면, FCP는 화면에 처음으로 어떤 콘텐츠라도 나타나는 시점이다.
LCP만 늦다면 대표 이미지나 큰 콘텐츠의 문제일 가능성이 있다.
그런데 FCP부터 늦다면 페이지의 첫 렌더링 자체가 막히고 있을 수 있다.
이때는 다음을 확인해볼 수 있다.
| 확인할 항목 | 어떤 문제가 있을 수 있는가 |
|---|---|
| HTML 응답 | 서버 응답 자체가 느릴 수 있다 |
| CSS | 초기 렌더링을 막는 CSS가 클 수 있다 |
| JavaScript | 초기 렌더링 전에 너무 많은 스크립트가 실행될 수 있다 |
| 폰트 | 텍스트 렌더링이 폰트를 기다리고 있을 수 있다 |
| 초기 데이터 요청 | 데이터를 모두 받을 때까지 화면을 숨기고 있을 수 있다 |
여기서도 바로 특정 기법을 적용하는 것이 아니라, 어떤 구간이 오래 걸리는지 먼저 확인해야 한다.
첫 화면은 잘 떴는데 클릭이나 스크롤이 느리다면 이제는 런타임 성능을 봐야 한다.
예를 들어 다음과 같은 상황이다.
이때는 페이지를 새로고침하며 기록하는 것이 아니라, 문제가 발생하는 행동만 기록한다.
1. 페이지를 먼저 로딩한다.
2. Performance 기록을 시작한다.
3. 느린 행동을 한 번 수행한다.
4. 바로 기록을 종료한다.
5. Main Thread에서 오래 걸린 작업을 찾는다.
Performance 탭을 처음 보면 Flame Chart가 굉장히 복잡하게 느껴진다.
수많은 함수와 색깔이 한 번에 보이기 때문이다.
하지만 처음부터 모든 함수를 이해하려고 할 필요는 없다.
가장 먼저 해야 하는 일은 이것이다.
느린 순간에 가로로 길게 늘어진 작업을 찾기
가로 폭은 실행 시간을 의미한다.
길게 이어진 작업이 있다면 그동안 Main Thread가 바빴다는 뜻이다.
처음에는 다음 정도만 구분해도 충분하다.
| 영역 | 의미 | 의심할 방향 |
|---|---|---|
| Scripting | JavaScript 실행 | 이벤트 핸들러, 반복 계산, 데이터 가공 |
| Rendering | 스타일 계산, Layout | DOM 크기, 레이아웃 계산 |
| Painting | 실제 픽셀 그리기 | 넓은 repaint, 무거운 시각 효과 |
| Loading | 리소스 로딩 처리 | Network 요청과 연결하여 확인 |
그다음 긴 작업을 클릭하고, Summary와 Bottom-Up을 확인한다.
1. 긴 작업을 클릭한다.
2. Summary에서 작업 종류와 소요 시간을 확인한다.
3. Bottom-Up에서 시간이 많이 걸린 함수를 확인한다.
4. Call Tree에서 어떤 흐름으로 호출됐는지 확인한다.
5. 실제 코드로 이동한다.
Performance 탭에서 가장 중요한 것은 모든 정보를 읽는 것이 아니다.
느린 순간에 가장 많은 시간을 사용한 작업 하나를 찾는 것
이렇게 접근하면 Flame Chart가 조금 덜 무섭다.
필터 버튼을 누르거나 검색어를 입력했을 때 Scripting 구간이 길게 나타난다면, JavaScript 실행 비용이 큰 상황일 수 있다.
예를 들어 다음과 같은 코드가 있다고 해보자.
const filteredMovies = movies
.filter((movie) => movie.title.includes(keyword))
.sort((a, b) => b.popularity - a.popularity);
데이터가 적으면 문제가 없을 수 있다.
하지만 데이터가 많고, 입력할 때마다 반복해서 실행되고, 그 결과로 많은 컴포넌트가 다시 렌더링된다면 버벅임이 생길 수 있다.
이 경우에는 다음과 같은 흐름으로 판단한다.
검색어 입력
→ Performance 기록
→ 입력 직후 Scripting 구간이 길게 나타남
→ Bottom-Up에서 반복 계산 함수 확인
→ 불필요한 계산 여부 확인
→ 최적화 적용
→ 동일한 조건으로 재측정
수정 방향은 상황에 따라 달라질 수 있다.
중요한 것은 무작정 useMemo를 붙이는 것이 아니다.
실제로 반복 계산 비용이 크다는 근거를 확인한 뒤
메모이제이션을 선택하는 것
Performance 탭에서 JavaScript 실행이나 렌더링 비용이 크다는 사실을 확인했다면, 그다음에는 React Profiler를 활용할 수 있다.
브라우저 Performance 탭과 React Profiler는 역할이 조금 다르다.
| 도구 | 확인하기 좋은 것 |
|---|---|
| Performance 탭 | 브라우저 전체 흐름에서 어떤 작업이 오래 걸렸는지 |
| React Profiler | 어떤 컴포넌트가 왜 다시 렌더링됐는지 |
따라서 다음과 같은 순서가 자연스럽다.
Performance 탭에서 느린 순간 찾기
→ JavaScript 또는 Rendering 비용이 크다는 근거 확인
→ React Profiler에서 컴포넌트 렌더링 흐름 확인
→ 불필요한 렌더링 원인 찾기
→ 상태 구조 또는 props 전달 방식 수정
→ 다시 측정
이때 검토할 수 있는 것은 다음과 같다.
memo, useMemo, useCallback이 실제로 필요한 상황인지React 최적화도 결국 측정 이후에 적용해야 한다.
Main Thread에서 Rendering 작업이 길게 나타난다면 레이아웃 계산을 확인해야 한다.
브라우저는 DOM과 CSS를 바탕으로 요소의 위치와 크기를 계산한다.
이 과정이 Layout이다.
특히 JavaScript에서 DOM 스타일을 변경한 직후 크기나 위치를 다시 읽으면, 브라우저가 미뤄두었던 레이아웃 계산을 즉시 수행해야 할 수 있다.
element.style.width = "200px";
const width = element.offsetWidth;
이런 작업이 반복되면 Layout Thrashing이 발생할 수 있다.
조금 더 극단적인 예시는 다음과 같다.
elements.forEach((element) => {
element.style.width = "200px";
console.log(element.offsetWidth);
});
스타일을 변경하고 크기를 읽는 작업이 계속 반복된다.
이런 경우에는 읽기와 쓰기를 나누는 방향을 생각할 수 있다.
const widths = elements.map((element) => element.offsetWidth);
elements.forEach((element, index) => {
element.style.width = `${widths[index] + 10}px`;
});
Performance 탭에서는 Recalculate Style, Layout 등의 작업이 반복되거나 길게 나타나는지 확인하면 된다.
Painting은 브라우저가 실제 픽셀을 그리는 과정이다.
Main Thread에서 Painting 작업이 많이 발생한다면 다음과 같은 것을 의심할 수 있다.
이 경우에는 어떤 스타일 변경이 repaint를 발생시키는지 확인하고, 가능한 경우 다시 그리는 범위를 줄이는 방향을 생각할 수 있다.
여기서도 핵심은 같다.
시각 효과가 많으니 전부 제거하는 것이 아니라,
실제로 Paint 비용이 크다는 근거를 확인한 뒤 수정한다.
카드 리스트나 테이블처럼 반복되는 요소가 많은 화면에서는 DOM 크기도 확인해야 한다.
DOM이 너무 커지면 초기 렌더링뿐 아니라 이후의 스타일 계산, Layout, Paint 비용도 커질 수 있다.
예를 들어 게시글 카드 수백 개를 한 번에 렌더링하고 있다면 다음을 검토할 수 있다.
이 역시 무조건 적용할 필요는 없다.
Performance 탭에서 Rendering 시간이 길고, DOM이 지나치게 크다는 근거가 있을 때 적용하면 된다.
스크롤이나 애니메이션이 부드럽지 않은 경우에는 Frames 영역을 확인한다.
브라우저는 여러 프레임을 연속으로 그리면서 움직임을 표현한다.
한 프레임을 처리하는 데 너무 오래 걸리면 화면이 끊겨 보인다.
확인 흐름은 다음과 같다.
1. Performance 기록을 시작한다.
2. 스크롤하거나 애니메이션을 실행한다.
3. 기록을 종료한다.
4. Frames 영역에서 유난히 오래 걸린 프레임을 찾는다.
5. 해당 프레임의 Main Thread 작업을 확인한다.
6. Scripting, Rendering, Painting 중 어디에서 시간이 많이 들었는지 확인한다.
이때 검토할 수 있는 방향은 다음과 같다.
top, left, width 변경이 많이 사용되는지transform, opacity 중심으로 바꿀 수 있는지requestAnimationFrame이 필요한 상황인지최적화를 하다 보면 여러 가지를 한 번에 고치고 싶어진다.
이미지도 줄이고, 폰트도 바꾸고, lazy loading도 추가하고, 컴포넌트 구조도 바꾸고 싶다.
하지만 여러 작업을 한 번에 하면 무엇이 실제로 효과가 있었는지 알 수 없다.
그래서 한 번에 하나씩 수정하고 다시 측정하는 편이 좋다.
예를 들어 다음과 같이 기록할 수 있다.
| 항목 | 수정 전 | 수정 내용 | 수정 후 |
|---|---|---|---|
| LCP | 4.1초 | 대표 이미지 요청 우선순위 조정 | 2.8초 |
| 대표 이미지 요청 시작 | 1.5초 | 초기 HTML에서 더 빨리 발견되도록 변경 | 0.4초 |
| 필터 클릭 후 Long Task | 170ms | 반복 계산 줄이기 | 55ms |
| 초기 DOM node 수 | 2,100개 | 페이지네이션 적용 | 480개 |
숫자가 크게 개선되지 않을 수도 있다.
하지만 그것도 의미가 있다.
이 수정은 현재 병목과 큰 관련이 없었다.
→ 다른 구간을 다시 확인한다.
이 과정을 반복하다 보면 성능 최적화가 막연한 작업이 아니라 디버깅처럼 느껴지기 시작한다.
마지막으로 전체 흐름을 한 번에 정리해보면 다음과 같다.
1. 느린 순간을 구체적으로 정한다.
2. 같은 조건에서 기록한다.
- 초기 로딩 문제라면 새로고침과 함께 기록
- 인터랙션 문제라면 느린 행동 하나만 기록
3. Lighthouse로 어떤 방향을 먼저 볼지 확인한다.
4. 첫 화면이 느리다면 LCP element를 찾는다.
- TTFB가 긴가?
- 리소스 요청 시작이 늦은가?
- 다운로드가 오래 걸리는가?
- 다운로드 후 렌더링이 늦는가?
5. 사용 중에 버벅인다면 Main Thread를 본다.
- Scripting이 긴가?
- Rendering이 긴가?
- Painting이 긴가?
- Long Task가 있는가?
6. React 렌더링이 의심된다면 React Profiler로 좁힌다.
7. 원인과 연결되는 수정 하나를 적용한다.
8. 같은 조건으로 다시 측정한다.
9. 수치와 그래프를 비교한다.
10. 다음 병목으로 이동한다.
조금 더 짧게 정리하면 다음과 같다.
느린 순간 정의
→ 측정
→ 병목 구간 확인
→ 원인 추측
→ 하나 수정
→ 재측정
성능 최적화를 공부할 때는 보통 다양한 기법을 먼저 접하게 된다.
이미지 최적화, 폰트 최적화, JavaScript 최적화, 렌더링 최적화, 코드 스플리팅, 캐싱, 메모이제이션 등 알아야 할 것은 정말 많다.
하지만 실제 프로젝트에서는 기법을 많이 아는 것보다, 현재 문제에 맞는 기법을 선택하는 능력이 더 중요하다는 생각이 들었다.
무작정 이미지를 줄이고, 무작정 memo를 붙이고, 무작정 lazy loading을 적용하는 것이 아니라 먼저 질문해야 한다.
지금 사용자가 느끼는 느린 순간은 언제인가?
그 순간 브라우저는 무엇을 하고 있었는가?
가장 오래 걸린 작업은 무엇인가?
이 수정은 정말 현재 병목을 줄였는가?
Performance 탭은 처음 보면 복잡하다.
하지만 모든 그래프를 완벽하게 읽으려고 하지 않아도 된다.
처음에는 단 하나만 찾으면 된다.
내가 느리다고 생각한 순간에, 가장 오래 걸린 작업은 무엇이었는가?
그 질문에서부터 실제 성능 최적화가 시작된다.
:::