RSS듀오랩스
React

React 훅 규칙과 조건부 return

작성자
듀오랩스 대표·10분 읽기

로그인하지 않은 사용자에게는 안내 문구만 보여 주는 컴포넌트입니다.

function Dashboard({ user }) {
  if (!user) {
    return <p>로그인이 필요합니다.</p>;
  }

  const [range, setRange] = useState("month");
  const stats = useMemo(() => summarize(user.orders, range), [user.orders, range]);
  return <StatsView stats={stats} range={range} onRangeChange={setRange} />;
}

로그인한 상태로 처음 열면 잘 나옵니다. 로그아웃한 상태로 처음 열어도 잘 나옵니다. 그런데 화면을 연 채로 로그인하거나 로그아웃하는 순간 오류로 바뀝니다. 운영 빌드에서는 방향에 따라 이런 메시지만 남습니다.

Minified React error #310   (로그아웃 상태에서 로그인할 때)
Minified React error #300   (로그인 상태에서 로그아웃할 때)

훅 규칙은 스타일 규칙이라는 생각

「훅은 컴포넌트 최상단에서만 부른다」는 규칙은 대부분 린터 경고로 처음 만납니다. 그래서 코드를 일관되게 쓰자는 스타일 규칙이나, React 가 붙여 둔 제약 정도로 받아들이기 쉽습니다. 위 코드는 조건문 안에서 훅을 부르지도 않았습니다. 조건문 뒤에서 불렀을 뿐이라 규칙을 어긴 것처럼 보이지도 않습니다.

이 이해로는 오류가 왜 「처음 열 때」가 아니라 「로그인 상태가 바뀌는 순간」에만 나는지 설명되지 않습니다. 규칙은 모양이 아니라 렌더링과 렌더링 사이에 관한 것이기 때문입니다.

useState 는 이름이 아니라 순서로 state 를 찾는다

useState("month") 에는 이 state 가 무엇인지 알려 주는 이름이 없습니다. 첫 렌더링에서는 「month」로 시작하는 state 를 만들면 되지만, 다음 렌더링에서 같은 줄이 다시 실행될 때 React 는 어느 state 를 돌려줘야 할까요.

State: A Component's Memory 문서가 이 질문에 답합니다.

Hooks rely on a stable call order on every render of the same component.

React 는 컴포넌트마다 state 를 호출 순서대로 늘어놓은 목록으로 들고 있습니다. 첫 번째로 호출된 훅은 목록의 첫 칸을, 두 번째 훅은 둘째 칸을 씁니다. 렌더링할 때마다 칸 번호를 0 부터 세면서 훅 호출과 칸을 짝짓습니다.

key 를 다룬 앞 글에서 목록의 state 가 key 라는 자리에 붙는다는 점을 봤습니다. 한 컴포넌트 안에서는 호출 순서가 그 자리 역할을 합니다. 그리고 순서에는 key 같은 이름표가 없습니다.

조건부 return 뒤의 훅이 순서를 바꾸는 방식

처음 코드를 칸 번호로 따라가 보겠습니다.

렌더링 user 호출된 훅
1 없음 없음 (return 으로 끝남)
2 있음 0번 useState, 1번 useMemo

첫 렌더링에서 훅이 0개였는데, 두 번째 렌더링에서는 2개가 호출됩니다. React 입장에서는 지난번 목록에 없던 칸을 달라는 요청입니다. 반대 방향(로그인 상태에서 로그아웃)이면 지난번에는 2개였던 훅이 이번에는 0개입니다.

표의 방향(0개에서 2개)은 310번 오류입니다. React 오류 코드 페이지의 문장은 「Rendered more hooks than during the previous render.」입니다. 반대 방향은 300번이고, 원인까지 짚어 줍니다.

Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

두 오류 모두 이전 렌더링과 훅 개수가 다르다는 같은 사실을 말합니다. 처음 열 때 나지 않는 이유도 여기 있습니다. 비교할 이전 렌더링이 없으면 순서가 어긋날 수 없습니다.

개수가 같은데 순서만 바뀌는 경우는 더 위험합니다. 조건에 따라 useState 두 개 중 하나만 건너뛰면, 뒤의 훅이 앞의 훅이 쓰던 칸을 받습니다. 개발 빌드에서는 순서가 바뀌었다는 경고가 나오기도 하지만, 개수가 맞으면 오류 화면 없이 문자열이 들어 있어야 할 state 에 숫자가 들어오는 식의 이상한 동작만 남을 수 있습니다.

훅 규칙의 정확한 범위

Rules of Hooks 문서는 규칙을 둘로 정리합니다.

첫째, 최상단에서만 부릅니다. 문서의 표현은 이렇습니다.

Don't call Hooks inside loops, conditions, nested functions, or try/catch/finally blocks. Instead, always use Hooks at the top level of your React function, before any early returns.

