웹 개발에서 성능은 곧 사용자 경험, 그 자체입니다.
웹 페이지가 1초 늦게 로드되면 전환율은 7%까지 떨어지고, 3초 이상 느리면 사용자의 53%가 이탈한다고 해요.
프론트엔드 개발자는 점점 더 많은 기능을 담당하게 되었지만, 그만큼 성능에 대한 책임도 커졌어요.
성능 이슈는 단순히 “빠르게 만들자”의 문제가 아니라, 렌더링 구조, 리소스 최적화, 이벤트 핸들링, 그리고 사용자 네트워크 환경까지 고려하는 일입니다.
구현은 공식 문서나 라이브러리 가이드가 도와주지만, 성능 최적화는 어디에도 정답이 없고, 개발자가 직접 체감하고 해결해야만 하는 영역입니다.
저는 React DevTools Profiler로 병목 구간을 찾고, useMemo / useCallback / memo로 불필요한 렌더링을 없애서 화면이 실제로 그려지는 과정(Frame Rendering)을 개선한다는 주제를 다뤄보려고 합니다!
들어가며

성능 최적화를 처음 시도할 때 팀원분이 남겨주신 질문입니다.
"모든 부분에 메모이제이션 vs 필요한 부분만 메모이제이션 중 어떤 의견이 더 본인과 맞는 것 같았나요?"
적용 기준이 뭔지 스스로도 흐릿했기 떄문에 당시에는 확신 있게 답하지 못했습니다..
그런데 무작정 메모이제이션을 붙이면 오히려 성능이 나빠질 수 있습니다. useMemo와 useCallback은 계산 결과나 함수 참조를 메모리에 저장해두는 비용이 있기 때문입니다. 그래서 아래와 같이 가벼운 계산이라면 매번 새로 실행하는 게 더 빠를 것입니다.
function sum(a, b) {
return a + b;
}
올바른 렌더링 최적화 순서는 이렇습니다.
측정 → 병목 발견 → 원인 파악 → 최적화 적용 → 재측정
이번 주에는 실제 프로젝트 예시로, 렌더링 최적화 흐름을 따라가보겠습니다!
1. 성능 측정 먼저! Lighthouse와 React Profiler
Lighthouse 측정 환경부터 고정하기
측정 결과가 매번 달라지면 최적화 효과를 알 수 없기 때문에 측정 전에 환경을 먼저 고정해야 합니다.
반드시 지켜야 하는 것들
첫째, 시크릿 모드에서 측정합니다. 브라우저 확장 프로그램이 성능 측정에 영향을 주기 때문이예요.

Chrome DevTools에 "Chrome extensions negatively affected this page's load performance"라는 위와 같은 경고가 뜨면, 확장 프로그램 때문에 점수가 낮게 잡히고 있다는 신호입니다.
둘째, 프로덕션 빌드에서 측정합니다. 로컬 개발 서버에는 HMR, Fast Refresh, Source Map이 포함되어 있어 실제 환경보다 훨씬 느리게 나옵니다.
pnpm build # 프로덕션 빌드 생성
pnpm preview # 빌드 결과를 로컬에 띄우기
# 이후 시크릿 모드에서 Lighthouse 실행
셋째, 같은 조건으로 여러 번 측정합니다. 네트워크 상태, 서버 부하에 따라 결과가 달라질 수 있기 때문에 최소 3~5회 측정해서 평균을 보는 게 정확합니다.
실제로 로컬 측정과 빌드+시크릿 모드 측정 결과는 꽤 큰 차이가 났습니다. 점수 1-2점 차이보다 측정 항목과 개선점에 집중하는 것이 더 나은 전략일 거 같습니당. 👍

