🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

React Hooks 완벽 가이드 초급자용 - 슬라이드 1/11
A

AI Generated

2025. 10. 26. · 57 Views

React Hooks 완벽 가이드 초급자용

React 16.8에서 도입된 Hooks는 함수형 컴포넌트에서 상태 관리와 생명주기 기능을 사용할 수 있게 해주는 혁신적인 기능입니다. 이 가이드에서는 초급 개발자들이 실무에서 바로 활용할 수 있는 핵심 Hooks들을 친절하게 설명합니다.


목차

  1. useState
  2. useEffect
  3. useContext
  4. useRef
  5. useMemo
  6. useCallback
  7. useReducer
  8. Custom Hooks

1. useState

시작하며

여러분이 버튼을 클릭했을 때 카운터가 증가하거나, 입력 필드에 타이핑할 때 화면에 텍스트가 실시간으로 나타나는 것을 본 적 있나요? 이런 동적인 기능들은 모두 '상태(state)'를 통해 구현됩니다.

과거 React에서는 클래스 컴포넌트에서만 상태를 관리할 수 있었습니다. 이는 코드가 복잡해지고, 재사용이 어려워지는 문제를 일으켰습니다.

함수형 컴포넌트는 간결했지만, 상태를 다룰 수 없어서 제한적이었죠. 바로 이럴 때 필요한 것이 useState입니다.

이제 여러분은 간단한 함수형 컴포넌트 안에서도 강력한 상태 관리를 할 수 있습니다.

개요

간단히 말해서, useState는 함수형 컴포넌트에 상태를 추가할 수 있게 해주는 Hook입니다. 실무에서는 사용자 입력을 받거나, 모달을 열고 닫거나, 체크박스 상태를 관리하는 등 수많은 상황에서 useState를 사용합니다.

예를 들어, 쇼핑몰에서 장바구니에 담긴 상품 개수를 표시하는 경우나, 로그인 폼에서 이메일과 비밀번호를 입력받는 경우에 매우 유용합니다. 기존 클래스 컴포넌트에서는 this.state와 this.setState를 사용했다면, 이제는 간단한 배열 구조 분해로 상태와 업데이트 함수를 받을 수 있습니다.

useState의 핵심 특징은 첫째, 초기값을 설정할 수 있고, 둘째, 상태 업데이트가 자동으로 리렌더링을 트리거하며, 셋째, 여러 개의 독립적인 상태를 관리할 수 있다는 점입니다. 이러한 특징들이 컴포넌트를 더 예측 가능하고 관리하기 쉽게 만들어줍니다.

코드 예제

import React, { useState } from 'react';

function Counter() {
  // 초기값 0으로 count 상태를 생성
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  // 증가 버튼 클릭 핸들러
  const handleIncrement = () => {
    setCount(count + 1); // 상태 업데이트
  };

  return (
    <div>
      <h1>카운트: {count}</h1>
      <button onClick={handleIncrement}>증가</button>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="이름을 입력하세요"
      />
      <p>입력한 이름: {name}</p>
    </div>
  );
}

설명

이것이 하는 일: useState는 컴포넌트가 기억해야 할 값을 저장하고, 그 값이 변경될 때 화면을 자동으로 업데이트합니다. 첫 번째로, useState(0)을 호출하면 React는 현재 컴포넌트에 count라는 상태 변수와 setCount라는 업데이트 함수를 생성합니다.

초기값으로 0이 설정되어 있어서, 컴포넌트가 처음 렌더링될 때 count는 0입니다. 배열 구조 분해 문법을 사용하여 이 두 값을 깔끔하게 받아올 수 있습니다.

그 다음으로, 사용자가 버튼을 클릭하면 handleIncrement 함수가 실행되면서 setCount(count + 1)이 호출됩니다. 이때 React는 내부적으로 새로운 값을 저장하고, 컴포넌트를 다시 렌더링하라는 신호를 보냅니다.

중요한 점은 상태 업데이트가 비동기적으로 처리된다는 것입니다. 마지막으로, 리렌더링이 발생하면 새로운 count 값(1)이 화면에 표시됩니다.

동시에 입력 필드의 예시처럼 여러 개의 독립적인 상태를 관리할 수 있습니다. name 상태는 count와 완전히 분리되어 있어서, 하나가 변경되어도 다른 하나에는 영향을 주지 않습니다.

여러분이 이 코드를 사용하면 사용자 인터랙션에 반응하는 동적인 UI를 쉽게 만들 수 있습니다. 폼 입력, 토글 버튼, 탭 전환 등 실무에서 마주치는 거의 모든 UI 상태 관리에 useState를 활용할 수 있으며, 코드가 클래스 컴포넌트보다 훨씬 간결해지고 읽기 쉬워집니다.

실전 팁

💡 상태 업데이트 시 이전 값을 기반으로 해야 한다면 setCount(prevCount => prevCount + 1) 형태의 함수형 업데이트를 사용하세요. 여러 번 연속으로 업데이트할 때 예상치 못한 버그를 방지할 수 있습니다.

💡 객체나 배열을 상태로 관리할 때는 반드시 새로운 객체/배열을 생성해야 합니다. 기존 객체를 직접 수정하면 React가 변경을 감지하지 못합니다. 예: setUser({...user, name: 'John'})

💡 너무 많은 useState를 사용하고 있다면 useReducer나 상태 관리 라이브러리 사용을 고려하세요. 일반적으로 하나의 컴포넌트에서 5개 이상의 상태가 필요하다면 리팩토링 신호입니다.

💡 초기 상태 계산이 복잡하다면 useState(() => expensiveComputation())처럼 함수를 전달하세요. 이렇게 하면 초기 렌더링 시에만 계산되어 성능이 향상됩니다.

💡 Boolean 상태를 토글할 때는 setIsOpen(prev => !prev) 패턴을 사용하면 항상 정확한 반대 값으로 업데이트됩니다.


2. useEffect

시작하며

