"Hook은 컴포넌트의 최상위 레벨에서만 호출해야 한다"
리액트를 사용하다 보면 마주치는 규칙 중 하나는 "Hook은 컴포넌트의 최상위 레벨에서만 호출해야 한다"는 것이다. 특히
useState
를 조건문이나 반복문, 중첩된 함수 내부에서 사용하면 안 된다. 이 규칙을 어기면 어떤 문제가 발생하는지, 그리고 왜 이런 규칙이 존재하는지 자세히 알아보자.
리액트는 내부적으로 컴포넌트의 상태(state)를 관리하기 위해 '순서'에 의존한다. 컴포넌트가 렌더링될 때마다 Hook이 호출되는 순서를 기억하여 각 Hook과 해당 상태를 연결한다.
예를 들어, 다음과 같은 컴포넌트가 있다고 가정해 보자.
function ProfileCard() {
const [name, setName] = useState('홍길동'); // 첫 번째 Hook
const [age, setAge] = useState(30); // 두 번째 Hook
const [isAdmin, setIsAdmin] = useState(false); // 세 번째 Hook
// ...
}
리액트는 내부적으로 이 Hook들을 배열 형태로 저장하고 관리한다. 첫 번째 렌더링에서는:
- 첫 번째
useState
: 'name' 상태를 '홍길동'으로 초기화- 두 번째
useState
: 'age' 상태를 30으로 초기화- 세 번째
useState
: 'isAdmin' 상태를 false로 초기화
다음 렌더링에서도 같은 순서로 Hook이 호출되기 때문에, 리액트는 각 Hook 호출이 어떤 상태와 연결되어 있는지 정확히 알 수 있다.
이제 useState
를 조건문 안에서 사용하면 어떤 일이 발생하는지 살펴보자.
function ProfileCard({ isAdmin }) {
const [name, setName] = useState('홍길동'); // 항상 첫 번째 Hook
if (isAdmin) {
const [adminSince, setAdminSince] = useState('2023-01-01'); // 조건부 두 번째 Hook
}
const [age, setAge] = useState(30); // 조건에 따라 두 번째 또는 세 번째 Hook
// ...
}
첫 렌더링에서 isAdmin
이 true
인 경우:
- 첫 번째
useState
: 'name' 상태 초기화- 두 번째
useState
: 'adminSince' 상태 초기화- 세 번째
useState
: 'age' 상태 초기화
하지만 다음 렌더링에서 isAdmin
이 false
로 바뀌면:
- 첫 번째
useState
: 'name' 상태 (유지)- 두 번째
useState
가 호출되지 않음- 두 번째(원래는 세 번째)
useState
: 이제 이 Hook은 이전 렌더링의 'adminSince' 상태를 참조하게 된다.
이렇게 되면 리액트는 'age' 상태를 'adminSince' 상태로 오인하게 되며, 이는 버그를 발생시킬 수 있다.
더 구체적인 예시로 문제를 살펴보자.
function Counter({ showSpecialCounter }) {
const [count, setCount] = useState(0);
if (showSpecialCounter) {
const [specialCount, setSpecialCount] = useState(10);
}
const [step, setStep] = useState(1);
return (
<div>
<p>일반 카운터: {count}</p>
<button onClick={() => setCount(count + step)}>증가</button>
<p>스텝: {step}</p>
<button onClick={() => setStep(step + 1)}>스텝 증가</button>
</div>
);
}
이 컴포넌트는 다음과 같은 시나리오에서 문제가 발생한다:
- 처음에
showSpecialCounter
가true
인 경우:
- 첫 번째 Hook:
count
= 0- 두 번째 Hook:
specialCount
= 10- 세 번째 Hook:
step
= 1
- 사용자가 "스텝 증가" 버튼을 클릭하여
step
을 2로 변경한다.
- 부모 컴포넌트에서
showSpecialCounter
를false
로 변경한다.
- 다음 렌더링에서:
- 첫 번째 Hook:
count
= 0 (유지)- 두 번째 Hook: 이제
specialCount
의 Hook이 사라지고, 원래 세 번째였던 Hook이 두 번째가 된다.- 리액트는 두 번째 Hook에 저장된 값(원래는
specialCount
의 10)을step
의 값으로 사용한다.
결과적으로
step
은 사용자가 설정한 2가 아닌 10으로 표시되며, "증가" 버튼을 클릭하면count
가 1씩 증가하는 것이 아니라 10씩 증가하게 된다.
리액트는 이러한 문제를 방지하기 위해 개발 모드에서 Hook 규칙 위반을 감지하고 경고를 표시한다.
React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render.
이 경고는 우리가 앞서 살펴본 문제를 방지하기 위한 것이다.
조건부로 상태를 사용해야 하는 경우, Hook을 컴포넌트 최상위 레벨에 배치하고 조건을 내부 로직에 적용하는 것이 올바른 방법이다.
function ProfileCard({ isAdmin }) {
const [name, setName] = useState('홍길동');
const [adminSince, setAdminSince] = useState(isAdmin ? '2023-01-01' : null);
const [age, setAge] = useState(30);
// adminSince를 조건부로 사용
const adminSection = isAdmin && (
<div>
<h3>관리자 정보</h3>
<p>관리자 등록일: {adminSince}</p>
</div>
);
return (
<div>
<h2>{name}, {age}세</h2>
{adminSection}
</div>
);
}
또는 조건에 따라 다른 컴포넌트를 렌더링하는 방법도 있다:
function AdminProfile() {
const [adminSince, setAdminSince] = useState('2023-01-01');
// ...
return <div>관리자 프로필...</div>;
}
function UserProfile() {
// 관리자 상태 없음
// ...
return <div>일반 사용자 프로필...</div>;
}
function ProfileContainer({ isAdmin }) {
return isAdmin ? <AdminProfile /> : <UserProfile />;
}
리액트의 Hook은 호출 순서에 의존하여 상태를 관리하는 특성을 가지고 있다. 따라서
useState
와 같은 Hook은 항상 컴포넌트의 최상위 레벨에서, 그리고 렌더링마다 동일한 순서로 호출되어야 한다.
조건문 안에서 Hook을 사용하면 렌더링마다 Hook의 호출 순서가 바뀔 수 있어 의도치 않은 상태 불일치와 버그가 발생할 수 있다. 이를 방지하기 위해서는 Hook을 최상위에 배치하고, 조건부 로직은 Hook 호출 이후에 처리하는 것이 중요하다.