목차
1. React.memo
2. Code Splitting
3. ErrorBoundary
React.memo
React에서 제공하는 memo 기술은 memoization기법으로 동작해서 컴포넌트가 변경되지 않은 경우 렌더링 되지 않도록 하는 성능 최적화 기능입니다.
기존에 memo를 사용하지 않는 경우 부모가 렌더링 될 때 자식 또한 함께 렌더링 됩니다. 자식에 변경이 없는 경우 리렌더링은 불필요한 동작이기 때문에 비효율적이라 생각할 수 있습니다.
하지만 memo를 사용하게 되면 부모가 자식에게 전달하는 props를 얕은 비교합니다. 값의 변경이 발생하지 않았다면 자식의 렌더링을 건너뛰게 되고 렌더링 비용이 발생하지 않으면서 성능을 최적화 할 수 있습니다.
자식 컴포넌트 렌더링 최적화 하기
메모이제이션 훅을 사용하지 않은 초기 코드부터 함수, 컴포넌트에 메모이제이션을 적용하면서 렌더링을 건너뛰도록 하는 과정을 코드레벨에서 설명드리겠습니다.
초기 코드
아무런 메모이제이션 훅이 적용되어있지 않습니다.
function Parent() {
const [count, setCount] = useState(0);
const handleClick = function () {
console.log("Clicked!");
}
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Parent 증가</button>
<Child onClick={handleClick} state={1}/>
</>
);
import {useEffect} from "react"
function Child(props) {
useEffect(() => {
console.log("자식 useEffect 호출");
// console.log("count : ",props.state);
});
return (
<button onClick={props.onClick}>Child button</button>
)
}
export default Child;
부모에서 click이벤트 버튼을 발생시켰을때 부모의 상태인 count가 변경되어 렌더링됩니다.
자식은 메모이제이션이 적용되지 않은 컴포넌트기 때문에 당연히 렌더링 되게 됩니다.

자식도 함께 렌더링 되는 이유는 참조값 변경때문입니다.
click이벤트 발생시 호출되는 handleClick이라는 함수가
리액트 훅의 useMemo부분을 참고하시면 좋을것 같습니다.
useCallback 적용
부모에서 호출되는 함수에 메모이제이션인 useCallback을 적용했습니다.
function Parent() {
const [count, setCount] = useState(0);
// 버튼 클릭 함수 캐싱(메모이제이션)
const handleClick = useCallback(() => {
console.log("Clicked!");
}, []);
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Parent 증가</button>
<Child onClick={handleClick} state={1}/>
</>
);
}

함수에 메모이제이션 기법을 적용해도 자식의 렌더링은 계속 발생합니다.
왜냐면 재조정 과정에서 부모가 렌더링되면 자식은 자동으로 함께 리렌더링되기 때문입니다.
memo 적용
자식 컴포넌트에 memo를 적용합니다. (부모에는 useCallback이 적용된 상태입니다.)
import React, {useEffect} from "react"
function Child(props) {
useEffect(() => {
console.log("자식 useEffect 호출");
// console.log("count : ",props.state);
});
return (
<button onClick={props.onClick}>Child button</button>
)
}
export default React.memo(Child);

부모의 상태 변화로 렌더링이 발생하지만 자식은 렌더링이 발생하지 않은것을 확인 할 수 있습니다.
왜 memo를 적용하면 자식의 렌더링이 발생하지 않는 걸까요?
단순히 memo만 적용했다고 자식의 렌더링이 스킵되는 것은 아닙니다.
memo 적용 전 부모에게 적용했던 useCallback과 복합적으로 동작되면서 자식의 렌더링을 스킵하게 만든것입니다.
작동 원리는 아래와 같습니다.
- 부모 렌더링 시
- handleClick함수는 useCallback으로 동일한 참조값을 가집니다. (자식으로 넘기는 props 변경 x)
- memo 동작
- memo는 이전 렌더링의 props와 현재 props를 비교합니다.
- 함수가 동일한 참조값을 가지기 때문에 memo는 자식의 렌더링을 건너뜁니다.
- useEffect 호출
- 자식의 렌더링이 스킵되었기 때문에 렌더링 기반으로 동작하는 useEffect는 호출되지 않습니다.
정리하자면, 자식의 props가 변경되지 않는 것을 가정했을때 memo는 props의 변경을 이전 props와 비교해서 변경되지 않았다고 판단되면 렌더링을 건너뛰게됩니다.
이러한 동작으로 렌더링 비용이 발생하지 않게 되면서 성능 최적화를 낼 수 있게 됩니다.
Code Splitting
코드 스플리팅은 애플리케이션의 초기 로딩 속도를 개선하기 위해 코드를 여러 개의 청크로 나누는 기법입니다.
함수형 컴포넌트에서 코드 스플리팅이 필요한 경우는 javascript파일을 브라우저가 다운로드 하는 시간이 오래걸리는 경우입니다.
예를 들어, 네트워크로 인한 파일 다운로드의 지연이 발생했을때 사용할 수 있습니다.
이런 경우를 방지하기 위해 코드 스플리팅의 React.lazy와 suspense를 사용할 수 있습니다.
import React, { Suspence } from "react";
const LazyComponent = React.lazy(() => import("./MyComponent"));
function App() {
return (
<div>
<Suspense fallback={<div>로딩 중...</div>}>
<LazyComponent/>
</Suspense>
</div>
);
}
렌더링하고자 하는 자식 컴포넌트의 지연 로딩 발생시 fallback에 정의되어있는 UI를 사용자에게 출력하게 됩니다.
이를 통해 지연 로딩 발생시에도 자연스러운 사용자 경험을 유도할 수 있게 됩니다.
강제로 지연 로딩 발생시
function App() {
const LazyComponent = React.lazy(() =>
new Promise((resolve) =>
setTimeout(()=>resolve(import("./component/MainComponent")), 3000)
)
)
return (
<>
<ReduxProvider>
<Suspense fallback = {<div>로딩 중..........</div>}>
<LazyComponent/>
</Suspense>
</ReduxProvider>
</>
)
}

