프로필 로고
2026-04-16

React 커스텀 훅

React 훅 규칙(최상위 호출·렌더링 컨텍스트)이 필요한 이유를 파이버 슬롯 구조와 ReactCurrentDispatcher로 설명하고, use 접두사가 런타임 차단이 아니라 ESLint rules-of-hooks용 표시인 이유, 커스텀 훅이 상태를 호출 컴포넌트의 파이버에 저장하는 동작 원리를 다룬다.

  • React
  • 커스텀 훅

등장 배경

  1. React에서 상태 로직은 컴포넌트 내부에서만 다룰 수 있다는 제약이 있다.

  2. 같은 상태 로직이 여러 컴포넌트에 반복되어도, 일반 함수로 분리하면 훅 규칙상 호출할 수 없다는 문제가 생긴다.

  3. 그렇다고 컴포넌트마다 로직을 직접 작성하면 중복이 늘어 유지보수가 어려워진다.

  4. 이 딜레마를 해결하기 위해 등장한 것이 커스텀 훅이다.

훅 규칙

  1. React는 훅 호출에 두 가지 규칙을 강제한다.

  2. 첫째, 훅은 React 컴포넌트나 다른 커스텀 훅의 최상위에서만 호출해야 하며, 조건문·반복문·중첩 함수 안에서는 호출할 수 없다.

  3. 둘째, 훅은 React가 렌더링을 수행하는 실행 컨텍스트 안에서만 호출해야 한다.

  4. 이 규칙들은 React 런타임이 함수 이름을 검사해서 강제하는 것이 아니라, react-hooks ESLint 플러그인이 정적 분석으로 잡아낸다.

  5. 규칙을 어겼을 때 발생하는 문제는 단순한 코드 스타일 위반이 아니라, React의 내부 상태 추적 메커니즘이 깨지는 실제 런타임 버그이다.

파이버와 슬롯 구조

  1. 훅 규칙을 이해하려면, React가 상태를 저장하는 방식부터 알아야 한다.

  2. useState를 호출하면 React는 그 상태를 파이버(Fiber)라는 내부 객체에 저장한다.

  3. 파이버는 컴포넌트 하나하나를 표현하는 객체로, 상태와 생명주기 정보를 담는다.

  4. 실제 구현은 Fiber.memoizedState를 시작점으로 하는 훅 연결 리스트이지만, 이해를 돕기 위해 호출 순서대로 채워지는 "슬롯"으로 비유해 설명한다.

  5. 핵심은 파이버가 상태를 변수 이름이 아니라 호출 순서로 관리한다는 점이다.

  6. 예를 들어 아래 컴포넌트가 처음 렌더링되면, React는 훅을 호출 순서대로 슬롯에 저장한다.

    function MyComponent() {
      const [name, setName] = useState('');   // 슬롯 1
      const [age, setAge] = useState(0);      // 슬롯 2
      useEffect(() => { ... });               // 슬롯 3
    }
  7. 다시 렌더링될 때 React는 새 슬롯을 만들지 않고 이전 슬롯 1, 2, 3을 순서대로 꺼낸다.

  8. 즉, "첫 번째로 호출된 훅 = 슬롯 1의 상태"라는 방식으로 상태를 추적한다.

  9. 이 구조가 작동하려면 매 렌더링마다 훅의 호출 순서가 동일해야 한다.

최상위에서만 호출해야 하는 이유

  1. 호출 순서를 깨는 가장 흔한 원인이 조건문이나 반복문 안에서 훅을 호출하는 경우이다.

  2. 아래는 잘못된 예시이다.

    function MyComponent({ isLoggedIn }) {
      if (isLoggedIn) {
        const [name, setName] = useState(''); // 조건부 호출
      }
      const [age, setAge] = useState(0);
    }
  3. isLoggedIntrue일 때는 훅이 슬롯 1, 2 순서로 매칭된다.

  4. 하지만 isLoggedInfalse로 바뀌면 첫 번째 훅이 건너뛰어지고, age가 슬롯 1을 차지하게 된다.

  5. React 입장에서는 슬롯 1에 name 상태가 저장되어 있었는데, 갑자기 age가 슬롯 1을 달라고 요청하는 상황이 된다.

  6. 결과적으로 상태가 뒤섞이고 예측 불가능한 버그가 발생한다.

  7. 이를 막기 위해 ESLint의 훅 규칙은 모든 훅 호출이 함수 최상위에 있어야 한다고 정적으로 강제한다.

  8. 조건부 로직이 필요하다면 훅은 최상위에서 호출하고, 훅이 반환한 값을 조건부로 사용하는 방식으로 풀어야 한다.

    const [name, setName] = useState('');
    if (isLoggedIn) {
      // 훅으로 얻은 값을 조건부로 사용하는 것은 안전하다
    }

