← Ascendy EN

frontend

한 번 고친 버그가 어떻게 되살아났나 — 그리고 진단이 한 번 더 뒤집힌 이야기

· Ascendy Engineering


TL;DR

소스 노트. 이 글은 프론트엔드 팀이 세션 만료 리다이렉트 회귀를 추적하며 남긴 인테이크(docs/intake/from-frontend/2026-05-30-nuxt-client-middleware-skips-initial-route.md)를 정제한 것이다. 게시화 과정에서 블로그 편집국이 원 진단(.client 미들웨어가 SSR에서 안 도는 Nuxt 트레이트)을 공식 문서로 팩트체크해 부정확함을 발견했고, 그 피드백이 프론트엔드 팀의 코드 정정(파일명 정통화 + import.meta.server 분기)으로 이어졌다. 이 글은 그 정정된 진단을 담는다. 내부 호스트명·엔드포인트·구체 컴포넌트 경로는 일반화했다.

멀쩡한 fix, 끊긴 경로

운영자 제보는 단순했다. “세션 만료된 상태로 /gallery에 들어갔는데 /login으로 안 보내진다.” 보안·UX 회귀다. 그리고 이 증상엔 전과가 있었다 — 글로벌 클라이언트 미들웨어에 sessionExpired 분기와 navigateTo('/login')을 넣어 이미 한 번 고친 적이 있었다. 그러니 처음엔 누군가 그 fix를 되돌린 단순 회귀처럼 보였다.

그래서 가설을 세 개 세우고, 각각에 반증 명령을 미리 붙였다. 추측만 늘어놓는 대신, 떨어뜨릴 방법을 먼저 정한 것이다.

셋 다 false. 세 가설이 다 떨어지면, 남은 건 우리가 아직 의심하지 않은 네 번째 무언가다.

결정적 단서: 미들웨어가 아예 안 돌았다

Playwright로 cold-load 시나리오(브라우저를 새로 띄워 URL을 직접 입력하는 상황)를 떠봤다. 콘솔 로그를 보고 단서가 잡혔다 — [middleware] 로그가 0건이었다. fix가 무용했던 게 아니라, 미들웨어가 아예 실행되지 않았다.

이전 fix는 바로 그 글로벌 클라이언트 미들웨어 안에 있었다. 코드는 살아 있었지만, cold-load path에서는 애초에 도달할 수 없는 자리에 있었던 것이다. 평소 앱을 돌아다니다 세션이 만료되면(=클라이언트 전환) 정상 작동했고, 그래서 한동안 아무도 몰랐다. 만료된 세션으로 보호 URL을 직접 열 때만 구멍이 열렸다.

첫 진단, 그리고 그것을 뒤집은 팩트체크

여기서 우리가 처음 내린 진단은 이거였다.

“Nuxt 3의 .client 글로벌 라우트 미들웨어는 SSR-rendered initial route에서 fire하지 않는다.”

그럴듯했다. 관찰된 동작과 맞아떨어졌으니까. 그런데 이 글을 게시하려고 다듬는 과정에서, 그 진단을 공식 문서로 한 번 더 확인하자 어긋났다. 정정해야 했다.

Nuxt 3 미들웨어 문서에 따르면 — 미들웨어는 플러그인과 달리 .client/.server 파일명 suffix를 지원하지 않는다. 환경 분기는 미들웨어 코드 안에서 import.meta.client / import.meta.server로 해야 한다. 우리 파일명 *.global.client.ts.global로 글로벌 등록은 트리거되지만, .client미들웨어 로더가 인지하지 않는 잉여 토큰이었다.

즉 “Nuxt가 .client 글로벌 미들웨어를 SSR에서 안 돌린다”는 프레임워크 일반 트레이트가 아니라, 우리가 붙인 비표준 파일명의 부산물이었다. 비표준 네이밍이 의도된 듯한 동작(SSR에서 안 도는 것처럼 보임)을 내는 바람에, “Nuxt가 원래 그렇다”고 잘못 일반화하기 쉬웠던 것이다. 일반화하기 전에 그렇게 도는지 공식 문서로 한 번 더 확인하는 단계가, 정확히 이 함정을 막는다.

Fix: 정통 패턴으로 돌아가고, 의도된 안전망은 남긴다

정정된 진단에 맞춰 코드도 정통 패턴으로 돌렸다.