ErrorBoundary
에러 바운더리는 자바스크립트의 오류를 자식 컴포넌트 트리 내에서 잡아내고 충돌할 컴포넌트 오류 내용 대신 대체 UI를 표시하는 React 컴포넌트입니다.
예시
아래와 같이 App컴포넌트에 하위 컴포넌트 Greeting과 Farewell으로 구성했습니다.
그리고 자식 컴포넌트에서 오류가 발생했을때 오류를 잡기 위해 반환 부분을 try/catch로 구분하였습니다.
import * as React from 'react'
import ReactDOM from 'react-dom'
function ErrorFallback({error}) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre style={{color: 'red'}}>{error.message}</pre>
</div>
)
}
function Greeting({subject}) {
return <div>Hello {subject.toUpperCase()}</div>
}
function Farewell({subject}) {
return <div>Goodbye {subject.toUpperCase()}</div>
}
function App() {
try {
return (
<div>
<Greeting />
<Farewell />
</div>
)
} catch (error) {
return <ErrorFallback error={error} />
}
}
ReactDOM.render(<App />, document.getElementById('root'))
예를들어 Greeting에서 subject가 undefined거나 null인 경우 runtime오류가 발생할것입니다.
그리고 catch에서 오류를 잡아서 ErrorFallback 컴포넌트가 출력되는것을 기대합니다.
하지만, 결과는 정상적으로 동작하지 않습니다.
try/catch가 동작하지 않는 이유
- 컴포넌트의 용도
- try/catch는 명령형 코드에서만 동작
- React 컴포넌트는 선언적이며 무엇을 렌더링 할지 구체화하는 용도입니다.
- 비동기적 렌더링
- react는 컴포넌트를 렌더링하고 DOM을 업데이트 하는 재조정 과정을 비동기로 처리합니다. 그렇기 때문에 App컴포넌트에서 try/catch가 실행되는 시점과 오류가 발생하는 시점이 다릅니다.
- 컴포넌트 함수 범위 밖의 오류
- Greeting에서 오류가 발생하는 경우 App함수 호출 스택에서 벗어나 React내부 렌더링 스택에서 발생합니다.
즉, 컴포넌트 레벨에서의 try/catch는 렌더링 레벨에서 발생하는 오류는 잡을 수 없다는 것입니다.
ErrorBoundary 적용
import React from 'react';
import ErrorFallback from "../component/ErrorFallback";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {hasError: false};
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 한다.
return {hasError: true};
}
componentDidCatch(error, errorInfo) {
// 에러 리포팅 서비스에 에러를 기록할 수도 있다.
// logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
//폴백 UI를 커스텀하여 렌더링할 수 있다.
return <ErrorFallback/>
}
return this.props.children;
}
}
export default ErrorBoundary
//App.js
return (
<>
<ReduxProvider>
<ErrorBoundary FallbackComponent={ErrorFallback}> //ErrorBoundary 적용
<Suspense fallback = {<div>로딩 중..........</div>}>
<LazyComponent/>
</Suspense>
</ErrorBoundary>
</ReduxProvider>
</>
)
//LazyComponent.js
function LazyComponent() {
...
const test = null.substring(); //오류 발생!
...
return(
...
)
}