「before any early returns」가 처음 코드의 경우입니다. 조건문 안이 아니어도, 어떤 렌더링에서는 거기까지 도달하지 못하는 자리라면 순서가 바뀝니다. 반복문도 같습니다. 배열 길이만큼 useState 를 부르면 길이가 바뀌는 순간 개수가 달라집니다. 이벤트 핸들러 같은 중첩 함수 안의 훅은 렌더링 때 호출되지 않거나, 호출 횟수가 렌더링과 무관해집니다.

둘째, React 함수에서만 부릅니다. 컴포넌트나 다른 커스텀 훅 안에서만 부릅니다. 평범한 유틸 함수 안에서 훅을 부르면, 그 함수가 어느 컴포넌트의 몇 번째 칸에 해당하는지 React 가 알 수 없습니다. 커스텀 훅 이름이 use 로 시작해야 하는 이유도 이것입니다. 린터가 이름을 보고 「이 함수 안의 호출도 훅 순서에 들어간다」고 판단합니다.

고치는 방법은 훅을 먼저, 분기를 나중에

처음 코드는 훅을 모두 조건부 return 앞으로 올리면 됩니다.

function Dashboard({ user }) {
  const [range, setRange] = useState("month");
  const stats = useMemo(
    () => (user ? summarize(user.orders, range) : null),
    [user, range],
  );

  if (!user) {
    return <p>로그인이 필요합니다.</p>;
  }
  return <StatsView stats={stats} range={range} onRangeChange={setRange} />;
}

이제 user 가 있든 없든 훅은 늘 두 번, 같은 순서로 호출됩니다. 조건은 훅 안으로 옮겼습니다. 「조건부로 훅을 부른다」가 아니라 「훅은 늘 부르고, 훅 안에서 조건을 본다」로 바꾸는 것이 기본 방향입니다.

훅이 많아서 위로 올리기 어렵다면 컴포넌트를 나눕니다.

function Dashboard({ user }) {
  if (!user) return <p>로그인이 필요합니다.</p>;
  return <LoggedInDashboard user={user} />;
}

LoggedInDashboard 는 로그인 상태에서만 렌더링되고, 그 안의 훅은 늘 같은 순서로 호출됩니다. 로그인 상태가 바뀌면 이 컴포넌트는 통째로 사라지거나 새로 생기므로 이전 렌더링과 비교할 일이 없습니다. 조건에 따라 전혀 다른 화면이라면 이쪽이 읽기도 쉽다고 봅니다.

개발 중에는 멀쩡해 보이는 이유

이 오류가 까다로운 것은 재현 조건 때문입니다. 한 상태로 페이지를 새로 열면 늘 정상입니다. 같은 컴포넌트가 화면에 남아 있는 채로 조건이 바뀌어야 납니다. 로그인, 권한 변경, 데이터 로딩 완료처럼 화면을 새로고침하지 않고 상태가 바뀌는 순간입니다. 개발 중에는 코드를 고칠 때마다 새로 열어 보니 그 순간을 지나치기 쉽습니다.

실제로 관리자 화면에서 「가끔 오류 카드가 뜨고 다시 시도하면 들어가지는」 형태로 이 오류를 만난 기록이 React error 300 글에 있습니다. 증상만 보면 배포 중 캐시 문제처럼 보여서 엉뚱한 곳을 먼저 고쳤던 사례입니다.

그래서 저는 이 규칙을 린터에 맡기는 편이 확실하다고 봅니다. eslint-plugin-react-hooksrules-of-hooks 규칙이 조건부 return 뒤의 훅까지 잡아 줍니다. 사람이 리뷰로 잡기보다 저장할 때 빨간 줄로 보이는 쪽이 빠릅니다.

예외처럼 보이는 use

React 19 의 use 는 이 규칙에서 벗어나 보입니다. use 문서에는 useContext 와 달리 조건문이나 반복문 안에서 부를 수 있다고 적혀 있습니다.

문서가 그 이유를 자세히 설명하지는 않습니다. 저는 use 가 컴포넌트의 state 칸을 만들지 않고 넘겨받은 context 나 Promise 에서 값을 읽기만 하기 때문에 호출 순서에 기댈 필요가 없다고 이해합니다. 규칙이 순서를 지키기 위한 것이라는 점에서 보면 예외인 것도 자연스럽습니다. 다만 use 도 컴포넌트나 훅 안에서 불러야 한다는 둘째 규칙은 그대로입니다.

여기까지가 확실한 부분

훅이 호출 순서에 기댄다는 설명, 두 규칙의 문구, 300번과 310번 오류 메시지, use 를 조건문 안에서 부를 수 있다는 점은 react.dev 문서 기준입니다. React 가 내부에서 state 를 실제로 어떤 자료구조로 들고 있는지는 문서가 「순서대로 늘어놓은 목록」이라는 설명 수준까지만 보여 주므로, 이 글도 그 수준에서 설명했습니다.

이 게시글 공유하기

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.