여러분이 컴포넌트가 화면에 나타난 후에 데이터를 서버에서 가져와야 하거나, 타이머를 설정해야 하는 상황을 겪어본 적 있나요? 또는 사용자가 페이지를 떠날 때 이벤트 리스너를 정리해야 하는 경우 말이죠.

이런 '부수 효과(side effects)'는 컴포넌트의 렌더링 과정 밖에서 일어나야 하는 작업들입니다. 렌더링 중에 API를 호출하거나 DOM을 직접 조작하면 예측 불가능한 버그와 성능 문제가 발생합니다.

클래스 컴포넌트에서는 componentDidMount, componentDidUpdate, componentWillUnmount 같은 여러 생명주기 메서드를 사용해야 했습니다. 바로 이럴 때 필요한 것이 useEffect입니다.

하나의 API로 모든 부수 효과를 깔끔하게 관리할 수 있습니다.

개요

간단히 말해서, useEffect는 컴포넌트가 렌더링된 후에 실행되어야 하는 코드를 작성하는 Hook입니다. 실무에서는 API 호출, 구독 설정, DOM 조작, 로컬 스토리지 접근, 타이머 설정 등 다양한 상황에서 useEffect를 사용합니다.

예를 들어, 사용자 프로필 페이지에서 사용자 정보를 로드하거나, 채팅 앱에서 실시간 메시지를 구독하거나, 페이지 제목을 동적으로 변경하는 경우에 필수적입니다. 기존에는 componentDidMount에서 데이터를 가져오고, componentWillUnmount에서 정리 작업을 하는 식으로 코드가 분산되었다면, 이제는 하나의 useEffect 안에서 관련된 로직을 모두 처리할 수 있습니다.

useEffect의 핵심 특징은 첫째, 의존성 배열로 실행 타이밍을 제어할 수 있고, 둘째, cleanup 함수를 반환하여 리소스를 정리할 수 있으며, 셋째, 여러 개의 독립적인 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);
      });

    // cleanup 함수 (컴포넌트 언마운트 시 실행)
    return () => {
      console.log('정리 작업 실행');
    };
  }, [userId]); // 의존성 배열

  if (loading) return <div>로딩 중...</div>;
  return <div>{user?.name}</div>;
}

설명

이것이 하는 일: useEffect는 렌더링이 완료된 후에 비동기 작업, 구독, DOM 조작 등의 부수 효과를 안전하게 실행합니다. 첫 번째로, 컴포넌트가 처음 렌더링되거나 userId가 변경될 때 useEffect 내부의 콜백 함수가 실행됩니다.

의존성 배열 [userId]가 이를 제어합니다. 만약 빈 배열 []을 전달하면 컴포넌트 마운트 시 한 번만 실행되고, 의존성 배열을 생략하면 매 렌더링마다 실행됩니다.

이것이 useEffect의 가장 강력한 기능 중 하나입니다. 그 다음으로, 콜백 함수 내부에서 fetch를 통해 API를 호출합니다.

이때 setLoading(true)로 로딩 상태를 먼저 설정하여 사용자에게 피드백을 제공합니다. API 응답이 도착하면 then 체인을 통해 데이터를 상태에 저장하고 로딩을 종료합니다.

이 모든 과정은 렌더링이 완료된 후에 일어나므로 렌더링을 블로킹하지 않습니다. 마지막으로, cleanup 함수를 반환하여 컴포넌트가 언마운트되거나 다음 effect가 실행되기 전에 정리 작업을 수행합니다.

실무에서는 여기서 이벤트 리스너 제거, 구독 취소, 타이머 정리 등을 합니다. 예를 들어, WebSocket 연결을 닫거나 setInterval을 clearInterval로 정리하는 작업이 여기에 해당합니다.

여러분이 이 코드를 사용하면 데이터 페칭, 실시간 구독, 외부 라이브러리 통합 등을 안전하고 예측 가능하게 처리할 수 있습니다. 특히 의존성 배열을 올바르게 사용하면 불필요한 재실행을 방지하여 성능을 최적화할 수 있고, cleanup 함수로 메모리 누수를 예방할 수 있습니다.

실전 팁

💡 의존성 배열에 사용하는 모든 값을 정직하게 포함하세요. ESLint의 exhaustive-deps 규칙을 활성화하면 누락된 의존성을 자동으로 경고해줍니다.

💡 API 호출 시 AbortController를 사용하여 컴포넌트 언마운트 시 진행 중인 요청을 취소하세요. 이렇게 하면 "Can't perform a React state update on an unmounted component" 경고를 방지할 수 있습니다.

💡 여러 개의 작은 useEffect를 사용하는 것이 하나의 큰 useEffect보다 낫습니다. 각 effect는 하나의 관심사만 다루도록 분리하면 코드가 더 명확해집니다.

💡 useEffect 내부에서 async/await를 직접 사용할 수 없으므로, 내부에 별도의 async 함수를 만들어 호출하세요: useEffect(() => { async function fetchData() {...} fetchData(); }, [])

💡 개발 모드에서 useEffect가 두 번 실행되는 것은 React 18의 Strict Mode 때문입니다. 이는 의도된 동작이며, cleanup 함수가 제대로 작동하는지 테스트하기 위함입니다.


3. useContext

시작하며

여러분이 애플리케이션에서 로그인한 사용자 정보나 테마 설정을 여러 컴포넌트에서 사용해야 하는 상황을 생각해보세요. 부모에서 자식으로, 또 그 자식으로 props를 계속 전달하다 보면 코드가 지저분해지고 관리가 어려워집니다.

이런 'props drilling' 문제는 컴포넌트 트리가 깊어질수록 더 심각해집니다. 중간에 있는 컴포넌트들은 실제로 그 데이터를 사용하지도 않으면서 단지 전달만 하기 위해 props를 받아야 합니다.

이는 코드의 가독성을 떨어뜨리고, 리팩토링을 어렵게 만듭니다. 바로 이럴 때 필요한 것이 useContext입니다.

