Next.js App Router에서 15분마다 로그인 풀리던 문제 해결하기

아더에러·2026년 6월 15일

프론트엔드

목록 보기
15/15
post-thumbnail

로그인한 지 15분쯤 지나면 세션이 풀린 것처럼 보이는 문제가 있었습니다.

처음에는 access token 유효 시간이 15분이라서 생긴 문제라고 생각했습니다.

const defaultAccessTokenTTL = 15 * time.Minute

하지만 원인은 15분이라는 시간 자체가 아니었습니다.

access token은 짧게 유지해도 됩니다. 만료되면 refresh token으로 새 access token을 발급받으면 되기 때문입니다. 문제는 이 refresh 과정이 Next.js App Router의 쿠키 쓰기 제약과 맞물리면서, 특정 상황에서 브라우저 쿠키가 제대로 갱신되지 않았다는 점이었습니다.

1. 문제 상황

사용자는 정상적으로 로그인할 수 있었습니다.

로그인 직후에는 마이페이지, 북마크, 다운로드, 댓글 작성 같은 로그인 기반 기능도 잘 동작했습니다. 그런데 시간이 조금 지나면 페이지 이동이나 액션 실행 시 로그인하지 않은 사용자처럼 처리되었습니다.

로그인 완료
→ 사이트를 이용함
→ 약 15분 뒤 페이지 이동 또는 액션 실행
→ 로그인하지 않은 사용자처럼 처리됨
→ 다시 로그인 필요

토큰이 발급되지 않은 것은 아니었습니다.

  • 로그인은 성공했습니다.
  • access token과 refresh token도 발급되고 있었습니다.
  • refresh API도 존재했습니다.
  • refresh token의 만료 시간은 30일이었습니다.

그런데도 사용자 입장에서는 15분마다 로그인이 풀리는 것처럼 느껴졌습니다.

2. 기존 구조

이 프로젝트에서는 access token과 refresh token을 httpOnly cookie로 저장했습니다.

로그인 성공
→ access token 발급
→ refresh token 발급
→ 브라우저 쿠키에 저장

access token은 API 요청에서 사용자를 인증하는 토큰입니다. 짧은 시간만 유효하도록 두었습니다. refresh token은 access token이 만료되었을 때 새 access token을 발급받기 위한 토큰입니다. access token보다 오래 유지되고, 보통 서버 세션과 함께 관리됩니다.

기존 구조에서는 호출하는 쪽이 access token 만료 여부를 신경 쓰지 않아도 되도록 만들었습니다. 현재 사용자를 가져오는 함수 안에서 access token이 만료되면 refresh까지 처리하는 방식이었습니다.

async function getCurrentUser() {
  const accessToken = await readAccessTokenFromCookie();

  if (accessToken) {
    const user = await fetchUserWithAccessToken(accessToken);

    if (user) {
      return user;
    }
  }

  const refreshed = await refreshSession();

  if (!refreshed?.accessToken) {
    return null;
  }

  return fetchUserWithAccessToken(refreshed.accessToken);
}

호출하는 쪽에서는 단순했습니다.

const user = await getCurrentUser();

보기에는 편했습니다. 문제는 getCurrentUser라는 이름과 실제 동작이 달랐다는 점입니다.

이 함수는 현재 사용자를 조회하는 것처럼 보이지만, 경우에 따라 세션 갱신까지 수행했습니다. 세션 갱신은 단순 조회가 아닙니다.

refresh token 검증
→ 서버 세션 업데이트
→ 새 access token 발급
→ 새 refresh token 발급
→ 브라우저 쿠키 저장

즉, getCurrentUser는 읽기 함수처럼 사용되었지만 실제로는 쿠키와 서버 세션을 바꾸는 상태 변경 함수이기도 했습니다.

3. 실제 원인

문제는 refresh가 쿠키를 쓸 수 없는 위치에서도 시도될 수 있었다는 점이었습니다.

Next.js App Router에서는 Server Component에서 쿠키를 읽을 수 있습니다. 하지만 Server Component 렌더링 중에는 쿠키를 설정하거나 삭제할 수 없습니다.

쿠키 저장은 서버 메모리 값을 바꾸는 일이 아닙니다. 응답 헤더에 Set-Cookie를 담아 브라우저에게 전달하는 일입니다. 따라서 쿠키를 쓰려면 응답 헤더를 제어할 수 있는 실행 위치가 필요합니다.

쿠키 읽기
→ Server Component에서도 가능

쿠키 쓰기
→ 응답 헤더를 제어할 수 있는 위치에서 가능
→ 예: Route Handler, Server Action

그런데 기존 코드에서는 getCurrentUser가 여러 Server Component에서 호출되고 있었습니다. 예를 들어 페이지나 레이아웃에서 헤더의 로그인 상태를 표시하기 위해 현재 사용자를 조회했습니다.

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser();

  return (
    <>
      <Header user={user} />
      {children}
    </>
  );
}

