Next.js 세션 만료 500 에러, 로그인 화면으로 보내는 법

로그인이 끝난 뒤 새로고침하면 Next.js 서버 화면이 500 에러로 터지나요? 세션 만료를 401 대신 로그인 이동으로 바꾸는 순서를 알려 드려요.

셀루모2026.10.21 · 12분 읽기
목차

한참 자리를 비웠다가 돌아와서 새로고침했더니 로그인 화면이 아니라 "서버 오류" 화면이 뜬 적 있으신가요? 로그인이 풀린 건 사실인데, 앱이 그걸 "에러"로 받아들이고 있다는 뜻이에요. 이 글은 그 에러를 어떻게 알아보고, 어떻게 로그인 화면으로 부드럽게 보내는지 순서대로 정리해요.

혹시 이런 고민 있으신가요?

  • 한동안 쉬다가 화면을 열면 가끔 500 에러가 뜨는데, 다시 로그인하면 멀쩡해요.
  • 에러 추적 도구에 "API 가 401 을 돌려줬다"는 에러가 올라오는데, 사실은 버그가 아니라 사용자가 로그아웃된 것뿐이에요.
  • 로그인 검사를 화면마다 넣었는데도 어떤 화면에서는 꼭 새어 나와요.

이 글을 읽으면

  • 세션이 끝난 사용자가 에러 화면 대신 로그인 화면으로 가도록 서버 화면 코드를 고칠 수 있어요.
  • 로그인이 풀리는 두 가지 경로, 쿠키가 없는 경우와 쿠키는 있는데 로그인 서버가 거절하는 경우를 구분할 수 있어요.
  • 화면 요청과 데이터 요청(API 호출)에서 세션 만료를 다르게 다뤄야 하는 이유를 설명할 수 있어요.

1단계. 에러가 어떤 모양인지 확인해요

먼저 에러 메시지를 읽어요. 이 문제는 보통 이렇게 보여요.

  • 에러 문구가 "어떤 주소가 401 로 응답했다"예요. 서버가 내 요청을 거절했다는 뜻이에요.
  • 사용자가 직접 겪을 때는 한참 쉰 뒤, 또는 다른 기기에서 로그아웃한 뒤에 나와요.
  • 개발할 때는 거의 안 나와요. 계속 쓰고 있으면 로그인이 유지되기 때문이에요.

여기서 401 은 "누구인지 확인할 수 없다"는 뜻이에요. 접수창구에서 신분증 없이 서류를 내민 상황과 같아요. 창구 직원은 거절하는 게 맞아요. 문제는 그 거절을 받은 우리 앱이 "서버 에러"로 처리해서 에러 화면을 보여 주고, 에러 추적 도구에도 버그처럼 기록한다는 점이에요.

TIP

에러 추적 도구에서 같은 문구의 에러를 시간대별로 모아 보세요. 아침이나 점심 직후처럼 "자리를 비웠다 돌아오는 시각"에 몰려 있다면 세션 만료일 가능성이 높아요. 이건 가설이니, 아래 2단계에서 실제로 재현해서 확인해요.

저희는 관리 화면에서 이 에러를 봤어요. 화면은 서버에서 데이터를 불러오다가 응답이 200 이 아니면 에러를 던지도록 되어 있었고, 세션이 끝난 사용자는 토큰이 없으니 401 을 받아 그대로 에러가 됐어요.

2단계. 로그인이 풀리는 길을 두 갈래로 나눠요

"로그인이 풀렸다"는 말에는 사실 두 가지 상황이 섞여 있어요. 둘을 나누지 않으면 고쳐도 한쪽이 새요.

구분상황앱이 아는 것
쿠키가 없음처음 방문했거나 쿠키가 지워졌어요세션 자체가 없어요
쿠키는 있음쿠키는 남아 있는데 로그인 서버가 그 세션을 이미 끝냈어요세션이 있다고 믿지만 사실은 죽었어요

첫 번째는 쉬워요. 세션이 없으면 로그인으로 보내면 돼요. 저희는 이미 모든 관리 화면의 공통 틀에서 이걸 하고 있었어요.