전역적으로 데이터를 공유하여 어떤 깊이의 컴포넌트에서도 직접 접근할 수 있습니다.

개요

간단히 말해서, useContext는 React Context API를 통해 생성된 컨텍스트 값을 컴포넌트에서 쉽게 읽을 수 있게 해주는 Hook입니다. 실무에서는 인증 상태, 테마 설정, 언어 설정, 사용자 권한 등 여러 컴포넌트에서 공유되어야 하는 데이터를 관리할 때 useContext를 사용합니다.

예를 들어, 다크 모드 토글을 헤더에 만들었는데 앱 전체의 스타일이 변경되어야 한다면, useContext가 완벽한 솔루션입니다. 기존에는 Consumer 컴포넌트와 render props 패턴을 사용해서 코드가 중첩되고 복잡했다면, 이제는 간단한 훅 호출로 컨텍스트 값을 바로 가져올 수 있습니다.

useContext의 핵심 특징은 첫째, props drilling을 완전히 제거할 수 있고, 둘째, 컨텍스트 값이 변경되면 자동으로 구독한 컴포넌트들이 리렌더링되며, 셋째, 여러 개의 독립적인 컨텍스트를 조합하여 사용할 수 있다는 점입니다. 이러한 특징들이 대규모 애플리케이션의 상태 관리를 훨씬 간단하게 만들어줍니다.

코드 예제

import React, { createContext, useContext, useState } from 'react';

// 1. Context 생성
const ThemeContext = createContext();

// 2. Provider 컴포넌트
function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Header />
      <Content />
    </ThemeContext.Provider>
  );
}

// 3. Context 사용하는 컴포넌트
function Header() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <header style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
      <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
        테마 변경
      </button>
    </header>
  );
}

설명

이것이 하는 일: useContext는 컴포넌트 트리 어디서든 Context Provider가 제공하는 값을 구독하고 읽을 수 있게 합니다. 첫 번째로, createContext()를 호출하여 ThemeContext라는 컨텍스트 객체를 생성합니다.

이것은 데이터를 담을 '컨테이너' 역할을 합니다. 컨텍스트를 모듈 최상단에 선언하면 여러 컴포넌트에서 import하여 사용할 수 있습니다.

초기값을 createContext('light')처럼 전달할 수도 있지만, Provider를 사용할 때는 보통 생략합니다. 그 다음으로, App 컴포넌트에서 ThemeContext.Provider로 자식 컴포넌트들을 감싸고 value prop으로 공유할 데이터를 전달합니다.

이때 theme 상태와 setTheme 함수를 객체로 묶어서 전달하면, 하위 컴포넌트에서 읽기와 쓰기를 모두 할 수 있습니다. Provider 하위의 모든 컴포넌트들은 이 값에 접근할 수 있게 됩니다.

마지막으로, Header 컴포넌트에서 useContext(ThemeContext)를 호출하여 컨텍스트 값을 가져옵니다. props를 통해 전달받을 필요가 없이 직접 접근합니다.

버튼을 클릭하면 setTheme이 호출되어 상태가 변경되고, ThemeContext를 구독하는 모든 컴포넌트(Header, Content 등)가 자동으로 리렌더링됩니다. 여러분이 이 코드를 사용하면 인증, 테마, 언어 등의 전역 상태를 깔끔하게 관리할 수 있습니다.

특히 컴포넌트 트리가 깊을 때 props drilling을 피할 수 있어서 코드가 훨씬 간결해지고, 나중에 구조를 변경할 때도 영향받는 컴포넌트가 적어집니다. 단, Context 값이 변경되면 구독하는 모든 컴포넌트가 리렌더링되므로, 자주 변경되는 값은 별도의 Context로 분리하는 것이 좋습니다.

실전 팁

💡 Context 값에 객체를 전달할 때는 useMemo로 감싸서 불필요한 리렌더링을 방지하세요: const value = useMemo(() => ({ theme, setTheme }), [theme])

💡 하나의 거대한 Context보다 여러 개의 작은 Context를 만드는 것이 성능에 유리합니다. 예를 들어, UserContext와 ThemeContext를 분리하면 테마만 변경될 때 사용자 정보를 사용하는 컴포넌트는 리렌더링되지 않습니다.

💡 Custom Hook을 만들어 Context 사용을 추상화하세요: function useTheme() { const context = useContext(ThemeContext); if (!context) throw new Error('Provider 외부에서 사용됨'); return context; }

💡 Context는 자주 변경되는 상태보다는 상대적으로 정적인 데이터(테마, 로케일, 사용자 정보)에 적합합니다. 빠르게 변경되는 상태는 상태 관리 라이브러리를 고려하세요.

💡 Provider를 여러 개 중첩할 수 있습니다. 예를 들어 앱 전체에는 ThemeProvider, 특정 섹션에만 LocaleProvider를 적용할 수 있습니다.


4. useRef

시작하며

여러분이 페이지가 로드되자마자 입력 필드에 자동으로 포커스를 주거나, 스크롤 위치를 제어하거나, 이전 값을 기억해야 하는 상황을 겪어본 적 있나요? 또는 setInterval의 ID를 저장해서 나중에 정리해야 하는 경우 말이죠.

이런 작업들을 위해 useState를 사용하면 문제가 생깁니다. 상태가 변경될 때마다 컴포넌트가 리렌더링되기 때문입니다.

때로는 값을 저장하되 리렌더링을 트리거하지 않아야 할 때가 있고, DOM 요소에 직접 접근해야 할 때도 있습니다. 바로 이럴 때 필요한 것이 useRef입니다.

리렌더링을 발생시키지 않으면서 값을 유지하고, DOM에 직접 접근할 수 있게 해줍니다.

개요

간단히 말해서, useRef는 렌더링 사이에 값을 유지하면서도 변경 시 리렌더링을 트리거하지 않는 변경 가능한 참조 객체를 반환하는 Hook입니다. 실무에서는 DOM 요소 접근, 이전 값 저장, 타이머 ID 보관, 외부 라이브러리 인스턴스 참조 등 다양한 상황에서 useRef를 사용합니다.

