Inertia.js가 조용히 앱을 깨뜨립니다

발행: (2026년 2월 17일 오전 02:12 GMT+9)
13 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 소스 링크 아래에 번역하고 싶은 텍스트를 붙여 주세요.
텍스트를 주시면 원본 형식과 마크다운을 유지하면서 한국어로 번역해 드리겠습니다.

TL;DR

몇 주 동안 Laravel 12 + React 19 + Inertia v2 프로덕션 앱을 운영하면서, 진단 비용이 많이 드는 실패 상황들을 반복적으로 겪었습니다:

  • 겹치는 방문 취소
  • 배포 시점의 오래된 청크 파손
  • 기본 실패 UX가 약함
  • 프레임워크‑특화 우회 코드

Note: 이것은 Inertia가 모든 프로젝트에서 실패한다는 주장이 아닙니다. 많은 팀이 CRUD‑중심 관리자 앱에서 Inertia를 성공적으로 사용하고 있습니다.
여기서 말하고자 하는 것은, 실제 운영 환경에서 활성 사용자가 존재하고 배포가 빈번히 이루어지는 상황에서 Inertia의 라우터 추상화가 반복적인 운영상의 고통과 명확하지 않은 실패 패턴을 만들었다는 점입니다.

Environment referenced throughout

ComponentVersion
Laravel12
React19
Inertia.jsv2
BundlerVite (code‑splitting)
Deploy일부 환경에서의 제자리 교체 방식 배포

Inertia의 핵심 주장은 강력합니다: 일반적인 웹 탐색을 위한 별도의 공개 API를 유지하지 않고도 SPA‑같은 UX를 구축한다는 것.
문제는 워크플로우가 비단순해지면서 시작되었습니다: 다단계 작업, 배포 빈도 증가, 그리고 엣지‑케이스 오류 처리 등.

1️⃣ await 은 Inertia Router 방문을 직렬화하지 않음

우리 앱에서 작업자를 할당하려면 두 개의 순서가 정해진 작업이 필요했습니다:

const handleAssign = async () => {
  // Step 1: Assign worker
  await router.put(`/admin/tasks/${task.id}/assign`, {
    assignee_id: Number(selectedUserId)
  })

  // Step 2: Update status
  await router.put(`/admin/tasks/${task.id}/status`, {
    status: 'In Progress'
  })

  setModalOpen(false)
}

Promise‑기반 클라이언트(fetch, axios)를 사용할 경우, 위와 같은 형태는 엄격한 순차 실행을 의미합니다.
하지만 우리 경우 관찰된 결과는 다음과 같습니다:

  • status 가 업데이트됨
  • assignment 되지 않음
  • 첫 번째 요청이 Network 탭에서 취소됨으로 표시됨
  • 기본적으로 나타나는 명확한 앱‑레벨 오류는 없음

왜 이런 일이 발생할 수 있는가

  • Inertia 라우터 메서드는 코드가 가정하는 방식대로 Promise를 반환하지 않습니다.
  • 따라서 await 은 요청 완료 순서를 보장하지 못합니다.
  • 겹치는 방문은 이전 방문을 취소할 수 있습니다(디자인상).

커뮤니티 논의: 수년간의 요청에도 불구하고 Promise 지원은 의도적으로 제거되었습니다.

작동하는 패턴

콜백 체이닝

const handleAssign = () => {
  router.put(`/admin/tasks/${task.id}/assign`, {
    assignee_id: Number(selectedUserId)
  }, {
    onSuccess: () => {
      router.put(`/admin/tasks/${task.id}/status`, {
        status: 'In Progress'
      }, {
        onSuccess: () => setModalOpen(false)
      })
    }
  })
}

방문을 직접 Promise 로 감싸기

await new Promise((resolve, reject) => {
  router.patch(route('profile.update'), data, {
    onSuccess: resolve,
    onError: reject,
  })
})

바로 여기서 좌절감이 폭발합니다: 일반적인 async/await HTTP처럼 보이는 코드가 일반적인 async/await HTTP가 아니라는 점이죠.

