프론트엔드 병목 현상, JavaScript 실행, API 지연 시간, 렌더링 지연 등 서버 메트릭이 놓치기 쉬운 성능 문제를 이해하기.
웹 애플리케이션은 강력한 서버에서 실행되고, API 응답을 밀리초 단위로 반환하더라도 사용자에게는 여전히 답답하게 느릴 수 있습니다.
버튼 반응이 너무 느립니다. 대시보드가 조각조각 나타납니다. 네트워크 요청이 완료되었음에도 불구하고 페이지가 공백으로 남아있습니다. 복잡한 컴포넌트가 렌더링될 때 스크롤하는 것이 불편해지고, 간단한 상호작용만으로도 인터페이스 전체가 간혹 멈춥니다.
개발자들이 이러한 문제를 조사할 때, 첫 번째 본능은 종종 서버 응답 시간, 데이터베이스 쿼리, 인프라 리소스를 확인하는 것입니다.
이러한 점검들은 중요하지만, 이야기의 전부는 아닙니다.
서버 속도와 사용자가 느끼는 애플리케이션 속도는 두 가지가 다릅니다.
웹 애플리케이션은 네트워크 통신, 브라우저 처리, JavaScript 실행, 렌더링, 에셋 전달 및 사용자 상호작용을 포함하는 완전한 파이프라인입니다. 이 파이프라인의 어느 곳에서든 병목 현상이 발생하면, 백엔드가 잘 작동하고 있더라도 애플리케이션이 느리게 느껴질 수 있습니다.
본 글에서는 왜 그런 일이 발생하는지, 실제 병목 지점을 어떻게 식별할 수 있는지, 그리고 불필요한 인프라 변경 없이 경험을 개선할 수 있는 최적화 기법은 무엇인지 살펴볼 것입니다.
1. 서버 성능과 사용자 경험의 차이 이해하기
주요 API 응답을 100밀리초 만에 반환하는 웹 애플리케이션을 가정해 봅시다.
듣기에는 빨라 보입니다. 하지만 사용자는 여전히 유효한 콘텐츠를 보기까지 몇 초를 기다릴 수 있습니다.
애플리케이션은 다음 작업을 수행해야 할 수도 있습니다:
- 도메인을 확인하고 연결을 설정합니다.
- HTML 문서와 JavaScript 번들을 다운로드합니다.
- JavaScript를 파싱하고 실행합니다.
- 여러 API 엔드포인트에서 데이터를 요청합니다.
- 수신된 데이터를 변환합니다.
- 컴포넌트를 렌더링하고 레이아웃을 계산합니다.
- 이미지를 포함한 다른 시각적 에셋을 다운로드합니다.
- 인터페이스가 상호작용 가능해지기를 기다립니다.
일부 작업은 병렬로 발생하지만, 다른 작업들은 이전 작업이 완료되는 것에 의존합니다.
예를 들어, 대시보드는 개인화된 콘텐츠를 요청하기 전에 사용자 프로필을 기다릴 수 있습니다. 그리고 그 콘텐츠는 구성 요소가 렌더링되기 전에 처리되어야 합니다.
개별 API 응답이 아무리 빨라도, 잘못 설계된 종속적인 작업 순서는 상당한 지연 시간을 유발할 수 있습니다.
이것이 개발자들이 백엔드 성능과 전체 사용자 경험을 구분해야 하는 이유입니다.
간단한 예시
다음 대략적인 타이밍을 가진 페이지를 상상해 보세요:
| 작업 | 시간 |
|---|
| 초기 HTML 응답 | 100 ms |
| ... | |
| 이 수치들은 참고용이며, 벤치마크 결과가 아닙니다. 작업들이 중첩될 수 있으므로, 이들을 단순히 합산한다고 해서 실제 페이지 로드 시간을 반드시 나타내는 것은 아닙니다. | |
하지만 이 예시는 중요한 점을 보여줍니다: 빠른 API가 페이지가 빠르게 사용 가능해질 것이라는 것을 보장하지는 않습니다.
목표는 서버의 응답뿐만 아니라 사용자의 경험을 측정하는 것입니다.
2. JavaScript가 주요 성능 병목 지점이 될 수 있다
현대 웹 애플리케이션은 라우팅, 상태 관리, 데이터 변환, 폼 유효성 검사 및 동적 렌더링에 종종 JavaScript에 의존합니다.
이러한 유연성은 대가를 치릅니다.
JavaScript를 다운로드하는 것은 과정의 일부일 뿐입니다. 브라우저는 또한 코드를 파싱(parse), 컴파일(compile)하고 실행해야 합니다. 큰 번들, 값비싼 계산, 과도한 렌더링 작업은 사용자 상호 작용을 지연시킬 수 있습니다.
장시간 실행되는 JavaScript 작업의 문제점
브라우저의 메인 스레드(main thread)에서 실행되는 JavaScript는 긴 작업이 실행되는 동안 브라우저가 사용자 입력에 신속하게 응답하는 것을 막을 수 있습니다.
예를 들어:
function processLargeDataset(items) {
const results = [];
...
이 함수는 작은 컬렉션에는 합리적입니다. 하지만 만약 수십만 개의 객체를 동기적으로 처리한다면, 인터페이스를 비응답 상태로 만들 만큼 충분히 오랫동안 메인 스레드를 차단할 수 있습니다.
더 나은 접근 방식은 작업 부하(workload)에 따라 달라집니다.
가능한 개선 사항에는 다음이 포함됩니다:
- 데이터를 더 작은 청크로 처리하기.
- 비용이 많이 드는 계산을 Web Worker로 옮기기.
- 불필요한 변환(transformation) 피하기.
- 대규모 데이터셋에 대해 페이지네이션(pagination) 또는 가상화(virtualization) 사용하기.
- 적절할 때 서버에서 필터링 및 집계(aggregation) 수행하기.
계산 집약적인 작업의 경우, Web Worker는 메인 UI 스레드와 분리하여 JavaScript를 실행할 수 있습니다:
const worker = new Worker("/data-worker.js");
worker.postMessage(largeDataset);
...
이 워커 파일은 해당 메시지 핸들러 및 처리 로직을 구현해야 합니다. 그러면 메인 스레드는 전체 계산을 직접 수행하지 않고도 결과를 받을 수 있습니다.
워커는 오버헤드(overhead)가 있으며 DOM을 직접 조작할 수 없으므로, 독립적인 계산 작업에 가장 유용합니다.
불필요한 JavaScript 줄이기
번들 크기(Bundle size) 또한 중요합니다.
큰 의존성 트리(dependency tree)는 다운로드, 파싱 및 실행 비용을 증가시킬 수 있으며, 특히 느린 장치에서 그렇습니다.
유용한 기술에는 다음이 포함됩니다:
- 사용하지 않는 내보내기 항목에 대한 트리 쉐이킹(Tree-shaking) 적용하기.
- 라우트 및 비용이 많이 드는 컴포넌트를 지연 로딩(Lazy-loading)하기.
- 사용자 여정(user journey)에 따라 코드를 분할하기.
- 불필요한 의존성 제거하기.
- 공개 페이지에서 관리 기능 로드하는 것 피하기.
- 새 라이브러리를 추가하기 전에 프로덕션 번들을 분석하기.
목표는 단순히 총 킬로바이트(kilobytes) 수를 최소화하는 것이 아닙니다. 사용자가 필요하지 않을 수도 있는 코드 때문에 기다리게 하지 않으면서 현재 작업에 필요한 JavaScript를 전달하는 것입니다.
3. API는 빠르지만 애플리케이션이 느릴 수 있습니다
100밀리초(milliseconds) 만에 응답을 반환하는 백엔드 엔드포인트는 효율적으로 들립니다. 하지만 프론트엔드는 사용자가 필요한 정보를 표시하기 전에 여러 엔드포인트에 의존할 수 있습니다.
흔한 문제는 순차적인 API 요청입니다.
다음 예시를 고려해 보세요:
async function loadDashboard() {
const user = await fetch("/api/user")
.then((response) => response.json());
...
코스 및 진행 상황 요청은 사용자 요청이 완료될 때까지 시작되지 않습니다.
만약 사용자 요청에 100밀리초가 걸리고 다른 두 요청 각각에 150밀리초가 걸린다면, 추가적인 처리 및 네트워크 오버헤드를 제외하고도 요청 순서가 약 400밀리초가 필요할 수 있습니다.
만약 사용자 ID를 아는 시점 이후의 두 번째와 세 번째 요청이 독립적이라면, 이들은 동시에 실행될 수 있습니다:
async function loadDashboard() {
const userResponse = await fetch("/api/user");
...
이렇게 하면 코스 및 진행 상황 요청 간의 불필요한 순차적 종속성을 제거할 수 있습니다.
하지만 동시성이 항상 정답은 아닙니다. 너무 많은 동시 요청은 서비스에 과부하를 주거나, 중복 작업을 발생시키거나, 불필요한 리소스를 소모할 수 있습니다.
개발자는 요청 패턴을 변경하기 전에 실제 종속성을 파악해야 합니다.
기타 API 관련 병목 현상
응답 시간이 낮더라도 애플리케이션은 다음 문제로 어려움을 겪을 수 있습니다:
- 필요한 레코드 몇 개만 필요한데 전체 데이터셋을 가져오는 경우.
- 불필요하게 큰 JSON 페이로드를 반환하는 경우.
- 동일한 데이터를 반복적으로 요청하는 경우.
- 작은 상호 작용(interaction)마다 데이터를 다시 가져오는 경우.
- 필수적이지 않은 분석(analytics)이나 추천을 기다리는 경우.
- 결합될 수 있는 정보에 대해 여러 번의 왕복(round trip)이 필요한 경우.
백엔드에서 보고하는 지속 시간뿐만 아니라 전체 요청 흐름을 측정해야 합니다.
4. 너무 많은 렌더링은 UI를 반응성이 없게 만듭니다
프론트엔드 프레임워크는 복잡한 인터페이스를 구축하기 쉽게 만들지만, 그 렌더링 비용 자체를 제거하지는 못합니다.
컴포넌트는 상태가 변경되거나, 부모 컴포넌트가 다시 렌더링되거나, props에 새로운 참조(reference)가 전달될 때 인해 재렌더링(rerender)될 수 있습니다. 일부 재렌더링은 필수적입니다. 하지만 다른 재렌더링들은 보이는 결과에 기여하지 않는 작업을 수행합니다.
각각 여러 개의 중첩된 컴포넌트를 가진 수백 개의 카드를 표시하는 대시보드를 고려해 보세요.
만약 애플리케이션이 작은 상태 변화가 있을 때마다 모든 카드들을 반복적으로 재계산한다면, 사용자는 필터링, 정렬 또는 메뉴를 열 때 지연을 느낄 수 있습니다.
일반적인 렌더링 문제
가상화(Virtualization) 없이 대규모 목록 렌더링하기
수천 개의 DOM 요소를 표시하는 것은 레이아웃, 메모리 및 렌더링 오버헤드를 발생시킵니다.
목록 가상화는 보이는 항목과 주변의 작은 버퍼만 렌더링합니다. react-window와 같은 라이브러리가 이 접근 방식을 구현하는 데 도움을 줄 수 있습니다.
렌더링 중에 비용이 많이 드는 계산 수행하기
대규모 정렬, 필터링 또는 집계 작업은 반복적인 렌더링을 비용이 많이 들게 만들 수 있습니다.
계산을 적절한 위치로 이동시키고, 유용할 때 결과를 캐싱하거나, 워크로드가 필요하다면 메인 스레드 외부에서 데이터를 처리하세요.
비용이 많이 드는 컴포넌트를 불필요하게 재생성하기
메모이제이션(Memoization)은 컴포넌트가 변경되지 않은 입력을 반복적으로 받고 렌더링 비용이 상당할 때 도움이 될 수 있습니다.
예를 들어, React에서는 다음과 같습니다:
import { memo } from "react";
const CourseCard = memo(function CourseCard({ course }) {
...
memo는 props가 비교 규칙에 따라 동일하게 유지될 때 일부 리렌더링을 건너뛸 수 있습니다. 하지만 모든 렌더링이 건너뛰어짐을 보장하지 않으며, 컴포넌트 자체의 상태나 소비된 컨텍스트로 인해 발생하는 리렌더링을 막지는 못합니다.
메모이제이션을 사방에 추가하기 전에 측정하세요. 불필요한 메모이제이션은 복잡성을 더하고 코드를 유지보수하기 어렵게 만들 수 있습니다.
5. 대용량 이미지와 폰트가 페이지 표시를 지연시킬 수 있음
애플리케이션이 효율적인 JavaScript와 빠른 API를 가지고 있더라도, 시각적 에셋이 늦게 도착하면 여전히 느리게 보일 수 있습니다.
대형 히어로 이미지, 최적화되지 않은 스크린샷, 과도한 일러스트레이션, 그리고 폰트 파일은 렌더링을 지연시키거나 눈에 띄는 레이아웃 이동(visible layout shifts)을 유발할 수 있습니다.
이는 콘텐츠가 많은 애플리케이션, 대시보드, 교육 플랫폼, 마케팅 웹사이트에서 특히 중요합니다.
이미지 전송 최적화
유용한 기술에는 다음이 포함됩니다:
- 뷰포트(viewport)에 적절한 크기의 이미지를 제공합니다.
- 전달 파이프라인(delivery pipeline)에서 지원하는 경우 WebP 또는 AVIF와 같은 최신 형식을 사용합니다.
- 허용할 수 없는 품질 손실 없이 이미지를 압축합니다.
- 레이아웃 이동(layout shifts)을 줄이기 위해 너비(width)와 높이(height) 치수를 제공합니다.
srcset과 sizes를 통해 반응형 이미지 후보(responsive image candidates)를 사용합니다.
- 접히는 부분 아래에 있는 이미지(below the fold)는 지연 로딩(Lazy-loading)합니다.
예시:
<img
src="https://dev.to/images/study-guide-800.webp"
srcset="
...
브라우저는 사용 가능한 정보를 기반으로 적절한 이미지 후보를 선택할 수 있습니다.
만약 주된 위쪽 영역(above-the-fold) 이미지가 가장 큰 콘텐츠 페인트(Largest Contentful Paint, LCP) 요소일 가능성이 높다면, 이를 지연 로딩하지 마십시오. 해당 이미지를 지연시키면 페이지가 더 느리게 느껴질 수 있습니다.
폰트도 중요합니다
사용자 정의 폰트(Custom fonts)는 텍스트 렌더링을 차단하거나 지연시킬 수 있고, 추가적인 네트워크 요청을 유발하거나, 대체 폰트가 적용될 때 레이아웃 이동을 일으킬 수 있습니다.
적절하게 서브셋팅된(subsetted) 폰트 파일을 사용하고, 더 적은 폰트 두께(font weights)를 고려하며, 적합한 경우 font-display: swap을 적용하는 것을 고려하십시오.
목표는 모든 시각적 세부 사항이 로드되기를 기다리는 것이 아니라, 의미 있는 콘텐츠가 빠르게 보이도록 하는 것입니다.
6. 핵심 웹 지표(Core Web Vitals)가 사용자 경험을 설명하는 데 도움을 주는 방법
Google의 Core Web Vitals는 로딩 성능, 반응성 및 시각적 안정성을 평가하는 데 유용한 측정 기준을 제공합니다.
현재의 세 가지 Core Web Vitals는 다음과 같습니다:
| 지표 (Metric) | 측정 내용 (What it measures) | 양호한 기준치 (Good threshold) |
|---|
| Largest Contentful Paint (LCP) | 주요 보이는 콘텐츠가 로드되는 속도 | 2.5초 이하 |
| ... | | |
| 이러한 기준치는 페이지 방문의 75번째 백분위수(75th percentile)를 기준으로, 적용 가능한 경우 모바일과 데스크톱으로 세분화하여 평가됩니다. | | |
Largest Contentful Paint (LCP)
느린 LCP는 주요 콘텐츠가 서버 응답 시간, 렌더링 차단 리소스(render-blocking resources), JavaScript 의존성 또는 큰 이미지에 의해 지연되고 있음을 나타낼 수 있습니다.
최적화를 선택하기 전에 실제 LCP 요소를 조사하십시오.
Interaction to Next Paint
INP가 좋지 않다는 것은 클릭, 키보드 입력 또는 기타 상호 작용이 과도한 JavaScript 작업, 비용이 많이 드는 렌더링(rendering), 또는 결과 표시 지연을 유발할 수 있음을 나타낼 수 있습니다.
브라우저 성능 추적(browser performance traces)을 사용하여 상호 작용의 어느 부분이 시간을 소비하는지 파악하십시오.
누적 레이아웃 이동 (Cumulative Layout Shift, CLS)
CLS가 좋지 않은 경우는 종종 예약된 크기(reserved dimensions)가 없는 이미지, 동적으로 삽입되는 콘텐츠, 또는 렌더링 후 레이아웃을 변경하는 글꼴(fonts)에서 발생합니다.
비동기적으로 로드되는 콘텐츠를 위해 공간을 확보하고, 요소의 크기를 고려하지 않고 기존 콘텐츠 위에 요소를 삽입하는 것을 피하십시오.
Core Web Vitals는 가치 있는 신호이지만, 단일 지표가 모든 성능 문제를 설명할 수는 없습니다. 페이지가 임계값(thresholds)을 충족하더라도 여전히 조사할 가치가 있는 답답한 상호 작용이 있을 수 있습니다.
7. 캐싱은 반복 작업을 줄일 수 있습니다