여기서 access token이 만료되면 다음 흐름이 생깁니다.

Server Component 렌더링
→ getCurrentUser 호출
→ access token 만료 확인
→ getCurrentUser 내부에서 refreshSession 호출
→ 새 토큰 발급 시도
→ 쿠키 저장 시도

이 refresh는 Route Handler나 Server Action 안에서 실행된 것이 아니라 Server Component 렌더링 중에 실행되었습니다. 이 위치에서는 요청 쿠키를 읽어 화면을 렌더링할 수는 있지만, 브라우저로 내려갈 Set-Cookie 헤더를 안전하게 제어할 수 없습니다.

그 결과 서버 쪽 refresh token은 교체되었는데, 새 refresh token이 브라우저 쿠키에 반영되지 않는 상황이 생길 수 있었습니다.

access token 만료
→ 현재 사용자 조회 중 세션 갱신 시도
→ 서버에서는 refresh token을 새 값으로 교체
→ 브라우저 쿠키에는 이전 refresh token이 남음

이제 서버와 브라우저가 서로 다른 refresh token을 바라보게 됩니다.

서버
→ 새 refresh token만 유효

브라우저
→ 이전 refresh token을 계속 보관

다음 요청에서 브라우저가 이전 refresh token으로 다시 갱신을 시도하면, 서버는 이미 무효화된 토큰으로 판단합니다.

브라우저: 이전 refresh token으로 갱신 요청
서버: 이미 무효화된 token
결과: 인증 실패

그래서 사용자에게는 access token이 만료되는 시점, 즉 약 15분 뒤에 로그인이 풀리는 것처럼 보였습니다.

4. 수정 방향

수정 방향은 네 가지였습니다.

1. 현재 사용자 조회와 세션 갱신의 책임 분리
2. 쿠키를 쓸 수 있는 위치에서만 세션 갱신
3. 공개 페이지에서는 마운트 후 세션 갱신 시도
4. 보호 페이지에서는 로그인으로 보내기 전에 세션 갱신 먼저 시도

5. 현재 사용자 조회는 읽기 전용으로 분리하기

우선 현재 사용자 조회 함수에서 자동 refresh를 제거했습니다. 개선 후 이 함수는 access token으로 현재 사용자를 확인하는 일만 합니다.

type AuthUser = {
  id: string;
  email: string;
  name: string;
};

async function readCurrentUser(): Promise<AuthUser | null> {
  const accessToken = await readAccessTokenFromCookie();

  if (!accessToken) {
    return null;
  }

  try {
    return await authApi.getCurrentUser({
      authorization: `Bearer ${accessToken}`,
    });
  } catch (error) {
    if (isUnauthenticatedError(error)) {
      return null;
    }

    throw error;
  }
}

이 함수 안에는 refresh token을 읽는 코드도, 쿠키를 쓰는 코드도 없습니다.

현재 사용자 조회
→ access token 확인
→ 유효하면 사용자 반환
→ 실패하면 null 반환
→ 쿠키 쓰기 없음
→ refresh token 교체 없음

이제 readCurrentUser는 이름 그대로 현재 요청 쿠키에 담긴 access token만 확인합니다.

6. 세션 갱신은 별도 함수에서 처리하기

세션 갱신이 필요한 경우에는 별도 함수를 사용했습니다.

async function readCurrentUserWithRefresh(): Promise<AuthUser | null> {
  const user = await readCurrentUser();

  if (user) {
    return user;
  }

  const tokens = await refreshSessionSafely();

  if (!tokens) {
    return null;
  }

  try {
    return await authApi.getCurrentUser({
      authorization: `Bearer ${tokens.accessToken}`,
    });
  } catch (error) {
    if (isUnauthenticatedError(error)) {
      return null;
    }

    throw error;
  }
}

두 함수의 책임은 이렇게 나뉩니다.

readCurrentUser
→ 현재 쿠키만 읽어서 사용자 확인

readCurrentUserWithRefresh
→ 필요하면 세션 갱신까지 시도

예를 들어 댓글 작성 같은 Server Action에서는 refresh까지 가능한 함수를 사용할 수 있습니다.

'use server';

type ActionResult =
  | { ok: true }
  | { ok: false; reason: 'LOGIN_REQUIRED' | 'VALIDATION_FAILED' };

export async function submitComment(input: { deckId: string; body: string }): Promise<ActionResult> {
  const user = await readCurrentUserWithRefresh();

  if (!user) {
    return { ok: false, reason: 'LOGIN_REQUIRED' };
  }

  if (!input.body.trim()) {
    return { ok: false, reason: 'VALIDATION_FAILED' };
  }

  await commentService.create({
    deckId: input.deckId,
    authorId: user.id,
    body: input.body,
  });

  return { ok: true };
}

