등장 배경
-
React에서 상태 로직은 컴포넌트 내부에서만 다룰 수 있다는 제약이 있다.
-
같은 상태 로직이 여러 컴포넌트에 반복되어도, 일반 함수로 분리하면 훅 규칙상 호출할 수 없다는 문제가 생긴다.
-
그렇다고 컴포넌트마다 로직을 직접 작성하면 중복이 늘어 유지보수가 어려워진다.
-
이 딜레마를 해결하기 위해 등장한 것이 커스텀 훅이다.
훅 규칙
-
React는 훅 호출에 두 가지 규칙을 강제한다.
-
첫째, 훅은 React 컴포넌트나 다른 커스텀 훅의 최상위에서만 호출해야 하며, 조건문·반복문·중첩 함수 안에서는 호출할 수 없다.
-
둘째, 훅은 React가 렌더링을 수행하는 실행 컨텍스트 안에서만 호출해야 한다.
-
이 규칙들은 React 런타임이 함수 이름을 검사해서 강제하는 것이 아니라,
react-hooksESLint 플러그인이 정적 분석으로 잡아낸다. -
규칙을 어겼을 때 발생하는 문제는 단순한 코드 스타일 위반이 아니라, React의 내부 상태 추적 메커니즘이 깨지는 실제 런타임 버그이다.
파이버와 슬롯 구조
-
훅 규칙을 이해하려면, React가 상태를 저장하는 방식부터 알아야 한다.
-
useState를 호출하면 React는 그 상태를 파이버(Fiber)라는 내부 객체에 저장한다. -
파이버는 컴포넌트 하나하나를 표현하는 객체로, 상태와 생명주기 정보를 담는다.
-
실제 구현은
Fiber.memoizedState를 시작점으로 하는 훅 연결 리스트이지만, 이해를 돕기 위해 호출 순서대로 채워지는 "슬롯"으로 비유해 설명한다. -
핵심은 파이버가 상태를 변수 이름이 아니라 호출 순서로 관리한다는 점이다.
-
예를 들어 아래 컴포넌트가 처음 렌더링되면, React는 훅을 호출 순서대로 슬롯에 저장한다.
function MyComponent() { const [name, setName] = useState(''); // 슬롯 1 const [age, setAge] = useState(0); // 슬롯 2 useEffect(() => { ... }); // 슬롯 3 } -
다시 렌더링될 때 React는 새 슬롯을 만들지 않고 이전 슬롯 1, 2, 3을 순서대로 꺼낸다.
-
즉, "첫 번째로 호출된 훅 = 슬롯 1의 상태"라는 방식으로 상태를 추적한다.
-
이 구조가 작동하려면 매 렌더링마다 훅의 호출 순서가 동일해야 한다.
최상위에서만 호출해야 하는 이유
-
호출 순서를 깨는 가장 흔한 원인이 조건문이나 반복문 안에서 훅을 호출하는 경우이다.
-
아래는 잘못된 예시이다.
function MyComponent({ isLoggedIn }) { if (isLoggedIn) { const [name, setName] = useState(''); // 조건부 호출 } const [age, setAge] = useState(0); } -
isLoggedIn이true일 때는 훅이 슬롯 1, 2 순서로 매칭된다. -
하지만
isLoggedIn이false로 바뀌면 첫 번째 훅이 건너뛰어지고,age가 슬롯 1을 차지하게 된다. -
React 입장에서는 슬롯 1에
name상태가 저장되어 있었는데, 갑자기age가 슬롯 1을 달라고 요청하는 상황이 된다. -
결과적으로 상태가 뒤섞이고 예측 불가능한 버그가 발생한다.
-
이를 막기 위해 ESLint의 훅 규칙은 모든 훅 호출이 함수 최상위에 있어야 한다고 정적으로 강제한다.
-
조건부 로직이 필요하다면 훅은 최상위에서 호출하고, 훅이 반환한 값을 조건부로 사용하는 방식으로 풀어야 한다.
const [name, setName] = useState(''); if (isLoggedIn) { // 훅으로 얻은 값을 조건부로 사용하는 것은 안전하다 }
일반 함수에서 훅을 호출하면 안 되는 이유
-
React 런타임은 컴포넌트 렌더링이 시작될 때
ReactCurrentDispatcher라는 내부 객체에 현재 렌더링 중인 파이버를 연결한다. -
useState같은 훅 호출은 이 디스패처를 통해 현재 활성화된 파이버에 상태를 등록하는 방식으로 동작한다. -
따라서 렌더링과 무관한 시점에 훅을 호출하면 디스패처가 비어 있어 런타임 오류가 발생한다.
function getUser() { const [user, setUser] = useState(null); // 렌더링 컨텍스트 밖이라 오류 } -
다만 엄밀히 말하면, 렌더링 도중 일반 함수가 호출되어 그 안에서 훅이 실행되는 경우에는 디스패처가 활성 상태이므로 런타임상으로는 동작할 수 있다.
-
그럼에도 일반 함수에서의 훅 호출을 막는 이유는, 일반 함수가 매 렌더링마다 동일한 순서로 호출된다는 보장이 없기 때문이다.
-
이 보장을 정적으로 강제하기 위해 ESLint는
use로 시작하는 함수만 훅 호출이 허용된 함수로 간주하고, 그 외 함수에서 훅을 호출하면 경고를 발생시킨다. -
정리하면 이 규칙의 본질은 런타임이 함수 이름을 검사하기 때문이 아니라, 호출 순서를 보장하기 위해 ESLint가
use컨벤션 위에서 정적 검사를 수행하기 때문이다.
React 런타임
-
React 런타임은 함수 이름에
use가 붙어 있는지 검사하지 않는다. -
런타임이 확인하는 것은 오직
ReactCurrentDispatcher가 활성화되어 있는가, 즉 지금이 컴포넌트 렌더링 중인가뿐이다. -
따라서
use접두사 자체는 기술적 차단 장치가 아니라, ESLint의react-hooks/rules-of-hooks가 정적 검사를 걸 수 있게 만들어 주는 표시일 뿐이다.
런타임이 깨지는 두 가지 양상
-
렌더링 바깥에서 훅을 호출하면, 예를 들어 이벤트 핸들러나 일반 유틸 함수에서 호출하면 디스패처가 비어 있어 즉시 런타임 오류가 발생한다.
-
반면 렌더링 도중
use가 붙지 않은 일반 함수를 거쳐 훅을 호출하면, 디스패처가 활성 상태이므로 런타임상 일단 동작은 한다. -
문제는 그 함수가 매 렌더링마다 같은 순서로 호출된다는 보장이 없다는 점이며, 호출 순서가 어긋나는 순간 슬롯과 상태가 잘못 매칭되면서 조용히 오염된다.
-
즉 "런타임에서도 깨진다"는 표현은 "즉시 에러로 차단된다"보다는 "어긋난 시점부터 상태가 꼬인다"에 가깝다.
-
ESLint가
use컨벤션으로 미리 막아두는 이유가 바로 이 조용한 오염을 사전에 차단하기 위함이다.
커스텀 훅의 동작 방식
-
커스텀 훅은 내부에서 다른 훅을 호출하는 함수에
use접두사를 붙인 형태이다. -
접두사 덕분에 ESLint의 훅 규칙 검사가 적용되어, 조건부 호출이나 잘못된 위치에서의 사용을 컴파일 단계에서 잡아낼 수 있다.
-
중요한 점은 커스텀 훅 함수 자체가 상태를 저장하는 것이 아니라는 것이다.
-
상태는 항상 그 훅을 호출한 컴포넌트의 파이버에 저장되며, 커스텀 훅은 단지 그 호출 패턴을 재사용 가능한 함수로 묶어둔 것일 뿐이다.
-
간단한 예시로 카운터 로직을 분리하면 다음과 같다.
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>; } -
Counter가 렌더링되는 동안useCounter내부의useState가 실행되면, 그 상태는useCounter함수가 아니라Counter의 파이버 슬롯에 저장된다. -
따라서 같은
useCounter를 여러 컴포넌트에서 호출해도, 각 컴포넌트의 파이버가 자기 슬롯을 갖기 때문에 상태가 섞이지 않는다.
의의
-
커스텀 훅 덕분에 컴포넌트는 UI 렌더링에만 집중하고, 로직은 훅이 담당하는 구조가 자연스럽게 만들어진다.
-
로직이 한곳에 모여 있어 수정할 때 훅 하나만 고치면 이를 쓰는 모든 컴포넌트에 반영된다.
-
이는 관심사의 분리 원칙에 부합하며, 코드의 가독성과 유지보수성을 함께 높이는 결과로 이어진다.