PromleeBlog
sitemapaboutMe

posting thumbnail
React Hooks 심층 탐구 (useEffect, useRef, useReducer) - React, 알고 쓰자 4일차
Deep Dive into React Hooks (useEffect, useRef, useReducer) - React Explained Day 4

📅

🚀

들어가기 전에🔗

우리는 지금까지 React가 어떻게 화면을 그리고(렌더링 파이프라인), 상태를 어떻게 기억하며 업데이트하는지(useState, 배칭, 동시성)에 대해 배웠습니다.
오늘은 React 함수형 컴포넌트의 강력함을 더해주는 다양한 훅(Hook)들을 좀 더 깊이 있게 살펴보려고 합니다.
오늘은 그중에서도 자주 사용되지만 정확한 동작 방식이나 차이점을 헷갈리기 쉬운 useEffect, useLayoutEffect, useRef, useReducer에 대해 자세히 알아보고, 나아가 우리만의 커스텀 훅을 만드는 방법까지 함께 탐구해 보겠습니다.

🚀

useEffect vs useLayoutEffect🔗

useEffect와 useLayoutEffect는 모두 컴포넌트의
부수 효과
(Side Effect)를 처리하기 위해 사용되는 훅입니다.
부수 효과란 컴포넌트 렌더링 과정 자체와는 별개로 수행되어야 하는 작업들을 의미합니다. 예를 들어, 데이터 가져오기(fetching), 구독(subscription) 설정, 또는 직접 DOM을 조작하는 것 등이 있죠.
이 두 훅은 기능적으로 매우 유사하지만, 결정적인 차이점은 바로
언제 실행되느냐
하는 실행 타이밍에 있습니다.

언제 실행될까요?🔗

➡️

useEffect🔗

이 훅은 컴포넌트 렌더링이 완료되고 브라우저가 화면을 그린 이후(after paint)에
비동기적
으로 실행됩니다.
즉, useEffect 내부의 코드가 실행되기 전에 사용자는 일단 업데이트된 화면을 먼저 보게 됩니다.
무거운 작업이 있더라도 UI 렌더링을 막지 않기 때문에 대부분의 부수 효과 처리에 적합합니다.
➡️

useLayoutEffect🔗

이 훅은 컴포넌트 렌더링이 완료되었지만 브라우저가 화면을 그리기 전(before paint)에
동기적
으로 실행됩니다.
즉, useLayoutEffect 내부의 코드가 모두 실행될 때까지 브라우저는 화면 그리기를 기다립니다.
만약 이 훅 내부에서 시간이 오래 걸리는 작업을 하면 화면 깜빡임(flickering)은 막을 수 있지만, 전체적인 렌더링이 지연되어 사용자 경험을 해칠 수 있습니다.
렌더링부터 화면 표시까지의 타임라인
렌더링부터 화면 표시까지의 타임라인

어떤 것을 선택해야 할까요?🔗

결론부터 말씀드리면,
대부분의 경우 useEffect를 사용하는 것이 좋습니다.
UI 렌더링을 방해하지 않기 때문이죠.
useLayoutEffect는 다음과 같은 특별한 경우에만 제한적으로 사용해야 합니다.
  1. DOM을 직접 읽고 동기적으로 리렌더링을 유발해야 할 때
    예를 들어, 렌더링 직후 특정 요소의 크기나 위치를 측정하고, 그 값에 따라 즉시 스타일을 변경하거나 다른 상태를 업데이트해야 해서 화면이 깜빡이는 것을 막아야 할 때 사용합니다.
    스크롤 위치를 조정하거나, 툴팁의 위치를 계산하는 등의 작업이 해당될 수 있습니다.
  2. 클래스 컴포넌트의 componentDidMount나 componentDidUpdate와 동일한 타이밍에 실행되어야 하는 로직이 있을 때
Q: "useEffect와 useLayoutEffect의 차이점은 무엇인가요?"
A: "둘 다 부수 효과를 처리하지만, 실행 시점이 다릅니다. useEffect는 화면이 그려진 후 비동기적으로 실행되고, useLayoutEffect는 화면이 그려지기 전 동기적으로 실행됩니다. 따라서 DOM 레이아웃을 읽고 동기적으로 UI를 업데이트해야 하는 경우가 아니라면 대부분 useEffect를 사용해야 합니다."