Lighthouse 주요 지표 이해하기
| 지표 | 의미 | general 목표 |
|---|---|---|
| FCP (First Contentful Paint) | 첫 콘텐츠가 화면에 나타나는 시간 | 1.8초 이하 |
| LCP (Largest Contentful Paint) | 가장 큰 콘텐츠 요소가 렌더링되는 시간 | 2.5초 이하 |
| TBT (Total Blocking Time) | 메인 쓰레드가 차단된 총 시간 | 200ms 이하 |
| CLS (Cumulative Layout Shift) | 예상치 못한 레이아웃 이동 누적 수치 | 0.1 이하 |
| SI (Speed Index) | 콘텐츠가 얼마나 빠르게 채워지는지 | 3.4초 이하 |
Lighthouse를 통해 결과를 알 수 있습니다. TBT가 높다면 메인 쓰레드가 막히고 있다는 신호인데, 구체적으로 어떤 함수, 컴포넌트가 원인인지는 알려주지 않습니다. 그래서 그때 쓰는 도구가 React DevTools Profiler입니다!
React DevTools Profiler로 병목 찾기
설치와 기본 사용법
Chrome 웹 스토어에서 React Developer Tools를 설치하면 DevTools에 Profiler 탭이 생깁니다.

사용법은 아래와 같습니다.

Flame Graph 읽는 법
녹화를 멈추면 Flame Graph가 표시됩니다.

그래프의 각 요소 의미를 살펴보면,
- 가로 너비 = 렌더링에 걸린 시간을 의미해요. 넓을수록 느리다른 뜻
- 색상 = 렌더링 빈도를 의미하고 노란색/주황색일수록 자주 렌더링된다는 뜻
- 회색 컴포넌트 = 이번 렌더에서 실제로 렌더링되지 않은 컴포넌트를 의미해요 (memo 등이 막아주었다는 의미~)
Highlight updates when components render 옵션

Profiler 설정에서 이 옵션을 켜면, 리렌더링이 발생할 때 해당 컴포넌트 테두리가 깜빡입니다. 코드를 보지 않고도 어떤 컴포넌트가 불필요하게 렌더링되는지 눈으로 바로 확인할 수 있습니다.

상향식 (Bottom-up) 탭으로 실제 원인 함수 찾기
Flame Graph는 전체 트리를 보여주지만, "어떤 함수가 가장 오래 걸렸는가"를 정확히 찾으려면 Bottom-up 탭이 유용합니다.
- 지체 시간 (Self time) = 해당 함수 자체에서 소요된 시간
- 총 시간 (Total time) = 자식 함수 포함 총 소요 시간
지체 시간이 높은 함수가 실제 병목입니다. Total time만 보면 부모 컴포넌트가 오래 걸린 것처럼 보이지만, 실제 원인은 그 안의 특정 자식일 수 있습니다.