핵심은 refresh를 아무 곳에서나 하지 않는 것입니다. 세션 갱신은 쿠키를 쓸 수 있는 실행 흐름에서만 일어나야 합니다.

7. 쿠키를 쓸 수 있을 때만 refresh하기

세션 갱신 함수에는 쿠키 쓰기가 가능한지 확인하는 방어 로직도 추가했습니다.

중요한 점은 refresh API를 호출하기 전에 먼저 확인해야 한다는 것입니다. refresh API가 refresh token을 회전시키는 구조라면, API 호출 이후에는 이미 서버의 토큰 상태가 바뀌었을 수 있기 때문입니다.

async function canWriteCookies() {
  try {
    const cookieStore = await cookies();

    cookieStore.set('__cookie_write_probe', '1', {
      httpOnly: true,
      sameSite: 'lax',
      path: '/',
      maxAge: 0,
    });

    return true;
  } catch {
    return false;
  }
}
async function refreshSessionSafely(): Promise<SessionTokens | null> {
  const refreshToken = await readRefreshTokenFromCookie();

  if (!refreshToken) {
    return null;
  }

  if (!(await canWriteCookies())) {
    return null;
  }

  try {
    const tokens = await authApi.refresh({ refreshToken });

    if (!tokens.accessToken || !tokens.refreshToken) {
      return null;
    }

    await writeSessionCookies(tokens);

    return tokens;
  } catch (error) {
    if (isUnauthenticatedError(error)) {
      await clearSessionCookiesIfPossible();
      return null;
    }

    throw error;
  }
}

이 함수가 지키는 규칙은 단순합니다.

새 refresh token을 브라우저 쿠키에 저장할 수 없다면
서버의 refresh token도 회전시키지 않습니다.

refresh token 교체는 서버와 브라우저가 같은 새 토큰을 바라볼 때 안전합니다. 쿠키를 쓸 수 없는 상황에서 refresh를 진행하면 서버만 새 토큰으로 바뀌고, 브라우저는 이전 토큰을 가진 채 남을 수 있습니다.

8. 공개 페이지에서는 마운트 후 refresh하기

공개 페이지는 로그인하지 않아도 볼 수 있습니다.

따라서 Server Component에서는 읽기 전용 함수만 사용합니다. access token이 만료되어 사용자 정보가 없으면 일단 비로그인 상태로 렌더링합니다.

export default async function PublicPage() {
  const user = await readCurrentUser();

  return (
    <>
      <Header user={user} />
      <MainContent />
      <RefreshSessionOnMount enabled={!user} />
    </>
  );
}

그 뒤 클라이언트 마운트 이후 세션 갱신용 Route Handler를 호출합니다.

'use client';

import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';

type RefreshResponse = {
  ok?: boolean;
};

export function RefreshSessionOnMount({ enabled }: { enabled: boolean }) {
  const router = useRouter();
  const attempted = useRef(false);

  useEffect(() => {
    if (!enabled || attempted.current) {
      return;
    }

    attempted.current = true;

    void fetch('/api/session/refresh', {
      method: 'POST',
      credentials: 'same-origin',
      cache: 'no-store',
    })
      .then(async (response) => {
        if (!response.ok) {
          return;
        }

        const result = (await response.json().catch(() => null)) as RefreshResponse | null;

        if (result?.ok) {
          router.refresh();
        }
      })
      .catch(() => undefined);
  }, [enabled, router]);

  return null;
}

Route Handler는 쿠키를 쓸 수 있는 위치이므로 여기서 세션을 갱신합니다.

import { NextResponse } from 'next/server';

export async function POST() {
  const tokens = await refreshSessionSafely();

  if (!tokens) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  return NextResponse.json({ ok: true });
}

흐름은 이렇게 바뀝니다.

공개 페이지 접근
→ Server Component에서 현재 사용자 조회
→ access token 만료로 사용자 없음
→ 일단 비로그인 상태로 렌더링
→ 클라이언트 마운트 후 세션 갱신 요청
→ Route Handler에서 refresh token 검증
→ 새 토큰을 쿠키에 저장
→ 성공하면 페이지 다시 갱신
→ 새 쿠키 기준으로 로그인 상태 렌더링

9. 보호 페이지에서는 로그인 전에 refresh를 먼저 시도하기

로그인이 필요한 페이지도 바로 로그인 페이지로 보내지 않도록 바꿨습니다.

현재 사용자가 없다고 해서 항상 완전히 로그아웃된 것은 아닙니다. access token만 만료되었고 refresh token은 아직 유효할 수 있습니다.

import { redirect } from 'next/navigation';