🚀

useRef🔗

useRef 훅은 주로 특정 DOM 요소에 직접 접근해야 할 때 사용된다고 알려져 있습니다. 하지만 그 외에도 아주 유용한 기능이 숨어있답니다.

DOM 요소에 접근하기🔗

가장 기본적인 사용법은 JSX 요소의 ref 속성에 useRef로 생성한 객체를 연결하여 해당 DOM 노드에 접근하는 것입니다.
예를 들어, 특정 입력 필드에 포커스를 주거나, 비디오 요소의 재생/정지를 제어하는 등의 작업에 활용될 수 있습니다.
import React, { useRef, useEffect } from 'react';
 
function TextInputWithFocusButton() {
  // ref 객체 생성
  const inputEl = useRef<HTMLInputElement>(null);
 
  const onButtonClick = () => {
    // .current 프로퍼티로 실제 DOM 요소에 접근하여 focus() 메서드 호출
    inputEl.current?.focus();
  };
 
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

렌더링과 무관한 값 저장하기 (Mutable Container)🔗

useRef의 또 다른 중요한 용도는
렌더링 사이클에 영향을 주지 않으면서 변경 가능한(mutable) 값을 유지
하는 것입니다.
useRef는 .current 프로퍼티를 가진 객체를 반환하는데, 이 .current 값을 변경해도 컴포넌트가 리렌더링되지 않습니다.
그리고 컴포넌트가 리렌더링되더라도 .current에 저장된 값은 그대로 유지됩니다. 마치 컴포넌트 인스턴스 변수처럼 사용할 수 있는 것이죠. 이는 다음과 같은 경우에 유용합니다.
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 노드 또는 다른 어떤 값)을 가리키고 그 값을 직접 변경할 수 있는 통로 역할을 합니다.

.current 속성🔗

useRef가 반환하는 객체는 { current: initialValue } 형태입니다. 여기서 중요한 점은 .current 속성을 직접 변경해도 React에게 아무런 알림이 가지 않는다는 것입니다. 즉, .current 값을 바꾼다고 해서
자동으로 리렌더링이 일어나지 않습니다.
이것이 useState와의 가장 큰 차이점입니다. 상태 변경이 렌더링으로 이어져야 한다면 useState를, 렌더링과 무관하게 값을 유지하고 싶다면 useRef를 사용해야 합니다.

🚀

useReducer: 복잡한 상태 관리🔗

useState만으로도 많은 상태 관리가 가능하지만, 컴포넌트의 상태 로직이 복잡해지면 useReducer가 더 나은 선택지가 될 수 있습니다. useReducer는 주로 다음과 같은 상황에서 useState보다 유리합니다.

useState보다 좋을 때는 언제일까요?🔗

  1. 상태 로직이 복잡할 때
    여러 하위 값으로 이루어진 복잡한 상태 객체를 다루거나, 다음 상태가 이전 상태와 특정 액션(action)에 따라 결정되는 경우.
  2. 여러 상태 업데이트가 하나의 액션과 관련될 때
    하나의 사용자 행동(예: 폼 제출)이 여러 상태 값을 동시에 변경해야 할 때.
  3. 상태 업데이트 로직의 재사용 또는 테스트 용이성이 중요할 때
    Reducer 함수는 컴포넌트로부터 분리된 순수 함수이므로 테스트하기 쉽고 다른 곳에서 재사용하기도 좋습니다.
  4. 성능 최적화
    dispatch 함수는 컴포넌트 리렌더링 시에도 참조 동일성을 유지하는 경우가 많아, 콜백 함수를 자식 컴포넌트에 전달할 때 불필요한 리렌더링을 방지하는 데 도움이 될 수 있습니다. (특히 useCallback과 함께 사용될 때)

Reducer 함수와 Dispatch의 역할🔗

useReducer는 세 가지 주요 요소로 구성됩니다.
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가 더 적합합니다."

🚀

커스텀 훅🔗

React 훅의 가장 강력한 기능 중 하나는 바로 개발자가 직접
커스텀 훅
(Custom Hook)을 만들 수 있다는 점입니다. 커스텀 훅은 상태 관련 로직(Stateful Logic)을 컴포넌트로부터 분리하여 재사용 가능한 함수로 만드는 방법입니다.