두 번째가 함정이에요. 저희 구조에서는 브라우저 쿠키에 "갱신용 토큰"(refresh token)만 넣어 둬요. 이 토큰은 API 를 부를 때마다 로그인 서버에 내밀어서 짧게 쓰는 출입증, 즉 접근 토큰(access token)으로 바꿔 쓰는 용도예요. 접근 토큰은 쿠키에 저장하지 않아요. 훔쳐 가도 금방 못 쓰게 하려는 거예요.

그런데 로그인 서버(저희는 Keycloak 을 써요)는 일정 시간 동안 아무 활동이 없으면 세션을 끝내요. 기본값은 30분이고, 로그아웃하거나 최대 수명(기본 10시간)이 다 돼도 끝나요. 그러면 쿠키는 브라우저에 멀쩡히 남아 있는데, 그 안의 갱신용 토큰을 내밀면 로그인 서버가 invalid_grant 라는 오류로 거절해요. 쿠키 유효 기간과 서버 세션 기간이 서로 따로 놀기 때문에 생기는 일이에요.

재현은 간단해요.

  1. 로그인해서 아무 화면이나 열어요.
  2. 로그인 서버 관리 화면에서 그 사용자의 세션을 강제로 끝내요. 로그아웃과 같은 효과예요.
  3. 브라우저는 그대로 두고 화면을 새로고침해요.

500 에러가 뜬다면 재현에 성공한 거예요. 이제 고칠 차례예요.

참고

갱신용 토큰을 쿠키에 쓰는 구조가 아니라면 이 두 갈래가 다르게 생길 수 있어요. 서버 세션 방식이라면 "세션 저장소에 키가 없음"이 두 번째 경우에 해당해요. 어느 쪽이든 핵심은 "있다고 믿었던 세션이 죽어 있는 경우"를 따로 다루는 거예요.

세션이 끝났을 때 고치기 전과 고친 뒤의 흐름세션이 끝났을 때 고치기 전에는 에러 화면으로, 고친 뒤에는 로그인 화면으로 가요

3단계. 만료된 세션을 알아보는 함수를 만들어요

고치려면 먼저 "이 오류는 세션이 끝난 거구나"를 코드로 알아봐야 해요. 모든 오류를 로그인으로 보내면 진짜 장애를 가려 버리니까, 정확히 그 오류만 골라야 해요.

TS
import * as client from "openid-client" // 로그인 서버는 세션이 끝나면 invalid_grant 로 답해요.export function isEndedSession(error: unknown): boolean {  return error instanceof client.ResponseBodyError && error.error === "invalid_grant"}

그리고 접근 토큰을 만드는 곳에서 이 오류만 "토큰 없음"으로 바꿔요.