동작원리
위와 같이 클래스형 컴포넌트인 ErrorBoundary를 만들었을때 핵심은 getDerivedStateFromError() 정적 함수입니다.
해당 함수는 오류가 발생한 직후 호출되며, 특히 렌더링 단계(render phase)에서 호출됩니다.
오류가 발생했을때 부터 시작해서 순차적으로 알아보겠습니다.
- 자식 컴포넌트 렌더링 중 오류 발생
- 오류를 발생시킨 컴포넌트부터 트리를 거슬러 올라가 가장 가까운 ErrorBoundary 컴포넌트를 찾습니다.
- ErrorBoundary 컴포넌트 렌더링 (render phase)
- getDerivedStateFromError 함수가 호출되면서 상태 업데이트
- 리렌더링 되면서 대체 UI 반환
- 커밋 단계 및 componentDidCatch (commit phase)
- 메모리에 저장된 오류 내용을 전달받고 사이드 이펙트 (오류 로그 저장 및 관리)
- 대체 UI 출력
getDerivedStateFromError 가 정적인 이유
함수형 컴포넌트나 hook에서는 설계의 복잡성으로 인한 동기화 문제 때문에 getDerivedStateFrom Error나 componentDidCatch와 같은 함수를 제공해주지 않는다고 합니다.
그래서 클래스형 컴포넌트를 사용하면서 위와 같은 함수를 사용합니다.
클래스형 컴포넌트를 사용하기 때문에 정적 함수로 정의합니다.
- this접근을 제한해서 setState함수와 같은 비동기 작업 트리거 발생 방지
- 인수로 받은 error와 기존 state기반으로 작업하는 순수 함수 강제
- 다른 변수 사용 지양
- 컴포넌트에 대한 의존성 제거 (오류를 포착했다는 사실만 상태에 반영하기 위함)
- hasError = true 동작
리액트의 성능 최적화 하는 memo, Code Splitting, ErrorBoundary 기능을 알아보았습니다.
성능 최적화라고는 하지만 이런 방법들을 코드에 덕지덕지 붙여놓는다고 해서 모두 성능적으로 좋아지는 것은 아닙니다.
뭐든지 성능 최적화가 필요한지 다양한 상황을 알아보고 알맞게 판단해야합니다.
예를들어 memo인 경우, memo를 공부하고 있을때는 모든 자식에 memo를 적용해서 렌더링 자체를 다 막아버리면 되는거 아닌가? 라는 생각이 들었었습니다.
하지만 메모이제이션 기법도 결국 메모리의 자원을 사용해서 이전 상태와 현재 상태를 비교해서 렌더링 적용 여부를 판단해야합니다.
즉, 렌더링의 비용과 메모이제이션 비용을 비교해보았을때 어떤게 더 효율적인이 나름의 판단이 필요하다는 겁니다.
렌더링 시 단순한 데이터와 html 코드만 존재한다면 렌더링은 엄청 빠르게 지나갈겁니다. 그런데 그걸 굳이 이전과 지금 엘리먼트들을 비교하는건 비효율적이라고 생각합니다.
또, 부모가 자식에게 넘기는 props가 자주 변경되는 경우에도 memo를 적용시켜 메모이제이션하면 자식이 이전 DOM을 사용하는 것보다 렌더링 되서 새로운 DOM을 사용하는 경우가 훨씬 더 많을 것입니다.
이렇듯, 성능 최적화라고는 하지만 상황과 예측을 통해 어떻게 컴포넌트를 구성하고 사용할것인지는 오로지 개발자의 판단이라고 생각합니다.
참고
https://kentcdodds.com/blog/usememo-and-usecallback
When to useMemo and useCallback
Stay up to date Stay up to date All rights reserved © Kent C. Dodds 2025
kentcdodds.com
https://www.elancer.co.kr/blog/detail/267
React Suspense: 리액트 서스펜스를 사용하는 이유와 사용법 총정리 I 이랜서 블로그
컴포넌트의 렌더링을 일시 중지하고 데이터 로딩을 기다릴 수 있게 해주는 React의 기능으로 데이터 로딩 중에도 자연스러운 사용자 경험을 유도할 수 있도록 도와주는 react suspense의 사용하는 이
www.elancer.co.kr
https://ko.legacy.reactjs.org/docs/error-boundaries.html
에러 경계(Error Boundaries) – React
A JavaScript library for building user interfaces
ko.legacy.reactjs.org
'cs > react' 카테고리의 다른 글
| [React] React Router (0) | 2025.12.05 |
|---|---|
| [React] Zustand (0) | 2025.12.04 |
| [React] Custom Hook (0) | 2025.12.02 |
| [React] Redux Toolkit (0) | 2025.12.01 |
| [React] Context API & Provider 구조 (0) | 2025.11.29 |