왜 커스텀 훅을 사용할까요?🔗

커스텀 훅을 만드는 규칙은 간단합니다.
  1. 함수 이름은 반드시 use로 시작해야 합니다. (예: useFetch, useWindowScroll)
  2. 함수 내부에서 다른 내장 훅(useState, useEffect, useRef 등)이나 다른 커스텀 훅을 호출할 수 있습니다.

예시 1: 폼 입력 관리 훅 (useFormInput)🔗

간단한 폼 입력 값을 관리하는 커스텀 훅 예시입니다.
import { useState, useCallback } from 'react';
 
// 커스텀 훅 정의
function useFormInput(initialValue: string) {
  const [value, setValue] = useState(initialValue);
 
  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    setValue(e.target.value);
  }, []); // useCallback으로 함수 재생성 방지
 
  const reset = useCallback(() => {
    setValue(initialValue);
  }, [initialValue]);
 
  // 훅은 상태 값과 이벤트 핸들러, 리셋 함수를 반환
  return {
    value,
    onChange: handleChange,
    reset
  };
}
 
// 컴포넌트에서 커스텀 훅 사용
function MyForm() {
  const nameInput = useFormInput('');
  const emailInput = useFormInput('');
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log('Name:', nameInput.value);
    console.log('Email:', emailInput.value);
    nameInput.reset();
    emailInput.reset();
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" placeholder="이름" {...nameInput} /> {/* 스프레드 연산자로 props 전달 */}
      <input type="email" placeholder="이메일" {...emailInput} />
      <button type="submit">제출</button>
    </form>
  );
}

예시 2: 다크 모드 훅 (useDarkMode)🔗

시스템 설정이나 로컬 스토리지를 사용하여 다크 모드를 관리하는 훅 예시입니다.
import { useState, useEffect, useCallback } from 'react';
 
function useDarkMode(initialValue = false): [boolean, () => void] {
  const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
    // 로컬 스토리지에서 값 읽어오거나 초기값 사용 (Lazy Initial State)
    try {
      const storedValue = window.localStorage.getItem('darkMode');
      return storedValue ? JSON.parse(storedValue) : initialValue;
    } catch (error) {
      console.error("Error reading localStorage key 'darkMode':", error);
      return initialValue;
    }
  });
 
  // 다크 모드 상태 변경 시 로컬 스토리지 업데이트 및 body 클래스 변경
  useEffect(() => {
    try {
      window.localStorage.setItem('darkMode', JSON.stringify(isDarkMode));
      if (isDarkMode) {
        document.body.classList.add('dark-mode');
      } else {
        document.body.classList.remove('dark-mode');
      }
    } catch (error) {
       console.error("Error writing to localStorage key 'darkMode':", error);
    }
  }, [isDarkMode]);
 
  const toggleDarkMode = useCallback(() => {
    setIsDarkMode(prevMode => !prevMode);
  }, []);
 
  return [isDarkMode, toggleDarkMode];
}
 
// 컴포넌트에서 사용
function Settings() {
  const [isDark, toggleDark] = useDarkMode();
 
  return (
    <div>
      <p>현재 모드: {isDark ? '다크 모드' : '라이트 모드'}</p>
      <button onClick={toggleDark}>모드 전환</button>
    </div>
  );
}
두 컴포넌트의 로직 공유
두 컴포넌트의 로직 공유

🚀

결론🔗

오늘은 React의 주요 훅들인 useEffect, useLayoutEffect, useRef, useReducer에 대해 더 깊이 알아보았습니다.
각 훅의 특징과 적절한 사용 시점을 이해하는 것은 React 애플리케이션의 성능, 유지보수성, 코드 품질을 높이는 데 매우 중요합니다. 오늘 배운 내용들을 바탕으로 실제 프로젝트에서 훅을 더욱 효과적으로 활용해보시길 바랍니다.
다음 시간에는 애플리케이션 전역에서 상태를 관리하는 방법, 즉
글로벌 상태 관리 패턴
에 대해 알아보겠습니다. Context API부터 Redux, Zustand, Recoil 같은 인기 라이브러리까지 다양한 방법들을 비교하고 최적화 전략을 살펴보겠습니다.

참고🔗