예를 들어, 동영상 플레이어를 만들 때 video 요소를 제어하거나, 무한 스크롤을 구현할 때 스크롤 위치를 추적하거나, 디바운싱을 위해 타이머를 저장하는 경우에 필수적입니다. 기존에는 클래스 컴포넌트에서 React.createRef()를 사용하거나 인스턴스 변수를 만들었다면, 이제는 함수형 컴포넌트에서도 useRef로 동일한 기능을 구현할 수 있습니다.

useRef의 핵심 특징은 첫째, .current 속성을 통해 저장된 값에 접근할 수 있고, 둘째, 값이 변경되어도 리렌더링이 발생하지 않으며, 셋째, 컴포넌트의 전체 생명주기 동안 값이 유지된다는 점입니다. 이러한 특징들이 성능 최적화와 DOM 조작을 가능하게 합니다.

코드 예제

import React, { useRef, useEffect, useState } from 'react';

function VideoPlayer() {
  const videoRef = useRef(null); // DOM 참조
  const intervalRef = useRef(null); // 값 저장
  const [isPlaying, setIsPlaying] = useState(false);

  // 컴포넌트 마운트 시 자동 포커스
  useEffect(() => {
    videoRef.current?.focus();
  }, []);

  const handlePlay = () => {
    // DOM 요소에 직접 접근
    videoRef.current?.play();
    setIsPlaying(true);

    // 타이머 ID 저장 (리렌더링 없이)
    intervalRef.current = setInterval(() => {
      console.log('재생 중...');
    }, 1000);
  };

  const handlePause = () => {
    videoRef.current?.pause();
    setIsPlaying(false);

    // 저장된 타이머 정리
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      <video ref={videoRef} src="video.mp4" />
      <button onClick={handlePlay}>재생</button>
      <button onClick={handlePause}>일시정지</button>
    </div>
  );
}

설명

이것이 하는 일: useRef는 컴포넌트 생명주기 동안 유지되는 변경 가능한 참조를 생성하며, DOM 요소에 직접 접근하거나 렌더링과 무관한 값을 저장합니다. 첫 번째로, useRef(null)을 호출하면 { current: null }이라는 객체가 생성됩니다.

이 객체는 컴포넌트가 리렌더링되어도 동일한 참조를 유지합니다. videoRef를 video 요소의 ref prop에 전달하면, React가 자동으로 videoRef.current에 실제 DOM 요소를 할당합니다.

이렇게 하면 나중에 videoRef.current로 그 요소에 접근할 수 있습니다. 그 다음으로, handlePlay 함수에서 videoRef.current.play()를 호출하여 비디오를 재생합니다.

이는 React의 선언적 패러다임을 벗어나 명령형으로 DOM을 조작하는 경우입니다. 동시에 intervalRef.current에 setInterval의 반환값을 저장합니다.

이 값은 나중에 타이머를 정리할 때 필요하지만, 저장한다고 해서 컴포넌트가 리렌더링되지는 않습니다. 마지막으로, handlePause에서 저장해둔 intervalRef.current를 사용하여 clearInterval을 호출합니다.

만약 useState로 타이머 ID를 저장했다면 불필요한 리렌더링이 발생했을 것입니다. useRef 덕분에 성능을 희생하지 않고도 필요한 값을 안전하게 보관할 수 있습니다.

여러분이 이 코드를 사용하면 폼 필드 포커스 제어, 스크롤 위치 조작, 애니메이션 라이브러리 통합, 이전 props/state 값 추적 등을 효율적으로 구현할 수 있습니다. 특히 외부 라이브러리와 통합하거나 브라우저 API를 직접 사용해야 할 때 useRef는 필수입니다.

단, DOM 조작은 최소화하고 가능한 한 React의 선언적 방식을 우선 사용하는 것이 좋습니다.

실전 팁

💡 useRef를 이전 값을 저장하는 용도로 활용하세요: usePrevious 커스텀 훅을 만들어 이전 props나 state를 비교할 수 있습니다.

💡 ref.current 값을 useEffect의 의존성 배열에 넣지 마세요. current가 변경되어도 React가 감지하지 못하므로 effect가 실행되지 않습니다.

💡 초기 렌더링 시 DOM 요소가 아직 생성되지 않았을 수 있으므로, ref.current?.method()처럼 옵셔널 체이닝을 사용하세요.

💡 함수 컴포넌트를 ref로 참조하려면 forwardRef를 사용해야 합니다. 일반적으로 ref는 DOM 요소나 클래스 컴포넌트에만 직접 전달할 수 있습니다.

💡 디버깅 시 ref.current를 변경해도 화면이 업데이트되지 않는다는 점을 기억하세요. 화면 업데이트가 필요하면 useState와 함께 사용해야 합니다.


5. useMemo

시작하며

여러분이 대용량 데이터 배열을 필터링하거나 정렬하는 컴포넌트를 만들었는데, 사용자가 다른 입력 필드에 타이핑할 때마다 화면이 버벅거리는 경험을 해본 적 있나요? 아무 관련 없는 상태가 변경되었는데도 무거운 계산이 매번 다시 실행되는 것입니다.

이런 성능 문제는 React가 리렌더링될 때마다 컴포넌트 함수를 처음부터 다시 실행하기 때문에 발생합니다. 함수 내부의 모든 계산, 배열 필터링, 객체 생성 등이 반복됩니다.

입력이 변하지 않았는데도 같은 결과를 계속 다시 계산하는 것은 낭비입니다. 바로 이럴 때 필요한 것이 useMemo입니다.

비용이 큰 계산 결과를 메모이제이션하여 의존성이 변경될 때만 다시 계산하도록 최적화할 수 있습니다.

개요

간단히 말해서, useMemo는 계산 비용이 큰 연산의 결과값을 메모이제이션하여, 의존성이 변경되지 않으면 이전에 계산된 값을 재사용하는 Hook입니다. 실무에서는 복잡한 데이터 변환, 무거운 필터링/정렬, 대용량 배열 처리, 비용이 큰 객체 생성 등을 최적화할 때 useMemo를 사용합니다.

