패키지 설치 후 Invalid hook call? Vite 캐시 무효화가 답이다

Vite 프로젝트에서 새 패키지를 설치한 뒤 Invalid hook call 에러가 발생하는 원인은 의존성 사전 번들링 캐시(.vite/deps)다. React 중복 인스턴스 문제의 근본 원인부터 node_modules/.vite 삭제, vite --force, optimizeDeps 설정까지 실전 해결법과 예방 전략을 정리한다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • pnpm add로 새 패키지를 설치해도 Vite의 사전 번들링 캐시(.vite/deps)가 자동으로 갱신되지 않을 수 있다
  • 캐시가 오래된 상태로 남으면 React가 두 개 로드되어 Invalid hook call 에러가 발생한다
  • rm -rf node_modules/.vite 또는 vite --force로 캐시를 강제 갱신하면 해결된다
  • tailwind.config.js에서 require() 대신 ESM import를 사용해야 Vite 환경과 충돌하지 않는다
  • shadcn CLI로 컴포넌트를 추가한 뒤에도 같은 증상이 발생할 수 있다

🔍 증상 — pnpm add 직후 React가 폭발한다

학원 포탈에 반 이동 미리보기 기능을 구현하던 중이었다. 미리보기 결과를 접었다 펼 수 있는 Collapsible 컴포넌트가 필요해서, shadcn의 @radix-ui/react-collapsible을 설치했다.

pnpm add @radix-ui/react-collapsible

설치는 깔끔하게 끝났다. collapsible.tsx 컴포넌트 파일도 만들었다.

// src/components/ui/collapsible.tsx
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"

const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent

export { Collapsible, CollapsibleTrigger, CollapsibleContent }

그리고 반 이동 다이얼로그에서 사용했다.

// src/pages/students/class-transfer-dialog.tsx
import {
  Collapsible,
  CollapsibleTrigger,
  CollapsibleContent,
} from "@/components/ui"

// 보존되는 완료 레벨 - 접었다 펼 수 있는 섹션
<Collapsible className="rounded-lg bg-emerald-50/50 border border-emerald-100 px-3 py-2">
  <CollapsibleTrigger className="flex items-center justify-between w-full">
    <span>유지되는 완료 레벨 ({preservedCount}개)</span>
    <ChevronDown className="h-4 w-4 transition-transform" />
  </CollapsibleTrigger>
  <CollapsibleContent className="pt-2">
    {/* 레벨 뱃지 목록 */}
  </CollapsibleContent>
</Collapsible>

브라우저를 열었다. 화면 전체가 하얗게 날아갔다. 콘솔에 찍힌 에러 메시지는 이거였다.

❌ 실제 에러 메시지

Error: Cannot read properties of null (reading 'useState')

React 개발자라면 이 에러를 보는 순간 본능적으로 알아챈다. Invalid hook call 계열이다.

Warning: Invalid hook call. Hooks can only be called inside of the body
of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and React DOM.
2. You might be breaking the Rules of Hooks.
3. You might have more than one copy of React in the same app.

1번? package.json을 확인했다. React 18.3, ReactDOM 18.3. 일치한다.

2번? 방금 추가한 건 Collapsible 컴포넌트뿐이다. hook을 조건부로 호출하거나 하지 않았다.

3번? 이게 범인이었다.


🎯 원인 — Vite 사전 번들링 캐시의 구조적 함정

알고 보니 🎯 원인 — Vite 사전 번들링 캐시의 구조적 함정은 이 한 줄 때문이었다
알고 보니 🎯 원인 — Vite 사전 번들링 캐시의 구조적 함정은 이 한 줄 때문이었다

Vite는 개발 서버를 시작할 때 node_modules의 의존성을 사전 번들링(pre-bundling) 한다. lodash-es처럼 수백 개의 내부 모듈로 쪼개진 패키지를 하나로 묶어서, 브라우저가 수백 개의 HTTP 요청을 날리는 것을 방지하는 최적화다.

