React error 300: 조건부 return 뒤의 훅과 가끔 나는 오류
관리자 화면에 들어갈 때마다 오류 카드가 한 번씩 떴습니다. 「문제가 발생했습니다. 잠시 후 다시 시도해 주세요.」 다시 시도를 누르면 그냥 들어가집니다. 그래서 한동안 넘겼습니다.
넘길 일이 아니었습니다. 원인은 제가 이틀 전에 넣은 코드였고, 고치는 데는 한 줄이 들었지만 찾는 데는 엉뚱한 길로 한참 갔습니다.
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() 안에는 useState 와 useEffect 가 하나씩 들어 있습니다. 훅 두 개가 조건부 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 를 건드리는 일이라 따로 잡아야 합니다. 다만 무엇을 켤지는 정했습니다. 규칙 하나로 시작합니다.
함께 읽기
- iframe 안의 분석 스크립트: 자사 사이트를 창으로 띄웠을 때 생기는 일저희 홈페이지를 저희가 만든 브라우저 안 데스크톱(os.duolabs.co.kr)에 창 하나로 띄웠습니다. 데모를 한 번 열어 본 사람이 방문 기록에는 두 번 찍혔습니다. 처음에는 창을 두 개 여니까 그렇겠거니 했는데, 세어 보니 문제는 개수가 아니라 그 숫자가 우리 통계 전부를 통과하고 있다는 쪽이었습니다.
- WebP가 JPEG보다 커질 때: 사진 종류별 WebP·AVIF 실측인쇄기 롤러 견적 사이트를 만들면서 화면에 나가는 사진을 전부 WebP로 정했습니다. 제품 사진 네 장으로 재 보고 내린 결론이었습니다. 그 뒤에 설계도 사진 세 장이 들어왔고, 같은 스크립트에 그대로 태웠습니다. 이 글을 쓰려고 다시 재 보니 그중 두 장은 WebP가 JPEG보다 컸습니다.
- 웹 퍼블리셔와 프론트엔드 개발자 차이: 산출물이 갈리는 지점견적서에 이런 두 줄이 나란히 놓이는 경우가 있습니다.
- GA4 쿠키 없이 쓰기: client_id를 직접 넘기는 구성방문 분석은 하고 싶은데 쿠키 동의 팝업은 만들기 싫었습니다. 배너를 하나 붙이면 화면에 층이 하나 더 생기고, 동의 상태를 저장하고 갱신하고 철회까지 받는 코드가 따라옵니다. 방문자가 하루 수십 명인 회사 사이트에 그만한 장치를 두는 것은 과합니다.
- Next.js 웹앱을 Electron 데스크톱 앱으로 만들기 전에 따져볼 것사내 웹 도구를 쓰다 보면 어느 순간 "이거 그냥 데스크톱 앱으로 만들면 안 되나"라는 말이 나옵니다.