이번 주에는 웹 성능 최적화 중에서도 JavaScript 실행시간 최적화를 중심으로 공부했다.
주요 키워드는 다음과 같다.
웹 페이지를 그릴 때 브라우저는 HTML, CSS, JavaScript를 각각 처리한다.
HTML → 파싱 → DOM 생성
CSS → 파싱 → CSSOM 생성
JavaScript → 파싱 → 컴파일 → 실행
HTML은 문서 구조를 만들고, CSS는 스타일 규칙을 만든다.
반면 JavaScript는 실제 동작을 수행한다. 예를 들어 버튼을 찾고, 이벤트를 등록하고, 상태를 변경하고, DOM을 업데이트한다.
즉 JavaScript는 단순히 읽히는 것이 아니라 실행되는 코드이기 때문에 브라우저 입장에서 더 큰 비용이 든다.
React 앱에서는 JavaScript가 더 중요해진다. React 컴포넌트를 실행하고, 상태 변경에 따라 렌더링하고, 이벤트를 처리하는 대부분의 작업이 JavaScript를 통해 이루어지기 때문이다.
그래서 JavaScript 파일이 너무 크거나, 초기 실행 코드가 많거나, 불필요한 리렌더링이 많으면 사용자는 페이지가 느리다고 느낄 수 있다.
Code Splitting은 JavaScript 번들을 여러 조각으로 나누는 기법이다.
처음부터 모든 코드를 한 번에 불러오는 대신, 필요한 코드만 먼저 불러오고 나머지는 나중에 불러올 수 있다.
예를 들어 모든 페이지를 정적으로 import하면 초기 번들에 많은 코드가 포함될 수 있다.
import HomePage from './pages/HomePage';
import DetailPage from './pages/DetailPage';
import MyPage from './pages/MyPage';
이 방식은 페이지 이동은 빠를 수 있지만, 처음 로딩이 무거워질 수 있다.
반면 동적 import를 사용하면 특정 페이지 코드를 별도 chunk로 분리할 수 있다.
const DetailPage = lazy(() => import('./pages/DetailPage'));
이렇게 하면 DetailPage 코드는 처음부터 불러오지 않고, 실제로 해당 페이지가 필요해지는 순간에 로드된다.
처음에는 Code Splitting과 Lazy Loading이 같은 개념처럼 느껴질 수 있다.
하지만 정확히는 역할이 다르다.
Code Splitting
→ 코드를 여러 chunk로 나누는 것
Lazy Loading
→ 필요한 시점까지 로딩을 미루는 것
React에서 자주 사용하는 코드는 다음과 같다.
const DetailPage = lazy(() => import('./pages/DetailPage'));
여기서 역할을 나누면 다음과 같다.
import('./pages/DetailPage')
→ 번들러가 코드를 별도 chunk로 나눌 수 있게 함
→ Code Splitting
React.lazy(...)
→ 해당 컴포넌트를 필요한 시점에 렌더링하도록 함
→ Lazy Loading
즉, Code Splitting은 “나누는 것”이고 Lazy Loading은 “나중에 불러오는 것”이다.
Code Splitting을 하면 초기 로딩은 가벼워질 수 있다.
하지만 처음 방문하는 페이지에서는 해당 chunk를 새로 다운로드해야 하므로 로딩 화면이 보일 수 있다.
따라서 무조건 많이 나누는 것이 좋은 것은 아니다.
기준은 다음과 같이 잡을 수 있다.
초기 화면에 꼭 필요한 코드
→ 처음부터 로드
자주 사용하는 핵심 페이지
→ 상황에 따라 처음부터 로드
가끔 쓰는 무거운 페이지나 기능
→ Code Splitting 적용
사용자가 곧 접근할 가능성이 높은 페이지
→ Prefetching 고려
예를 들어 다음과 같은 기능은 Code Splitting 대상으로 적합하다.
차트 페이지
마크다운 에디터
지도
이미지 압축 기능
관리자 페이지
채팅방
PDF 생성 기능
이런 기능은 모든 사용자가 처음부터 사용하는 것이 아니기 때문에, 필요할 때만 불러오는 것이 더 효율적일 수 있다.
Code Splitting은 꼭 페이지 컴포넌트에만 적용하는 것이 아니다.
사용자 액션이 발생했을 때 무거운 라이브러리를 동적으로 불러올 수도 있다.
예를 들어 이미지 압축 라이브러리를 처음부터 불러오지 않고, 사용자가 이미지를 업로드할 때만 불러올 수 있다.
const handleImageChange = async (file: File) => {
const { default: imageCompression } = await import(
'browser-image-compression'
);
const compressedFile = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 1024,
});
console.log(compressedFile);
};
이 경우 React.lazy는 사용하지 않았지만 import()를 사용했기 때문에 번들러가 해당 라이브러리를 별도 chunk로 분리할 수 있다.
즉, 컴포넌트 Lazy Loading이 아니더라도 특정 기능이나 라이브러리를 필요할 때만 불러오는 방식으로 Code Splitting을 활용할 수 있다.
최적화는 감으로 하는 것이 아니라 측정이 먼저다.
무거운 코드를 찾을 때는 보통 다음 도구들을 함께 사용한다.
Chrome DevTools Network 탭
→ 어떤 JS 파일이 얼마나 다운로드되는지 확인
Chrome DevTools Performance 탭
→ JS 실행, 렌더링, 메인 스레드 부담 확인
번들 분석 도구
→ 번들 안에서 어떤 라이브러리가 큰 비중을 차지하는지 확인
Network 탭에서는 JS 파일의 크기와 다운로드 시간을 볼 수 있다.
하지만 index-abc123.js 같은 파일명만 보고는 그 안에 어떤 라이브러리가 들어 있는지 알기 어렵다.
그래서 번들 분석 도구가 필요하다.
Webpack 환경에서는 webpack-bundle-analyzer를 사용할 수 있다.
이 도구는 번들 안에 어떤 모듈이 얼마나 큰 비중을 차지하는지 시각적으로 보여준다.
하지만 Vite 프로젝트에서는 보통 rollup-plugin-visualizer를 사용한다.
Vite는 프로덕션 빌드에서 Rollup 기반으로 번들링되기 때문이다.
pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
filename: 'dist/stats.html',
open: true,
gzipSize: true,
brotliSize: true,
}),
],
});
이후 빌드하면 분석 리포트를 확인할 수 있다.
pnpm build
번들 분석 도구를 사용하면 다음과 같은 판단이 가능하다.
어떤 라이브러리가 번들을 크게 만드는지
중복으로 포함된 패키지가 있는지
Code Splitting이 잘 적용되었는지
특정 페이지 chunk가 너무 큰지
예를 들어 차트 라이브러리가 특정 페이지에서만 사용되는데 초기 번들에 포함되어 있다면, 해당 페이지를 Code Splitting 대상으로 고려할 수 있다.
CSS 최적화는 JavaScript 실행시간과 별개처럼 보일 수 있다.
하지만 실제 렌더링 과정에서는 JS, CSS, DOM이 서로 연결되어 있다.
브라우저는 화면을 그릴 때 다음 과정을 거친다.
HTML 파싱
CSS 파싱
DOM 생성
CSSOM 생성
렌더 트리 생성
레이아웃 계산
페인트
합성
JavaScript가 DOM을 변경하면 브라우저는 다시 스타일을 계산하거나 레이아웃을 계산해야 할 수 있다.
이때 CSS가 복잡하거나 불필요한 스타일이 많으면 화면 업데이트 비용이 커진다.
CSS 최적화에서 신경 쓸 수 있는 부분은 다음과 같다.
사용하지 않는 CSS 제거
복잡한 선택자 줄이기
불필요한 레이아웃 재계산 줄이기
애니메이션 성능 개선
초기 화면에 필요한 CSS 우선 로드
특히 레이아웃을 자주 바꾸는 속성은 주의해야 한다.
width
height
top
left
margin
padding
이런 속성은 요소의 크기나 위치를 바꾸기 때문에 레이아웃 계산을 다시 발생시킬 수 있다.
반면 애니메이션에서는 보통 다음 속성이 더 유리하다.
transform
opacity
예를 들어 모달이나 버튼 애니메이션을 구현할 때 top이나 height를 계속 바꾸는 것보다 transform을 사용하는 편이 성능상 유리할 수 있다.
React.memo는 컴포넌트의 불필요한 리렌더링을 줄이기 위한 기능이다.
부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 다시 렌더링될 수 있다.
하지만 자식 컴포넌트가 받는 props가 바뀌지 않았다면 다시 렌더링할 필요가 없을 수 있다.
이때 React.memo를 사용할 수 있다.
import { memo } from 'react';
interface PostItemProps {
title: string;
content: string;
}
const PostItem = ({ title, content }: PostItemProps) => {
return (
<li>
<h3>{title}</h3>
<p>{content}</p>
</li>
);
};
export default memo(PostItem);
React.memo는 이런 경우에 유용하다.
리스트 아이템 컴포넌트
렌더링 비용이 큰 컴포넌트
부모가 자주 리렌더링되는 경우
props가 자주 바뀌지 않는 경우
하지만 모든 컴포넌트에 무조건 붙이는 것은 좋지 않다.
React.memo도 이전 props와 다음 props를 비교하는 비용이 있기 때문이다.
컴포넌트가 아주 단순하거나 props가 매번 바뀐다면 효과가 거의 없을 수 있다.
useMemo는 계산 결과를 메모이제이션하는 Hook이다.
즉, 값을 매번 다시 계산하지 않고 의존성이 바뀔 때만 다시 계산하게 한다.
const filteredPosts = useMemo(() => {
return postList.filter((post) => post.title.includes(keyword));
}, [postList, keyword]);
useMemo는 다음과 같은 경우에 유용하다.
계산 비용이 큰 작업
큰 배열에 대한 filter, sort, reduce
객체나 배열 참조를 안정적으로 유지해야 하는 경우
memo 컴포넌트에 객체나 배열 props를 넘기는 경우
하지만 간단한 계산에는 굳이 사용할 필요가 없다.
const fullName = firstName + lastName;
이런 정도의 계산은 그냥 매번 계산해도 부담이 거의 없다.
또한 의존성이 자주 바뀌는 경우에는 useMemo가 계산을 막아주지 못한다.
예를 들어 keyword가 입력될 때마다 바뀐다면, filteredPosts는 결국 매번 다시 계산된다.
따라서 useMemo는 “모든 재계산을 막는 도구”가 아니라, 의존성이 바뀌지 않았을 때 이전 계산 결과를 재사용하는 도구로 이해해야 한다.
useCallback은 함수를 메모이제이션하는 Hook이다.
React 컴포넌트 안에서 함수를 선언하면 렌더링될 때마다 새로운 함수가 생성된다.
const handleClick = () => {
console.log('click');
};
이 함수가 memo된 자식 컴포넌트에 props로 전달되면, 자식 입장에서는 props가 매번 바뀐 것으로 판단될 수 있다.
이때 useCallback을 사용할 수 있다.
const handlePostClick = useCallback((postId: number) => {
console.log(`${postId}번 게시글 클릭`);
}, []);
useCallback은 다음과 같은 경우에 유용하다.
memo된 자식 컴포넌트에 함수를 props로 넘길 때
함수가 useEffect 의존성으로 들어갈 때
커스텀 훅에서 함수를 반환할 때
하지만 모든 함수에 useCallback을 붙일 필요는 없다.
단순한 이벤트 핸들러에 무조건 적용하면 코드만 복잡해질 수 있다.
세 가지는 모두 메모이제이션과 관련이 있지만 최적화 대상이 다르다.
| 개념 | 최적화 대상 | 사용 목적 |
|---|---|---|
| React.memo | 컴포넌트 | props가 바뀌지 않으면 리렌더링 방지 |
| useMemo | 값 | 계산 결과 재사용 |
| useCallback | 함수 | 함수 참조 재사용 |
간단히 정리하면 다음과 같다.
React.memo
→ 컴포넌트 렌더링 결과를 재사용
useMemo
→ 계산된 값을 재사용
useCallback
→ 함수를 재사용
이번 내용을 공부하면서 가장 중요하게 느낀 점은, 최적화는 무작정 적용하는 것이 아니라는 점이다.
성능 최적화는 다음 흐름으로 접근하는 것이 좋다.
1. 실제로 느린지 확인한다
2. DevTools로 병목 지점을 찾는다
3. 번들 크기와 실행 시간을 확인한다
4. 문제가 되는 코드나 라이브러리를 찾는다
5. 필요한 곳에만 최적화를 적용한다
무조건 React.memo, useMemo, useCallback을 붙인다고 성능이 좋아지는 것은 아니다.
오히려 코드가 복잡해지고 유지보수가 어려워질 수 있다.
중요한 것은 다음 질문을 먼저 던지는 것이다.
이 컴포넌트가 정말 자주 리렌더링되는가?
이 계산이 정말 비싼가?
이 함수 참조가 자식 리렌더링에 영향을 주는가?
이 라이브러리가 초기 번들에 꼭 필요한가?
측정 없이 최적화하기보다, 병목을 확인한 뒤 필요한 부분에 적용하는 것이 더 실무적인 접근이라고 느꼈다.
이번 주에는 JavaScript 실행시간을 줄이기 위한 여러 방법을 공부했다.
Code Splitting은 초기 번들 크기를 줄이는 데 도움이 되고, 번들 분석 도구는 어떤 코드가 번들을 무겁게 만드는지 확인하는 데 유용하다.
CSS 최적화 역시 렌더링 과정과 연결되어 있기 때문에 JavaScript 실행시간 최적화와 함께 고려해야 한다.
또한 React.memo, useMemo, useCallback은 불필요한 리렌더링이나 재계산을 줄일 수 있지만, 무조건 사용하는 도구는 아니다.
결국 성능 최적화의 핵심은 단순히 기술을 많이 적용하는 것이 아니라, 측정하고, 원인을 찾고, 필요한 곳에 적절한 최적화를 적용하는 것이다.
Code Splitting이랑 Lazy Loading을 명확하게 구분해서 설명해주신 부분이 좋았어요. React.lazy 없이도 Code Splitting이 가능하다는 것도 실제로 쓰다 보면 자주 마주치는 패턴인데 같이 다뤄주셔서 좋았습니다. 최적화는 측정 먼저라는 마지막 정리도 공감합니다!