이 번들링 결과가 저장되는 곳이 바로 node_modules/.vite/deps다.

캐시 갱신 조건

Vite 공식 문서에 따르면, 사전 번들링 캐시는 다음 조건 중 하나가 변경될 때만 갱신된다.

조건예시
lockfile 변경pnpm-lock.yaml, package-lock.json
vite.config.ts 변경플러그인 추가, alias 변경 등
NODE_ENV 변경development → production

여기서 핵심은 pnpm add로 패키지를 설치하면 lockfile이 변경되므로 이론상 캐시가 갱신되어야 한다는 점이다. 하지만 실제로는 몇 가지 상황에서 캐시 갱신이 실패한다.

갱신이 실패하는 실제 시나리오

1. 개발 서버가 이미 실행 중일 때 패키지 설치

# 터미널 1: 개발 서버 실행 중
pnpm dev

# 터미널 2: 새 패키지 설치
pnpm add @radix-ui/react-collapsible

Vite dev 서버는 시작 시점에 캐시를 확인한다. 서버가 이미 돌아가는 중에 패키지를 설치하면, 서버는 이전 캐시를 계속 사용한다. 자동 감지가 동작할 수도 있지만, 모노레포 구조에서는 종종 실패한다.

2. 모노레포에서 워크스페이스 의존성 변경

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

모노레포의 특정 앱(apps/academy-portal)에 패키지를 추가할 때, 루트의 lockfile은 갱신되지만 Vite의 캐시 무효화 로직이 이를 올바르게 감지하지 못하는 경우가 있다.

3. shadcn CLI가 내부적으로 패키지를 설치할 때

npx shadcn@latest add collapsible

shadcn CLI는 내부적으로 pnpm add를 실행한다. CLI가 끝난 뒤 개발 서버를 재시작하지 않으면 같은 문제가 발생한다.

React 중복 인스턴스가 만들어지는 과정

캐시가 갱신되지 않으면 이런 일이 벌어진다.

  1. Vite가 .vite/deps오래된 번들에서 React를 가져온다 (React 인스턴스 A)
  2. 새로 설치한 @radix-ui/react-collapsiblenode_modules의 React를 직접 참조한다 (React 인스턴스 B)
  3. Collapsible 내부에서 useState를 호출하면 인스턴스 B의 React를 사용한다
  4. 하지만 렌더링 컨텍스트는 인스턴스 A의 React가 관리한다
  5. 두 React가 서로 다른 존재이므로 hook 호출이 실패한다

이것이 Cannot read properties of null (reading 'useState') 에러의 정체다. React가 두 개 있으면, hook이 올바른 React 인스턴스를 찾지 못한다.


✅ 해결 — 캐시를 날려라

해결법은 단순하다. Vite의 사전 번들링 캐시를 강제로 삭제하면 된다.

방법 1: .vite 캐시 디렉토리 직접 삭제

# Before — 오래된 캐시가 남아 있음
ls node_modules/.vite/deps/
# react.js (오래된 번들)
# react-dom.js (오래된 번들)
# ... 새 패키지의 번들은 없음

# After — 캐시 삭제
rm -rf node_modules/.vite
# 이후 개발 서버 재시작하면 Vite가 모든 의존성을 다시 번들링

모노레포에서는 해당 앱의 node_modules를 정확히 지정해야 한다.

# 모노레포 — 특정 앱의 캐시만 삭제
rm -rf apps/academy-portal/node_modules/.vite

# 개발 서버 재시작
pnpm dev --filter academy-portal

방법 2: —force 플래그로 재시작

# Vite에게 캐시를 무시하고 처음부터 번들링하라고 지시
pnpm vite --force

# 또는 package.json scripts에 등록
{
  "scripts": {
    "dev": "vite",
    "dev:force": "vite --force"
  }
}

--force 플래그는 .vite/deps 캐시를 완전히 무시하고, 모든 의존성을 처음부터 다시 번들링한다. lockfile 기반 검증도 건너뛴다.

방법 3: optimizeDeps.force 설정 (개발 중 임시)

