scent-jo 소개

Next.js 에서 JWT 이용한 인증/인가 처리

Engineering Opinion

⚠️ 문제: SSR 환경에서 JWT 인증 방식을 사용하기 위한 고민

Next.js 를 도입하여 처음으로 개발을 진행하면서 기획 단계에서 미처 고려하지 못했던 문제 발견하게 되었다.

SSR (Server Side Rendering) 방식의 환경에서 인증/인가 처리를 하는 것에 문제가 발생한 것이다. 우리 팀은 기획 단계에서 익숙한 “JWT(JSON Web Token)” 을 통한 인증 상태를 관리하기로 하였다. 지금까지는 CSR 환경에서 개발할 때에는 다음과 같이 토큰을 관리하였다.

  1. accessToken 을 로컬 스토리지에 저장
  2. refreshToken을 httpOnly Cookie로 저장

아무 생각 없이 똑같이 기존과 같이 작업하려고 했는데, 미처 생각하지 못했던 부분이 있었다…😱

SSR 을 적용하는 상황에서는, 클라이언트의 브라우저(로컬스토리지)에 접근하지 못하기 때문에 서버에서 렌더링 과정에서 accessToken 을 가져올 수 없다는 것이다.

백엔드 측에 API를 요청할 때, authorization header 에 accessToken을 담아서 인가 처리를 하여 요청을 했어야 했기 때문에, SSR 환경에서 accessToken 을 알 수 있어야 했다.

그래서 SSR 환경에서 JWT 인증 방식을 사용하기 위한 고민을 하게 되었다.


현재 우리의 상황 파악 (고려할 조건)

1️⃣ SSR + CSR 을 동시에 고려해야함

우리 프로젝트의 특성상 인터렉션이 있는 페이지가 있기 때문에 페이지마다 렌더링이 되는 방식을 구분하였다.

  • 단순한 통계정보를 확인하는 페이지 → SSR
  • 지도를 띄우거나, 사용자의 인터렉션이 발생하는 페이지 → CSR

두 방식에서 모두 accessToken을 통한 인가가 필요하기 때문에, 모두 고려해야한다.

2️⃣ 서버는 클라이언트 (브라우저)에 접근할 수 없다.

기존에 로컬 스토리지에 JWT 기반의 accessToken 을 관리하는 방식을 사용하지 못하는 주된 이유는 앞서 언급한 것 처럼 다음과 같다.

“SSR 환경에서는 클라이언트(브라우저)를 모른다.”

기본적으로 HTTP을 사용할 때 클라이언트가 먼저 요청해야만 서버는 응답이 가능한 특성을 가지고 있다. 그렇기 때문에, 서버는 클라이언트에 직접 접근 할 수 없다.

그래서 CSR 방식에서 accessToken 를 로컬 스토리지에 저장하는 것이 가능하지만, SSR 에서는 어렵기 때문에 새로운 접근 방식이 필요하다.

3️⃣ React Native + Webview 구조 이기 때문에 토큰 동기화가 필요

우리 프로젝트의 가장 큰 특징이 바로 React Native + Webview 구조를 도입했다는 것이다. 이러한 구조를 도입한 이유는 3가지 이다.

  1. 백그라운드 실시간 GPS 정보를 가져오는 것이 필요
  2. 런닝 커브를 최대한으로 낮추기 위해 (팀 안에서 앱을 다뤄본 팀원 전무)
  3. 짧은 개발 기간 (기획 ~ 테스트까지 6주)

위 3가지 이유로 인해 우리는 React Native + Webview 구조 를 체택하게 되었다. 여기서 굳이 Next.js를 도입한 이유는 모바일 환경을 타겟으로 하기 때문에 조금이나마 사용자 경험을 높이고자 했기 때문이다.

따라서 현재의 JWT 관리 문제를 해결하기 위해서는 고려해야할 사항을 다음과 같이 정리해볼 수 있다.

💡 JWT 를 관리 하기 위해 고려 해야 할 사항

  • SSR , CSR 환경에서 모두 사용 가능해야한다.
  • WebView + React Native 간의 토큰 동기화가 필요하다. (로그인 Webview 에서, 요청은 APP 에서)
  • XSS 나 CSRF 공격을 고려한 보안성
  • 개발 유지 보수성 (코드 양 최소화)

JWT 을 관리 하기 위한 전략 ❗

**accessToken**과 **refreshToken**을 사용하는 매커니즘 특성상 refreshToken은 노출이 되면 안되기 때문에 Secure + HttpOnly Cookie로 저장을 하는 것이 일반적이다.

인증 기능을 구현한 백엔드 측에서도 refreshTokenSecure + HttpOnly Cookie 로 보내주고 있다.

따라서 우리는 accessToken을 어떻게 관리하느냐가 주요 포인트가 되는 것이다. 토큰을 관리하는 방법에는 한 가지 방법만 있는 것이 아니라, 여러가지가 있을 수 있기 때문에 떠올려 볼 수 있는 관점에서 몇가지 생각해보았다.

(방법 1️⃣) 극단적으로 짧은 유효 시간을 지정 후, 일반 Cookie에 저장

