페이지가 빠르게 로딩되는 것만으로 충분하지는 않다.
화면이 처음 나타난 이후 사용자가 스크롤하거나 버튼을 클릭하고, 모달을 열거나 애니메이션을 볼 때도 화면이 부드럽게 반응해야 한다.
이번 글에서는 런타임 성능 최적화와 관련된 핵심 개념을 정리해보겠다.
requestAnimationFrame웹 성능은 크게 로딩 성능과 런타임 성능으로 나누어 생각할 수 있다.
사용자가 페이지에 처음 진입했을 때 콘텐츠가 얼마나 빠르게 나타나는지 확인한다.
예를 들면 다음과 같다.
FCP
LCP
CLS
이미지 로딩 시간
폰트 로딩 시간
JavaScript 번들 크기
네트워크 요청 지연
이미지 크기를 줄이거나, 폰트를 최적화하고, 코드 스플리팅을 적용하는 작업은 주로 로딩 성능과 관련이 있다.
페이지가 이미 로딩된 이후 사용자 동작에 얼마나 빠르고 부드럽게 반응하는지 확인한다.
예를 들면 다음과 같다.
스크롤할 때 화면이 버벅이는가?
애니메이션이 끊겨 보이는가?
검색어를 입력할 때 타이핑이 밀리는가?
모달을 열 때 화면이 순간적으로 멈추는가?
상태 변경 시 불필요한 컴포넌트까지 다시 렌더링되는가?
런타임 성능 최적화는 화면이 실제로 그려지는 흐름과 밀접하게 연결되어 있다.
사용자가 버튼을 클릭하거나 스크롤하면 JavaScript가 실행되고, 상태나 DOM이 변경될 수 있다.
그 이후 브라우저는 화면을 다시 그리기 위해 다음 과정을 거친다.
JavaScript 실행
↓
Style Calculation
↓
Layout
↓
Paint
↓
Composite
브라우저는 움직이는 화면을 한 번에 만드는 것이 아니다.
짧은 간격으로 정지 화면을 계속 새로 만들어 보여준다.
이때 화면 한 장을 Frame이라고 한다.
1번째 Frame: 요소가 x = 0에 위치
2번째 Frame: 요소가 x = 5에 위치
3번째 Frame: 요소가 x = 10에 위치
4번째 Frame: 요소가 x = 15에 위치
이 정지 화면들이 빠르게 교체되면 사용자는 요소가 자연스럽게 움직이는 것처럼 느낀다.
FPS(Frames Per Second)는 1초 동안 표시되는 Frame의 수다.
일반적인 60Hz 모니터를 기준으로 브라우저는 1초 동안 약 60개의 Frame을 보여줄 수 있다.
1000ms ÷ 60 ≈ 16.67ms
즉, 브라우저는 약 16.67ms 안에 하나의 Frame을 준비해야 한다.
한 Frame 안에서 처리해야 하는 작업이 너무 많다고 가정해 보자.
JavaScript 실행 8ms
Style Calculation 3ms
Layout 6ms
Paint 5ms
-------------------------
총합 22ms
총 작업 시간이 16.67ms를 초과했다.
이 경우 브라우저는 다음 Frame을 제시간에 보여주지 못한다. 일부 Frame이 누락되면서 화면이 끊겨 보일 수 있다.
이를 흔히 Frame Drop 또는 Jank라고 한다.
Chrome Performance 패널에서 다음과 같은 작업을 확인했다고 가정해 보자.
긴 작업 소요 시간: 62.93ms
60FPS 기준으로 한 Frame 예산은 약 16.67ms다.
62.93ms ÷ 16.67ms ≈ 3.8
하나의 긴 작업 때문에 약 3~4개의 Frame을 제시간에 처리하지 못할 가능성이 있다.
따라서 Performance 패널에서 긴 작업을 분석할 때는 다음 질문을 함께 생각해야 한다.
이 작업 때문에 Frame 예산을 초과하고 있지는 않은가?
Frame rendering stats는 현재 페이지의 FPS와 Frame 상태를 실시간으로 확인할 수 있는 도구다.
Chrome에서 다음 순서로 확인할 수 있다.
개발자 도구 열기
→ Ctrl + Shift + P
→ Rendering 검색
→ Show Rendering 선택
→ Frame rendering stats 체크
정리하면 다음과 같다.
FPS
→ 1초 동안 표시되는 Frame 수
Frame rendering stats
→ Chrome DevTools에서 FPS 상태를 확인하는 도구
requestAnimationFrame()은 브라우저에게 다음과 같이 요청하는 API다.
다음 화면을 다시 그리기 직전에 이 함수를 실행해 줘.
일반적으로 줄여서 rAF라고 부른다.
requestAnimationFrame(() => {
console.log("다음 Frame 직전에 한 번 실행");
});
이 코드는 콜백을 한 번만 실행한다.
Frame마다 반복해서 실행하려면 콜백 내부에서 다시 requestAnimationFrame()을 호출해야 한다.
const animate = () => {
console.log("Frame마다 실행");
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
흐름은 다음과 같다.
첫 번째 Frame 직전
→ animate 실행
→ 다음 animate 실행 예약
두 번째 Frame 직전
→ animate 실행
→ 다음 animate 실행 예약
세 번째 Frame 직전
→ animate 실행
→ 다음 animate 실행 예약
60Hz 모니터라면 보통 약 16.67ms 간격으로 실행된다.
하지만 정확히 항상 같은 간격으로 실행되는 것은 아니다.
60Hz 모니터 → 약 16.67ms 간격
120Hz 모니터 → 약 8.33ms 간격
144Hz 모니터 → 약 6.94ms 간격
메인 스레드가 바쁘거나 브라우저 탭이 백그라운드에 있다면 실행 간격이 달라질 수도 있다.
requestAnimationFrame()은 콜백에 현재 시간을 전달한다.
let previousTime: number | null = null;
const animate = (currentTime: number) => {
if (previousTime !== null) {
console.log(currentTime - previousTime);
}
previousTime = currentTime;
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
60Hz 환경에서는 대략 다음과 같은 값이 출력된다.
16.67
16.66
16.72
16.64
애니메이션을 다음처럼 작성할 수도 있다.
setInterval(() => {
position += 5;
box.style.transform = `translateX(${position}px)`;
}, 16);
하지만 setInterval()은 브라우저가 실제로 화면을 그리는 시점을 고려하지 않는다.
반면 requestAnimationFrame()은 다음 repaint 직전에 실행되기 때문에 화면 갱신 타이밍에 맞춰 작업을 처리하기 쉽다.
setInterval
→ 개발자가 지정한 시간마다 실행을 시도
→ 화면 갱신 시점과 어긋날 수 있음
requestAnimationFrame
→ 다음 repaint 직전에 실행
→ 애니메이션 타이밍을 맞추기 쉬움
requestAnimationFrame()은 무거운 코드를 자동으로 가볍게 만들어 주는 기능이 아니다.
const animate = () => {
for (let index = 0; index < 100_000_000; index += 1) {
// 무거운 연산
}
requestAnimationFrame(animate);
};
한 Frame마다 무거운 작업을 실행하면 여전히 화면은 끊긴다.
핵심은 다음과 같다.
requestAnimationFrame
→ 무거운 작업을 자동으로 최적화하는 기능 X
→ 화면을 그리는 타이밍에 맞춰 작업을 예약하는 기능 O
다음 코드처럼 Frame마다 고정된 값을 더하면 모니터 주사율에 따라 이동 속도가 달라질 수 있다.
position += 5;
60Hz 환경에서는 1초에 약 300px 이동한다.
5px × 60 = 300px
120Hz 환경에서는 1초에 약 600px 이동한다.
5px × 120 = 600px
따라서 지난 Frame 이후 흐른 시간을 기준으로 이동 거리를 계산하는 편이 좋다.
let previousTime: number | null = null;
let position = 0;
const animate = (currentTime: number) => {
if (previousTime !== null) {
const deltaTime = currentTime - previousTime;
position += 0.3 * deltaTime;
box.style.transform = `translateX(${position}px)`;
}
previousTime = currentTime;
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
deltaTime은 지난 Frame 이후 흐른 시간이다.
이 방식을 사용하면 모니터 주사율이 달라도 이동 속도를 일정하게 유지하기 쉽다.
그렇지 않다.
버튼 클릭, 좋아요 상태 변경, 모달 열기처럼 일반적인 UI 상태 변경은 React에 맡기면 된다.
const [isOpen, setIsOpen] = useState(false);
<button onClick={() => setIsOpen(true)}>
모달 열기
</button>
requestAnimationFrame()은 주로 화면이 지속적으로 바뀌는 작업에 사용한다.
직접 구현한 애니메이션
드래그 앤 드롭
스크롤 위치 기반 UI
Canvas 애니메이션
게임 화면
실시간 그래프
마우스 이동을 따라가는 요소
React Profiler는 React 컴포넌트의 렌더링 성능을 측정하는 도구다.
다음과 같은 질문에 답하기 위해 사용한다.
버튼을 하나 눌렀는데 왜 화면이 버벅이는가?
검색창에 글자를 입력했는데 왜 카드 목록 전체가 다시 렌더링되는가?
어떤 컴포넌트의 렌더링 비용이 가장 큰가?
memo, useMemo, useCallback을 적용한 효과가 실제로 있는가?
React 렌더링과 브라우저 렌더링은 다르다.
React가 컴포넌트 함수를 실행하여 어떤 UI가 필요한지 계산하는 과정이다.
state 또는 props 변경
↓
컴포넌트 함수 다시 실행
↓
이전 JSX와 새로운 JSX 비교
↓
필요한 변경 사항만 실제 DOM에 반영
DOM과 CSS를 기반으로 화면을 실제로 그리는 과정이다.
Style Calculation
→ Layout
→ Paint
→ Composite
React 컴포넌트가 다시 실행되었다고 해서 실제 DOM이 반드시 변경되는 것은 아니다.
하지만 컴포넌트 함수가 불필요하게 많이 실행되고 내부 연산까지 반복된다면 React 자체의 계산 비용이 커질 수 있다.
React Developer Tools를 설치하면 Chrome 개발자 도구에서 Profiler 탭을 사용할 수 있다.
개발자 도구 열기
→ Profiler 탭
→ Record 버튼 클릭
→ 분석할 동작 하나 수행
→ 기록 종료
분석할 동작은 하나씩 나누어 확인하는 것이 좋다.
검색창에 글자 한 개 입력
좋아요 버튼 클릭
수량 증가 버튼 클릭
모달 열기
페이지 이동
Profiler 화면 상단에는 여러 개의 막대가 표시된다.
각 막대는 하나의 Commit을 의미한다.
Render Phase
→ 무엇이 바뀌어야 하는지 계산
Commit Phase
→ 계산된 변경 사항을 실제 DOM에 반영
React가 계산한 결과를 실제 화면에 반영한 한 번의 작업이 하나의 Commit이다.
막대가 높을수록 렌더링 비용이 큰 Commit일 가능성이 있다.
특정 Commit을 클릭하면 컴포넌트 구조를 확인할 수 있다.
App
└── PostListPage
├── SearchInput
├── PostItem
├── PostItem
└── PostItem
검색어를 한 글자 입력했는데 모든 PostItem이 다시 렌더링되었다면 다음 내용을 확인해야 한다.
모든 카드가 다시 렌더링되어야 하는가?
카드에 전달되는 props가 실제로 변경되었는가?
카드 내부에 무거운 계산이 있는가?
Ranked Chart에서는 렌더링 비용이 큰 컴포넌트를 순서대로 확인할 수 있다.
1. PostListPage 8.3ms
2. PostItem 4.1ms
3. SearchInput 0.8ms
4. Header 0.3ms
우선 시간이 오래 걸리는 컴포넌트부터 확인한다.
Profiler 설정에서 다음 옵션을 활성화하면 각 컴포넌트가 렌더링된 원인을 확인할 수 있다.
Record why each component rendered while profiling
대표적인 원인은 다음과 같다.
props 변경
state 변경
Hook 변경
부모 컴포넌트 렌더링
const Parent = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log("카드 클릭");
};
return (
<>
<button onClick={() => setCount((prev) => prev + 1)}>
{count}
</button>
<PostItem onClick={handleClick} />
</>
);
};
count가 바뀔 때마다 Parent가 다시 실행된다.
그 과정에서 handleClick 함수도 다시 만들어진다.
이전 렌더링의 handleClick
!==
다음 렌더링의 handleClick
PostItem을 memo()로 감싸더라도 함수 props의 참조가 달라졌기 때문에 다시 렌더링될 수 있다.
const PostItem = memo(({ onClick }: PostItemProps) => {
return <button onClick={onClick}>게시글</button>;
});
필요하다면 useCallback()을 고려할 수 있다.
const handleClick = useCallback(() => {
console.log("카드 클릭");
}, []);
하지만 모든 코드에 무조건 useCallback()을 추가하면 안 된다.
Profiler로 불필요한 렌더링 확인
→ 원인 확인
→ 필요한 경우에만 최적화 적용
→ Profiler로 개선 효과 검증
모달 상태 변경
→ Page 렌더링
→ PostList 렌더링
→ PostItem 수백 개 렌더링
<PostItem style={{ marginTop: 12 }} />
객체는 내용이 같아도 렌더링할 때마다 새로 생성된다.
{ marginTop: 12 } !== { marginTop: 12 }
const filteredProducts = products
.filter((product) => product.name.includes(keyword))
.sort((a, b) => b.price - a.price);
데이터가 많고 계산 비용이 실제로 크다면 useMemo()를 고려할 수 있다.
const filteredProducts = useMemo(() => {
return products
.filter((product) => product.name.includes(keyword))
.sort((a, b) => b.price - a.price);
}, [products, keyword]);
Zustand 같은 전역 상태 관리 도구를 사용할 때는 필요한 상태만 선택하여 구독하는 것이 좋다.
const isModalOpen = useModalStore(
(state) => state.isModalOpen,
);
React의 <Profiler> 컴포넌트를 직접 사용할 수도 있다.
import {
Profiler,
type ProfilerOnRenderCallback,
} from "react";
const handleRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
) => {
console.table({
영역: id,
렌더링종류: phase,
실제렌더링시간: `${actualDuration.toFixed(2)}ms`,
기준렌더링시간: `${baseDuration.toFixed(2)}ms`,
});
};
const PostPage = () => {
return (
<Profiler id="PostList" onRender={handleRender}>
<PostList />
</Profiler>
);
};
주요 값은 다음과 같다.
| 값 | 의미 |
|---|---|
id | 측정 영역 이름 |
phase | 최초 렌더링인지 업데이트인지 구분 |
actualDuration | 이번 렌더링에서 실제로 걸린 시간 |
baseDuration | 최적화가 없다고 가정했을 때 예상되는 기준 비용 |
렌더링이 발생했다고 해서 무조건 문제가 있는 것은 아니다.
React에서 상태가 변경되면 다시 렌더링되는 것이 자연스럽다.
다음과 같은 경우에 최적화를 고민하면 된다.
사용자가 체감할 정도로 느림
렌더링 시간이 실제로 큼
불필요한 컴포넌트가 대량으로 반복 렌더링됨
동일한 무거운 계산이 자주 반복됨
memo, useMemo, useCallback도 공짜가 아니다.
값을 비교하고 메모리에 저장하는 비용이 추가되므로, Profiler로 실제 병목을 확인한 뒤 필요한 경우에만 적용하는 것이 좋다.
Performance Timeline API는 브라우저가 수집한 성능 기록을 시간순으로 확인할 수 있게 해 주는 API 체계다.
브라우저 내부에 성능 기록용 타임라인이 존재한다고 생각하면 쉽다.
페이지 진입
↓
HTML 문서 요청
↓
CSS 파일 다운로드
↓
이미지 다운로드
↓
첫 콘텐츠 표시
↓
사용자 정의 작업 시작
↓
사용자 정의 작업 종료
이 타임라인에 여러 종류의 기록이 쌓이고, JavaScript 코드에서 기록을 조회하거나 관찰할 수 있다.
Chrome DevTools Performance 탭은 개발자가 직접 화면을 열고 녹화하여 분석한다.
개발자 도구 열기
→ Performance 탭
→ Record
→ 동작 수행
→ 기록 종료
→ 결과 분석
Performance Timeline API는 코드에서 성능 데이터를 조회하거나 수집한다.
const entries = performance.getEntries();
console.log(entries);
| 방식 | 특징 |
|---|---|
| Chrome Performance 패널 | 사람이 직접 상세 흐름을 시각적으로 분석 |
| Performance Timeline API | 코드에서 성능 기록을 조회하거나 수집 |
| React Profiler | React 컴포넌트 렌더링 비용에 집중 |
타임라인에 저장되는 성능 기록 하나를 PerformanceEntry라고 한다.
{
name: "https://example.com/image.webp",
entryType: "resource",
startTime: 324.5,
duration: 81.2
}
| 값 | 의미 |
|---|---|
name | 기록 이름 |
entryType | 기록 종류 |
startTime | 측정 시작 시점 |
duration | 걸린 시간 |
모든 기록을 조회할 수 있다.
const entries = performance.getEntries();
console.log(entries);
특정 종류의 기록만 조회할 수도 있다.
const resources =
performance.getEntriesByType("resource");
console.log(resources);
특정 이름을 가진 기록만 조회할 수도 있다.
const results =
performance.getEntriesByName("filter-posts");
console.log(results);
entryType | 의미 |
|---|---|
"navigation" | 페이지 진입과 HTML 문서 로딩 |
"resource" | 이미지, CSS, JavaScript 등의 리소스 로딩 |
"mark" | 개발자가 직접 표시한 특정 시점 |
"measure" | 개발자가 직접 측정한 구간 |
"paint" | 콘텐츠가 화면에 그려진 시점 |
"longtask" | 메인 스레드를 오래 점유한 작업 |
performance.mark()는 타임라인에 특정 시점을 기록한다.
performance.mark("filter-start");
스톱워치의 시작 지점을 표시하는 것과 비슷하다.
performance.measure()는 두 시점 사이의 시간을 계산하여 타임라인에 기록한다.
performance.mark("filter-start");
// 측정할 작업
performance.mark("filter-end");
performance.measure(
"filter-posts",
"filter-start",
"filter-end",
);
performance.mark("filter-start");
const filteredPosts = postList.filter((post) =>
post.title.includes(keyword),
);
performance.mark("filter-end");
performance.measure(
"filter-posts",
"filter-start",
"filter-end",
);
const results =
performance.getEntriesByName("filter-posts");
console.log(results);
결과는 대략 다음과 같다.
[
{
name: "filter-posts",
entryType: "measure",
startTime: 824.4,
duration: 1.72,
},
]
duration: 1.72라면 필터링에 약 1.72ms가 걸렸다는 의미다.
간단한 측정에는 performance.now()를 사용할 수도 있다.
const startTime = performance.now();
const filteredPosts = postList.filter((post) =>
post.title.includes(keyword),
);
const endTime = performance.now();
console.log(endTime - startTime);
차이는 다음과 같다.
| 방식 | 특징 |
|---|---|
performance.now() | 간단하게 시간 차이를 확인하기 좋음 |
performance.mark() + performance.measure() | 의미 있는 이름을 붙여 타임라인에 기록하기 좋음 |
같은 이름으로 측정하면 결과가 계속 누적된다.
performance.clearMarks();
performance.clearMeasures();
특정 이름의 기록만 지울 수도 있다.
performance.clearMarks("filter-start");
performance.clearMarks("filter-end");
performance.clearMeasures("filter-posts");
새로운 성능 기록이 생길 때마다 자동으로 감지하려면 PerformanceObserver를 사용할 수 있다.
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
console.log(entry.name, entry.duration);
});
});
observer.observe({
entryTypes: ["measure"],
});
이후 새로운 measure 기록을 만들면 Observer가 자동으로 감지한다.
performance.mark("filter-start");
// 측정할 작업
performance.mark("filter-end");
performance.measure(
"filter-posts",
"filter-start",
"filter-end",
);
일부 환경에서는 "longtask" 타입을 관찰하여 메인 스레드를 오래 막은 작업을 감지할 수 있다.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log("긴 작업 감지:", entry.duration);
});
});
observer.observe({
entryTypes: ["longtask"],
});
Long Task는 메인 스레드를 50ms 이상 점유한 작업이다.
긴 작업 감지: 72.4
이 경우 메인 스레드가 약 72.4ms 동안 막혀 있었다는 의미다.
60FPS 기준 한 Frame 예산은 약 16.67ms이므로 화면이 순간적으로 끊길 가능성이 있다.
각 도구는 서로 대체하는 관계가 아니다.
문제 원인을 좁히기 위해 함께 사용한다.
| 도구 | 중심 관점 | 확인할 내용 |
|---|---|---|
| Frame rendering stats | 사용자 체감 | FPS가 실제로 떨어지는가? |
requestAnimationFrame() | 렌더링 타이밍 | 화면 갱신 타이밍에 맞춰 작업하고 있는가? |
| React Profiler | React 컴포넌트 | 불필요한 리렌더링이 발생하는가? |
| Performance Timeline API | 코드 기반 측정 | 특정 로직에 몇 ms가 걸리는가? |
| Chrome Performance 패널 | 브라우저 전체 흐름 | JS, Layout, Paint 중 무엇이 병목인가? |
실제 화면에서 버벅임을 발견했다면 다음 순서로 접근할 수 있다.
사용자가 화면 버벅임을 느낌
↓
Frame rendering stats로 FPS 저하 확인
↓
React Profiler로 불필요한 리렌더링 확인
↓
Performance Timeline API로 특정 로직 시간 측정
↓
Chrome Performance 패널로
JavaScript, Layout, Paint 흐름 분석
↓
원인에 맞춰 개선
↓
같은 조건에서 다시 측정
예를 들어 검색 기능이 느리다면 다음과 같이 원인을 구분할 수 있다.
검색 요청 시간이 긴가?
데이터 가공 시간이 긴가?
React 렌더링 시간이 긴가?
Layout 또는 Paint 비용이 큰가?
막연히 화면이 느리다고 판단하는 것이 아니라, 구간별로 시간을 측정하여 실제 병목을 찾아야 한다.
FPS
→ 1초 동안 표시되는 Frame 수
Frame rendering stats
→ Chrome DevTools에서 FPS 상태를 확인하는 도구
requestAnimationFrame
→ 다음 repaint 직전에 작업을 실행하도록 예약하는 API
React Profiler
→ React 컴포넌트의 렌더링 비용과 불필요한 리렌더링을 확인하는 도구
Performance Timeline API
→ 브라우저 성능 기록을 조회하고 특정 작업 시간을 코드로 측정하는 API 체계
런타임 성능 최적화의 핵심도 로딩 성능 최적화와 마찬가지로
측정
→ 병목 확인
→ 필요한 부분만 개선
→ 다시 측정
이 과정을 반복하여 실제 사용자 경험을 개선해야 한다.
항상 실제 병목을 먼저 확인한 뒤에 비용 대비 효율이 있을 때만 최적화를 적용하는 게 중요한 것 같아요. 최적화 과정을 흐름대로 설명해주신 거 인상깊게 봤습니다. 각 원인에 맞는 방법을 사용하는 게 중요한 것 같네요!!