[ 롤모임 운영일지 ] - 26. 훅 하나를 아래에 뒀더니 경매가 죽었다 — 그물 세 겹이 전부 구멍이었던 이야기
9월 4일 오후 5시 17분에 경매 꿀팁 패널을 배포했다. 매물이 올라올 때마다 티모 말투로 한 줄씩 조언을 띄워주는 기능이다.
다음 날까지 신고가 두 건 들어왔다.
“경매 만들면 이런 창이 뜬다”
첨부된 스크린샷의 그 창은 에러 바운더리였다. 실시간 경매 화면이 통째로 죽어 있었다.
원인은 코드 한 줄의 위치였다. 그런데 이 편의 본론은 버그 자체가 아니다. 이 버그를 막았어야 할 안전장치가 세 겹 있었는데, 세 겹이 전부 구멍이었다는 쪽이다.
TL;DR
- 꿀팁 패널을 붙이면서
useMemo를 “불러오는 중” early return 아래에 두었다.session이null인 첫 렌더에서는 이 훅이 호출되지 않고, 데이터가 도착한 다음 렌더에서 하나 늘어난다. React는 렌더 사이에 훅 개수가 달라지는 걸 허용하지 않는다 → #310으로 화면 전체가 죽었다. - LIVE 모드 경매에 들어가면 예외 없이 재현된다.
session은 항상null로 시작하기 때문이다. - 이 레포에는 ESLint 설정이 없어
react-hooks/rules-of-hooks가 돌지 않는다. - 게다가 API 패키지의
npm run build(tsc)는 6월 9일부터 3개월간 실패하고 있었다. Dockerfile이tsc를 돌리지 않고npx tsx로 런타임 실행하기 때문에 배포는 멀쩡했고, 그래서 아무도 몰랐다. - vitest는 타입체크를 하지 않는다. 596개 테스트가 내내 통과하고 있었다.
- 회귀 테스트를 두 방향으로 묶었고, 그 테스트가 다시 배포를 깨뜨렸다(이건 5절에서).
1. 훅은 순서로 기억된다
React의 훅은 이름이 아니라 호출 순서로 자기 상태를 찾는다. 그래서 렌더마다 같은 순서로 같은 개수가 호출되어야 한다. 이게 “훅 규칙”의 전부다.
문제의 코드는 이렇게 생겼었다.
// packages/frontend/src/pages/LiveAuctionPage.tsx (수정 전)
if (!session || !session.teams)
return <div>불러오는 중...</div>; // ← early return
const myUserId = account?.linkedUserId;
const tipCtx = useMemo<TipContext | null>(() => { // ← 이 훅이 가드 아래에 있다
if (!session || session.mode !== "LIVE") return null;
// ...
}, [session, myUserId, laneRemaining]);
읽기에는 자연스럽다. “데이터 없으면 로딩 화면, 있으면 그때 계산” — 논리적으로 맞는 순서다. 하지만 렌더 두 번을 나란히 놓으면 이렇게 된다.
1번째 렌더 (session = null) → early return → tipCtx 의 useMemo 호출 안 됨 → 훅 N개
2번째 렌더 (session 도착) → 통과 → tipCtx 의 useMemo 호출됨 → 훅 N+1개
React가 두 번째 렌더에서 던지는 에러가 바로 이것이다.
Rendered more hooks than during the previous render. (React error #310)
그리고 LIVE 경매는 이 경로를 반드시 지난다. 세션 데이터를 서버에서 받아오므로 session은 항상 null로 시작하기 때문이다. 가끔 터지는 버그가 아니라 100% 재현되는 버그였는데도 이틀이 걸린 건, 경매가 상시 열려 있는 기능이 아니라서다.
수정은 위치를 바꾸는 것뿐이었다.
// packages/frontend/src/pages/LiveAuctionPage.tsx (수정 후)
const tipCtx = useMemo<TipContext | null>(() => {
// ⚠️ early return 위로 올라왔으므로 session·teams 를 여기서 직접 막는다.
if (!session || !session.teams || session.mode !== "LIVE") return null;
// ...
}, [session, myUserId, laneRemaining]);
// ⚠️ 여기부터 아래는 훅을 부를 수 없다 — 새 훅은 반드시 이 줄 위에 놓는다.
if (!session || !session.teams)
return <div>불러오는 중...</div>;
훅이 가드보다 앞서게 되니 session이 없는 경우를 훅 안에서 직접 막아야 한다. 그리고 early return 자리에 “아래로는 훅을 부를 수 없다”고 못을 박아 뒀다. 이 파일은 800줄이 넘어서, 다음에 기능을 붙이는 사람이 파일 끝 근처에 훅을 추가할 가능성이 충분히 있다.
2. 첫 번째 구멍 — ESLint 설정이 없다
이 버그는 react-hooks/rules-of-hooks가 잡는 교과서적인 케이스다. ESLint를 쓰는 프로젝트였다면 에디터에서 빨간 줄이 그어졌을 것이다.
그런데 이 레포에는 ESLint 설정이 없다. 그래서 훅 규칙 위반이 tsc도, vite build도 그대로 통과한다. 타입 시스템은 훅의 호출 위치에 관심이 없고, 번들러는 더더욱 그렇다.
이건 이번에 확인된 사실로 적어 뒀다. 추측이 아니라, 버그가 있는 버전으로 빌드를 돌려서 통과하는 걸 봤다.
3. 두 번째 구멍 — 빌드가 3개월째 멈춰 있었다
프론트의 그물을 확인하다가 백엔드 쪽도 한번 돌려봤는데, 거기서 더 나쁜 걸 발견했다.
$ npm run build # tsc && prisma generate
error TS2345: ...
npm run build가 실패하고 있었다. 커밋 이력을 따라가 보니 6월 9일 “운영 프로덕션 코드 git 반영” 이후로 줄곧 그랬다. 3개월이다.
에러 자체는 사소했다. 테스트의 Prisma 목에서 $transaction이 배열 오버로드와 콜백 오버로드를 함께 갖고 있는데, 콜백 시그니처만 좁게 적으면 오버로드 해석이 실패하는 케이스였다.
// packages/api/src/routes/auction.integration.test.ts
// ⚠️ as never 로 감싼다. $transaction 은 배열 오버로드와 콜백 오버로드를 함께 갖고 있어
// 콜백 시그니처만 좁게 적으면 오버로드 해석이 실패한다(TS2345). 이 캐스팅이 없으면
// `npm run build` 가 깨지고, 그러면 이 패키지 전체의 타입체크가 통째로 멈춘다.
prismaMock.$transaction.mockImplementation((async (fn: (tx: unknown) => Promise<unknown>) => {
// ...
}) as never);
문제는 에러가 아니라 아무도 몰랐다는 사실이다. 왜 몰랐는지는 두 갈래다.
- 배포가 이 에러에 걸리지 않는다. Dockerfile이
tsc를 돌리지 않고npx tsx src/index.ts로 런타임 실행한다. 타입체크 없이 그냥 돈다. - 테스트도 걸리지 않는다. vitest는 타입체크를 하지 않는다. 596개 테스트가 내내 초록이었다.
즉 3개월 동안 이 패키지에는 타입 버그를 막을 그물이 한 겹도 없었다. 진짜 타입 에러가 들어왔어도 배포되고, 테스트도 통과하고, 아무 경고도 없었을 것이다. 다행히 실제로 그런 사고가 난 흔적은 없었지만, 그건 운이었다.
4. 회귀 테스트 — 두 방향으로 묶었다
고친 게 진짜 고쳐졌는지, 그리고 다시 들어오면 잡히는지는 다른 질문이다. 두 방향으로 묶었다. 프론트에는 테스트가 아예 없었으므로 vitest + jsdom + @testing-library/react를 함께 들였다(테스트와 빌드가 다른 모듈을 보면 의미가 없으니, vitest 설정은 vite의 alias를 그대로 물려받게 했다).
① 화면을 실제로 렌더한다. 핵심은 전이다. session이 null인 첫 렌더를 반드시 거친 뒤에 데이터를 흘려보낸다. 처음부터 데이터를 쥔 채 한 번만 렌더하면 훅 개수가 달라질 일이 없어서, 이 버그를 놓친다. 실서비스와 같은 자리(에러 바운더리)에서 잡고, 잡힌 에러를 직접 확인한다.
② 같은 실수를 화면 전체에서 막는다. early return 뒤에 훅이 오는 패턴을 소스에서 정적으로 찾는 검사다. ESLint가 없으니 그 역할을 대신한다.
두 테스트가 헛돌지 않는지도 확인했다. 버그 버전으로 되돌려 실행하니 렌더 테스트는 Rendered more hooks than during the previous render.로, 정적 검사는 LiveAuctionPage.tsx:811 — useMemo (early return @ 795)로 각각 실패한다. 고친 버전에서는 7개 모두 통과한다.
여기서 한 번 더 걸렸던 게 있다. 정적 검사기가 처음에는 버그를 코앞에 두고도 “0건”을 반환했다. useMemo<TipContext>(...)의 제네릭 표기를 파싱하지 못해서였다. 그래서 검사기가 살아 있는지를 fixture로 먼저 확인한 뒤에야 코드베이스를 훑도록 바꿨다. 그 fixture를 지우면 이 테스트는 조용히 무의미해진다는 주석도 같이 남겼다.
검사기를 믿으려면 검사기를 검사해야 한다 — 이건 다음 편에서 또 한 번 겪는다.
5. 그 테스트가 배포를 깨뜨렸다
회귀 테스트를 넣은 커밋이 Vercel에서 빌드 실패했다.
src/rules-of-hooks.test.ts(2,53): error TS2307: Cannot find module 'node:fs'
src/rules-of-hooks.test.ts(25,18): error TS2304: Cannot find name '__dirname'
tsconfig의 include가 src/**/*라 tsc --noEmit이 테스트 파일까지 검사하는데, 새로 넣은 정적 검사 테스트가 node:fs · node:path · __dirname을 쓴다.
내 로컬에서는 통과했다. node_modules에 @types/node가 다른 경로로 남아 있었기 때문이다. Vercel의 클린 설치에는 없다. 로컬 빌드 통과를 근거로 삼은 게 틀렸다.
두 가지를 고쳤다.
@types/node를 devDependency로 명시한다. vitest를 쓰는 이상 필요하다.__dirname→dirname(fileURLToPath(import.meta.url)).__dirname은 ESM에 없는 이름이고, 로컬에서 돌아간 건 번들러가 채워 준 덕이었다.
실서비스는 영향받지 않았다. Vercel은 실패한 빌드를 promote하지 않으므로 경매 수정본이 계속 서빙됐다 — 확인했다.
그리고 이번엔 로컬 빌드로 끝내지 않았다. Vercel과 같은 조건(pnpm 10 + --frozen-lockfile + 빈 node_modules)을 임시 디렉터리에 만들어 검증했고, @types/node를 도로 빼면 같은 TS2307이 재현되는 것까지 확인했다.
6. 지금 상태 / 배운 것
- 훅 순서 버그는 9월 7일 오전에 수정 배포했다. 신고부터 수정까지 이틀이 걸렸다.
- API 패키지의
npm run build는 3개월 만에 다시 초록이 됐다. - 프론트에 테스트 환경이 생겼다(vitest + jsdom). 훅 순서 회귀 테스트 7건이 첫 입주자다.
배운 건 그물의 개수가 아니라 상태를 확인해야 한다는 것이다. 이 프로젝트에는 타입체크·테스트·린트라는 세 겹이 있다고 생각하고 있었다. 실제로는 린트는 설정이 없어 안 돌고, 타입체크는 3개월째 실패 중이고, 테스트는 타입을 보지 않았다. 세 겹이라고 믿은 것과 실제로 걸러지는 것 사이의 거리가 이 사건의 진짜 크기였다.
그물을 새로 짜는 것보다, 지금 있는 그물이 살아 있는지 한 번 돌려보는 게 먼저다. npm run build를 그냥 한 번 쳐본 게 이번에 제일 값싼 발견이었다.
댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요.