TS
export async function apiOptions() {  const session = await readSession(await cookies())  const accessToken = session    ? await getAccessToken(session.refreshToken).catch((error) => {        if (isEndedSession(error)) return undefined // 세션이 끝났어요        throw error // 그 밖의 오류는 진짜 장애예요      })    : undefined  return { accessToken }}

여기서 중요한 건 마지막 줄이에요. 로그인 서버가 아예 응답하지 않는 것 같은 오류까지 "로그아웃"으로 처리하면, 장애가 났을 때 모든 사용자가 조용히 로그인 화면으로 쫓겨나고 아무도 원인을 몰라요. 알아본 오류만 삼키고 나머지는 그대로 던져서 에러 추적 도구가 잡게 해요.

이렇게 하면 세션이 없는 경우와 죽은 경우가 같은 모양, 즉 "접근 토큰이 없다"로 합쳐져요. 두 갈래가 한 갈래가 된 거예요.

TIP

오류를 "삼키는" 코드를 쓸 때는 반드시 삼킬 조건을 좁게 쓰고, 나머지는 throw 하세요. 넓게 잡고 싶은 유혹이 올 때마다 "이게 장애일 때도 같은 처리를 해도 되나?"를 자문해 보면 돼요.

4단계. 화면에서는 로그인으로 보내요

토큰이 없다는 걸 알았으니, 이제 어떻게 할지를 정해요. 여기서 화면과 데이터 요청을 나눠요.

  • 화면(서버 컴포넌트): 토큰이 없으면 로그인 화면으로 이동시켜요. 사용자가 보고 있는 건 화면이니까, 로그인으로 가는 게 자연스러워요.
  • 데이터 요청(API 를 부르는 라우트): 이동시키지 않고 401 을 그대로 돌려줘요. 요청을 보낸 쪽은 브라우저의 스크립트일 수 있어서, 갑자기 로그인 화면의 HTML 이 응답으로 오면 오히려 깨져요.

그래서 같은 함수를 두 가지로 만들었어요.

TS
import { redirect } from "next/navigation" // 화면용. 토큰이 없으면 어차피 401 뿐이니 로그인으로 보내요.export async function screenApiOptions() {  const options = await apiOptions()  if (!options.accessToken) {    redirect("/auth/login")  }  return options}

그리고 화면에서는 apiOptions() 대신 이걸 쓰면 돼요.

TS
export async function ReportPage() {  const options = await screenApiOptions()  const result = await listReports(options)  if (result.status !== 200) {    throw new Error(`GET /reports answered ${result.status}`)  }  // ...}

바뀐 건 한 줄이에요. 그래도 이제 세션이 끝난 사용자는 에러 대신 로그인 화면을 만나고, 에러 추적 도구에도 401 에러가 더 올라오지 않아요. 남은 200 이 아닌 응답은 정말 이상한 응답이라서 에러로 던져도 돼요.

참고

Next.js 에서 redirect() 는 값을 돌려주는 함수가 아니라 특별한 예외를 던져서 동작해요. 그래서 try/catch 안에서 부르면 우리가 그 예외를 잡아 버려서 이동이 안 될 수 있어요. 이동은 try 바깥에서 부르세요.

5단계. 공통 틀에 맡기지 말고 화면에서 확인해요

"그냥 맨 위의 공통 틀(root layout)에서 로그인 검사를 한 번만 하면 안 되나요?" 하는 질문이 나올 수 있어요. 저희도 처음엔 그렇게 했어요. 공통 틀에서 세션 쿠키가 있는지 확인하고 없으면 로그인으로 보냈어요. 그런데 이 방법으로는 두 번째 경우, 쿠키는 있는데 서버가 거절하는 경우를 못 잡아요.

공통 틀에서 서버 세션까지 확인하면 되지 않을까 싶지만, Next.js 는 공통 틀과 화면을 "차례로" 그리지 않고 나란히 그려요. 그래서 공통 틀이 이동을 결정하기 전에 화면이 이미 데이터를 부르다가 401 을 받아 버릴 수 있어요. 공통 틀의 검사는 "첫 번째 방어선"일 뿐, 화면이 직접 부르는 데이터 요청은 화면이 책임져야 해요.

점검 순서를 정리하면 이래요.

  1. 공통 틀: 세션 쿠키가 아예 없으면 로그인으로 보내요. 가장 흔한 경우를 싸게 처리해요.
  2. 화면: 데이터를 부르기 직전에 토큰이 없으면 로그인으로 보내요. 죽은 세션을 잡아요.
  3. 데이터 요청 라우트: 401 을 그대로 돌려줘요. 호출한 쪽이 알아서 판단해요.

한 가지 더 있어요. 화면을 새로 만들 때 screenApiOptions 대신 apiOptions 를 쓰면 같은 문제가 되살아나요. 이런 실수는 사람이 기억해서 막기 어려워요. 저희는 화면 폴더 안에서는 화면용 함수를 쓴다는 규칙을 코드 리뷰에서 확인해요. 여러분은 린트 규칙으로 막거나, 아예 apiOptions 라는 이름을 화면에서 가져오지 못하게 하는 방법도 있어요.

TIP

수정 후에는 2단계의 재현 순서를 한 번 더 따라 해 보세요. 세션을 강제로 끝내고 새로고침했을 때 로그인 화면이 뜨는지 눈으로 확인하는 게 가장 확실해요. 가능하면 이 재현을 자동화 테스트로 남겨 두세요.

6단계. 세션이 덜 끊기게 만드는 것도 함께 봐요

끊긴 사용자를 잘 안내하는 것만큼, 끊기는 횟수를 줄이는 것도 중요해요. 로그인 서버의 유휴 시간(30분) 안에 한 번이라도 갱신용 토큰을 쓰면 세션이 이어져요. 그런데 서버 컴포넌트는 쿠키를 쓸 수 없어서, 토큰을 갱신하고도 새 토큰을 쿠키에 저장하지 못해요.

그래서 저희는 요청이 들어올 때 가장 먼저 실행되는 프록시 단계(Next.js 16 의 proxy)에서 갱신해요. 거기서는 쿠키를 쓸 수 있어요. 다만 매 요청마다 갱신하면 로그인 서버에 부담이 되니까, 마지막 갱신으로부터 1분이 지났을 때만 새 토큰으로 바꿔 써요. 이때도 invalid_grant 가 오면 세션을 지워서, 죽은 쿠키가 계속 남아 있지 않게 해요.

TS
const ROTATE_AFTER_MS = 60 * 1000 if (!session.refreshToken || Date.now() - session.refreshedAt < ROTATE_AFTER_MS) returntry {  const tokens = await client.refreshTokenGrant(config, session.refreshToken)  session.refreshToken = tokens.refresh_token ?? session.refreshToken  session.refreshedAt = Date.now()  await session.save()} catch (error) {  if (isEndedSession(error)) {    session.destroy() // 죽은 세션은 쿠키에서도 지워요    return  }  throw error}

이렇게 하면 활발히 쓰는 사용자는 끊기지 않고, 오래 자리를 비운 사용자만 로그인 화면으로 가요. 그리고 죽은 세션은 프록시에서 한 번 더 정리되니, 4단계의 화면 검사는 프록시를 거치지 않은 경로의 안전망이 돼요.

각자의 환경에서 바꿀 값은 두 가지예요. 갱신 간격은 로그인 서버의 유휴 시간보다 충분히 짧게 잡으세요. 유휴 시간이 30분이라면 1분이든 5분이든 괜찮아요. 쿠키 수명은 로그인 서버의 최대 세션 수명보다 길게 잡지 마세요. 쿠키가 서버보다 오래 살아 봐야 거절당하는 토큰만 들고 있게 돼요.

자주 묻는 질문

Q1. 401 이면 무조건 로그인으로 보내면 안 되나요?

데이터 요청에서는 위험해요. 브라우저 스크립트가 보낸 요청에 로그인 화면이 응답으로 오면, 스크립트가 그걸 데이터로 읽으려다 깨져요. 화면 요청은 이동, 데이터 요청은 401 그대로, 이렇게 나누는 게 안전해요.

Q2. 로그인 서버가 잠깐 죽었을 때도 로그인 화면으로 가나요?

아니요, 가면 안 돼요. 3단계처럼 invalid_grant 만 "세션이 끝났다"로 보고, 연결 실패 같은 다른 오류는 그대로 던져요. 그래야 사용자는 에러 화면을 만나고, 운영자는 에러 추적 도구에서 장애를 알아챌 수 있어요. 서버가 죽었는데 모든 사용자가 조용히 로그아웃되는 것이 더 나쁜 상황이에요.

Q3. 로그인하고 나면 원래 보던 화면으로 돌아오게 할 수 있나요?

가능해요. 이동할 때 원래 주소를 같이 넘기고, 로그인이 끝나면 그 주소로 돌려보내면 돼요. 다만 돌아갈 주소는 반드시 우리 사이트 안의 주소인지 확인하세요. 아무 주소로나 보내 주면 다른 사이트로 사용자를 끌고 가는 통로가 될 수 있어요.

정리

  • 로그인이 풀린 사용자를 서버 에러로 처리하면 사용자는 당황하고, 에러 추적 도구에는 가짜 버그가 쌓여요.
  • 세션이 없는 경우와 쿠키는 있지만 서버가 거절하는 경우를 따로 생각하세요.
  • 세션이 끝났다는 오류만 정확히 골라 "토큰 없음"으로 바꾸고, 나머지 오류는 그대로 던지세요.
  • 화면에서는 로그인으로 이동시키고, 데이터 요청에서는 401 을 그대로 돌려주세요.
  • 공통 틀의 검사는 첫 방어선일 뿐이에요. 화면이 데이터를 부르기 직전에 한 번 더 확인하세요.

세션이 끝나는 건 오류가 아니라 평범한 일상이에요. 앱이 그걸 평범하게 다루면 사용자는 로그인 화면 한 번에 하던 일을 이어 갈 수 있어요.

이어서 읽기 좋은 글