예를 들어, 수천 개의 제품 목록을 필터링하거나, 통계를 계산하거나, 차트 데이터를 변환하는 경우에 성능 향상을 체감할 수 있습니다. 기존에는 클래스 컴포넌트에서 shouldComponentUpdate나 PureComponent로 최적화했다면, 이제는 useMemo로 값 단위의 세밀한 최적화가 가능합니다.

useMemo의 핵심 특징은 첫째, 의존성 배열의 값이 변경될 때만 재계산하고, 둘째, 참조 동일성을 유지하여 자식 컴포넌트의 불필요한 리렌더링을 방지하며, 셋째, 개발자가 명시적으로 최적화 대상을 선택할 수 있다는 점입니다. 이러한 특징들이 성능 병목을 효과적으로 제거합니다.

코드 예제

import React, { useState, useMemo } from 'react';

function ProductList({ products }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [category, setCategory] = useState('all');
  const [sortOrder, setSortOrder] = useState('asc');

  // 비용이 큰 계산을 메모이제이션
  const filteredAndSortedProducts = useMemo(() => {
    console.log('필터링 및 정렬 실행'); // 의존성 변경 시에만 출력

    let result = products.filter(p =>
      p.name.toLowerCase().includes(searchTerm.toLowerCase()) &&
      (category === 'all' || p.category === category)
    );

    result.sort((a, b) =>
      sortOrder === 'asc' ? a.price - b.price : b.price - a.price
    );

    return result;
  }, [products, searchTerm, category, sortOrder]); // 의존성 배열

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="검색..."
      />
      {/* 메모이제이션된 결과 사용 */}
      <p>결과: {filteredAndSortedProducts.length}개</p>
      {filteredAndSortedProducts.map(p => <div key={p.id}>{p.name}</div>)}
    </div>
  );
}

설명

이것이 하는 일: useMemo는 렌더링 사이에 계산 결과를 캐싱하고, 의존성 배열의 값이 변경되었을 때만 콜백 함수를 다시 실행하여 새로운 값을 계산합니다. 첫 번째로, useMemo는 첫 렌더링 시 콜백 함수를 실행하여 filteredAndSortedProducts의 초기값을 계산합니다.

이때 products 배열을 필터링하고 정렬하는 과정이 일어납니다. 계산된 결과는 React 내부에 캐싱되어 저장됩니다.

의존성 배열 [products, searchTerm, category, sortOrder]에 있는 값들의 현재 상태도 함께 기록됩니다. 그 다음으로, 컴포넌트가 리렌더링될 때 React는 의존성 배열의 값들을 이전 값과 비교합니다.

만약 사용자가 검색어만 변경했다면 searchTerm만 달라졌으므로 useMemo는 콜백을 다시 실행하여 새로운 필터링 결과를 계산합니다. 하지만 전혀 관련 없는 상태(예: 다른 입력 필드)가 변경되어 리렌더링이 발생했다면, 의존성이 동일하므로 캐싱된 이전 결과를 그대로 반환합니다.

마지막으로, 메모이제이션된 값은 컴포넌트 내부뿐만 아니라 자식 컴포넌트에 props로 전달될 때도 유용합니다. 동일한 참조를 유지하므로 자식 컴포넌트가 React.memo로 최적화되어 있다면 불필요한 리렌더링을 방지할 수 있습니다.

예를 들어, filteredAndSortedProducts를 props로 받는 자식 컴포넌트는 실제로 데이터가 변경될 때만 리렌더링됩니다. 여러분이 이 코드를 사용하면 대용량 데이터 처리, 복잡한 계산, 차트 렌더링 등에서 눈에 띄는 성능 향상을 경험할 수 있습니다.

특히 사용자 입력이 빈번한 검색, 필터링, 정렬 기능에서 효과적입니다. 단, 간단한 계산에는 useMemo를 사용하지 마세요.

메모이제이션 자체에도 비용이 들기 때문에 오히려 성능이 나빠질 수 있습니다.

실전 팁

💡 useMemo는 성능 최적화 도구이지 보장된 캐싱이 아닙니다. React는 메모리 절약을 위해 캐시를 버릴 수 있으므로, 로직의 정확성이 메모이제이션에 의존해서는 안 됩니다.

💡 프로파일러로 실제 성능 병목을 측정한 후 적용하세요. 모든 계산에 useMemo를 사용하면 코드만 복잡해지고 성능 이점은 없을 수 있습니다.

💡 객체나 배열을 useMemo로 감싸서 자식 컴포넌트에 전달하면, 자식이 React.memo로 감싸져 있을 때 props 비교가 정확해집니다.

💡 의존성 배열에 객체나 배열을 넣을 때는 주의하세요. 매번 새로 생성되는 객체는 항상 다른 참조로 인식되어 메모이제이션이 무용지물이 됩니다.

💡 계산 비용이 큰지 확인하려면 console.time/timeEnd로 측정하세요. 일반적으로 1ms 이하의 계산은 메모이제이션할 필요가 없습니다.


6. useCallback

시작하며

여러분이 자식 컴포넌트에 이벤트 핸들러 함수를 props로 전달했는데, 부모가 리렌더링될 때마다 자식도 함께 리렌더링되어 성능이 저하되는 경험을 해본 적 있나요? 자식 컴포넌트를 React.memo로 감쌌는데도 계속 리렌더링되는 이유가 바로 함수 참조가 매번 새로 생성되기 때문입니다.

JavaScript에서 함수는 객체이고, 컴포넌트가 리렌더링될 때마다 내부의 함수들도 새로 생성됩니다. 비록 같은 코드를 담고 있어도 메모리 주소가 다른 완전히 새로운 함수입니다.

React는 props를 얕은 비교(shallow comparison)로 확인하기 때문에, 함수 참조가 달라지면 props가 변경되었다고 판단합니다. 바로 이럴 때 필요한 것이 useCallback입니다.