// vite.config.ts — 개발 중 캐시 문제가 자주 발생할 때
export default defineConfig({
  optimizeDeps: {
    force: true,  // 매 시작마다 강제 재번들링
  },
  // ...
})

이 설정은 개발 중 디버깅 용도로만 사용해야 한다. 매번 모든 의존성을 번들링하므로 서버 시작이 느려진다. 문제가 해결되면 반드시 제거한다.

실제 적용: academy-portal vite.config.ts

우리 프로젝트의 Vite 설정은 이렇게 생겼다.

// apps/academy-portal/vite.config.ts
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 5173,
    host: true,
    allowedHosts: ['jm.fnj.i234.me', 'jmbe.fnj.i234.me'],
    proxy: {
      '/api': {
        target: 'https://jmbe.fnj.i234.me',
        changeOrigin: true,
        secure: true,
      },
    },
  },
});

별도의 optimizeDeps 설정이 없다. Vite 기본 동작에 의존하고 있으므로, 캐시 문제가 발생하면 수동으로 .vite 디렉토리를 삭제해야 한다.


🔍 진단 — 정말 캐시 문제인지 확인하는 방법

에러가 발생했을 때, 캐시 문제인지 아닌지를 구분하는 체크리스트다.

1단계: 에러 메시지 확인

Cannot read properties of null (reading 'useState')
// 또는
Invalid hook call. Hooks can only be called inside...
// → React 중복 인스턴스 가능성

2단계: 패키지 설치 직후인지 확인

# 최근 git 변경사항 확인
git diff --name-only

# pnpm-lock.yaml이 변경되었는가?
# package.json에 새 의존성이 추가되었는가?

3단계: React 인스턴스 개수 확인

브라우저 콘솔에서 이 코드를 실행한다.

// React DevTools가 없어도 확인 가능
window.__REACT_DEVTOOLS_GLOBAL_HOOK__
// 여러 렌더러가 등록되어 있으면 중복 인스턴스

4단계: .vite/deps 내용 확인

# 캐시된 의존성 목록 확인
ls node_modules/.vite/deps/

# 새로 설치한 패키지가 여기 없으면 캐시가 오래된 것
# @radix-ui_react-collapsible.js가 없다면 → 캐시 갱신 필요

5단계: 해결 후 확인

# 캐시 삭제 후 재시작
rm -rf node_modules/.vite && pnpm dev

# 이번에는 .vite/deps에 새 패키지가 포함되어야 함
ls node_modules/.vite/deps/ | grep collapsible
# @radix-ui_react-collapsible.js ← 있으면 성공

🛡️ 예방 — 같은 실수를 반복하지 않으려면

1. 패키지 설치 후 개발 서버 재시작을 습관화

가장 간단하고 확실한 방법이다. 새 패키지를 설치한 뒤에는 반드시 개발 서버를 재시작한다.

# 1. 개발 서버 중단 (Ctrl+C)
# 2. 패키지 설치
pnpm add @radix-ui/react-collapsible
# 3. 개발 서버 재시작
pnpm dev

2. postinstall 훅으로 자동 캐시 삭제

package.jsonpostinstall 스크립트를 추가하면, pnpm install이나 pnpm add 실행 후 자동으로 캐시를 정리한다.

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "postinstall": "rm -rf node_modules/.vite"
  }
}

이렇게 하면 pnpm add 직후 .vite 캐시가 자동 삭제된다. 다음 pnpm dev 실행 시 Vite가 처음부터 번들링한다.

3. tailwind.config.js에서 ESM import 사용

Vite는 ESM 환경이다. tailwind.config.js에서 require()를 사용하면 Vite의 모듈 해석과 충돌할 수 있다.

// Before — CJS (Vite 환경에서 문제 가능)
const tailwindcssAnimate = require("tailwindcss-animate")

module.exports = {
  plugins: [tailwindcssAnimate],
}

// After — ESM (Vite 환경과 호환)
import tailwindcssAnimate from "tailwindcss-animate"

