RSS
Next.js

Next.js 서버 액션 unexpected response 오류: proxy의 로그인 리다이렉트

작성자
듀오랩스 대표·8분 읽기

관리 화면에서 로그아웃을 눌렀는데 화면 전체가 「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 로 이렇게 했습니다.

  1. 로그인하고 /admin/account 화면을 연다
  2. 화면은 그대로 두고 세션 쿠키 하나만 지운다(다른 탭에서 로그아웃한 것과 같은 상태)
  3. 로그아웃을 누른다

결과는 운영에서 본 것과 같았습니다. 화면에 「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 인지부터 봅니다.

우리 서비스에 적용할 계획이 있으신가요?

홈페이지·웹서비스 개발 살펴보기 →

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.