우리는 지금까지 React가 어떻게 화면을 그리고(렌더링 파이프라인), 상태를 어떻게 기억하며 업데이트하는지(useState, 배칭, 동시성)에 대해 배웠습니다.
오늘은 React 함수형 컴포넌트의 강력함을 더해주는 다양한 훅(Hook)들을 좀 더 깊이 있게 살펴보려고 합니다.
오늘은 그중에서도 자주 사용되지만 정확한 동작 방식이나 차이점을 헷갈리기 쉬운 useEffect, useLayoutEffect, useRef, useReducer에 대해 자세히 알아보고, 나아가 우리만의 커스텀 훅을 만드는 방법까지 함께 탐구해 보겠습니다.
(Side Effect)를 처리하기 위해 사용되는 훅입니다.
부수 효과란 컴포넌트 렌더링 과정 자체와는 별개로 수행되어야 하는 작업들을 의미합니다. 예를 들어, 데이터 가져오기(fetching), 구독(subscription) 설정, 또는 직접 DOM을 조작하는 것 등이 있죠.
이 두 훅은 기능적으로 매우 유사하지만, 결정적인 차이점은 바로
이 훅은 컴포넌트 렌더링이 완료되었지만 브라우저가 화면을 그리기 전(before paint)에
동기적
으로 실행됩니다.
즉, useLayoutEffect 내부의 코드가 모두 실행될 때까지 브라우저는 화면 그리기를 기다립니다.
만약 이 훅 내부에서 시간이 오래 걸리는 작업을 하면 화면 깜빡임(flickering)은 막을 수 있지만, 전체적인 렌더링이 지연되어 사용자 경험을 해칠 수 있습니다.
UI 렌더링을 방해하지 않기 때문이죠.
useLayoutEffect는 다음과 같은 특별한 경우에만 제한적으로 사용해야 합니다.
DOM을 직접 읽고 동기적으로 리렌더링을 유발해야 할 때
예를 들어, 렌더링 직후 특정 요소의 크기나 위치를 측정하고, 그 값에 따라 즉시 스타일을 변경하거나 다른 상태를 업데이트해야 해서 화면이 깜빡이는 것을 막아야 할 때 사용합니다.
스크롤 위치를 조정하거나, 툴팁의 위치를 계산하는 등의 작업이 해당될 수 있습니다.
클래스 컴포넌트의 componentDidMount나 componentDidUpdate와 동일한 타이밍에 실행되어야 하는 로직이 있을 때
Q: "useEffect와 useLayoutEffect의 차이점은 무엇인가요?"
A: "둘 다 부수 효과를 처리하지만, 실행 시점이 다릅니다. useEffect는 화면이 그려진 후 비동기적으로 실행되고, useLayoutEffect는 화면이 그려지기 전 동기적으로 실행됩니다. 따라서 DOM 레이아웃을 읽고 동기적으로 UI를 업데이트해야 하는 경우가 아니라면 대부분 useEffect를 사용해야 합니다."
하는 것입니다.
useRef는 .current 프로퍼티를 가진 객체를 반환하는데, 이 .current 값을 변경해도 컴포넌트가 리렌더링되지 않습니다.
그리고 컴포넌트가 리렌더링되더라도 .current에 저장된 값은 그대로 유지됩니다. 마치 컴포넌트 인스턴스 변수처럼 사용할 수 있는 것이죠. 이는 다음과 같은 경우에 유용합니다.
이전 상태 값 저장: 이전 props나 state 값을 저장했다가 현재 값과 비교할 때.
타이머 ID 저장: setTimeout이나 setInterval의 ID를 저장하고 나중에 clearTimeout, clearInterval 할 때.
변경 가능하지만 리렌더링을 유발하지 않는 값 저장: 특정 계산 결과나 플래그 값 등.
import React, { useState, useEffect, useRef } from 'react';function Timer() { const [seconds, setSeconds] = useState(0); const intervalRef = useRef<NodeJS.Timeout | null>(null); // 타이머 ID 저장용 ref useEffect(() => { // 컴포넌트 마운트 시 타이머 시작 intervalRef.current = setInterval(() => { setSeconds(prevSeconds => prevSeconds + 1); }, 1000); // 컴포넌트 언마운트 시 타이머 정리 (Cleanup 함수) return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, []); // 빈 의존성 배열: 마운트 시 1회, 언마운트 시 1회 실행 return ( <div> <p>타이머: {seconds}초</p> <button onClick={() => clearInterval(intervalRef.current!)}>타이머 중지</button> </div> );}
useRef가 반환하는 객체의 .current 프로퍼티는 마치 C언어의 포인터나 다른 언어의 참조(Reference)처럼, 특정 값(DOM 노드 또는 다른 어떤 값)을 가리키고 그 값을 직접 변경할 수 있는 통로 역할을 합니다.
Reducer 함수: (state, action) => newState 형태의 순수 함수입니다. 현재 상태(state)와 어떤 행동을 나타내는 객체(action)를 받아서, 새로운 상태(newState)를 반환합니다. 모든 상태 변경 로직은 이 함수 안에 정의됩니다.
초기 상태 (initialState): 상태의 초기값입니다.
Dispatch 함수: Reducer에게 '어떤 행동을 해주세요'라고 알리는 함수입니다. 이 함수에 action 객체를 전달하면, React는 Reducer 함수를 호출하여 상태 업데이트를 처리합니다.
import React, { useReducer } from 'react';// 1. 액션 타입 정의 (TypeScript 사용 시)type Action = { type: 'INCREMENT' } | { type: 'DECREMENT' };// 2. 초기 상태 정의const initialState = { count: 0 };// 3. Reducer 함수 정의function reducer(state: typeof initialState, action: Action): typeof initialState { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; default: throw new Error('Unhandled action type'); }}function Counter() { // 4. useReducer 사용 const [state, dispatch] = useReducer(reducer, initialState); return ( <div> <p>Count: {state.count}</p> {/* 5. Dispatch 함수로 액션 전달 */} <button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button> <button onClick={() => dispatch({ type: 'DECREMENT' })}>감소</button> </div> );}
dispatch 함수가 호출되면, React는 setState와 유사하게 업데이트를 예약합니다.
내부적으로는 전달된 action 객체를 포함한 Update 객체를 생성하고, 해당 훅의 Update Queue에 추가하는 방식으로 동작할 수 있습니다. 이후 렌더링 단계에서 Reducer 함수가 실행되어 새로운 상태를 계산합니다.
Q: "useState와 useReducer 중 어떤 것을 언제 사용해야 할까요?"
A: "간단한 상태(단일 값, 토글 등)는 useState가 편리하지만, 상태 로직이 복잡하거나 여러 값이 연관되어 있거나 상태 업데이트 로직의 테스트/재사용이 중요할 때는 useReducer가 더 적합합니다."