이 방법은 Next.js에서 브라우저 Cookie에 접근 할 수 있고, 동시에 브라우저에서 Javascript의 document.cookie 를 통해 접근 할 수 있다는 점에서 SSR + CSR 을 동시에 처리할 수 있다는 장점이 있다.

// CSR
const cookie = document.cookie();

// SSR
import { cookies } from 'next/headers'

const cookieStore = await cookies();
const accessToken = cookieStore.get('accessToken')?.value;

하지만, 보안이 되지 않은 Cookie에 저장하는 것은 위험 부담이 너무 크다. XSS 공격을 통해 쉽게 탈취당할 위험이 크기 때문이다. (이것은 사실 localStorage도 똑같이 해당이 된다.)

참고

XSS

  • 공격자가 웹사이트에 악성 스크립트를 삽입해 사용자 브라우저에서 실행시키는 공격

CSRF

  • 공격자가 사용자의 인증된 권한을 이용하여 사용자가 의도하지 않은 요청을 서버에 보내도록 유도하는 공격 기법

그래서 대안으로 accessToken 의 유효 시간을 을 짧게 (대략 1분 ~10분) 구성하는 방법을 생각한 것이다.

유효 시간을 을 짧게 구성하면 토큰을 탈취 당한다고 하더라도 어느 정도는 개인정보에 대해 보호할 수 있겠지만, *이미 탈취를 당한다는 가정하에 조치한 내용이기 때문에 *보안을 크게 신경쓰지 않은 점이 큰 단점으로 다가올 수 있다.

그리고 짧은 유효 시간은 많은 토큰 재발급 요청 수로 이어지게 될 것이다. 이것은 서버에 부하를 증가 시키며, 서버의 리소스가 낭비될 가능성이 커지게 된다.

장점

  • SSR, CSR 에서 동시에 접근 가능하여 비교적 구현 코드의 길이가 짧아짐
  • React Native - Webview 간 동기화가 편리함

단점

  • XSS 공격에 매우 취약하다.
  • 매우 짧은 유효기간으로 재발급 요청이 과도하게 많이 발생하여 서버 부하 증가 → 서버 리소스 낭비
  • 짧은 유효기간으로 인해 사용자는 의도치 않게 로그아웃이 될 수 있음

이 방식은 웹에서 보안이 아주 중요한 경우에 많이 사용된다. 브라우저에서 토큰에 아에 접근 할 수 없기 때문에 XSS로 부터는 아주 강한 보안을 보여줄 수 있다.

그리고 무엇보다 API 요청 시 쿠키가 자동으로 붙어서 가기 때문에, 프론트엔드 개발자 관점에서 토큰을 따로 관리해줄 필요가 없다는 편리함이 있다.

하지만 우리 프로젝트에서 구성 중인 React Native + Webview 구조 특성에는 맞지 않는 방식이다. Native 환경에서 API 요청을 보낼 때, 브라우저 Cookie 는 함께 전송되지 않기 때문이다. 그래서 우리 프로젝트의 구조에서는 반드시 Native 환경에도 토큰을 전달해줄 필요가 있다.

또 한 가지 문제가 있다. 바로 SSR 환경에서는 따로 Cookie를 파싱해주어 API 요청 시 직접 넣어 주어야 한다. SSR 환경에서 백엔드와 통신하는 과정은 서버와 서버 간의 통신이기 때문에 쿠키가 자동으로 전달되지 않기 때문이다.

const cookieStore = cookies();

const accessToken = cookieStore.get('accessToken')?.value;
const refreshToken = cookieStore.get('refreshToken')?.value;

// 1. 쿠키 문자열로 수동 구성
const cookieHeader = [
  accessToken ? `accessToken=${accessToken}` : '',
  refreshToken ? `refreshToken=${refreshToken}` : '',
]
  .filter(Boolean)
  .join('; ');

// 2. fetch 요청 시 직접 쿠키 전달
const res = await fetch('https://api.example.com/protected', {
  method: 'GET',
  headers: {
    Cookie: cookieHeader,
  },
  cache: 'no-store',
});

그렇기 때문에 accessToken도 HttpOnly Cookie 로 백엔드에서 관리하는 방법은 좋은 보안을 가지고 있지만, 우리 서비스의 구조상 한계로 인해 적용하기 힘들다.

장점

  • XSS 공격에 강함
  • 클라이언트에서 토큰을 직접 관리할 필요가 없음
  • 같은 도메인끼리 자동 인증 공유가 쉬움

단점

  • Cookie가 자동으로 전송되기 때문에 CSRF 공격에 노출될 가능성이 있음
  • React Native + Webview 환경에서는 APP에서 API 요청 시 쿠키가 자동으로 전송되지 않음
  • SSR 환경에서 Cookie를 읽어 토큰 상태를 구성해주어야 함
  • 개발 중 디버깅이 어려움

(방법 3️⃣) Next.js 서버에서 httpOnly Cookie로 만들기 + In-Memory 혹은 localStorage 에 저장

