RSS듀오랩스
프론트엔드

React error 300: 조건부 return 뒤의 훅과 가끔 나는 오류

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

관리자 화면에 들어갈 때마다 오류 카드가 한 번씩 떴습니다. 「문제가 발생했습니다. 잠시 후 다시 시도해 주세요.」 다시 시도를 누르면 그냥 들어가집니다. 그래서 한동안 넘겼습니다.

넘길 일이 아니었습니다. 원인은 제가 이틀 전에 넣은 코드였고, 고치는 데는 한 줄이 들었지만 찾는 데는 엉뚱한 길로 한참 갔습니다.

digest 가 없다는 첫 단서

먼저 서버를 봤습니다. 운영 로그에서 /admin 요청은 전부 정상이었습니다.

/admin  307  edge-middleware     (로그인 확인 후 리다이렉트)
/admin  200  serverless          ← 서버는 멀쩡하게 그렸다

5xx 는 한 건도 없었습니다. 그런데 화면은 오류 카드였습니다.

여기서 두 번째 단서가 붙습니다. 오류 카드에 코드(digest)가 찍혀 있지 않았습니다. Next.js 는 서버에서 렌더가 깨지면 오류에 digest 를 붙여 내려보냅니다. 그래야 서버 로그와 화면을 맞춰 볼 수 있기 때문입니다. digest 가 없다는 것은 서버가 아니라 브라우저에서 깨졌다는 뜻입니다.

그러니까 이 시점에 이미 알 수 있었습니다. 서버 로그를 아무리 뒤져도 안 나옵니다. 봐야 할 것은 브라우저 콘솔입니다.

엉뚱한 곳에 처방한 20분

저는 콘솔을 안 봤습니다. 대신 그럴듯한 가설을 세웠습니다.

그날 이 사이트를 여섯 번 배포했습니다. 배포가 나가면 자바스크립트 조각(chunk)의 이름이 바뀌고, 그 전에 열어 둔 탭은 사라진 이름을 계속 부릅니다. 그러면 화면이 깨지고, 새로 받아오면 풀립니다. 「한 번씩 나고 다시 시도하면 된다」와 증상이 똑같습니다.

그래서 오류 화면에 자동 복구를 넣었습니다. 조각을 못 받은 실패로 보이면 한 번만 새로고침하고, 30초 자물쇠로 무한 새로고침을 막는 코드입니다.

if (isChunkFailure(error)) {
  let last = 0;
  try { last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0; } catch {}
  if (Date.now() - last > RELOAD_COOLDOWN_MS) {
    try { sessionStorage.setItem(RELOAD_KEY, String(Date.now())); } catch {}
    window.location.reload();
    return;
  }
}

이 코드 자체는 쓸모가 있어서 지금도 남겨 뒀습니다. 다만 이번 오류와는 아무 상관이 없었습니다. 증상이 같다고 원인이 같지는 않습니다.

같이 넣은 것 하나는 실제로 값을 했습니다. 오류 이름과 메시지를 sessionStorage 에 적어 두고, 관리자 화면에서는 그 한 줄을 카드 아래에 보여주게 했습니다. 오류 화면은 새로고침하면 사라져서, 남겨 두지 않으면 나중에 물어볼 방법이 없습니다.

콘솔 한 줄이 끝낸 진단, React #300

그러고 나서 콘솔 스크린샷을 받았습니다. 거기 답이 있었습니다.

Error: Minified React error #300;
visit https://react.dev/errors/300 for the full message

#300 은 이렇게 읽습니다. 「이번 렌더에서 훅이 이전 렌더보다 적게 실행됐다. 대개 중간에 끼어든 early return 때문이다.」

이 문장을 보자마자 어디를 봐야 하는지 알았습니다. 훅을 마지막으로 건드린 곳은 챗봇 버튼이었습니다. 이틀 전, 클라우드 데스크톱(os) 창 안에서는 이 버튼을 숨기려고 useInOs() 라는 훅을 하나 붙였습니다. 붙인 자리가 문제였습니다.

// 358행
if (pathname.includes("/admin") || pathname.includes("/login")) return null;

// ... 스무 줄쯤 건너뛰고

// 379행
const inOs = useInOs();   // ← 훅이 return 뒤에 있다
if (inOs) return null;

useInOs() 안에는 useStateuseEffect 가 하나씩 들어 있습니다. 훅 두 개가 조건부 return 뒤에 서 있었던 것입니다.

주소창으로 들어가면 멀쩡한 이유

여기가 이 버그에서 제일 재미있는 부분입니다. 같은 화면인데 들어온 길에 따라 결과가 달랐습니다.

주소창에 /admin 을 치고 들어가면 이 부품은 그 순간 처음 마운트됩니다. 첫 렌더부터 358행에서 끝나므로 훅은 늘 같은 개수만 실행됩니다. React 는 비교할 이전 렌더가 없으니 아무 말도 하지 않습니다.

