본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 29. · 50 Views
React Hooks 완벽 가이드
React Hooks는 함수형 컴포넌트에서 상태 관리와 생명주기 기능을 사용할 수 있게 해주는 혁신적인 기능입니다. 클래스형 컴포넌트 없이도 강력한 React 앱을 만들 수 있습니다. 이 가이드에서는 실무에서 자주 사용되는 핵심 Hooks를 초급자 눈높이에서 상세히 다룹니다.
목차
- useState - 상태 관리의 시작
- useEffect - 사이드 이펙트 처리하기
- useContext - 전역 상태 공유하기
- useRef - DOM 접근과 값 유지하기
- useMemo - 비용 큰 계산 최적화하기
- useCallback - 함수 재생성 방지하기
- useReducer - 복잡한 상태 로직 관리하기
- Custom Hooks - 로직 재사용하기
- useLayoutEffect - 동기적 DOM 조작하기
- React.memo와 Hooks - 컴포넌트 최적화하기
1. useState - 상태 관리의 시작
시작하며
여러분이 React로 로그인 폼을 만들 때 이런 상황을 겪어본 적 있나요? 사용자가 입력 필드에 타이핑을 하면 화면에 즉시 반영되어야 하는데, 어떻게 그 값을 추적하고 업데이트해야 할지 막막했던 경험 말이죠.
예전에는 클래스형 컴포넌트를 만들고 this.state와 this.setState를 사용해야 했습니다. 하지만 이 방식은 코드가 복잡해지고, this 바인딩 문제로 골치를 앓게 됩니다.
특히 초급 개발자에게는 클래스 문법 자체가 진입장벽이 되기도 합니다. 바로 이럴 때 필요한 것이 useState입니다.
함수형 컴포넌트에서 단 한 줄로 상태를 선언하고 관리할 수 있게 해주는 강력한 도구입니다.
개요
간단히 말해서, useState는 함수형 컴포넌트에서 상태(state)를 관리할 수 있게 해주는 React Hook입니다. 실무에서 사용자 입력, 토글 버튼, 카운터, 폼 데이터 등 화면에 표시되는 모든 동적인 데이터를 관리할 때 필수적입니다.
예를 들어, 쇼핑몰의 장바구니 개수를 표시하거나, 다크모드 전환 버튼을 구현할 때 useState를 사용합니다. 기존 클래스형 컴포넌트에서는 constructor에서 state를 초기화하고 this.setState로 업데이트했다면, 이제는 useState 한 줄로 동일한 기능을 구현할 수 있습니다.
useState는 배열 구조분해를 통해 [현재 상태 값, 상태 업데이트 함수]를 반환합니다. 상태 값이 변경되면 자동으로 컴포넌트가 리렌더링되어 화면이 업데이트됩니다.
이러한 특징 덕분에 개발자는 DOM 조작 걱정 없이 데이터에만 집중할 수 있습니다.
코드 예제
import React, { useState } from 'react';
function Counter() {
// 초기값 0으로 count 상태를 선언
const [count, setCount] = useState(0);
// 증가 함수
const increment = () => {
setCount(count + 1); // 상태 업데이트
};
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={increment}>증가</button>
</div>
);
}
설명
이것이 하는 일: useState는 컴포넌트 내부에서 동적으로 변하는 값을 저장하고, 그 값이 변경될 때 자동으로 화면을 다시 그려주는 역할을 합니다. 첫 번째로, const [count, setCount] = useState(0); 부분은 count라는 상태 변수를 생성하고 초기값 0으로 설정합니다.
이때 useState는 배열을 반환하는데, 첫 번째 요소는 현재 상태값(count), 두 번째 요소는 그 상태를 변경할 수 있는 함수(setCount)입니다. 배열 구조분해 문법을 사용하여 두 값을 한 번에 받아옵니다.
그 다음으로, setCount(count + 1)이 실행되면서 React 내부적으로 상태 업데이트를 예약합니다. React는 성능 최적화를 위해 여러 상태 업데이트를 배치 처리하며, 업데이트가 완료되면 자동으로 컴포넌트를 리렌더링합니다.
이 과정에서 개발자가 직접 DOM을 조작할 필요가 전혀 없습니다. 마지막으로, 리렌더링된 컴포넌트에서 {count} 부분이 새로운 값으로 교체되어 최종적으로 화면에 업데이트된 숫자가 표시됩니다.
사용자는 버튼을 클릭할 때마다 숫자가 증가하는 것을 실시간으로 볼 수 있습니다. 여러분이 이 코드를 사용하면 복잡한 클래스 문법 없이 간결하게 상태 관리를 할 수 있고, 코드 가독성이 크게 향상됩니다.
또한 함수형 프로그래밍 패러다임에 더 잘 맞아떨어지며, 컴포넌트 로직을 재사용하기도 훨씬 쉬워집니다.
실전 팁
💡 상태 업데이트 시 이전 값을 참조해야 한다면 setCount(count + 1) 대신 setCount(prevCount => prevCount + 1) 함수형 업데이트를 사용하세요. 비동기 상황에서 안전합니다.
💡 객체나 배열을 상태로 관리할 때는 반드시 새로운 객체/배열을 생성해야 합니다. setUser({...user, name: '새이름'}) 처럼 스프레드 연산자를 활용하세요.
💡 useState의 초기값으로 함수를 전달하면 컴포넌트 마운트 시 한 번만 실행됩니다. useState(() => localStorage.getItem('key')) 처럼 무거운 연산은 함수로 감싸세요.
💡 너무 많은 useState를 사용하면 코드가 복잡해집니다. 연관된 상태는 하나의 객체로 묶어서 관리하는 것이 좋습니다.
💡 상태 업데이트 후 즉시 변경된 값을 사용하려고 하면 이전 값이 나옵니다. useEffect와 함께 사용하여 상태 변경 후의 작업을 처리하세요.
2. useEffect - 사이드 이펙트 처리하기
시작하며
여러분이 사용자 프로필 페이지를 만들 때 이런 상황을 겪어본 적 있나요? 컴포넌트가 화면에 나타나자마자 서버에서 사용자 데이터를 가져와야 하는데, 도대체 어디에 API 호출 코드를 넣어야 할지 고민되는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 데이터 페칭, 구독 설정, DOM 조작, 타이머 설정 등 컴포넌트의 렌더링 흐름 밖에서 실행되어야 하는 작업들은 적절한 시점에 실행되지 않으면 버그나 메모리 누수를 일으킬 수 있습니다.
바로 이럴 때 필요한 것이 useEffect입니다. 컴포넌트의 생명주기에 맞춰 사이드 이펙트를 안전하게 처리할 수 있게 해줍니다.
개요
간단히 말해서, useEffect는 함수형 컴포넌트에서 사이드 이펙트(side effect)를 처리하는 Hook입니다. 사이드 이펙트란 렌더링 외부에서 일어나는 모든 작업을 의미합니다.
API 호출, 구독(subscription), 타이머, 직접적인 DOM 조작, 로깅 등 React 렌더링 프로세스 밖에서 실행되어야 하는 모든 작업에 사용됩니다. 예를 들어, 채팅 앱에서 컴포넌트가 마운트될 때 WebSocket 연결을 시작하고, 언마운트될 때 연결을 해제하는 경우에 매우 유용합니다.
클래스형 컴포넌트에서는 componentDidMount, componentDidUpdate, componentWillUnmount 같은 여러 생명주기 메서드를 사용했다면, 이제는 useEffect 하나로 모든 생명주기 로직을 관리할 수 있습니다. useEffect는 첫 번째 인자로 실행할 함수를, 두 번째 인자로 의존성 배열(dependency array)을 받습니다.
의존성 배열에 있는 값이 변경될 때만 effect가 재실행되므로, 불필요한 작업을 방지하고 성능을 최적화할 수 있습니다. 이러한 특징으로 정확한 타이밍에 필요한 작업만 실행하여 애플리케이션의 효율성을 높입니다.
코드 예제
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// userId가 변경될 때마다 실행
useEffect(() => {
setLoading(true);
// API에서 사용자 데이터 가져오기
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
// 클린업 함수 (선택적)
return () => {
console.log('컴포넌트 언마운트 또는 userId 변경');
};
}, [userId]); // 의존성 배열
if (loading) return <p>로딩 중...</p>;
return <div>{user?.name}</div>;
}
설명
이것이 하는 일: useEffect는 컴포넌트가 렌더링된 후에 특정 작업을 수행하고, 필요에 따라 정리(cleanup) 작업도 수행할 수 있게 해줍니다. 첫 번째로, 컴포넌트가 화면에 렌더링되면 useEffect 내부의 함수가 실행됩니다.
위 코드에서는 fetch API를 통해 서버에서 사용자 데이터를 가져옵니다. 이 작업은 비동기적으로 진행되며, 렌더링을 차단하지 않습니다.
이렇게 렌더링과 분리하는 이유는 사용자 경험을 해치지 않고 백그라운드에서 데이터를 준비하기 위함입니다. 그 다음으로, 의존성 배열 [userId]가 중요한 역할을 합니다.
React는 이 배열에 있는 값들을 이전 렌더링과 비교하여, 값이 변경되었을 때만 effect를 재실행합니다. 만약 배열을 비워두면([]) 마운트 시 한 번만 실행되고, 배열을 생략하면 매 렌더링마다 실행됩니다.
이런 세밀한 제어로 불필요한 API 호출을 방지할 수 있습니다. 세 번째로, return문으로 클린업 함수를 반환할 수 있습니다.
이 함수는 컴포넌트가 언마운트되거나, 다음 effect가 실행되기 직전에 호출됩니다. 타이머를 정리하거나, 이벤트 리스너를 제거하거나, WebSocket 연결을 해제하는 등의 작업을 여기서 처리하여 메모리 누수를 방지합니다.
여러분이 이 코드를 사용하면 컴포넌트의 생명주기를 명확하게 관리할 수 있고, 데이터 동기화 문제를 해결할 수 있습니다. 또한 클린업 함수를 통해 메모리 누수 없는 안전한 코드를 작성할 수 있으며, 의존성 배열로 성능을 최적화할 수 있습니다.
실전 팁
💡 useEffect 내부에서 async/await를 직접 사용할 수 없습니다. 대신 내부에 별도의 async 함수를 선언하고 호출하세요: useEffect(() => { async function fetchData() {...} fetchData(); }, []);
💡 의존성 배열을 생략하면 매 렌더링마다 effect가 실행되어 무한 루프에 빠질 수 있습니다. ESLint의 exhaustive-deps 규칙을 활성화하여 누락된 의존성을 찾으세요.
💡 클린업 함수는 필수는 아니지만, 구독이나 타이머를 사용할 때는 반드시 작성해야 메모리 누수를 방지할 수 있습니다.
💡 여러 개의 useEffect를 사용하여 관심사를 분리하세요. 하나의 effect에 모든 로직을 넣지 말고, 데이터 페칭용, 구독용, 로깅용 등으로 나누면 코드가 훨씬 깔끔해집니다.
💡 effect 내부에서 상태를 업데이트할 때는 무한 루프를 조심하세요. 상태를 의존성 배열에 넣으면 상태 변경 → effect 실행 → 상태 변경의 사이클이 반복될 수 있습니다.
3. useContext - 전역 상태 공유하기
시작하며
여러분이 대시보드 애플리케이션을 만들 때 이런 상황을 겪어본 적 있나요? 로그인한 사용자 정보나 테마 설정을 여러 컴포넌트에서 사용해야 하는데, props를 5~6단계나 내려보내야 하는 "props drilling" 지옥에 빠진 경험 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 중간 컴포넌트들은 해당 데이터를 사용하지도 않으면서 단순히 전달만 하게 되고, 코드는 점점 복잡해지며 유지보수가 어려워집니다.
특히 컴포넌트 구조가 변경될 때마다 모든 props 전달 경로를 수정해야 하는 번거로움이 있습니다. 바로 이럴 때 필요한 것이 useContext입니다.
Context API와 함께 사용하여 props drilling 없이 전역 상태를 어디서든 접근할 수 있게 해줍니다.
개요
간단히 말해서, useContext는 React Context의 값을 읽고 구독할 수 있게 해주는 Hook입니다. 컴포넌트 트리 어디서든 props 전달 없이 데이터를 공유할 수 있습니다.
실무에서 사용자 인증 정보, 테마 설정(다크모드/라이트모드), 언어 설정, 장바구니 데이터 등 여러 컴포넌트에서 공통으로 필요한 데이터를 관리할 때 필수적입니다. 예를 들어, 헤더, 사이드바, 콘텐츠 영역 모두에서 현재 로그인한 사용자 이름을 표시해야 하는 경우 useContext를 사용합니다.
기존에는 최상위 컴포넌트에서 props를 하위로 계속 전달해야 했다면, 이제는 Context Provider로 한 번 감싸고 필요한 곳에서 useContext로 바로 가져올 수 있습니다. useContext는 createContext로 생성된 Context 객체를 인자로 받아 현재 context 값을 반환합니다.
Context Provider의 value가 변경되면 자동으로 구독 중인 모든 컴포넌트가 리렌더링됩니다. 이러한 특징으로 전역 상태 관리를 간단하면서도 효율적으로 할 수 있으며, 컴포넌트 간 결합도를 낮춰 코드 재사용성을 높입니다.
코드 예제
import React, { createContext, useContext, useState } from 'react';
// Context 생성
const ThemeContext = createContext();
// Provider 컴포넌트
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Consumer 컴포넌트
function ThemedButton() {
// Context 값 가져오기
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button
style={{ background: theme === 'light' ? '#fff' : '#333' }}
onClick={toggleTheme}
>
테마 전환 (현재: {theme})
</button>
);
}
설명
이것이 하는 일: useContext는 컴포넌트 트리 어디서든 Context에 저장된 값을 직접 읽어올 수 있게 하며, 값이 변경되면 자동으로 해당 컴포넌트를 리렌더링합니다. 첫 번째로, createContext()로 새로운 Context 객체를 생성합니다.
이것은 데이터를 담는 컨테이너 역할을 하며, 초기값을 인자로 전달할 수 있습니다. 그런 다음 ThemeProvider 컴포넌트를 만들어 Context.Provider로 감싸고, value prop으로 공유할 데이터를 전달합니다.
이 Provider 하위의 모든 컴포넌트는 이 값에 접근할 수 있습니다. 그 다음으로, 실제로 데이터를 사용하는 컴포넌트(ThemedButton)에서 useContext(ThemeContext)를 호출하면, Provider가 제공하는 value 객체를 그대로 받아옵니다.
중간에 어떤 컴포넌트를 거치든 상관없이 직접 접근이 가능합니다. React는 내부적으로 컴포넌트 트리를 탐색하여 가장 가까운 Provider를 찾아 그 값을 반환합니다.
세 번째로, toggleTheme 함수가 실행되어 theme 상태가 변경되면, Provider의 value가 변경되고, 이를 구독하는 모든 컴포넌트(useContext를 사용하는 컴포넌트들)가 자동으로 리렌더링됩니다. 이 과정은 React가 자동으로 처리하므로 개발자는 상태 변경 로직에만 집중할 수 있습니다.
여러분이 이 코드를 사용하면 props drilling을 완전히 제거할 수 있고, 컴포넌트 구조 변경 시에도 데이터 전달 코드를 수정할 필요가 없습니다. 또한 관련 상태와 로직을 Provider에 모아두어 관심사 분리가 잘 되고, 여러 컴포넌트가 동일한 상태를 공유하면서도 독립적으로 동작할 수 있습니다.
실전 팁
💡 Context는 자주 변경되는 값보다는 테마, 언어, 사용자 정보 같은 상대적으로 변경이 적은 데이터에 적합합니다. 자주 변경되면 불필요한 리렌더링이 많이 발생할 수 있습니다.
💡 여러 Context를 사용할 때는 Provider를 중첩하거나, 하나의 컴포넌트에서 여러 Provider를 조합할 수 있습니다. 관심사별로 Context를 분리하면 유지보수가 쉬워집니다.
💡 useContext를 사용하는 컴포넌트는 반드시 해당 Provider 하위에 있어야 합니다. 그렇지 않으면 createContext의 기본값이 사용되거나 에러가 발생할 수 있습니다.
💡 성능 최적화를 위해 Context value를 useMemo로 메모이제이션하세요. value={{ theme, toggleTheme }}는 매번 새 객체를 생성하여 불필요한 리렌더링을 유발할 수 있습니다.
💡 큰 애플리케이션에서는 Redux나 Zustand 같은 전문 상태 관리 라이브러리를 고려하세요. useContext는 간단한 전역 상태 관리에는 완벽하지만, 복잡한 상태 로직이나 미들웨어가 필요한 경우 한계가 있습니다.
4. useRef - DOM 접근과 값 유지하기
시작하며
여러분이 검색창을 만들 때 이런 상황을 겪어본 적 있나요? 페이지가 로드되자마자 자동으로 입력 필드에 포커스를 주고 싶은데, React에서 어떻게 특정 DOM 요소에 직접 접근해야 할지 막막했던 경험 말이죠.
또는 타이머 ID를 저장해야 하는데, useState를 쓰면 불필요한 리렌더링이 발생하는 문제를 겪어보셨을 겁니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
파일 업로드 버튼 클릭, 스크롤 위치 제어, 비디오 재생/정지, 애니메이션 제어 등 직접적인 DOM 조작이 필요한 경우가 많습니다. 또한 컴포넌트가 리렌더링되어도 값을 유지해야 하지만 화면 업데이트는 필요 없는 경우도 있죠.
바로 이럴 때 필요한 것이 useRef입니다. DOM 요소에 직접 접근하거나, 리렌더링 없이 값을 저장할 수 있는 강력한 도구입니다.
개요
간단히 말해서, useRef는 컴포넌트의 생명주기 동안 변하지 않는 참조(reference)를 생성하는 Hook입니다. DOM 접근과 가변 값 저장이라는 두 가지 주요 용도가 있습니다.
실무에서 input 필드 포커스 제어, 스크롤 위치 조작, 이전 상태값 저장, 타이머/인터벌 ID 저장, 외부 라이브러리 인스턴스 저장 등에 사용됩니다. 예를 들어, 무한 스크롤을 구현할 때 스크롤 위치를 추적하거나, 채팅 앱에서 새 메시지가 오면 자동으로 맨 아래로 스크롤하는 기능을 만들 때 useRef를 활용합니다.
기존 바닐라 JavaScript에서는 document.getElementById나 querySelector를 사용했다면, React에서는 useRef와 ref 속성으로 안전하게 DOM에 접근할 수 있습니다. 또한 useState와 달리 값이 변경되어도 리렌더링을 유발하지 않습니다.
useRef는 .current 프로퍼티를 가진 객체를 반환합니다. 이 current 값은 컴포넌트가 언마운트될 때까지 유지되며, 직접 수정해도 컴포넌트가 리렌더링되지 않습니다.
이러한 특징으로 성능에 영향을 주지 않으면서 필요한 데이터를 보관할 수 있고, 명령형 API(포커스, 재생 등)를 안전하게 사용할 수 있습니다.
코드 예제
import React, { useRef, useEffect } from 'react';
function AutoFocusInput() {
// DOM 참조를 위한 ref 생성
const inputRef = useRef(null);
// 렌더링 횟수를 추적 (리렌더링 없이)
const renderCount = useRef(0);
useEffect(() => {
// 컴포넌트 마운트 시 input에 자동 포커스
inputRef.current.focus();
// 렌더링 횟수 증가 (리렌더링 발생 안 함)
renderCount.current += 1;
console.log(`렌더링 횟수: ${renderCount.current}`);
});
const handleReset = () => {
// input 값 초기화 및 포커스
inputRef.current.value = '';
inputRef.current.focus();
};
return (
<div>
<input ref={inputRef} type="text" placeholder="여기에 입력" />
<button onClick={handleReset}>초기화</button>
</div>
);
}
설명
이것이 하는 일: useRef는 컴포넌트 전체 생명주기 동안 유지되는 가변 참조 객체를 생성하여, DOM 조작이나 값 저장을 리렌더링 없이 처리할 수 있게 합니다. 첫 번째로, const inputRef = useRef(null);로 ref 객체를 생성합니다.
초기값 null은 나중에 DOM 요소로 교체됩니다. JSX에서 ref={inputRef} 속성을 설정하면, React가 해당 DOM 요소가 생성될 때 자동으로 inputRef.current에 그 요소를 할당합니다.
이 방식으로 React의 선언적 패러다임을 유지하면서도 필요할 때 명령형 DOM 조작을 할 수 있습니다. 그 다음으로, useEffect 내부에서 inputRef.current.focus()를 호출하여 실제 DOM 요소의 메서드를 실행합니다.
current에는 실제 HTMLInputElement가 저장되어 있으므로, focus(), blur(), click() 같은 모든 DOM API를 사용할 수 있습니다. 이렇게 ref를 통한 DOM 접근은 React의 가상 DOM을 우회하므로 즉시 실제 브라우저 화면에 반영됩니다.
세 번째로, renderCount ref는 다른 방식으로 활용됩니다. DOM과는 무관하게 단순히 값을 저장하는 용도로, 렌더링 횟수를 추적합니다.
renderCount.current += 1로 값을 변경해도 컴포넌트가 리렌더링되지 않으므로, 성능에 영향을 주지 않으면서 필요한 정보를 보관할 수 있습니다. 이는 타이머 ID, 이전 props/state 값, 외부 라이브러리 인스턴스 등을 저장할 때 매우 유용합니다.
여러분이 이 코드를 사용하면 React의 선언적 방식과 명령형 DOM 조작을 조화롭게 결합할 수 있고, 불필요한 리렌더링을 방지하여 성능을 최적화할 수 있습니다. 또한 포커스 관리, 스크롤 제어, 애니메이션 등 사용자 경험에 중요한 기능을 자연스럽게 구현할 수 있으며, 디버깅이나 개발 도구를 위한 메타데이터도 안전하게 저장할 수 있습니다.
실전 팁
💡 ref.current를 의존성 배열에 넣지 마세요. ref 객체 자체는 변하지 않으므로 의존성으로 추가해도 의미가 없고, current 값은 변경 추적이 안 됩니다.
💡 조건부 렌더링되는 요소에 ref를 사용할 때는 current가 null일 수 있으므로 항상 null 체크를 하세요: inputRef.current?.focus()
💡 React가 관리하는 상태는 useState를, React가 관리할 필요 없는 값은 useRef를 사용하세요. 예를 들어 타이머 ID는 화면에 표시되지 않으므로 useRef가 적합합니다.
💡 useRef는 함수형 컴포넌트에서 인스턴스 변수 역할을 합니다. 클래스 컴포넌트의 this.variable과 같은 개념으로 생각하면 이해가 쉽습니다.
💡 ref를 통해 자식 컴포넌트의 DOM에 접근하려면 forwardRef를 사용해야 합니다. 함수형 컴포넌트는 기본적으로 ref를 전달받을 수 없습니다.
5. useMemo - 비용 큰 계산 최적화하기
시작하며
여러분이 대용량 데이터 테이블을 표시하는 페이지를 만들 때 이런 상황을 겪어본 적 있나요? 검색 필터를 적용하거나 정렬하는 계산이 매우 무거운데, 관련 없는 상태가 변경될 때마다 똑같은 계산이 반복되어 화면이 버벅이는 경험 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. React는 상태나 props가 변경되면 컴포넌트를 리렌더링하는데, 이때 컴포넌트 함수 내부의 모든 코드가 다시 실행됩니다.
복잡한 계산, 데이터 변환, 필터링 같은 작업이 매번 실행되면 불필요한 CPU 자원을 낭비하고 사용자 경험을 저하시킵니다. 바로 이럴 때 필요한 것이 useMemo입니다.
계산 결과를 메모이제이션하여 동일한 입력에 대해 이전 결과를 재사용함으로써 성능을 크게 향상시킵니다.
개요
간단히 말해서, useMemo는 비용이 큰 계산의 결과를 캐싱하여 의존성이 변경될 때만 재계산하는 Hook입니다. 불필요한 연산을 건너뛰어 성능을 최적화합니다.
실무에서 대용량 배열 필터링/정렬, 복잡한 수학 계산, 데이터 변환, 객체/배열 생성, 정규식 처리 등 무거운 작업을 최적화할 때 사용됩니다. 예를 들어, 1만 개의 제품 목록에서 검색어로 필터링하고 가격순으로 정렬하는 경우, 검색어가 변경될 때만 재계산하고 다른 상태 변경 시에는 이전 결과를 재사용합니다.
기존에는 모든 렌더링마다 동일한 계산을 반복했다면, 이제는 useMemo로 결과를 기억해두고 입력이 같으면 저장된 값을 즉시 반환할 수 있습니다. useMemo는 첫 번째 인자로 계산 함수를, 두 번째 인자로 의존성 배열을 받습니다.
의존성 배열의 값이 변경될 때만 함수를 재실행하고, 그렇지 않으면 이전에 계산된 값을 그대로 반환합니다. 이러한 특징으로 CPU 집약적인 작업을 효율적으로 관리하고, 참조 동일성(referential equality)을 보장하여 자식 컴포넌트의 불필요한 리렌더링도 방지할 수 있습니다.
코드 예제
import React, { useState, useMemo } from 'react';
function ProductList({ products }) {
const [searchTerm, setSearchTerm] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
// 무거운 계산을 메모이제이션
const filteredAndSortedProducts = useMemo(() => {
console.log('필터링 및 정렬 계산 실행');
// 1. 검색어로 필터링
const filtered = products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// 2. 가격순 정렬
const sorted = [...filtered].sort((a, b) =>
sortOrder === 'asc' ? a.price - b.price : b.price - a.price
);
return sorted;
}, [products, searchTerm, sortOrder]); // 이 값들이 변경될 때만 재계산
return (
<div>
<input
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="상품 검색"
/>
<button onClick={() => setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')}>
정렬 순서 변경
</button>
<ul>
{filteredAndSortedProducts.map(product => (
<li key={product.id}>{product.name} - {product.price}원</li>
))}
</ul>
</div>
);
}
설명
이것이 하는 일: useMemo는 주어진 함수를 실행하여 결과를 계산하고, 그 결과를 저장해두었다가 동일한 조건에서는 저장된 값을 즉시 반환하여 불필요한 재계산을 방지합니다. 첫 번째로, 컴포넌트가 처음 렌더링될 때 useMemo 내부의 함수가 실행됩니다.
위 코드에서는 products 배열을 searchTerm으로 필터링하고, sortOrder에 따라 정렬하는 복잡한 작업을 수행합니다. 만약 products가 10,000개 항목이라면 이 계산은 꽤 무거울 수 있습니다.
첫 계산 결과와 의존성 배열의 값들이 함께 저장됩니다. 그 다음으로, 컴포넌트가 리렌더링될 때 React는 의존성 배열 [products, searchTerm, sortOrder]의 현재 값과 이전 값을 비교합니다.
만약 다른 상태(예: 사이드바 토글)가 변경되어 리렌더링이 발생했지만 이 세 값은 동일하다면, useMemo는 함수를 재실행하지 않고 저장해둔 이전 결과를 즉시 반환합니다. 콘솔에 "필터링 및 정렬 계산 실행"이 출력되지 않는 것을 확인할 수 있습니다.
세 번째로, searchTerm이나 sortOrder가 변경되면 의존성이 달라졌으므로 함수가 재실행되어 새로운 결과를 계산합니다. 이때는 콘솔 로그가 출력되고, 새 결과가 저장되어 다음 렌더링을 위해 대기합니다.
이렇게 필요할 때만 계산하는 방식으로 CPU 자원을 효율적으로 사용합니다. 여러분이 이 코드를 사용하면 대용량 데이터 처리 시 체감 성능이 크게 향상됩니다.
사용자가 다른 UI를 조작할 때 불필요한 계산이 실행되지 않아 화면이 부드럽게 동작하며, 배터리 소모도 줄일 수 있습니다. 또한 useMemo는 참조 동일성을 유지하므로, 메모이제이션된 배열/객체를 props로 받는 자식 컴포넌트가 React.memo와 함께 사용될 때 불필요한 리렌더링을 방지하는 추가 효과도 있습니다.
실전 팁
💡 모든 계산에 useMemo를 사용하지 마세요. 간단한 계산은 메모이제이션 오버헤드가 오히려 더 비쌀 수 있습니다. 프로파일러로 측정 후 필요한 곳에만 적용하세요.
💡 useMemo는 성능 최적화 도구이지 의미적 보장이 아닙니다. React는 메모리 확보를 위해 캐시를 버릴 수 있으므로, 로직의 정확성이 useMemo에 의존해서는 안 됩니다.
💡 객체나 배열을 생성하는 코드가 자식 컴포넌트의 props로 전달될 때 useMemo를 사용하면 React.memo와 시너지 효과를 냅니다.
💡 의존성 배열을 정확하게 지정하세요. 누락하면 오래된 데이터를 표시할 수 있고, 불필요한 값을 넣으면 캐시 효율이 떨어집니다.
💡 useMemo 내부에서 부수효과(side effect)를 일으키지 마세요. API 호출, 상태 변경 등은 useEffect에서 처리해야 합니다.
6. useCallback - 함수 재생성 방지하기
시작하며
여러분이 성능 최적화를 위해 React.memo로 자식 컴포넌트를 감쌌는데도 여전히 불필요한 리렌더링이 발생하는 상황을 겪어본 적 있나요? 원인을 찾아보니 부모에서 전달하는 콜백 함수가 매번 새로 생성되어 props가 변경된 것으로 인식되는 문제였던 경험 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. JavaScript에서 함수는 객체이므로, 컴포넌트가 리렌더링될 때마다 함수가 새로 생성되면 이전 함수와 참조가 달라집니다.
따라서 React.memo나 useEffect의 의존성 배열에서 함수를 비교할 때 항상 다른 것으로 간주되어 최적화가 무효화됩니다. 바로 이럴 때 필요한 것이 useCallback입니다.
함수의 참조를 메모이제이션하여 불필요한 재생성을 방지하고, 자식 컴포넌트의 최적화를 가능하게 합니다.
개요
간단히 말해서, useCallback은 함수의 참조를 메모이제이션하여 의존성이 변경될 때만 새 함수를 생성하는 Hook입니다. useMemo의 함수 특화 버전이라고 생각하면 됩니다.
실무에서 자식 컴포넌트에 전달하는 이벤트 핸들러, useEffect의 의존성으로 사용되는 함수, 디바운스/쓰로틀 함수, 커스텀 훅에서 반환하는 함수 등을 최적화할 때 사용됩니다. 예를 들어, 긴 목록의 각 아이템에 삭제 버튼이 있고, 각 버튼에 onClick 핸들러를 전달하는 경우 useCallback으로 함수를 메모이제이션하면 아이템 컴포넌트의 불필요한 리렌더링을 방지할 수 있습니다.
기존에는 매 렌더링마다 새로운 함수가 생성되어 자식 컴포넌트가 항상 리렌더링되었다면, 이제는 useCallback으로 함수 참조를 유지하여 props가 실제로 변경되지 않았을 때는 리렌더링을 건너뛸 수 있습니다. useCallback은 첫 번째 인자로 메모이제이션할 함수를, 두 번째 인자로 의존성 배열을 받습니다.
의존성이 변경되지 않으면 이전에 생성한 함수를 그대로 반환하여 참조 동일성을 유지합니다. 이러한 특징으로 React.memo로 최적화된 컴포넌트가 제대로 작동하게 하고, useEffect가 불필요하게 재실행되는 것을 방지할 수 있습니다.
코드 예제
import React, { useState, useCallback, memo } from 'react';
// 메모이제이션된 자식 컴포넌트
const TodoItem = memo(({ todo, onDelete }) => {
console.log(`${todo.text} 렌더링`);
return (
<li>
{todo.text}
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
});
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '운동하기' },
{ id: 2, text: '책 읽기' }
]);
const [count, setCount] = useState(0);
// 함수를 메모이제이션
const handleDelete = useCallback((id) => {
console.log('삭제 함수 생성');
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []); // 빈 배열: 컴포넌트 생명주기 동안 한 번만 생성
return (
<div>
<button onClick={() => setCount(count + 1)}>
카운트: {count}
</button>
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={handleDelete}
/>
))}
</ul>
</div>
);
}
설명
이것이 하는 일: useCallback은 함수를 감싸서 그 함수의 참조를 저장하고, 의존성이 변경될 때만 새 함수를 생성하여 참조 동일성을 유지합니다. 첫 번째로, useCallback((id) => {...}, [])로 handleDelete 함수를 메모이제이션합니다.
의존성 배열이 비어있으므로 이 함수는 컴포넌트가 마운트될 때 한 번만 생성되고, 이후 리렌더링에서는 동일한 함수 참조가 반환됩니다. "삭제 함수 생성" 로그는 최초 한 번만 출력됩니다.
이렇게 함수 참조를 고정하면 props로 전달받는 자식 컴포넌트가 변경을 감지하지 않습니다. 그 다음으로, TodoItem 컴포넌트가 memo()로 감싸져 있으므로, props(todo, onDelete)가 변경되지 않으면 리렌더링을 건너뜁니다.
count 상태가 변경되어 부모(TodoList)가 리렌더링되어도, handleDelete의 참조가 동일하므로 TodoItem은 리렌더링되지 않습니다. 콘솔에 "운동하기 렌더링" 같은 로그가 출력되지 않는 것을 확인할 수 있습니다.
세 번째로, 실제로 삭제 버튼을 클릭하면 handleDelete가 실행되고 setTodos로 상태를 업데이트합니다. 이때는 todos 배열이 변경되므로, map으로 렌더링되는 TodoItem들 중 실제로 변경된 부분만 리렌더링됩니다.
함수형 업데이트 prev => prev.filter(...)를 사용하여 의존성 배열에 todos를 넣지 않아도 최신 상태에 접근할 수 있습니다. 여러분이 이 코드를 사용하면 긴 목록을 렌더링할 때 성능이 극적으로 향상됩니다.
한 아이템과 무관한 상태가 변경되어도 다른 아이템들이 리렌더링되지 않아 화면이 부드럽게 동작합니다. 또한 useEffect의 의존성 배열에 함수를 넣을 때도 useCallback을 사용하면 불필요한 effect 재실행을 방지할 수 있으며, 이벤트 리스너 등록/해제가 반복되는 문제도 해결됩니다.
실전 팁
💡 useCallback은 useMemo(() => fn, deps)의 단축 문법입니다. 즉, useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일합니다.
💡 자식 컴포넌트가 React.memo로 감싸져 있지 않다면 useCallback을 사용해도 효과가 없습니다. 두 기법은 함께 사용해야 합니다.
💡 의존성 배열에 상태를 넣는 대신 함수형 업데이트(setState(prev => ...))를 사용하면 의존성을 줄일 수 있어 더 안정적입니다.
💡 모든 함수에 useCallback을 적용하지 마세요. 프로파일러로 병목 지점을 찾아 필요한 곳에만 사용하세요. 과도한 사용은 코드 복잡도만 높입니다.
💡 useCallback으로 감싼 함수를 다시 useCallback으로 감쌀 필요는 없습니다. 한 번만 적용하면 충분합니다.
7. useReducer - 복잡한 상태 로직 관리하기
시작하며
여러분이 쇼핑몰 장바구니 기능을 만들 때 이런 상황을 겪어본 적 있나요? 상품 추가, 수량 변경, 삭제, 전체 선택, 쿠폰 적용 등 다양한 액션에 따라 상태를 업데이트해야 하는데, useState가 너무 많아지고 업데이트 로직이 여기저기 흩어져서 관리가 어려웠던 경험 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 여러 개의 연관된 상태가 복잡하게 얽혀있고, 상태 업데이트 로직이 많은 컴포넌트에서는 useState만으로는 코드가 지저분해지고 버그가 발생하기 쉽습니다.
특히 다음 상태가 이전 상태에 복잡하게 의존하는 경우 로직을 추적하기 어렵습니다. 바로 이럴 때 필요한 것이 useReducer입니다.
Redux 스타일의 리듀서 패턴으로 복잡한 상태 로직을 체계적으로 관리할 수 있게 해줍니다.
개요
간단히 말해서, useReducer는 복잡한 상태 로직을 reducer 함수로 분리하여 관리하는 Hook입니다. useState의 대안으로, 여러 하위 값을 포함하는 복잡한 상태에 적합합니다.
실무에서 폼 관리, 장바구니, 다단계 위저드, 게임 상태, 채팅 메시지 관리 등 여러 액션 타입이 있고 상태 구조가 복잡한 경우에 사용됩니다. 예를 들어, 여러 필드가 있는 회원가입 폼에서 각 필드의 값, 유효성 검사 결과, 제출 상태 등을 하나의 reducer로 관리하면 코드가 훨씬 명확해집니다.
기존 useState로 여러 상태와 업데이트 함수를 관리했다면, 이제는 useReducer로 모든 상태를 하나의 객체로 통합하고, 모든 업데이트 로직을 reducer 함수에 중앙화할 수 있습니다. useReducer는 reducer 함수와 초기 상태를 인자로 받아 [현재 상태, dispatch 함수]를 반환합니다.
dispatch에 액션 객체를 전달하면 reducer가 호출되어 새로운 상태를 계산합니다. 이러한 특징으로 상태 업데이트 로직을 컴포넌트에서 분리하여 테스트하기 쉽고, 액션 타입으로 의도를 명확히 표현할 수 있으며, 시간 여행 디버깅도 가능합니다.
코드 예제
import React, { useReducer } from 'react';
// 액션 타입 상수
const ACTIONS = {
ADD_ITEM: 'add_item',
REMOVE_ITEM: 'remove_item',
UPDATE_QUANTITY: 'update_quantity',
CLEAR_CART: 'clear_cart'
};
// Reducer 함수: 상태 업데이트 로직을 한 곳에 모음
function cartReducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_ITEM:
return { ...state, items: [...state.items, action.payload] };
case ACTIONS.REMOVE_ITEM:
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case ACTIONS.UPDATE_QUANTITY:
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
};
case ACTIONS.CLEAR_CART:
return { ...state, items: [] };
default:
return state;
}
}
function ShoppingCart() {
// useReducer 사용
const [state, dispatch] = useReducer(cartReducer, { items: [] });
const addItem = (item) => {
dispatch({ type: ACTIONS.ADD_ITEM, payload: item });
};
return (
<div>
<button onClick={() => addItem({ id: 1, name: '책', quantity: 1 })}>
상품 추가
</button>
<button onClick={() => dispatch({ type: ACTIONS.CLEAR_CART })}>
장바구니 비우기
</button>
<p>상품 개수: {state.items.length}</p>
</div>
);
}
설명
이것이 하는 일: useReducer는 현재 상태와 액션 객체를 받아 새로운 상태를 반환하는 reducer 함수를 사용하여, 모든 상태 업데이트 로직을 한 곳에 모으고 예측 가능하게 만듭니다. 첫 번째로, cartReducer 함수를 정의합니다.
이 함수는 현재 상태(state)와 액션(action) 객체를 받아 새로운 상태를 반환합니다. switch문으로 action.type에 따라 다른 로직을 실행하며, 각 케이스는 특정한 상태 변경을 담당합니다.
이렇게 모든 업데이트 로직을 한 함수에 모으면 상태 변경을 추적하고 디버깅하기 훨씬 쉬워집니다. reducer는 순수 함수여야 하므로 항상 새로운 객체를 반환해야 합니다.
그 다음으로, useReducer(cartReducer, { items: [] })로 reducer를 초기화합니다. 이때 반환되는 state는 현재 상태이고, dispatch는 액션을 보내는 함수입니다.
dispatch를 호출하면 React가 cartReducer를 실행하여 새 상태를 계산하고, 컴포넌트를 리렌더링합니다. 액션 객체는 관례적으로 { type: '액션타입', payload: 데이터 } 형태로 구성됩니다.
세 번째로, 실제 사용 시 dispatch({ type: ACTIONS.ADD_ITEM, payload: item })처럼 액션을 보냅니다. 이 방식은 "무엇을 할지"(what)만 선언하고 "어떻게 할지"(how)는 reducer에 위임합니다.
따라서 컴포넌트 코드가 간결해지고, 같은 액션을 여러 곳에서 재사용할 수 있습니다. 또한 액션 타입을 상수로 관리하면 오타를 방지하고 IDE의 자동완성을 활용할 수 있습니다.
여러분이 이 코드를 사용하면 복잡한 상태 로직이 명확하게 정리되어 가독성이 크게 향상됩니다. reducer 함수는 순수 함수이므로 독립적으로 테스트하기 쉽고, 상태 변경 이력을 추적하여 시간 여행 디버깅도 가능합니다.
또한 여러 컴포넌트에서 동일한 reducer를 재사용할 수 있으며, useContext와 결합하면 간단한 전역 상태 관리 솔루션으로도 활용할 수 있습니다.
실전 팁
💡 useState와 useReducer 중 어느 것을 사용할지 고민될 때: 단순한 값은 useState, 여러 연관된 값과 복잡한 업데이트 로직은 useReducer를 선택하세요.
💡 액션 타입을 문자열 상수로 관리하여 오타를 방지하고, TypeScript를 사용한다면 액션의 유니온 타입을 정의하여 타입 안정성을 높이세요.
💡 reducer 함수는 반드시 순수 함수여야 합니다. API 호출, 타이머 설정 같은 사이드 이펙트는 useEffect에서 처리하세요.
💡 초기 상태 계산이 무거우면 useReducer의 세 번째 인자로 초기화 함수를 전달할 수 있습니다: useReducer(reducer, initialArg, init)
💡 useReducer와 useContext를 결합하면 Redux 없이도 전역 상태 관리를 구현할 수 있습니다. 소규모 프로젝트에서는 충분한 대안이 됩니다.
8. Custom Hooks - 로직 재사용하기
시작하며
여러분이 여러 컴포넌트에서 API 데이터를 가져오는 코드를 작성하다 보니 똑같은 패턴이 반복되는 상황을 겪어본 적 있나요? loading 상태, error 상태, 데이터 상태를 매번 선언하고, useEffect로 fetch를 호출하는 보일러플레이트 코드가 중복되는 문제 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 폼 입력 관리, 로컬스토리지 동기화, 윈도우 크기 추적, 디바운스 처리 등 비슷한 로직이 여러 컴포넌트에서 필요할 때마다 코드를 복사-붙여넣기하면 유지보수가 어려워지고 버그 수정도 여러 곳을 고쳐야 합니다.
바로 이럴 때 필요한 것이 Custom Hooks입니다. 반복되는 로직을 재사용 가능한 함수로 추출하여 코드 중복을 제거하고 관심사를 분리할 수 있습니다.
개요
간단히 말해서, Custom Hooks는 여러분이 직접 만드는 Hook으로, 컴포넌트 로직을 재사용 가능한 함수로 추출한 것입니다. "use"로 시작하는 이름을 가진 일반 JavaScript 함수입니다.
실무에서 데이터 페칭(useFetch), 폼 관리(useForm), 로컬스토리지 동기화(useLocalStorage), 디바운스(useDebounce), 미디어 쿼리(useMediaQuery), 이전 값 추적(usePrevious) 등 다양한 패턴을 캡슐화할 때 사용됩니다. 예를 들어, 여러 페이지에서 사용자 데이터를 가져오는 경우 useFetch 훅으로 공통 로직을 추출하면 한 번의 수정으로 모든 곳이 업데이트됩니다.
기존에는 HOC(Higher-Order Components)나 Render Props 패턴으로 로직을 재사용했다면, 이제는 Custom Hooks로 더 간결하고 직관적으로 로직을 공유할 수 있습니다. Custom Hook은 내부에서 다른 Hook들(useState, useEffect 등)을 사용할 수 있으며, 필요한 값이나 함수를 반환합니다.
Hook의 규칙(최상위에서만 호출, React 함수 내에서만 호출)을 따라야 합니다. 이러한 특징으로 복잡한 로직을 숨기고 깔끔한 인터페이스만 제공할 수 있으며, 로직을 독립적으로 테스트하고 여러 프로젝트에서 재사용할 수 있습니다.
코드 예제
import { useState, useEffect } from 'react';
// Custom Hook: API 데이터 페칭
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 정리를 위한 플래그
let isCancelled = false;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(data => {
if (!isCancelled) {
setData(data);
setError(null);
}
})
.catch(err => {
if (!isCancelled) {
setError(err.message);
setData(null);
}
})
.finally(() => {
if (!isCancelled) setLoading(false);
});
// 클린업: 컴포넌트 언마운트 시 플래그 설정
return () => {
isCancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Custom Hook 사용 예시
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(
`https://api.example.com/users/${userId}`
);
if (loading) return <p>로딩 중...</p>;
if (error) return <p>에러: {error}</p>;
return <div>{user?.name}</div>;
}
설명
이것이 하는 일: Custom Hook은 여러 Hook들을 조합하여 특정 기능을 캡슐화하고, 그 기능을 여러 컴포넌트에서 쉽게 재사용할 수 있게 합니다. 첫 번째로, useFetch 함수를 정의합니다.
이 함수는 url을 인자로 받고, 내부에서 useState와 useEffect 같은 React Hook들을 사용합니다. 이것이 Custom Hook이 되는 핵심입니다.
이름이 "use"로 시작하므로 React는 이를 Hook으로 인식하고 Hook의 규칙을 적용합니다. 내부 로직은 일반 컴포넌트에서 작성하는 것과 동일하지만, 재사용 가능한 형태로 추상화되어 있습니다.
그 다음으로, useEffect 내부에서 fetch API를 호출하여 데이터를 가져옵니다. loading, error, data 세 가지 상태를 관리하는 일반적인 데이터 페칭 패턴이 여기 캡슐화되어 있습니다.
중요한 것은 isCancelled 플래그를 사용하여 컴포넌트가 언마운트된 후 상태 업데이트를 방지하는 메모리 누수 방지 로직도 포함되어 있다는 점입니다. 이런 복잡한 로직을 매번 작성할 필요 없이 훅으로 숨길 수 있습니다.
세 번째로, 실제 컴포넌트에서 useFetch(url)을 호출하면 마치 내장 Hook처럼 간단하게 사용할 수 있습니다. 반환된 객체를 구조분해하여 필요한 값을 가져오고, 컴포넌트는 UI 렌더링에만 집중합니다.
만약 데이터 페칭 로직을 수정해야 한다면 useFetch 훅 한 곳만 수정하면 이를 사용하는 모든 컴포넌트가 자동으로 업데이트됩니다. 여러분이 이 코드를 사용하면 중복 코드가 극적으로 줄어들고, 각 컴포넌트가 훨씬 간결해집니다.
Custom Hook은 독립적으로 테스트할 수 있어 품질이 높은 코드를 작성할 수 있으며, 팀원들과 공유하거나 npm 패키지로 배포하여 여러 프로젝트에서 재사용할 수도 있습니다. 또한 로직과 UI를 명확히 분리하여 관심사 분리 원칙을 지킬 수 있습니다.
실전 팁
💡 Custom Hook 이름은 반드시 "use"로 시작해야 합니다. 그래야 React가 Hook의 규칙을 검사하고, ESLint 플러그인이 제대로 작동합니다.
💡 Custom Hook은 일반 JavaScript 함수이므로 인자를 받고 값을 반환할 수 있습니다. 배열, 객체, 함수 등 무엇이든 반환 가능합니다.
💡 같은 Custom Hook을 여러 컴포넌트에서 사용해도 각 컴포넌트는 독립적인 상태를 가집니다. Hook을 호출할 때마다 새로운 상태가 생성됩니다.
💡 useLocalStorage, useDebounce, useWindowSize 같은 유용한 Custom Hook을 제공하는 라이브러리(usehooks-ts, react-use 등)를 활용하세요.
💡 Custom Hook도 다른 Custom Hook을 호출할 수 있습니다. 복잡한 로직을 여러 작은 Hook으로 나누어 조합하면 유지보수가 쉬워집니다.
9. useLayoutEffect - 동기적 DOM 조작하기
시작하며
여러분이 툴팁이나 모달의 위치를 계산하여 배치할 때 이런 상황을 겪어본 적 있나요? useEffect로 DOM 요소의 크기를 측정하고 위치를 조정했는데, 화면에 잠깐 잘못된 위치가 보이다가 깜빡이며 올바른 위치로 이동하는 "깜박임" 현상이 발생하는 경험 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. useEffect는 브라우저가 화면을 그린 후 비동기적으로 실행되므로, DOM 측정 후 스타일을 변경하는 작업에서는 사용자가 중간 상태를 볼 수 있습니다.
특히 애니메이션, 스크롤 위치 복원, 레이아웃 계산 등에서 이런 시각적 문제가 두드러집니다. 바로 이럴 때 필요한 것이 useLayoutEffect입니다.
브라우저가 화면을 그리기 전에 동기적으로 실행되어 깜박임 없는 부드러운 사용자 경험을 제공합니다.
개요
간단히 말해서, useLayoutEffect는 useEffect와 동일한 시그니처를 가지지만, 브라우저가 화면을 그리기 전에 동기적으로 실행되는 Hook입니다. DOM 변경 사항이 화면에 반영되기 전에 실행됩니다.
실무에서 DOM 요소의 크기/위치 측정, 스크롤 위치 조정, 툴팁/팝오버 위치 계산, 애니메이션 초기 설정, 레이아웃 계산 등 시각적 일관성이 중요한 작업에 사용됩니다. 예를 들어, 드롭다운 메뉴가 화면 밖으로 나가면 자동으로 위쪽으로 열리도록 하는 경우, useLayoutEffect로 위치를 계산하면 깜박임 없이 처음부터 올바른 위치에 표시됩니다.
useEffect는 화면 그리기 후 비동기 실행되어 사용자가 중간 상태를 볼 수 있다면, useLayoutEffect는 화면 그리기 전 동기 실행되어 최종 상태만 표시됩니다. useLayoutEffect는 useEffect와 완전히 동일한 API를 가지며, 실행 타이밍만 다릅니다.
DOM 변경 → useLayoutEffect 실행 → 브라우저 페인팅 순서로 진행되므로, effect 내부에서 DOM을 수정해도 사용자는 최종 결과만 봅니다. 이러한 특징으로 시각적 일관성을 보장하고 레이아웃 계산을 안전하게 수행할 수 있지만, 동기적으로 실행되므로 무거운 작업은 피해야 합니다.
코드 예제
import React, { useRef, useLayoutEffect, useState } from 'react';
function Tooltip({ children, text }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
// 브라우저 페인팅 전에 실행되어 깜박임 방지
useLayoutEffect(() => {
const tooltip = tooltipRef.current;
if (!tooltip) return;
// DOM 요소의 크기와 위치 측정
const rect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 화면 밖으로 나가면 위치 조정
let top = rect.top;
let left = rect.left;
if (rect.right > viewportWidth) {
left = viewportWidth - rect.width - 10;
}
if (rect.bottom > viewportHeight) {
top = viewportHeight - rect.height - 10;
}
// 계산된 위치 적용 (페인팅 전이므로 깜박임 없음)
setPosition({ top, left });
}, [text]); // text가 변경되면 재계산
return (
<div className="tooltip-container">
{children}
<div
ref={tooltipRef}
className="tooltip"
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`
}}
>
{text}
</div>
</div>
);
}
설명
이것이 하는 일: useLayoutEffect는 DOM 업데이트 직후, 브라우저가 화면에 변경사항을 그리기 전에 동기적으로 실행되어, 시각적으로 일관된 결과를 보장합니다. 첫 번째로, 컴포넌트가 렌더링되면 React는 가상 DOM을 실제 DOM에 반영합니다(commit phase).
이때 아직 브라우저 화면에는 그려지지 않은 상태입니다. 바로 이 시점에서 useLayoutEffect가 동기적으로 실행됩니다.
위 코드에서는 getBoundingClientRect()로 툴팁의 실제 크기와 위치를 측정합니다. 이 측정값은 현재 DOM 상태를 정확히 반영합니다.
그 다음으로, 측정값을 기반으로 툴팁이 화면 밖으로 나가는지 검사하고, 필요하면 위치를 조정합니다. setPosition을 호출하여 상태를 업데이트하면 React는 즉시 재렌더링을 예약합니다.
중요한 점은 이 모든 과정이 브라우저 페인팅 전에 이루어지므로, 사용자는 중간 과정을 보지 못하고 최종 위치의 툴팁만 봅니다. 세 번째로, 브라우저는 모든 useLayoutEffect가 완료된 후에야 화면에 그리기 시작합니다.
따라서 처음부터 올바른 위치에 툴팁이 나타나며, 잘못된 위치에서 이동하는 깜박임이 발생하지 않습니다. 만약 useEffect를 사용했다면 처음에 기본 위치에 툴팁이 그려지고, 그 후 위치 계산이 완료되어 다시 그려지면서 깜박임이 보였을 것입니다.
여러분이 이 코드를 사용하면 사용자 경험이 훨씬 부드러워지고 전문적으로 보입니다. 툴팁, 드롭다운, 모달 같은 UI 요소의 위치를 자동으로 조정할 때 깜박임 없이 자연스럽게 표시할 수 있습니다.
또한 스크롤 위치 복원, 포커스 관리, 애니메이션 준비 등 타이밍이 중요한 작업도 정확하게 처리할 수 있습니다.
실전 팁
💡 대부분의 경우 useEffect를 사용하세요. useLayoutEffect는 동기 실행으로 화면을 차단하므로 성능에 영향을 줍니다. DOM 측정이 필요할 때만 사용하세요.
💡 서버 사이드 렌더링(SSR)을 사용한다면 useLayoutEffect는 서버에서 경고를 발생시킵니다. 조건부로 useEffect를 사용하거나, useIsomorphicLayoutEffect 패턴을 활용하세요.
💡 useLayoutEffect 내부에서 무거운 계산을 하지 마세요. 메인 스레드를 차단하여 사용자가 지연을 느낄 수 있습니다.
💡 애니메이션 라이브러리(Framer Motion, React Spring)는 내부적으로 useLayoutEffect를 사용합니다. 직접 구현하기보다는 검증된 라이브러리 사용을 고려하세요.
💡 깜박임 문제를 겪고 있다면 useEffect를 useLayoutEffect로 바꿔보세요. API는 동일하므로 쉽게 전환할 수 있습니다.
10. React.memo와 Hooks - 컴포넌트 최적화하기
시작하며
여러분이 긴 목록을 렌더링하는 앱을 만들 때 이런 상황을 겪어본 적 있나요? 목록의 한 아이템만 변경되었는데 모든 아이템이 리렌더링되어 화면이 버벅이고, 사용자 경험이 저하되는 문제 말이죠.
특히 각 아이템이 복잡한 컴포넌트로 구성되어 있다면 성능 저하가 더욱 심각합니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
React는 기본적으로 부모 컴포넌트가 리렌더링되면 모든 자식 컴포넌트도 리렌더링합니다. props가 변경되지 않았어도 무조건 다시 렌더링하므로, 대규모 애플리케이션에서는 불필요한 계산이 많이 발생합니다.
바로 이럴 때 필요한 것이 React.memo와 Hooks의 조합입니다. 메모이제이션을 통해 props가 변경되지 않은 컴포넌트의 리렌더링을 건너뛰어 성능을 크게 향상시킵니다.
개요
간단히 말해서, React.memo는 고차 컴포넌트(HOC)로, props가 변경되지 않으면 컴포넌트의 리렌더링을 건너뛰는 최적화 기법입니다. useMemo, useCallback과 함께 사용하면 시너지 효과를 냅니다.
실무에서 목록의 아이템 컴포넌트, 복잡한 차트/그래프, 대용량 테이블의 셀, 사이드바/헤더 같은 정적 컴포넌트 등 렌더링 비용이 크고 자주 변경되지 않는 컴포넌트에 사용됩니다. 예를 들어, 1000개의 할 일 목록에서 하나만 체크 표시를 변경할 때, 메모이제이션으로 나머지 999개의 리렌더링을 방지할 수 있습니다.
기존에는 클래스 컴포넌트의 shouldComponentUpdate로 수동으로 비교했다면, 이제는 React.memo로 자동으로 props를 얕은 비교(shallow comparison)하여 최적화할 수 있습니다. React.memo는 컴포넌트를 감싸면 이전 props와 현재 props를 비교하여 변경되지 않았으면 이전 렌더링 결과를 재사용합니다.
useCallback으로 함수 참조를, useMemo로 객체/배열 참조를 유지하면 props 비교가 정확해져 최적화가 제대로 작동합니다. 이러한 특징으로 대규모 애플리케이션에서 렌더링 성능을 극적으로 개선하고, 60fps를 유지하여 부드러운 사용자 경험을 제공할 수 있습니다.
코드 예제
import React, { useState, useCallback, useMemo, memo } from 'react';
// 메모이제이션된 자식 컴포넌트
const ExpensiveItem = memo(({ item, onToggle, theme }) => {
console.log(`${item.text} 렌더링`);
// 복잡한 계산 (실제로는 더 무거울 수 있음)
const processedText = useMemo(() => {
return item.text.toUpperCase();
}, [item.text]);
return (
<li style={{ background: theme.background }}>
<input
type="checkbox"
checked={item.done}
onChange={() => onToggle(item.id)}
/>
{processedText}
</li>
);
});
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '운동하기', done: false },
{ id: 2, text: '책 읽기', done: false },
{ id: 3, text: '코딩하기', done: false }
]);
const [darkMode, setDarkMode] = useState(false);
// 함수 메모이제이션
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}, []);
// 객체 메모이제이션
const theme = useMemo(() => ({
background: darkMode ? '#333' : '#fff',
color: darkMode ? '#fff' : '#000'
}), [darkMode]);
return (
<div>
<button onClick={() => setDarkMode(!darkMode)}>
테마 전환
</button>
<ul>
{todos.map(todo => (
<ExpensiveItem
key={todo.id}
item={todo}
onToggle={handleToggle}
theme={theme}
/>
))}
</ul>
</div>
);
}
설명
이것이 하는 일: React.memo는 컴포넌트를 감싸서 props의 얕은 비교를 수행하고, 변경되지 않았으면 이전 렌더링 결과를 재사용하여 불필요한 계산을 건너뜁니다. 첫 번째로, memo(ExpensiveItem)으로 컴포넌트를 감싸면 React는 이 컴포넌트에 메모이제이션을 적용합니다.
부모(TodoApp)가 리렌더링될 때마다 React는 ExpensiveItem에 전달되는 props(item, onToggle, theme)를 이전 값과 비교합니다. 얕은 비교를 수행하므로 원시값은 값 자체를, 객체/배열/함수는 참조를 비교합니다.
모든 props가 동일하면 ExpensiveItem의 함수 본문은 실행되지 않고 이전 렌더링 결과가 재사용됩니다. 그 다음으로, handleToggle을 useCallback으로, theme을 useMemo로 메모이제이션합니다.
이게 핵심입니다. darkMode 버튼을 클릭하면 theme은 변경되지만 handleToggle은 동일한 참조를 유지합니다.
반대로 todo를 토글하면 todos가 변경되어 map이 재실행되지만, 변경되지 않은 아이템의 item 객체는 동일한 참조를 유지합니다(스프레드 연산자로 새 배열을 만들지만 변경 안 된 객체는 재사용). 이렇게 참조를 유지해야 memo가 제대로 작동합니다.
세 번째로, 실제 동작을 보면 darkMode를 전환할 때 theme이 변경되므로 모든 ExpensiveItem이 리렌더링됩니다(콘솔 로그 출력). 하지만 하나의 todo만 토글하면 해당 아이템만 리렌더링되고 나머지는 건너뜁니다.
만약 memo를 사용하지 않았다면 todos 배열이 새로 생성되었으므로 모든 아이템이 리렌더링되었을 것입니다. 여러분이 이 코드를 사용하면 목록 성능이 극적으로 향상됩니다.
수백 개의 아이템이 있어도 변경된 것만 리렌더링하므로 화면이 부드럽게 동작합니다. 또한 React DevTools Profiler로 실제 렌더링 횟수를 측정하여 최적화 효과를 확인할 수 있으며, 사용자는 지연 없이 앱을 사용할 수 있습니다.
대규모 데이터 그리드, 복잡한 대시보드, 실시간 업데이트 피드 등에서 필수적인 기법입니다.
실전 팁
💡 React.memo는 성능 최적화 도구이지 보장이 아닙니다. React는 필요시 메모이제이션을 무시할 수 있으므로, 로직의 정확성이 memo에 의존해서는 안 됩니다.
💡 props로 전달하는 함수, 객체, 배열은 반드시 useCallback/useMemo로 메모이제이션하세요. 그렇지 않으면 매번 새 참조가 생성되어 memo가 무용지물이 됩니다.
💡 모든 컴포넌트에 memo를 적용하지 마세요. 프로파일러로 병목을 찾아 정말 필요한 곳에만 사용하세요. 과도한 사용은 코드 복잡도만 높입니다.
💡 memo의 두 번째 인자로 커스텀 비교 함수를 전달할 수 있습니다: memo(Component, (prevProps, nextProps) => {...}). 복잡한 props 비교 로직이 필요할 때 사용하세요.
💡 React DevTools의 Profiler 탭에서 "Highlight updates when components render" 옵션을 켜서 실제로 어떤 컴포넌트가 리렌더링되는지 시각적으로 확인하세요.
이 카드뉴스가 포함된 코스
댓글 (0)
함께 보면 좋은 카드 뉴스
서비스 메시 완벽 가이드
마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Grafana 대시보드 완벽 가이드
실시간 모니터링의 핵심, Grafana 대시보드를 처음부터 끝까지 배워봅니다. Prometheus 연동부터 알람 설정까지, 초급 개발자도 쉽게 따라할 수 있는 실전 가이드입니다.
분산 추적 완벽 가이드
마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템의 핵심 개념을 배웁니다. Trace, Span, Trace ID 전파, 샘플링 전략까지 실무에 필요한 모든 것을 다룹니다.
CloudFront CDN 완벽 가이드
AWS CloudFront를 활용한 콘텐츠 배포 최적화 방법을 실무 관점에서 다룹니다. 배포 생성부터 캐시 설정, HTTPS 적용까지 단계별로 알아봅니다.