- 사진 상에서는 스타일 다시 계산이 191ms로 1위였고, 전체의 27.7%를 차지하고 있었어요. Vanilla Extract 정적 CSS-in-JS를 쓰고 있으니 런타임 스타일 계산은 아닐 테고, 컴포넌트가 너무 자주 리렌더링되면서 레이아웃 재계산이 따라오는 케이스일 가능성이 높을 거 같아요.
- 프로파일링 오버헤드 (측정 도구 자체의 비용이라 실제 앱 성능 X)
2. useMemo, useCallback, memo 적용 기준
측정을 통해 느린 부분을 찾았다면 이제 최적화를 적용해야합니다. 그 적용 기준을 정리해볼게요!
React 리렌더링의 기본 규칙
최적화 방법들을 이해하기 전에 React가 언제 리렌더링하는지 알아야 합니다.
컴포넌트는 아래 세 가지 중 하나가 바뀌면 리렌더링됩니다.
- state가 변경될 때
- props가 변경될 때
- 부모 컴포넌트가 리렌더링 될 때 (props가 바뀌지 않더라도)
부모가 리렌더링되면 자식 컴포넌트는 props가 동일해도 기본적으로 함께 리렌더링됩니다. 이런 상황이 불필요한 렌더링입니다.
memo: 컴포넌트 자체를 메모이제이션
memo로 컴포넌트를 감싸면 props가 변경될 때만 리렌더링됩니다. 부모가 리렌더링되어도 props가 같으면 스킵합니다.
// before: 부모가 리렌더링될 때마다 함께 렌더링됨
const BoardItem = ({ content, createdAt }: BoardItemProps) => {
return <button>...</button>
}
// after: props가 바뀔 때만 렌더링됨
const BoardItem = memo(({ content, createdAt }: BoardItemProps) => {
return <button>...</button>
})
그렇다면 memo의 적용 기준은,
- 부모가 자주 리렌더링되는데, 자식의 props는 자주 바뀌지 않을 때
- 렌더링 비용이 큰 컴포넌트 (복잡한 계산, 긴 리스트 항목 등) 일 때 사용하면 좋겠죠.
다만 주의할 점은,
props로 객체나 함수를 넘기면 효과가 없습니다. JavaScript에서 {} !== {}이기 때문에 memo가 매번 props가 바뀌었다고 판단합니다. 이때는 useMemo와 useCallback이 필요합니다.
useMemo: 계산 결과를 메모이제이션
의존성 배열의 값이 바뀔 때만 계산을 다시 실행하고, 그 사이에는 이전 결과를 재사용합니다.
// before: 컴포넌트가 렌더링될 때마다 정규식 생성 + split 실행
const SearchHighlight = ({ searchTerm, content }: SearchHighlightProps) => {
const splitContent = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.split(new RegExp(`(${searchTerm})`, "giu"))
return <p>{splitContent.map(...)}</p>
}
// after: searchTerm이나 content가 바뀔 때만 재계산
const SearchHighlight = ({ searchTerm, content }: SearchHighlightProps) => {
const splitContent = useMemo(() => {
const escaped = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const reg = new RegExp(`(${escaped})`, "giu")
return content.split(reg)
}, [searchTerm, content])
return <p>{splitContent.map(...)}</p>
}
검색 결과 리스트에서 각 항목마다 이 연산이 반복된다면, 검색어가 바뀌지 않는 한 재계산을 막을 수 있습니다.
useMemo의 적용 기준
- 정규식 생성, 대용량 배열 필터링, 정렬처럼 계산 비용이 클 때
- 매번 새로운 참조가 생성돼서 자식 컴포넌트의 memo를 무력화할 때
다만, 단순한 계산은 useMemo의 메모리에 저장하고 비교하는 비용이 오히려 더 클 수 있어서 주의해야합니다. (역효과!)
// useMemo가 불필요한 경우
const doubled = useMemo(() => count * 2, [count]) // 그냥 count * 2가 빠름
// 이런 건 useMemo 효과적
const filtered = useMemo(
() => largeList.filter(item => item.category === selectedCategory),
[largeList, selectedCategory]
)
useCallback: 함수 참조를 메모이제이션
useCallback은 함수를 메모이제이션합니다. 의존성이 바뀌지 않으면 동일한 함수 참조를 유지합니다.
// before: 부모가 리렌더링될 때마다 handleSearch가 새로 생성됨
// → SearchResultList의 useEffect 의존성이 매번 바뀌어 재실행
const handleSearch = (page: number = 1) => {
setAppliedCategory(categoryKorToEng(category))
setAppliedKeyword(keyword.trim())
setAppliedPage(page)
setSearchParams({ category: categoryKorToEng(category), keyword, page: String(page) })
}
// after: category, keyword가 바뀔 때만 새 함수 생성
const handleSearch = useCallback((page: number = 1) => {
const engCategory = categoryKorToEng(category)
setAppliedCategory(engCategory)
setAppliedKeyword(keyword.trim())
setAppliedPage(page)
setSearchParams({ category: engCategory, keyword, page: String(page) })
}, [category, keyword, setSearchParams])
이 경우 handleSearch는 Pagination의 onPageChange로 전달되고, handleDataLoad는 SearchResultList의 useEffect 의존성으로 사용됩니다. useCallback 없이는 부모가 렌더링될 때마다 함수가 새로 생성되어 불필요한 useEffect 재실행이 발생합니다.
useCallback의 적용 기준
- 함수가 memo로 감싼 자식 컴포넌트에 props로 전달될 때
- 함수가 useEffect의 의존성 배열에 포함될 때
적용하지 말아야 할 때
단순 setState 호출 함수에는 useCallback이 오히려 오버헤드입니다.
// useCallback 불필요한 경우: 단순 setState는 참조가 안정적임
const onCategoryChange = useCallback((newCategory: string) => {
setCategory(newCategory)
}, [])
// 그냥 이렇게 써도 됨!
const onCategoryChange = (newCategory: string) => {
setCategory(newCategory)
}
3. 실제 프로젝트 적용
최적화 전 상태 파악
rollup-plugin-visualizer로 번들을 분석하면 어떤 라이브러리가 얼마나 공간을 차지하는지 한눈에 볼 수 있습니다.
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
visualizer({
filename: './dist/report.html',
open: true, // 빌드 후 자동으로 브라우저에 열림
})
]
})
pnpm build # 빌드하면 dist/report.html이 열림