함수를 메모이제이션하여 의존성이 변경되지 않으면 동일한 함수 참조를 유지할 수 있습니다.

개요

간단히 말해서, useCallback은 함수를 메모이제이션하여, 의존성이 변경되지 않으면 이전에 생성된 함수 인스턴스를 재사용하는 Hook입니다. 실무에서는 자식 컴포넌트에 콜백 함수를 전달할 때, useEffect의 의존성에 함수를 포함할 때, 커스텀 훅이 함수를 반환할 때 useCallback을 사용합니다.

예를 들어, 목록 컴포넌트에서 각 아이템에 삭제 핸들러를 전달하거나, 폼 컴포넌트에서 입력 변경 핸들러를 최적화할 때 필수적입니다. 기존에는 클래스 컴포넌트에서 메서드를 인스턴스 속성으로 만들어 바인딩을 유지했다면, 이제는 useCallback으로 함수형 컴포넌트에서도 동일한 최적화를 할 수 있습니다.

useCallback의 핵심 특징은 첫째, 의존성이 변경될 때만 새 함수를 생성하고, 둘째, 자식 컴포넌트의 불필요한 리렌더링을 방지하며, 셋째, useEffect나 다른 훅의 의존성으로 사용될 때 안정성을 제공한다는 점입니다. 이러한 특징들이 컴포넌트 트리 전체의 성능을 향상시킵니다.

코드 예제

import React, { useState, useCallback, memo } from 'react';

function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [text, setText] = useState('');

  // 함수를 메모이제이션 - todos가 변경될 때만 재생성
  const handleAddTodo = useCallback(() => {
    if (text.trim()) {
      setTodos(prev => [...prev, { id: Date.now(), text }]);
      setText('');
    }
  }, [text]); // text가 변경될 때만 새 함수 생성

  // id만 의존성으로 가지는 삭제 핸들러
  const handleDeleteTodo = useCallback((id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []); // 의존성 없음 - 항상 같은 함수 참조

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleAddTodo}>추가</button>

      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onDelete={handleDeleteTodo}
        />
      ))}
    </div>
  );
}

// React.memo로 최적화된 자식 컴포넌트
const TodoItem = memo(({ todo, onDelete }) => {
  console.log(`${todo.text} 렌더링`);
  return (
    <div>
      {todo.text}
      <button onClick={() => onDelete(todo.id)}>삭제</button>
    </div>
  );
});

설명

이것이 하는 일: useCallback은 함수 인스턴스를 캐싱하여, 의존성 배열의 값이 변경되지 않으면 이전에 생성된 동일한 함수 참조를 반환합니다. 첫 번째로, useCallback은 첫 렌더링 시 전달받은 콜백 함수를 저장하고 그 참조를 반환합니다.

handleAddTodo의 경우 text를 의존성으로 가지므로, text의 현재 값과 함께 함수가 캐싱됩니다. 이때 함수 내부에서 사용하는 모든 props와 state는 클로저를 통해 "기억"됩니다.

그 다음으로, 컴포넌트가 리렌더링될 때 useCallback은 의존성 배열을 확인합니다. text가 이전과 동일하다면 캐싱된 함수를 그대로 반환하고, text가 변경되었다면 새로운 함수를 생성하여 반환합니다.

handleDeleteTodo는 빈 의존성 배열 []을 가지므로 컴포넌트의 전체 생명주기 동안 항상 같은 함수 참조를 유지합니다. setTodos처럼 함수형 업데이트를 사용하면 의존성을 제거할 수 있습니다.

마지막으로, TodoItem 컴포넌트는 React.memo로 감싸져 있어서 props가 변경될 때만 리렌더링됩니다. onDelete가 useCallback으로 메모이제이션되어 있기 때문에, 새로운 todo가 추가되어도 기존 TodoItem들은 리렌더링되지 않습니다.

만약 useCallback을 사용하지 않았다면, 부모가 리렌더링될 때마다 onDelete가 새로운 함수로 생성되어 모든 TodoItem이 리렌더링되었을 것입니다. 여러분이 이 코드를 사용하면 목록 컴포넌트, 폼 컴포넌트, 대시보드 등에서 성능을 크게 개선할 수 있습니다.

특히 자식 컴포넌트 개수가 많거나, 렌더링 비용이 큰 경우에 효과가 두드러집니다. 단, useCallback 자체에도 비용이 들므로 실제로 성능 문제가 있는 경우에만 적용하세요.

실전 팁

💡 useCallback은 useMemo의 특수한 형태입니다. useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일합니다.

💡 함수 내부에서 state를 참조할 때는 함수형 업데이트(setState(prev => ...))를 사용하면 의존성을 줄일 수 있어 더 안정적입니다.

💡 자식 컴포넌트가 React.memo로 감싸져 있지 않다면 useCallback을 사용해도 리렌더링 방지 효과가 없습니다. 두 가지를 함께 사용해야 합니다.

💡 이벤트 핸들러에 인자를 전달할 때는 인라인 화살표 함수보다 메모이제이션된 함수에서 id를 받는 방식이 더 효율적입니다: onClick={() => onDelete(todo.id)}

💡 useCallback을 useEffect의 의존성으로 사용하면, 함수가 변경될 때만 effect가 실행되어 무한 루프를 방지할 수 있습니다.


7. useReducer

시작하며

여러분이 여러 개의 연관된 상태를 관리하는 복잡한 폼을 만들거나, 상태 전환이 많은 컴포넌트를 작성하다가 useState 호출이 너무 많아져서 코드를 읽기 어려워진 경험이 있나요? 또는 하나의 액션이 여러 상태를 동시에 업데이트해야 하는 경우 말이죠.

여러 개의 useState를 사용하면 상태 업데이트 로직이 컴포넌트 전체에 흩어져서 유지보수가 어려워집니다. 특히 상태 간에 의존성이 있거나, 복잡한 비즈니스 로직이 포함된 경우 버그가 발생하기 쉽습니다.