export default {
  plugins: [tailwindcssAnimate],
}

실제로 우리 academy-portaltailwind.config.js도 ESM으로 작성되어 있다.

// apps/academy-portal/tailwind.config.js
import tailwindcssAnimate from "tailwindcss-animate"

/** @type {import('tailwindcss').Config} */
export default {
  darkMode: ["class"],
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  // ...
  plugins: [tailwindcssAnimate],
}

4. shadcn CLI 사용 후 주의사항

shadcn CLI로 컴포넌트를 추가할 때도 같은 문제가 발생할 수 있다.

# shadcn이 내부적으로 패키지를 설치한다
npx shadcn@latest add collapsible
# → pnpm add @radix-ui/react-collapsible 자동 실행

# shadcn 완료 후 반드시:
# 1. .vite 캐시 확인
# 2. 필요하면 캐시 삭제
# 3. 개발 서버 재시작

5. CI/CD에서는 캐시 문제가 없다

pnpm build(= vite build)는 .vite/deps 캐시를 사용하지 않는다. 사전 번들링은 개발 모드 전용 최적화다. 프로덕션 빌드는 Rollup이 처리하므로, CI/CD 파이프라인에서는 이 문제를 걱정할 필요가 없다.


📊 Vite 캐시의 전체 구조

직접 정리한 Vite 의존성 사전 번들링 캐시 흐름도
직접 정리한 Vite 의존성 사전 번들링 캐시 흐름도

위 도식이 이 글에서 다룬 Vite 캐시 문제의 전체 흐름이다. 왼쪽 경로(캐시 오래됨)를 타면 React 중복 인스턴스가 로드되어 hook 에러가 발생하고, 오른쪽 경로(--force 또는 캐시 삭제)를 타면 의존성이 새로 번들링되어 정상 동작한다.

핵심은 Vite가 캐시를 자동 갱신하는 조건(lockfile, vite.config.ts, NODE_ENV)이 있지만, 모노레포 환경이나 개발 서버 실행 중 설치에서는 이 자동 갱신이 실패할 수 있다는 점이다.


🔗 관련 에러 패턴

이 문제와 혼동하기 쉬운 유사 에러들을 정리한다.

React 버전 불일치

Error: Minified React error #321

이 에러는 reactreact-dom메이저 버전이 다를 때 발생한다. 캐시 문제가 아니라 package.json의 버전 명시가 잘못된 것이다.

# 확인
pnpm list react react-dom
# react 18.3.1
# react-dom 18.3.1  ← 이 두 버전이 같아야 함

hook 호출 규칙 위반

Warning: React has detected a change in the order of Hooks

이건 캐시와 무관하다. 조건부로 hook을 호출하거나, 루프 안에서 hook을 사용할 때 발생한다.

// ❌ 조건부 hook 호출
if (condition) {
  const [value, setValue] = useState(0)
}

// ✅ hook은 항상 최상위에서 호출
const [value, setValue] = useState(0)

Vite HMR 에러

[vite] Internal server error: Failed to resolve import

이건 HMR(Hot Module Replacement) 중에 발생하는 에러로, 대부분 개발 서버를 재시작하면 해결된다. 캐시 삭제까지는 필요 없는 경우가 많다.


📋 정리

항목내용
증상패키지 설치 직후 Invalid hook call 또는 Cannot read properties of null (reading 'useState')
원인Vite .vite/deps 캐시가 갱신되지 않아 React 인스턴스가 중복 로드됨
해결rm -rf node_modules/.vite 후 개발 서버 재시작, 또는 vite --force
예방패키지 설치 후 서버 재시작 습관화, postinstall 훅 설정, ESM import 사용
적용 범위개발 모드 전용 (프로덕션 빌드에서는 발생하지 않음)

패키지를 설치했는데 갑자기 React가 터진다면, 코드를 의심하기 전에 .vite 폴더부터 확인하자. 캐시를 날리는 5초가 디버깅 2시간을 아껴준다.