treemap에서 큰 사각형이 큰 번들입니다. 즉 가장 큰 용량을 차지하는 부분은 가장 큰 영역인 react-router, @tanstack/query-core, react-dom 그외 axios .. 등이 있었어요.
Before 최적화 전 측정
- vite-size 결과:
pnpm run size
[{"name":"Total Size","size":3794.023,"gzip":3482.403}]
즉 최적화 전 번들 크기는
- Total Size: 3794 KB
- Gzip Size: 3482 KB
참고
- 번들 크기 (Total Size): 3794.023
번들 크기는 최종적으로 생성된 파일의 전체 크기를 의미해요. 이 값은 소스 코드와 모든 의존성을 포함한 원본 상태의 파일 크기를 나타냅니다.
- Gzip 크기 (Gzip Size): 3482.403
압축 후 예상되는 크기, 실제 네트워크 전송 시 중요한 값이예요.
- 소스 맵 크기 (Map Size)
소스 맵은 번들된 코드와 원본 코드 사이의 매핑 정보를 담고 있어, 개발자가 디버깅할 때 원본 코드를 쉽게 참조할 수 있도록 도와준다고 해요. 이는 실제로 실행되지 않으며, 배포 시 제외하거나 별도로 관리하는 경우가 많아요.
useMemo 적용: 날짜 포맷팅과 문자열 처리
리스트 항목처럼 같은 컴포넌트가 반복 렌더링되는 곳에서 매번 포맷팅 함수가 실행되는 건 낭비입니다.
날짜 포맷팅 메모이제이션
// BoardItem.tsx — 적용 전
const BoardItem = ({ content, createdAt }: BoardItemProps) => {
const date = formatDateForList(createdAt) // 렌더링마다 실행
return <button>...</button>
}
// 적용 후
const BoardItem = ({ content, createdAt }: BoardItemProps) => {
const date = useMemo(() => formatDateForList(createdAt), [createdAt])
return <button>...</button>
}
게시판 목록에는 BoardItem이 수십 개 렌더링됩니다. 부모가 리렌더링될 때마다 모든 항목에서 날짜 변환 함수가 실행되는 것을 createdAt이 바뀔 때만 실행되도록 막았습니다.
검색 하이라이트 메모이제이션
// 적용 전 SearchHighlight.tsx
const SearchHighlight = ({ searchTerm, content, styleCss }: SearchHighlightProps) => {
const textWithSpecialSymbol = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const reg = new RegExp(`(${textWithSpecialSymbol})`, "giu")
const splitContent = content.split(reg) // 렌더링마다 정규식 생성 + split
return <p>...</p>
}
// 적용 후
const SearchHighlight = ({ searchTerm, content, styleCss }: SearchHighlightProps) => {
const splitContent = useMemo(() => {
const textWithSpecialSymbol = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const reg = new RegExp(`(${textWithSpecialSymbol})`, "giu")
return content.split(reg)
}, [searchTerm, content])
return <p>...</p>
}
검색 결과 페이지에서는 SearchHighlight가 각 결과 항목의 제목과 본문에 모두 적용됩니다. 검색어가 바뀌지 않는 한 정규식을 매번 새로 만들 이유가 없습니다.
카테고리 레이블 변환 메모이제이션
// 적용 전 SearchResultListItem.tsx
const SearchResultListItem = ({ searchTerm, searchResult }: SearchResultListItemProps) => {
const label = categoryEngToKor(searchResult.category) // 렌더링마다 실행
// ...
}
// 적용 후
const SearchResultListItem = ({ searchTerm, searchResult }: SearchResultListItemProps) => {
const label = useMemo(
() => categoryEngToKor(searchResult.category),
[searchResult.category]
)
// ...
}
useCallback 적용: 함수 참조 안정화
// 적용 전 SearchContainer.tsx
const SearchContainer = () => {
const handleSearch = (page: number = 1) => {
const engCategory = categoryKorToEng(category)
setAppliedCategory(engCategory)
setAppliedKeyword(keyword.trim())
setAppliedPage(page)
setSearchParams({ category: engCategory, keyword, page: String(page) })
}
const handleDataLoad = (data: PostsSearchDataType) => {
setPaginationData(data)
}
// ...
}
// 적용 후
const SearchContainer = () => {
const handleSearch = useCallback((page: number = 1) => {
const engCategory = categoryKorToEng(category)
setAppliedCategory(engCategory)
setAppliedKeyword(keyword.trim())
setAppliedPage(page)
setSearchParams({ category: engCategory, keyword, page: String(page) })
}, [category, keyword, setSearchParams])
const handleDataLoad = useCallback((data: PostsSearchDataType) => {
setPaginationData(data)
}, [])
// ...
}
handleSearch는 Pagination 컴포넌트의 onPageChange로 전달됩니다. handleDataLoad는 SearchResultList 내부의 useEffect 의존성으로 사용됩니다.
useCallback 없이는 SearchContainer가 리렌더링될 때마다 두 함수가 새로 생성되고, handleDataLoad의 참조가 바뀌어 useEffect가 불필요하게 재실행됩니다.
불필요한 useCallback 제거
메모이제이션은 많이 쓴다고 좋은 게 아닙니다. 단순 setState 호출 함수에 useCallback을 붙이는 건 오히려 오버헤드입니다.
// useSearchForm.ts — 제거 전 (불필요한 useCallback)
export const useSearchForm = () => {
const [category, setCategory] = useState('ALL')
const [keyword, setKeyword] = useState('')
const onCategoryChange = useCallback((newCategory: string) => {
setCategory(newCategory)
}, []) // 의존성이 없기 떄문에 → 의미 없는 메모이제이션
const onKeywordChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value)
}, [])
return { category, keyword, onCategoryChange, onKeywordChange }
}
// 제거 후
export const useSearchForm = () => {
const [category, setCategory] = useState('ALL')
const [keyword, setKeyword] = useState('')
const onCategoryChange = (newCategory: string) => {
setCategory(newCategory)
}
const onKeywordChange = (e: ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value)
}
return { category, keyword, onCategoryChange, onKeywordChange }
}
setCategory, setKeyword 이 함수들만 호출하는 경우 useCallback을 붙이는 것은 메모리 저장 비용만 추가합니다.
제거 후 성능 비교
최적화 결과