또한 상태 업데이트 로직을 테스트하거나 재사용하기도 어렵습니다. 바로 이럴 때 필요한 것이 useReducer입니다.

Redux와 유사한 패턴으로 복잡한 상태 로직을 관리하고, 액션 기반으로 명확하게 상태 전환을 정의할 수 있습니다.

개요

간단히 말해서, useReducer는 복잡한 상태 로직을 reducer 함수로 분리하여 관리하는 Hook으로, (state, action) => newState 패턴을 사용합니다. 실무에서는 복잡한 폼, 장바구니, 다단계 위저드, 게임 상태, 편집기 등 상태 전환이 복잡하거나 여러 상태가 연관된 경우 useReducer를 사용합니다.

예를 들어, 이커머스 장바구니에서 상품 추가/제거/수량 변경을 처리하거나, 다단계 설문조사에서 진행 상태를 관리할 때 이상적입니다. 기존에는 Redux 같은 외부 라이브러리를 설치해야 했다면, 이제는 React 내장 Hook으로 동일한 패턴을 사용할 수 있습니다.

useState가 간단한 상태에 적합하다면, useReducer는 복잡한 상태에 적합합니다. useReducer의 핵심 특징은 첫째, 상태 업데이트 로직을 컴포넌트 외부로 분리할 수 있고, 둘째, 액션 타입으로 의도를 명확하게 표현할 수 있으며, 셋째, reducer 함수를 독립적으로 테스트할 수 있다는 점입니다.

이러한 특징들이 코드의 예측 가능성과 유지보수성을 높입니다.

코드 예제

import React, { useReducer } from 'react';

// 1. 초기 상태 정의
const initialState = {
  items: [],
  total: 0,
  isLoading: false
};

// 2. Reducer 함수 - 순수 함수로 상태 변환 로직 정의
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      const newItems = [...state.items, action.payload];
      return {
        ...state,
        items: newItems,
        total: newItems.reduce((sum, item) => sum + item.price, 0)
      };

    case 'REMOVE_ITEM':
      const filtered = state.items.filter(item => item.id !== action.payload);
      return {
        ...state,
        items: filtered,
        total: filtered.reduce((sum, item) => sum + item.price, 0)
      };

    case 'SET_LOADING':
      return { ...state, isLoading: action.payload };

    default:
      return state;
  }
}

function ShoppingCart() {
  // 3. useReducer 사용
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItem = (item) => {
    dispatch({ type: 'ADD_ITEM', payload: item });
  };

  const removeItem = (id) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id });
  };

  return (
    <div>
      <h2>장바구니 ({state.items.length}개)</h2>
      <p>총액: {state.total}원</p>
      <button onClick={() => addItem({ id: 1, name: '상품', price: 1000 })}>
        상품 추가
      </button>
    </div>
  );
}

설명

이것이 하는 일: useReducer는 현재 상태와 액션을 받아 새로운 상태를 반환하는 reducer 함수를 사용하여, 복잡한 상태 전환을 예측 가능하게 관리합니다. 첫 번째로, useReducer(cartReducer, initialState)를 호출하면 현재 상태(state)와 디스패치 함수(dispatch)를 반환합니다.

initialState는 컴포넌트의 초기 상태를 정의합니다. reducer 함수는 컴포넌트 외부에 정의되어 있어서, 컴포넌트가 리렌더링되어도 재생성되지 않습니다.

이는 성능상 이점이 있고, 테스트하기도 쉽습니다. 그 다음으로, 상태를 변경하고 싶을 때 dispatch 함수에 액션 객체를 전달합니다.

액션 객체는 보통 { type: '액션타입', payload: 데이터 } 형태입니다. dispatch가 호출되면 React는 현재 state와 전달받은 action을 cartReducer 함수에 전달합니다.

reducer는 action.type을 확인하여 적절한 상태 변환 로직을 실행하고 새로운 상태 객체를 반환합니다. 마지막으로, reducer가 반환한 새로운 상태가 컴포넌트의 state가 되고 리렌더링이 발생합니다.

중요한 점은 reducer 함수가 순수 함수여야 한다는 것입니다. 즉, 같은 입력에 항상 같은 출력을 반환하고, 부수 효과가 없어야 합니다.

직접 state를 수정하지 않고 항상 새로운 객체를 반환해야 합니다. 이렇게 하면 시간 여행 디버깅, 상태 기록, 실행 취소/재실행 같은 고급 기능도 구현할 수 있습니다.

여러분이 이 코드를 사용하면 복잡한 상태 로직을 체계적으로 관리할 수 있습니다. 특히 여러 개의 하위 값을 포함하는 상태 객체를 다루거나, 다음 상태가 이전 상태에 의존적인 경우 useReducer가 useState보다 명확하고 안전합니다.

또한 dispatch 함수는 항상 동일한 참조를 유지하므로, 자식 컴포넌트에 전달할 때 useCallback이 필요 없습니다.

실전 팁

💡 액션 타입을 상수로 정의하면 오타를 방지할 수 있습니다: const ADD_ITEM = 'ADD_ITEM'

💡 useReducer를 useContext와 함께 사용하면 간단한 전역 상태 관리 솔루션을 만들 수 있습니다. 소규모 앱에서 Redux를 대체할 수 있습니다.

💡 초기 상태 계산이 복잡하다면 세 번째 인자로 init 함수를 전달하세요: useReducer(reducer, initialArg, init)

💡 TypeScript를 사용한다면 액션 타입을 union type으로 정의하여 타입 안전성을 확보하세요: type Action = { type: 'ADD' } | { type: 'REMOVE', payload: number }

💡 복잡한 reducer는 여러 개의 작은 reducer로 분리할 수 있습니다. combineReducers 패턴을 직접 구현하거나, 라이브러리를 사용하세요.


8. Custom Hooks

시작하며

