Develope/PROJECTS

[BueaFit] - next에서 로그인 인증 관리하기

ccuccu 2025. 5. 2. 21:52

로그인이 성공하면 미들웨어에서 refresh token을 쿠키에 저장하여 aceess token이 없거나 만료 되었을 때 refresh token으로 재인증하는 방식을 구현 중, 쿠키에 refresh token이 있어도 api서버에 전달되지 않는 에러가 발생했다.

 

0. 문제 상황

리프레시 토큰이 있고, 액세스 토큰이 없으면 로그인 화면으로 redirect 되어 refresh token이 갱신되지 않고 있었다.

 

1. 문제 확인

리프레시 요청: Response {
  [Symbol(state)]: {
  aborted: false,
  rangeRequested: false,
  timingAllowPassed: false,
  requestIncludesCredentials: false,
  type: 'default',
  status: 401,
  timingInfo: null,
  cacheState: '',
  statusText: 'Unauthorized',
  headersList: HeadersList {
  cookies: null,

 

터미널에 찍힌 콘솔 로그를 보니, 쿠키가 전달되지 않아 에러 401이 나며 인증 하지 못한 상황인 것을 확인했다.

- cookie: null,

- status: 401,

- statusText: 'Unauthorized'

 

1.1. 코드 분석

if (refreshToken) {
    try {
      // 백엔드에 토큰 갱신 요청
      const refreshRes = await fetch(`${process.env.NEXT_PUBLIC_BUEAFIT_API}/auth/refresh`, {
        method: 'POST',
        credentials: 'include', // 쿠키 포함
        headers: {
          'Content-Type': 'application/json',
        },
      })

 

일단 credentials: 'include'를 통해 프론트 쪽에서는 쿠키 전달을 시도하긴 했으나, 되지 않는 것으로 보인다.

 

sameUrl이 아니라서 오류가 나나? 싶어서 fetch 주소를 '/api/auth/refresh' 로 만들고 해당 경로에 route 파일을 따로 만들어서 우회했으나, middleware에서는 우회해서 api를 호출 할 수 없었다.

 

1.2 코드 변경

const cookie = request.headers.get('cookie') || '';

if (refreshToken) {
    try {
      const refreshRes = await fetch(`${process.env.NEXT_PUBLIC_BUEAFIT_API}/auth/refresh`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Cookie": cookie,
        },
      });
    }
 }

 

헤더에서 쿠키를 가져와서 다시 api 요청 시 헤더에 cookie 전부를 보내고 나서, accessToken을 새로 만들고 나니 middleware에선 zustand store에 access token을 전달 할 수 없었다.

 

2. 코드 변경

찾아보니 페이지마다 새로 쿠키 값을 가져와서 zustand store에 저장하거나 등의 방식이 있었지만, 이러면 middleware를 쓰는 이유도 없고 코드도 길어지고 너무 번거로워서 구조를 변경하기로 했다.

 

처음에는 fetch 요청 전에 interceptor를 따로 만들어서 진행하려고 했었는데, 이것도 토큰을 쿠키에 넣는게 어려워서 결국 클라이언트 페이지를 만들어 거기서 처리 하기로 결정했다.

 

2.1. middleware에서는 토큰 체크만

미들웨어에서 토큰을 재발급 하기에는 zustand store 저장이 어려워 토큰 여부만 체크하고, 그 외에도 필요한 최소한의 처리만 하기로 정리했다.

액세스 토큰과 리프레시 토큰 여부만 확인하고 없다면 refresh 페이지로 이동한다.

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  const access_token = request.cookies.get('access_token')?.value // 액세스 토큰
  const refresh_token = request.cookies.get('refresh_token')?.value // 리프레시 토큰

  if (!access_token && refresh_token) {
    // access 토큰만 없으면 리프레시 토큰으로 재발급
    const url = request.nextUrl.clone()
    url.pathname = '/auth/refreshing'
    url.searchParams.set('to', pathname)
    return NextResponse.redirect(url)
  }

  if (!access_token && !refresh_token) {
    // 둘 다 없으면 로그인 페이지로
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  // 액세스 토큰이 있으면 통과
  return NextResponse.next()
}

 

2.2.  클라이언트 페이지에서 토큰 재발급하기

이동 된 페이지에서는 넘어 온 url(pathname)을 기억했다가, 우회 한 api 주소로 액세스 토큰 재 발급을 요청한다.

이 때 재발급에 성공한다면 다시 이전의 url로 돌아가며, 실패한다면 로그인 페이지로 이동한다.

"use client";

import { useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import LoadingSpinner from "@/components/loadingSpinner";

export default function RefreshingPage() {
  const router = useRouter()
  const params = useSearchParams()
  const to = params.get("to") || "/"

  useEffect(() => {
    fetch("/api/auth/refresh", {
      method: "POST",
      credentials: "include",
    })
      .then(res => {
        if (res.ok) {
          router.replace(to)
        } else {
          router.replace("/login")
        }
      })
      .catch(() => {
        router.replace("/login")
      })
  }, [router, to])

  return (
    <div className="w-dvw h-dvh flex items-center justify-center">
      <LoadingSpinner />
    </div>
  )
}

 

이 때 추가로, 페이지에 사용자에게 혼란을 주지 않기 위해서 로딩 스피너 이미지를 페이지에 추가했다.

 

2.3. 리프레시 api 우회 코드

// 1) 쿠키 스토어 가져오기
const cookieStore = await cookies()

const refreshToken = cookieStore.get('refresh_token')?.value
if (!refreshToken) {
  console.error('[auth/refresh] no refresh_token cookie')
  return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
}

// 2) FastAPI 재발급 요청
const backendUrl = `${process.env.NEXT_PUBLIC_BUEAFIT_API}/auth/refresh`
const backendRes = await fetch(backendUrl, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',      
    Cookie: `refresh_token=${refreshToken}`,
  },
})

// 3) 디버그: 백엔드 응답 확인
const text = await backendRes.text()

if (!backendRes.ok) {
  return NextResponse.json({ error: 'Refresh failed' }, { status: backendRes.status })
}

// 4) 성공 시 JSON 파싱 및 access_token 덮어쓰기
const { access_token, refresh_token: newRefresh } = JSON.parse(text)
cookieStore.set('access_token', access_token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  path: '/',
  maxAge: 60 * 15,
})

// 5) 백엔드가 새 refresh_token도 내려주면 덮어쓰기
if (newRefresh) {
  cookieStore.set('refresh_token', newRefresh, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  })
}

return NextResponse.json({ access_token })

 

이 우회 코드를 타면서 client page에서 재 발급 후 최종적으로 원래 페이지로 다시 돌아간다.

 

3. 마무리

혼자 공부하다보면 가장 아쉬운게 실무에서 어떻게 하는지 혹은 어떤게 맞는 방법인지, 특히 로그인 같은 경우에는 보안 이슈가 많아 고민이 많아지는데 명확하게 알 수 있는 방법이 적어 아쉽다. 코딩엔 정답이 없다고들 하지만 더 나은 방법은 늘 있을테니 이런 점을 계속해서 고민하게 되는 것도 코딩의 매력인 것 같다.

 

그리고 이상한 방식이든 어려운 방식이든 알게 된 방식을 그대로 구현을 해본다면, 나중에 어떻게 하는지 알게되거나, 보편적인 방법을 알게 되어도 좀 더 쉽게, 간단하게 코드를 구현할 수 있을 것 같아 최대한 노력중이다.

이게 좋은 밑거름이 되어주기를~_~