메모이제이션 PR 적용 후 성능 점수가 2점 향상되었고, 이후 이미지 최적화, tree shaking, 폰트, 캐시 최적화까지를 통해 전체 성능을 크게 향상 시켰었습니다.

4. 최적화 적용 기준 정리
실제로 적용하면서 세운 기준을 정리해보았어요.
useMemo를 쓸 때
- 리스트 항목처럼 같은 컴포넌트가 많이 렌더링되고, 내부에 비용 있는 계산이 있을 때
- 정규식 생성, 대용량 필터링/정렬 처럼 명백히 무거운 연산
- 결과 객체/배열이 자식에게 props로 전달되어 memo를 무력화하는 상황
useCallback을 쓸 때
- 함수가 memo로 감싼 자식 컴포넌트에 props로 내려갈 때
- 함수가 useEffect의 의존성 배열에 들어갈 때
쓰지 말아야 할 때
- 단순 setState 호출만 하는 함수
- 계산 결과를 해당 컴포넌트에서만 쓰고, 자식에게 전달하지 않을 때
- 의존성 배열이 렌더링마다 바뀌어 어차피 매번 재실행될 때
즉 메모이제이션 = 이전에 한 걸 기억한다
단, 기억하는 것도 비용이라는 것 주의하기
마무리
이 글에서 다룬 내용을 요약해보자면,
- 성능 측정은 시크릿 모드 + 프로덕션 빌드 기준으로 해야 의미가 있고
- Lighthouse는
결과를 보여주고, Profiler는원인을 보여주기 때문에 둘을 함께 써야함! - useMemo는 비싼 계산, useCallback은 함수 참조 안정화, memo는 불필요한 리렌더링 차단이 목적 (단순 setState 호출에 useCallback을 붙이는 건 오히려 역효과)
- 최적화는 측정 → 발견 → 적용 → 재측정 순서로 진행
번외: 웹 성능을 개선하는 여러가지 방법 (ft. React Compiler)
- React Profiler와 마찬가지로 Performance Timeline API를 통해 렌더링 비용을 측정하고 개선할 수 있어요.
- FPS 유지, requestAnimationFrame 활용으로 렌더링 타이밍을 제어할 수 있어요.
사실 useMemo, useCallback 사용 전략에 대해 이야기 했지만, 저는 최근 프로젝트에서 React Compiler를 사용했어요.

