Next.js 서버 액션 unexpected response 오류: proxy의 로그인 리다이렉트
관리 화면에서 로그아웃을 눌렀는데 화면 전체가 「This page couldn't load」로 바뀌었습니다. 로그아웃 단추 하나가 페이지를 통째로 꺼 버린 셈입니다. 원인을 찾는 동안 한 번 틀렸고, 틀린 쪽으로 해결책까지 준비했습니다. 그 과정을 순서대로 적습니다.
로그아웃 단추 하나에 꺼진 화면
Next.js 16 으로 만든 관리 화면입니다. 같은 날 오후에 버그 하나를 고쳐 배포했고, 몇 분 뒤 화면을 쓰던 쪽에서 로그아웃을 누르자 위의 오류 화면이 떴습니다. 개발자 도구 콘솔에는 두 줄이 있었습니다.
Uncaught Error: An unexpected response was received from the server.
Failed to load resource: the server responded with a status of 404 (favicon.ico)favicon 404 는 상관없는 소음이었습니다. 문제는 첫 줄입니다. Next.js 의 클라이언트가 서버 액션을 부르고 돌아온 응답을 해석하지 못할 때 내는 문구입니다. 로그아웃은 서버 액션으로 짜여 있습니다.
먼저 의심한 배포 버전 엇갈림
배포 직후였다는 점이 눈에 먼저 들어왔습니다. 서버 액션은 빌드할 때 붙는 식별자로 서버를 부릅니다. 배포 전에 열어 둔 화면은 옛 식별자를 들고 있고, 새 배포의 서버는 그 식별자를 모를 수 있습니다. 그래서 저는 이것을 버전 엇갈림(version skew) 문제로 보고, 해결책으로 Vercel 의 Skew Protection을 켜자고 했습니다. 배포 뒤에도 한동안 옛 화면의 요청을 옛 배포가 받아 주는 기능입니다.
로컬에서 새로 연 화면으로 로그인과 로그아웃을 해 보니 Chrome 과 Safari 엔진(WebKit) 모두 멀쩡했습니다. 「코드가 아니라 언제 연 화면이냐의 문제」라는 생각이 더 굳어졌습니다.
그런데 프로젝트 설정을 열어 보니 Skew Protection 은 이미 켜져 있었습니다. 유지 시간 12시간이었습니다. 해결책이라고 내놓은 것이 이미 돌고 있었으니, 원인 추정부터 다시 봐야 했습니다.
요청 기록에 남은 POST 307
Vercel 의 요청 기록을 오류가 난 시각 앞뒤로 뽑아 배포별로 정렬했습니다. 두 가지가 보였습니다.
하나는 Skew Protection 이 제대로 돌고 있었다는 것입니다. 배포 전에 열린 화면의 요청은 새 배포가 아니라 옛 배포 식별자로 처리되어 있었습니다. 버전 엇갈림은 막혀 있었습니다.
다른 하나가 진짜 원인이었습니다. 로그아웃을 누른 그 시각에 이런 줄이 있었습니다.
POST 307 /admin
POST 307 /admin/account서버 액션의 POST 가 307 리다이렉트를 받았습니다. 이 앱의 proxy.ts(Next.js 16 에서 middleware 가 이 이름이 되었습니다)는 /admin 아래 요청에 로그인 쿠키가 없으면 /admin/login 으로 돌려보냅니다. 그 화면은 이미 세션 쿠키가 없는 상태였던 것입니다. 다른 탭에서 먼저 로그아웃했거나 세션이 끊긴 채 화면만 남아 있던 경우입니다.
세션 쿠키만 지워서 다시 만든 장면
추정을 한 번 틀렸으니 이번에는 재현으로 확인했습니다. 운영과 같은 빌드를 로컬에 띄우고 Playwright 로 이렇게 했습니다.
- 로그인하고
/admin/account화면을 연다 - 화면은 그대로 두고 세션 쿠키 하나만 지운다(다른 탭에서 로그아웃한 것과 같은 상태)
- 로그아웃을 누른다
결과는 운영에서 본 것과 같았습니다. 화면에 「This page couldn't load」, 콘솔에 같은 문구가 떴고, POST 응답은 이 순서였습니다.
200 /admin/login ← 처음 로그인
307 /admin/account ← 쿠키 없는 로그아웃 액션
200 /admin/login ← 307 을 따라간 요청이 받은 로그인 화면 HTML마지막 줄이 핵심입니다. 브라우저는 307 을 받으면 같은 POST 를 새 주소로 다시 보냅니다. 돌아온 것은 로그인 페이지의 HTML 이고, 서버 액션의 응답 형식이 아닙니다. 클라이언트는 그걸 해석하지 못해 「unexpected response」를 던지고, 그 오류가 화면 전체를 오류 화면으로 바꿨습니다.
서버 액션이 307을 견디지 못하는 구조
평소에 페이지를 GET 으로 열 때는 proxy 의 리다이렉트가 아무 문제 없습니다. 브라우저가 주소를 옮기고 로그인 화면을 그리면 끝입니다. 서버 액션은 다릅니다. 화면 안의 자바스크립트가 fetch 로 POST 를 보내고, 정해진 형식의 응답을 기대합니다. 그 사이에 끼어든 307 은 그 약속을 깹니다.
서버 액션 안에서 redirect() 를 부르는 것은 괜찮습니다. Next.js 가 그 이동을 액션 응답에 실어 보내고, 클라이언트가 알아서 주소를 옮깁니다. 같은 「로그인 화면으로」라도 액션 안에서 보내면 되고 proxy 에서 보내면 깨집니다.
Next.js 문서도 proxy 를 낙관적 확인(optimistic check) 용도로 설명하고, 완전한 세션 관리나 권한 판단에 쓰지 말라고 적어 둡니다. 이 앱은 이미 페이지마다, 서버 액션마다 세션을 다시 확인하고 있었습니다. proxy 의 돌려보내기는 덤이었고, 그 덤이 POST 에까지 걸려 있었습니다.
GET만 돌려보내는 앞문
고친 것은 한 줄입니다. 세션이 없을 때 GET 과 HEAD 만 로그인으로 돌려보내고, 나머지 메서드는 그대로 통과시킵니다.
if (await readSessionToken(request.cookies.get(SESSION_COOKIE)?.value)) return pass();
// 서버 액션(POST)은 돌려보내지 않는다. 액션 안의 requireActor 가 세션을 다시 보고 redirect 한다.
if (request.method !== "GET" && request.method !== "HEAD") return pass();
return NextResponse.redirect(loginUrl);통과시켜도 내용이 새지 않는 이유는 문이 proxy 하나가 아니어서입니다. 액션 맨 앞의 requireActor() 가 세션이 없으면 redirect("/admin/login") 하고, 그 이동은 위에서 말한 대로 클라이언트가 제대로 따라갑니다. 로그아웃 액션은 세션이 없어도 쿠키를 지우고 로그인 화면으로 보내니 결과도 같습니다.
같은 재현을 다시 돌렸습니다. 이번에는 POST 가 200 으로 돌아오고, 화면은 로그인 화면으로 옮겨 갔고, 콘솔 오류는 없었습니다. 이 재현은 e2e 테스트로 남겨 두었습니다. 다음에 누가 proxy 를 고치다 POST 를 다시 돌려보내게 되면 테스트가 먼저 알려 줄 것입니다.
아직 307을 받는 빈 POST
고친 뒤 운영에 세션 없이 빈 POST 를 직접 보내 보면 여전히 307 이 나옵니다. 이건 서버 액션이 아니라 페이지에 보낸 그냥 POST 라서, 페이지의 로그인 확인이 돌려보낸 것입니다. 브라우저가 보내는 진짜 서버 액션 요청에는 액션 식별자가 실려 있고, 그때는 액션 안의 redirect 가 처리합니다. 운영에서 그 형태를 그대로 만들어 보내는 것은 하지 못했고, 확인은 로컬의 운영 빌드에서 한 것까지입니다.
돌아보면 처음 추정을 세운 근거는 「배포 직후」라는 시각 하나였습니다. 요청 기록을 먼저 열었다면 307 한 줄이 바로 보였을 것입니다. 이번에 남긴 규칙은 짧습니다. proxy 에서 리다이렉트를 쓸 때는 그 요청이 GET 인지부터 봅니다.
우리 서비스에 적용할 계획이 있으신가요?
홈페이지·웹서비스 개발 살펴보기 →함께 읽기
- Next.js 16 Proxy(구 Middleware)와 인증Next.js 16 으로 올리면 middleware.ts 에 경고가 붙습니다. 파일 규칙 이름이 proxy 로 바뀌었다는 안내입니다. Proxy 문서의 첫 문장은 기능이 그대로라고 적습니다.
- Next.js 오류 처리 파일: error.tsx, global-error, not-foundNext.js 앱 라우터에는 오류를 다루는 파일이 셋 있습니다. error.tsx, global-error.tsx, not-found.tsx 입니다. 이름만 보면 「오류 화면, 전체 오류 화면, 404 화면」으로 나뉘는 것 같은데, 실제로는 각자 감싸는 범위와 잡는 대상이 다릅니다. 하나로 다 될 것 같아서 error.…
- Next.js 라우트 핸들러와 서버 함수 구분앱 라우터에서 서버로 무언가를 보내는 방법이 둘입니다. app/api/.../route.ts 에 라우트 핸들러를 만들거나, "use server" 를 붙인 서버 함수를 부르는 것입니다. 폼 하나를 붙일 때마다 어느 쪽을 쓸지 정해야 하는데, 「둘 다 서버에서 도니 아무거나」로 두면 나중에 갈립니다.
- Google 번역을 켰더니 React 앱이 오류 화면으로 바뀐 이유한국어, 영어, 일본어, 중국어를 직접 제공하는 React 랜딩 페이지에서 예상하지 못한 문제가 생겼습니다. Chrome이 띄운 "이 페이지를 번역하시겠습니까?" 제안을 수락하면 번역이 시작되는 듯하다가, 잠시 뒤 사이트의 "일시적인 오류가 발생했습니다" 화면으로 바뀌었습니다.
- Vercel vs Cloudflare Pages vs Netlify vs 자체 서버: 배포 플랫폼 선택 가이드웹사이트를 만들 때 자주 듣는 이름으로 Vercel, Cloudflare Pages, Netlify가 있습니다. 여기에 AWS나 직접 관리하는 서버까지 더하면 선택지가 너무 많아 보입니다. 하지만 먼저 구분할 것이 있습니다. 이들은 완성된 홈페이지를 만들어 주는 서비스가 아니라, 개발자가 작성한 코드를 빌드하고 실행하는…