2️⃣ 배포 후 Stale Chunk 문제

코드‑스플릿 SPA는 배포 후 stale‑chunk 문제에 직면할 수 있습니다 – 이는 Inertia 전용 문제가 아닙니다.
Inertia는 서버‑사이드 컴포넌트 해석 플러스 클라이언트‑사이드 청크 임포트에 의존하기 때문에 영향을 더 넓게 만들었습니다.

대표적인 청크 이름

assets/bookings-show-A3f8kQ2.js
assets/profile-Bp7mXn1.js
assets/schedule-Ck9pLw4.js

배포 후 발생하는 일

  1. 서버는 최신 컴포넌트 매니페스트를 참조합니다.
  2. 클라이언트 탭은 여전히 오래된 런타임 가정을 가지고 있을 수 있습니다.
  3. 필요한 청크 임포트가 해당 자산이 더 이상 존재하지 않으면 실패합니다.
  4. 사용자는 하드 리로드를 할 때까지 “죽은” 네비게이션을 경험합니다.

중요한 뉘앙스

  • 불변‑아티팩트 / 스큐‑보호 플랫폼은 영향을 감소시킵니다.
  • 제자리 교체 배포는 위험 창을 확대합니다.
  • 캐시 및 롤아웃 전략은 프레임워크 선택만큼이나 중요합니다.

References: Inertia asset versioning / 409, 409 loop report.

Important precision: 모든 환경에서 모든 탭이 매 배포마다 죽는다고 주장하는 것이 아닙니다. 우리 환경에서 반복된 프로덕션 사고 패턴이 있었다고 주장하는 것입니다.

우리가 추가한 가드레일

// Catch navigation exceptions and force reload
router.on('exception', (event) => {
  event.preventDefault()
  window.location.href = event.detail.url || window.location.href
})

// Proactive manifest drift check
let manifest = null
fetch('/build/manifest.json')
  .then(res => res.text())
  .then(text => { manifest = text })
  .catch(() => {})

document.addEventListener('visibilitychange', async () => {
  if (document.visibilityState !== 'visible' || !manifest) return
  try {
    const res = await fetch('/build/manifest.json', { cache: 'no-store' })
    if (await res.text() !== manifest) window.location.reload()
  } catch {}
})

이러한 완화책은 작동했지만, 프레임워크‑특화 운영 부채이며 이를 알지 못하면 코드를 작성하기 어렵습니다.

3️⃣ JavaScript 오류 시 조용한 네비게이션 실패

대상 페이지 컴포넌트에서 JavaScript 오류가 발생하면, 네비게이션이 조용히 실패합니다:

  • 이전 페이지가 그대로 표시됩니다.
  • 오류 메시지도 없고, 콘솔 경고도 없으며, 멈추는 로딩 표시기도 없습니다.
  • 사용자가 링크를 클릭하고 기다려도 아무 일도 일어나지 않습니다.

서버 측 오류

Inertia의 기본 동작은 전체 오류 응답을 모달 오버레이 안에 렌더링하는 것입니다:

  • 개발: 앱 위에 모달로 전체 Laravel 디버그 페이지가 표시됩니다.
  • 운영: 일반적인 HTML 오류 페이지가 모달로 표시되며, 여전히 이상한 사용자 경험을 제공합니다.

해결 방법: 예외 핸들러를 오버라이드하여 JSON을 반환하도록 하고, 클라이언트 측에서 토스트 알림으로 잡아냅니다 (추가 우회 코드).

4️⃣ 혼합된 네비게이션 전략

내가 작업하는 코드베이스에서 router.reload() window.location.href 가 모두 네비게이션에 사용되는 것을 발견했습니다.

  • 후자는 개발자들이 특정 흐름에서 Inertia의 라우터 사용을 포기했음을 나타냅니다.
  • 이러한 분리는 타당할 수 있지만, 엔지니어가 두 가지 상호작용 패턴을 배워야 함을 의미합니다.