React Compiler란?
빌드 시점에 자동으로 최적의 메모이제이션을 적용해서 앱이 필요할 때만 리렌더링되도록 하고, 개발자가 기능 구현에만 집중할 수 있도록 돕는 것
설치: https://ko.react.dev/learn/react-compiler/installation
공식 문서에서도 수동 메모이제이션에 대해 "지루하고 실수하기 쉬우며 유지보수할 코드를 늘린다"고 언급하면서 컴파일러 사용을 권장하고 있더라구요. (https://ko.react.dev/learn/react-compiler)
라이브러리 사용은 자제하고 싶지만, 딱히 쓰지 않을 이유를 못찾아서 수동 메모이제이션 vs 리액트 컴파일러 사용 중 여러분은 어떤 의견이신지 궁금했어요!
✨ React Compiler 사용 결과: 수동 메모이제이션 없이 자동으로 최적화된 모습!

논의점에 대한 멘토님 답변
추상화가 올라갔다고 해서 그 아래 개념이 사라진 게 아니다라는 말이 특히 인상 깊었어요. 수동으로 메모이제이션을 적용해보면서, 렌더링이 언제 일어나는지 직접 추적하고 적용 기준을 정리해볼 수 있는 기회였던 거 같습니다!
읽어주셔서 감사하고, 궁금한 점이 있다면 언제든 댓글 남겨주세요 💬
'web' 카테고리의 다른 글
| React 테스트 전략 - TDD (0) | 2026.07.08 |
|---|---|
| 폴더 구조와 아키텍처 (0) | 2026.07.08 |
| TanStack Query 원리부터 제대로 쓰기 (0) | 2026.07.08 |
| 컴포넌트를 설계하는 원칙 (0) | 2026.07.08 |
| 엄격한 FSD 아키텍쳐 구조와 커스터 마이징 (0) | 2026.07.08 |