그런데 cold-load 자체를 막는 가드는 미들웨어가 아니라 플러그인(앱 부팅 시 도는 자리) 에 따로 두고, 그건 의도적으로 유지했다. hydrate 전에 “원래 로그인 상태였는지”를 capture하고, hydrate 후 사용자가 없으면 로그인으로 보낸다.

// 앱 부팅 플러그인 — cold-load guard (의도된 안전망, 유지)
const wasLoggedIn = authStore.isLoggedIn   // await 전에 capture
await hydrateUser()                         // fetchUser 중 401 → 인터셉터 → logout → cookie 정리

if (!authStore.user) {
  const route = useRoute()
  const onProtectedRoute = Boolean(route.meta?.requiresAuth)
  if ((wasLoggedIn || onProtectedRoute) && route.path !== '/login') {
    window.location.replace('/login')
    return
  }
}

왜 이 가드를 미들웨어로 합치지 않고 플러그인에 남겼나. 그 플러그인은 biometric·native splash·cold-load guard 같은 client-only 동작이 한 파일에 모여 있어, 잘못 건드리면 native 사용자 전체를 splash에 묶을 위험이 있다(그래서 내부 규칙이 “먼저 묻고 건드려라” 영역으로 못박았다). 그리고 그 가드는 아래의 e2e가 잠그고 있고, hard navigation을 쓰는 이유(아래 race)가 주석에 명시돼 있다 — 우연히 비표준 미들웨어를 메우던 코드가 아니라, 의도된 race-handling 안전망이라는 뜻이다. 같은 한 줄이라도 그 줄을 둘러싼 가드(spec·주석·규칙)가 의도를 결정한다.

작은 후속: navigateTo가 안 먹혔다

이 가드를 처음엔 navigateTo('/login')으로 짰는데, 리다이렉트가 안 일어났다.

추적해보니 boot-time race였다. 가짜 쿠키 cold-load에서 플러그인의 await fetchUser() 도중, axios 401 인터셉터가 logout() 안에서 이미 router.push('/login')을 큐에 넣는다. 그 뒤 우리 가드가 navigateTo('/login')을 또 큐에 넣는다. 두 navigation이 SSR-committed /gallery 라우트와 동시에 경쟁하고, 실측상 셋 중 어느 것도 commit되지 않는다. URL은 /gallery에 멈춰 있었다(Playwright trace로 확인).

window.location.replace는 브라우저 레벨이라 그 race를 부수고 깨끗하게 /login을 로드한다. SPA state도 같이 버려지는데, 만료 세션 회복 path에선 정확히 그게 원하는 동작이다. 평시엔 SPA 라우팅이 맞지만, app-boot 도중엔 어떤 SPA navigation도 신뢰할 수 없을 때가 있고, 회복 경로라면 그때는 window.location이 답이다.

진짜 원인은 “잠긴 테스트가 없었다”

버그를 고치는 것과, 같은 버그가 다시 들어오지 못하게 막는 것은 다른 일이다. 이 회귀의 진짜 원인은 미들웨어 네이밍이 아니라 그 위에 있었다 — 이전 fix는 client-side navigation에서만 수동으로 smoke됐고, cold-load 매트릭스에는 잠긴 테스트가 하나도 없었다. 그래서 회귀가 unblock된 순간에 아무도 몰랐다.

그래서 Playwright spec에 cold-load 두 케이스(쿠키 없음 / 가짜 쿠키)를 못 박고, PR 게이트 CI에 넣었다.

test.use({ storageState: { cookies: [], origins: [] } })

test('쿠키 없음 + /gallery 직접 진입 → /login', async ({ page }) => {
  await page.goto('/gallery', { waitUntil: 'domcontentloaded' })
  await page.waitForURL(/\/login(?:$|\?|#)/, { timeout: 10_000 })
})

test('가짜 auth-token 쿠키 + /gallery 직접 진입 → /login', async ({ page, context }) => {
  await context.addCookies([
    { name: 'auth-token', value: 'totally-not-a-jwt', domain: 'localhost', path: '/',
      expires: Math.floor(Date.now() / 1000) + 3600 },
  ])
  await page.goto('/gallery', { waitUntil: 'domcontentloaded' })
  await page.waitForURL(/\/login(?:$|\?|#)/, { timeout: 10_000 })
})

가져갈 것


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: nuxt3, ssr, middleware, session-expired, incident-prevention, playwright