5️⃣ Prop 과다 공유 (보안)

This is not an Inertia‑only security story – any client‑delivered data is visible client‑side.
Still, with Inertia, props serialized into data-page make over‑sharing easy if teams are careless.

References: 페이지 소스에 보이는 props, 로그아웃 후 캐시된 민감한 데이터.

방어 가능한 진술

Treat every prop as public output; never include data you would not expose in client payloads.

마무리 생각

  • 마케팅 문구는 초기 단계에서 유용할 수 있습니다: 웹 탐색을 위한 부품 감소.
  • 많은 실제 시스템에서, 팀들은 위에서 설명한 운영 부채 때문에 여전히 명시적인 API 레이어나 폴백을 추가합니다.

프로덕션 SPA에 Inertia를 고려하고 있다면, 다음을 유념하세요:

  1. Visit 취소 의미론await에 의존하지 마세요.
  2. 배포 시점 청크 드리프트 – 매니페스트 검사를 구현하거나 불변 배포를 사용하세요.
  3. 기본 오류 UX – 이를 오버라이드할 계획을 세우세요.
  4. 탐색 일관성 – 하나의 라우터 전략을 선택하고 고수하세요.
  5. Prop 위생 – 클라이언트에 전송되는 모든 것이 공개된 것으로 가정하세요.

이러한 패턴을 사전에 이해하면 디버깅에 몇 주씩 걸리는 시간을 절약하고 Inertia의 트레이드오프가 프로젝트의 운영 제약에 맞는지 판단하는 데 도움이 됩니다.

Endpoints for

  • third‑party integrations and webhooks
  • mobile clients
  • background workflows
  • specialized, strongly ordered interactions

Important correction for accuracy

  • Inertia supports file uploads and FormData patterns.
  • Our team still used direct fetch() in some upload paths for local reliability/control reasons.
  • That is a project‑level trade‑off, not proof that Inertia cannot upload files.

The recurring cost was a semantic mismatch:

  1. Code looked like a normal Promise‑based HTTP flow.
  2. Runtime behavior followed router‑visit semantics.

The failure surfaced under production conditions, not in happy‑path demos.
That mismatch consumed debugging time and required defensive patterns beyond what most developers expect from “simple SPA routing.”

Why explicit HTTP was easier for critical ordered operations

const handleAssign = async () => {
  await fetch(`/api/tasks/${task.id}/assign`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ assignee_id: Number(selectedUserId) })
  })

  await fetch(`/api/tasks/${task.id}/status`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ status: 'In Progress' })
  })

  setModalOpen(false)
}
  • This is not about fewer lines of code.
  • It’s about predictable behavior, standard tooling expectations, and portability across backends.

Frustrations with the framework

  • Request‑cancellation bug → consumed a full day of debugging.
  • Deploy issue → cost another afternoon.

Both were solvable, but required framework‑specific defensive code that ideally shouldn’t be necessary.

Takeaway

The defensible conclusion is not “never use Inertia.”
Many Laravel admin panels and internal tools run it without issues.

What matters:

  • If your system has multi‑step interactions, active‑user deploy churn, and strict operational reliability needs, evaluate whether an explicit API + standard HTTP client semantics lower your long‑term risk.

In our case, the answer was unambiguous.

About me

I build MVPs at CodeCrank.
If you’re evaluating tech stacks for your next project, let’s talk.

0 조회
Back to Blog

관련 글

더 보기 »

미친 React key

tsx에서 map을 통한 렌더링 export function Parent { const array, setArray = useState(1, 2, 3, 4, 5); useEffect(() => { setTimeout(() => { setArray(prev => [6, 7, 8, 9, 10, ...prev]); ... }); }); }

Server Components는 SSR이 아니다!

SSR vs. React Server Components 개발 세계에서 React Server Components(RSC)는 종종 또 다른 형태의 Server‑Side Rendering(SSR)으로 오해받는다. 두 가지 모두…