다른 화면을 보다가 관리자로 넘어가면 다릅니다. 이 버튼은 레이아웃에 붙어 있어서 화면이 바뀌어도 살아 있습니다. 죽지 않고 pathname 만 바뀝니다. 그러니 직전 렌더에서는 훅을 끝까지(useInOs 포함) 실행했고, 이번 렌더에서는 358행에서 끊겼습니다. 방금 전보다 훅이 두 개 적습니다. React 가 던집니다.

「가끔 난다」로 보였던 것의 정체가 이것입니다. 우연이 아니라 경로였습니다. 관리자 화면을 새 탭에서 열어 확인하던 날은 멀쩡했고, 사이트를 둘러보다 들어간 날은 터졌습니다.

증상에 「가끔」이 붙으면 보통 타이밍이나 부하를 의심하게 됩니다. 이번 일 이후로 하나를 더 보게 됐습니다. 이전 렌더가 남긴 것에 기대는 코드인가. 그렇다면 「가끔」은 시간이 아니라 순서의 문제입니다.

고친 자리는 한 줄, 옮긴 것은 훅 하나

고치는 방법은 간단합니다. 훅을 다른 훅들 옆으로 올리고, 판단만 아래에 남깁니다.

// 마운트 직후, 다른 훅들과 같은 자리
const footerVisible = useFooterVisible();
const [mounted, setMounted] = useState(false);
const inOs = useInOs();          // ← 훅은 여기서 끝난다

// ... 렌더 로직 ...

// 여기서부터 아래로는 훅을 부르지 않는다
if (inOs) return null;
if (pathname.includes("/admin") || pathname.includes("/login")) return null;

훅은 실행 순서로 자기 자리를 찾습니다. 이름표가 없고 번호만 있습니다. 그래서 조건에 따라 개수가 달라지는 순간 모든 훅의 번호가 밀립니다. if 안에 훅을 넣지 말라는 규칙은 익숙한데, return 에 두는 것도 정확히 같은 위반이라는 점은 덜 익숙합니다. 눈에는 조건문이 안 보이기 때문입니다.

같은 실수가 더 있는지 한 번에 세어 봤습니다

한 곳을 고쳤다고 끝이 아닙니다. 같은 모양이 더 있는지 세어야 합니다.

이 규칙은 도구가 잡아 줍니다. react-hooks/rules-of-hooks 는 저장하는 순간 이 코드에 빨간 줄을 긋습니다. 다만 이 리포에는 ESLint 가 설치돼 있지 않았습니다.

그래서 임시 폴더에 규칙 하나만 세워 한 번 돌렸습니다. 리포를 건드리지 않고 답만 얻는 방법입니다.

mkdir -p /tmp/lintbox && cd /tmp/lintbox && npm init -y
npm i eslint@9 eslint-plugin-react-hooks @typescript-eslint/parser
// /tmp/lintbox/config.mjs
import hooks from "eslint-plugin-react-hooks";
import tsparser from "@typescript-eslint/parser";

export default [
  {
    files: ["**/*.{ts,tsx}"],
    languageOptions: {
      parser: tsparser,
      parserOptions: { ecmaFeatures: { jsx: true }, sourceType: "module" },
    },
    plugins: { "react-hooks": hooks },
    rules: { "react-hooks/rules-of-hooks": "error" },
  },
];
cd <프로젝>/app
/tmp/lintbox/node_modules/.bin/eslint --config /tmp/lintbox/config.mjs "src/**/*.tsx" "src/**/*.ts"

결과는 0건이었습니다. 방금 고친 한 곳이 유일했고, 형제 프로젝트에서도 위반은 없었습니다. 세어 보기 전에는 「아마 그것 하나겠지」였고, 세어 본 뒤에는 사실이 됐습니다.

규칙 하나가 없어서 치른 값

이 버그는 타입 검사를 통과합니다. 빌드도 통과합니다. 로컬에서 관리자 화면을 새로고침으로 열면 재현도 안 됩니다. 통과하지 못하는 것은 react-hooks/rules-of-hooks 하나뿐이었고, 그것이 없었습니다.

린트는 보통 「스타일을 맞춰 주는 것」으로 취급됩니다. 세미콜론이나 따옴표 같은 것 말입니다. 그래서 안 쓰는 프로젝트가 있고, 쓰더라도 경고가 쌓여 아무도 안 보게 됩니다. 규칙 수백 개를 한꺼번에 켜면 그렇게 됩니다.

지금 제 생각은 이렇습니다. 처음부터 다 켜지 말고, 틀리면 화면이 깨지는 규칙만 error 로 켜는 편이 낫습니다. 훅 규칙이 딱 그것입니다. 어기면 취향이 아니라 런타임 오류가 나고, 그 오류는 정상 경로에서 안 나타나 테스트를 통과합니다.

아직 이 리포에 ESLint 를 붙이지는 않았습니다. 빌드와 CI 를 건드리는 일이라 따로 잡아야 합니다. 다만 무엇을 켤지는 정했습니다. 규칙 하나로 시작합니다.

참고: React error #300 전체 메시지 · Rules of Hooks

마지막 수정:

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