이 방법은 기존 방식을 거의 유지하면서 SSR 환경을 위해 로그인 후 응답 시점에 Next.js 서버에서 자체적으로 Cookie를 발행하는 방법이다. 이 방식은 고려한 방법 중 필요한 모든 조건을 가장 잘 충족 시켜줄 수 있는 방법이다.

CSR 환경에서는 기존에 개발하던 환경처럼 개발하면 되기 때문에 개발 시간을 많이 단축할 수 있을 고, SSR 환경에서는 ‘next/headers’ 를 통해 Cookie에 쉽게 접근 할 수 있기 때문에 이때도 개발하기 어느정도 수월함이 있다.

무엇보다 클라이언트에서 접근하기 쉽기 때문에 우리 프로젝트의 React Native + Webview 환경에서 토큰을 동기화 하기에도 수월하다는 장점이 있다.

그렇지만 이 방식은 CSR 환경과 SSR 환경을 따로 처리해야한다는 번거로움이 있고, 두 군데에서 저장하면서 토큰 내용이 불일치 할 수 도 있다는 단점이 있다. 이 부분은 accessToken을 재발급 하는 과정에서 동기화를 잘 처리해주면 문제가 없을 것 같다

장점

  • SSR/CSR 환경에서 모두 대응 가능
  • In-Memory에 저장 시 XSS 공격에 안정
  • 클라이언트에서 관리하기 쉬워서 React Native + Webview 동기화도 가능함

단점

  • accessToken이 두 군데 저장 → 동기화 이슈 발생 가능성 있음
  • localStorage에 저장 시 XSS 공격에 위험
  • 관리하는 저장소가 여러개다보니 구현 복잡도가 많이 증가한다.

결론

accessToken을 관리하기 위해 생각해본 방법 3가지 중 선택을 한다면 다음과 같은 사유로 선택할 수 있을 것 같다.

보안을 크게 신경쓰지 않고, 빠른 개발과 편의성을 가져가고 싶다!!

→ “(방법 1️⃣) 극단적으로 짧은 유효 시간을 지정 후, 일반 Cookie에 저장 ”

구현 복잡도가 커서 난이도가 조금 있지만, 보안성과 안정성, 편리성을 모두 가져가고 싶다!!

→ ” (방법 3️⃣) Next.js 서버에서 httpOnly Cookie로 만들기 + In-Memory 혹은 localStorage 에 저장 “

현재 우리 프로젝트에서 적용할 수 있는 가장 좋은 방법은 **“3️⃣번의 방법”**을 이용하는 것이 라는 생각이 든다.

하지만, 프로젝트를 진행할 당시에는 제한되는 상황들이 많이 있었다. 테스트를 시연하기까지 “3주 라는 짧은 시간” 동안 Webview 구조에 대한 학습과 메인 기능 개발로 인해 “인증/인가” 기능에 대해서는 우선순위가 많이 밀려있었다.

그래서 개발하던 당시에는 1️⃣번의 방법을 선택하여 빠르게 구성하고, 개발을 진행했었다. 최소한의 보안을 위해서 secure 과 sameSite 설정을 해주었다.

// 1. 쿠키 방식으로 액세스 토큰 생성
// app/api/login/route.ts (

nextResponse.cookies.set("accessToken", response.data.accessToken, {
  httpOnly: false,
  secure: true,
  sameSite: "strict",
  path: "/",
  maxAge: 60 * 60 * 24 * 30, // 개발 중 : 30일, 배포 시 :10분
})

추후 프로젝트가 리팩토링 단계에서는 3️⃣ 번의 방법과 더불어 Next.js에서 많이 이용한다는 Auth.js 를 함께 사용 해보려고 한다.


배운점 및 후기

Next.js에서 인증을 처리하는 과정에서 복잡하고, 다양한 상황 덕분에 많은 고민을 하는 시간을 가질 수 있었다. 이 과정에서 팀원과 함께 고민하는 것이 이상하게 재미있었고, 즐거웠던 것 같다.

(역시 나는 토론하고 고민하는 것을 좋아하는 듯하다…)

무엇보다 인증/인가 과정에서의 네트워크 흐름을 자세히 파악할 수 있었고, 그동안 헷갈렸던 XSS 나 CSRF 공격에 대해 체감하며 웹 보안에 대해 이해할 수 있었던 시간이었다.

프론트엔드 개발에서 사용자가 어떻게 우리 서비스를 이용할지를 고민하고, 적용하는 것이 가장 중요하다고 항상 생각한다. 개발 과정에서 UI/UX 뿐만아니라, 사용자의 개인정보를 책임진다는 사명감으로 보안도 철저하게 신경을 써줘야하겠구나 많이 배울 수 있었다.


Reference

프론트엔드 로그인 방식: 세션, 토큰, 그리고 JWT

🍪 프론트에서 안전하게 로그인 처리하기 (ft. React)

Next.js 에서 Auth하기(JWT를 안전하게 다루는 법)

Best Practices in Implementing JWT in Next.js 15

Cookie와 로그인 🍪(HttpOnly와 Secure Cookie 그리고 withCredentials)

JWT Security Best Practices 2025: Why JWTSecrets.com Sets the Gold Standard

함께 보면 좋은 콘텐츠