일반 함수에서 훅을 호출하면 안 되는 이유

  1. React 런타임은 컴포넌트 렌더링이 시작될 때 ReactCurrentDispatcher라는 내부 객체에 현재 렌더링 중인 파이버를 연결한다.

  2. useState 같은 훅 호출은 이 디스패처를 통해 현재 활성화된 파이버에 상태를 등록하는 방식으로 동작한다.

  3. 따라서 렌더링과 무관한 시점에 훅을 호출하면 디스패처가 비어 있어 런타임 오류가 발생한다.

    function getUser() {
      const [user, setUser] = useState(null); // 렌더링 컨텍스트 밖이라 오류
    }
  4. 다만 엄밀히 말하면, 렌더링 도중 일반 함수가 호출되어 그 안에서 훅이 실행되는 경우에는 디스패처가 활성 상태이므로 런타임상으로는 동작할 수 있다.

  5. 그럼에도 일반 함수에서의 훅 호출을 막는 이유는, 일반 함수가 매 렌더링마다 동일한 순서로 호출된다는 보장이 없기 때문이다.

  6. 이 보장을 정적으로 강제하기 위해 ESLint는 use로 시작하는 함수만 훅 호출이 허용된 함수로 간주하고, 그 외 함수에서 훅을 호출하면 경고를 발생시킨다.

  7. 정리하면 이 규칙의 본질은 런타임이 함수 이름을 검사하기 때문이 아니라, 호출 순서를 보장하기 위해 ESLint가 use 컨벤션 위에서 정적 검사를 수행하기 때문이다.

React 런타임

  1. React 런타임은 함수 이름에 use가 붙어 있는지 검사하지 않는다.

  2. 런타임이 확인하는 것은 오직 ReactCurrentDispatcher가 활성화되어 있는가, 즉 지금이 컴포넌트 렌더링 중인가뿐이다.

  3. 따라서 use 접두사 자체는 기술적 차단 장치가 아니라, ESLint의 react-hooks/rules-of-hooks가 정적 검사를 걸 수 있게 만들어 주는 표시일 뿐이다.

런타임이 깨지는 두 가지 양상

  1. 렌더링 바깥에서 훅을 호출하면, 예를 들어 이벤트 핸들러나 일반 유틸 함수에서 호출하면 디스패처가 비어 있어 즉시 런타임 오류가 발생한다.

  2. 반면 렌더링 도중 use가 붙지 않은 일반 함수를 거쳐 훅을 호출하면, 디스패처가 활성 상태이므로 런타임상 일단 동작은 한다.

  3. 문제는 그 함수가 매 렌더링마다 같은 순서로 호출된다는 보장이 없다는 점이며, 호출 순서가 어긋나는 순간 슬롯과 상태가 잘못 매칭되면서 조용히 오염된다.

  4. 즉 "런타임에서도 깨진다"는 표현은 "즉시 에러로 차단된다"보다는 "어긋난 시점부터 상태가 꼬인다"에 가깝다.

  5. ESLint가 use 컨벤션으로 미리 막아두는 이유가 바로 이 조용한 오염을 사전에 차단하기 위함이다.

커스텀 훅의 동작 방식

  1. 커스텀 훅은 내부에서 다른 훅을 호출하는 함수에 use 접두사를 붙인 형태이다.

  2. 접두사 덕분에 ESLint의 훅 규칙 검사가 적용되어, 조건부 호출이나 잘못된 위치에서의 사용을 컴파일 단계에서 잡아낼 수 있다.

  3. 중요한 점은 커스텀 훅 함수 자체가 상태를 저장하는 것이 아니라는 것이다.

  4. 상태는 항상 그 훅을 호출한 컴포넌트의 파이버에 저장되며, 커스텀 훅은 단지 그 호출 패턴을 재사용 가능한 함수로 묶어둔 것일 뿐이다.

  5. 간단한 예시로 카운터 로직을 분리하면 다음과 같다.

    function useCounter(initial = 0) {
      const [count, setCount] = useState(initial);
      const increment = () => setCount((c) => c + 1);
      return { count, increment };
    }
     
    function Counter() {
      const { count, increment } = useCounter();
      return <button onClick={increment}>{count}</button>;
    }
  6. Counter가 렌더링되는 동안 useCounter 내부의 useState가 실행되면, 그 상태는 useCounter 함수가 아니라 Counter의 파이버 슬롯에 저장된다.

  7. 따라서 같은 useCounter를 여러 컴포넌트에서 호출해도, 각 컴포넌트의 파이버가 자기 슬롯을 갖기 때문에 상태가 섞이지 않는다.

의의

  1. 커스텀 훅 덕분에 컴포넌트는 UI 렌더링에만 집중하고, 로직은 훅이 담당하는 구조가 자연스럽게 만들어진다.

  2. 로직이 한곳에 모여 있어 수정할 때 훅 하나만 고치면 이를 쓰는 모든 컴포넌트에 반영된다.

  3. 이는 관심사의 분리 원칙에 부합하며, 코드의 가독성과 유지보수성을 함께 높이는 결과로 이어진다.