여러분이 여러 컴포넌트에서 동일한 로직을 반복해서 작성하고 있다는 것을 깨달은 적이 있나요? 예를 들어, 윈도우 크기를 추적하거나, 로컬 스토리지와 동기화하거나, API 호출을 관리하는 코드가 여기저기 중복되어 있는 경우 말이죠.

컴포넌트 간에 상태 로직을 공유하는 것은 React에서 오랫동안 어려운 문제였습니다. Higher-Order Components나 Render Props 같은 패턴들이 있었지만, 이들은 컴포넌트 구조를 복잡하게 만들고 "래퍼 지옥(wrapper hell)"을 초래했습니다.

바로 이럴 때 필요한 것이 Custom Hooks입니다. 반복되는 로직을 재사용 가능한 함수로 추출하여, 컴포넌트 구조를 변경하지 않고도 코드를 공유할 수 있습니다.

개요

간단히 말해서, Custom Hook은 다른 Hook들을 사용하는 JavaScript 함수로, "use"로 시작하는 이름 규칙을 따르며 상태 로직을 재사용 가능하게 만듭니다. 실무에서는 폼 입력 관리, API 호출, 브라우저 API 접근, 애니메이션, 타이머, 구독 등 반복되는 패턴을 Custom Hook으로 추출합니다.

예를 들어, useForm, useFetch, useLocalStorage, useDebounce, useWindowSize 같은 훅들을 만들어 프로젝트 전체에서 재사용할 수 있습니다. 기존에는 HOC나 Render Props로 로직을 공유했다면, 이제는 간단한 함수 호출만으로 동일한 효과를 얻을 수 있습니다.

컴포넌트 계층이 깊어지지 않고, 여러 개의 Custom Hook을 동시에 사용하기도 쉽습니다. Custom Hook의 핵심 특징은 첫째, 내부에서 다른 Hook들을 자유롭게 사용할 수 있고, 둘째, 각 호출은 독립적인 상태를 가지며, 셋째, 컴포넌트 로직을 작은 단위로 분리하여 테스트와 유지보수를 쉽게 만든다는 점입니다.

이러한 특징들이 코드의 재사용성과 가독성을 크게 향상시킵니다.

코드 예제

import { useState, useEffect } from 'react';

// Custom Hook 1: 로컬 스토리지와 동기화
function useLocalStorage(key, initialValue) {
  // 로컬 스토리지에서 초기값 읽기
  const [value, setValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // 값이 변경되면 로컬 스토리지에 저장
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      console.error('로컬 스토리지 저장 실패');
    }
  }, [key, value]);

  return [value, setValue];
}

// Custom Hook 2: API 호출
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetch(url)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

// 사용 예시
function UserProfile() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const { data: user, loading } = useFetch('/api/user');

  if (loading) return <div>로딩 중...</div>;

  return (
    <div style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
      <h1>{user?.name}</h1>
      <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
        테마 변경
      </button>
    </div>
  );
}

설명

이것이 하는 일: Custom Hook은 React의 내장 Hook들을 조합하여 특정 기능을 캡슐화하고, 컴포넌트 간에 로직을 공유할 수 있게 합니다. 첫 번째로, Custom Hook을 만들 때는 반드시 "use"로 시작하는 이름을 사용해야 합니다.

이는 React의 Hook 규칙을 적용받기 위한 명명 규칙입니다. useLocalStorage처럼 명확하고 설명적인 이름을 사용하면 다른 개발자들이 쉽게 이해할 수 있습니다.

내부에서는 useState, useEffect 등 다른 Hook들을 자유롭게 사용할 수 있습니다. 그 다음으로, Custom Hook은 일반 JavaScript 함수처럼 매개변수를 받고 값을 반환할 수 있습니다.

useLocalStorage는 key와 initialValue를 받아서 [value, setValue] 배열을 반환하는데, 이는 useState와 동일한 인터페이스입니다. useFetch는 객체를 반환하여 여러 값을 명확하게 전달합니다.

반환 형태는 자유롭게 선택할 수 있습니다. 마지막으로, 여러 컴포넌트에서 같은 Custom Hook을 호출해도 각각 독립적인 상태를 가집니다.

UserProfile 컴포넌트에서 useLocalStorage를 호출하고 다른 컴포넌트에서도 호출하면, 각각 별도의 useState와 useEffect를 가지는 것입니다. 상태를 공유하는 것이 아니라 로직을 공유하는 것입니다.

만약 상태를 공유하고 싶다면 useContext와 함께 사용해야 합니다. 여러분이 이 패턴을 사용하면 코드 중복을 제거하고, 복잡한 로직을 간단한 인터페이스로 숨길 수 있으며, 단위 테스트를 작성하기 쉬워집니다.

실무에서는 프로젝트별 공통 훅 라이브러리를 만들어 팀 전체가 활용합니다. 또한 오픈 소스 커뮤니티에는 react-use, ahooks 같은 유용한 Custom Hook 컬렉션들이 많이 있습니다.

실전 팁

💡 Custom Hook의 이름은 항상 "use"로 시작해야 합니다. 그렇지 않으면 React가 Hook 규칙 위반을 감지할 수 없습니다.

💡 하나의 Custom Hook은 하나의 명확한 책임만 가져야 합니다. 너무 많은 기능을 넣으면 재사용성이 떨어집니다.

💡 Custom Hook에서 반환하는 값은 상황에 따라 배열(useState 스타일)이나 객체(명시적 이름)를 선택하세요. 2개 이하면 배열, 3개 이상이면 객체가 좋습니다.

💡 Custom Hook 내부의 useEffect에서 cleanup 함수를 항상 신경 쓰세요. 특히 구독이나 타이머를 다룰 때 메모리 누수를 방지해야 합니다.

💡 Custom Hook도 다른 Custom Hook을 조합할 수 있습니다. 작은 훅들을 조합하여 더 복잡한 훅을 만드는 것이 권장됩니다: useAuthenticatedFetch = useFetch + useAuth


#React#Hooks#useState#useEffect#CustomHooks

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.
이전4/4
다음