export default async function AccountPage() {
  const user = await readCurrentUser();

  if (!user) {
    redirect(sessionRefreshRedirectPath('/account'));
  }

  return <AccountView user={user} />;
}

refresh 라우트는 성공하면 원래 페이지로, 실패하면 로그인 페이지로 보냅니다.

import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const url = new URL(request.url);
  const redirectPath = normalizeRedirectPath(url.searchParams.get('redirect'));
  const fallbackPath = normalizeRedirectPath(url.searchParams.get('fallback'));

  const tokens = await refreshSessionSafely();
  const nextPath = tokens ? redirectPath : fallbackPath;

  return NextResponse.redirect(new URL(nextPath, url.origin));
}

function sessionRefreshRedirectPath(redirectPath: string) {
  const nextPath = normalizeRedirectPath(redirectPath);
  const fallbackPath = `/login?redirect=${encodeURIComponent(nextPath)}`;

  const params = new URLSearchParams({
    redirect: nextPath,
    fallback: fallbackPath,
  });

  return `/api/session/refresh?${params.toString()}`;
}

function normalizeRedirectPath(value: string | null) {
  if (!value || !value.startsWith('/') || value.startsWith('//')) {
    return '/';
  }

  return value;
}

normalizeRedirectPath는 열린 리다이렉트를 막기 위해 둔 함수입니다. 내부 경로로 시작하지 않는 값은 기본 경로로 돌립니다.

보호 페이지의 흐름은 이렇게 됩니다.

보호 페이지 접근
→ access token으로 사용자 조회
→ access token 만료로 사용자 없음
→ 세션 갱신 라우트로 이동
→ 갱신 성공: 원래 페이지로 복귀
→ 갱신 실패: 로그인 페이지로 이동

이제 access token만 만료된 사용자는 다시 로그인하지 않고 원래 페이지로 돌아올 수 있습니다.

10. 왜 access token 시간을 늘리지 않았을까

이 문제를 덮으려면 access token 시간을 늘릴 수도 있습니다.

하지만 그러면 증상이 늦게 나타날 뿐, refresh 흐름의 문제는 그대로 남습니다. 유출된 access token의 사용 가능 시간도 길어집니다.

access token 유효 시간 늘리기
→ 증상은 늦게 나타남
→ refresh 흐름의 문제는 그대로 남음
→ 유출된 access token의 사용 가능 시간도 길어짐

이번 문제는 access token이 짧아서 생긴 문제가 아니었습니다. access token 만료 이후 refresh token으로 세션을 이어가는 과정이 안전하게 끝나지 않은 것이 문제였습니다.

그래서 방향은 이렇게 잡았습니다.

access token은 짧게 유지합니다.
refresh token으로 세션을 이어갑니다.
refresh token 교체와 쿠키 저장은 안전한 위치에서만 수행합니다.

11. 정리

access token이 15분 뒤 만료되는 것은 의도된 동작이었습니다. 진짜 문제는 그 이후 refresh token으로 세션을 이어가는 과정이 Next.js의 쿠키 쓰기 제약과 맞지 않았다는 점입니다.

기존 구조에서는 현재 사용자 조회와 세션 갱신이 한 함수 안에 섞여 있었습니다. 호출하는 쪽에서는 편했지만, 쿠키를 쓸 수 없는 렌더링 위치에서도 refresh가 시도될 수 있었습니다.

그 결과 서버에서는 refresh token이 회전되었는데, 브라우저 쿠키는 이전 값으로 남는 상황이 생길 수 있었습니다. 다음 refresh 요청부터는 브라우저가 이미 무효화된 토큰을 보내게 되고, 사용자는 로그인이 풀린 것처럼 보게 됩니다.

개선 후에는 현재 사용자 조회와 세션 갱신의 책임을 분리했습니다.

readCurrentUser
→ access token으로 현재 사용자만 확인

readCurrentUserWithRefresh
→ 필요하면 쿠키를 쓸 수 있는 흐름에서 세션 갱신까지 수행

공개 페이지에서는 클라이언트 마운트 후 세션 갱신을 시도했습니다. 보호 페이지에서는 바로 로그인으로 보내기 전에 세션 갱신 라우트를 먼저 거치게 했습니다.

이 이슈를 한 줄로 줄이면 이렇습니다.

access token이 짧은 것은 문제가 아닙니다.
문제는 access token 만료 이후 refresh가 안전하게 이어지지 않는 것입니다.

세션 갱신은 조회가 아니라 상태 변경입니다.
refresh token 교체와 쿠키 저장은 반드시 함께 성공해야 합니다.

작은 로그인 이슈처럼 보였지만, Next.js App Router에서 쿠키를 다루는 방식과 refresh token rotation의 특성을 다시 확인하게 된 트러블슈팅이었습니다.

참고 자료

0개의 댓글