0️⃣ INTRO
- 해당 글은 Nextjs v14.2.4 기준으로 작성되었습니다.
- Nextjs App Router를 사용하였습니다.
- SSR(Server-Side Rendering)과 ISR(Incremental Static Regeneration) 방식을 활용하는 프로젝트에서 마주친 문제를 다룹니다.
1️⃣ 문제 상황
리액트에서는 로그인 시 백엔드에서 제공하는 두 가지 토큰(accessToken, refreshToken)을 대부분 웹 스토리지(LocalStorage, SessionStorage)에 저장하여 사용해 왔다. 웹 스토리지에 토큰을 저장하면, API 호출 시 간편하게 토큰을 참조할 수 있어 편리했기 때문이다.
이번 프로젝트에서는 Nextjs 를 사용하였고, 초기 렌더링 성능을 높이기 위해 웬만하면 모든 페이지에 ISR(Incremental Static Regeneration)이나 SSR(Server-Side Rendering) 방식을 적용하고자 했다.
(하지만 지금 생각해보면 초기 렌더링 성능에 너무 집중한 나머지, 기술 도입의 근본적인 이유를 잊었던 것 같다. 😂 자세한 내용은 해결 과정에서 설명하겠다.)
문제는, 서버 컴포넌트나 서버에서 데이터를 fetch하는 경우, 렌더링 이전 단계에서 실행되므로 웹 스토리지를 사용할 수 없다는 점이었다. 결과적으로, 유저 정보에 따라 렌더링이 달라지는 페이지(예: 마이페이지, 관리자 페이지)에서는 웹 스토리지에 저장된 토큰을 사용할 수 없었고, SSR이나 ISR을 통한 데이터 prefetch 방식 역시 적용할 수 없었다.
따라서 나는 이 문제를 해결하기 위해 서버와 클라이언트 양쪽에서 토큰을 자유롭게 조회하고 관리할 수 있는 방법을 찾아보기로 했다.
2️⃣ 해결 과정
1. next/cookies
Nextjs 공식 문서에서는 cookie
함수를 통해 서버 컴포넌트에서 http 수신 요청 쿠키를 읽고, 서버 작업, API Route에서 발신 요청 쿠키를 읽고 쓸 수 있는 비동기 함수라고 소개한다.
여기서 나는 "jwt 토큰을 쿠키에 저장한다면 어떨까?" 라는 의문을 품게 되었다. 웹 스토리지 처럼 서버에서 사용할 수 있는 스토리지 개념으로 쿠키에 접근하면 되지 않을까? 하는 생각이 들었고, 이에 기반하여 코드를 작성하게 되었다.
next/cookies 의 cookies() 함수를 이용하여 받아온 토큰을 쿠키에 저장하려고 했지만 사용할 수 없었다. cookies() 함수는 서버측에서만 사용할 수 있는 함수이기 때문이다. 현재 로그인 로직은 클라이언트 컴포넌트에서 사용되고 있었기 때문에 로그인 로직에서 cookies() 함수를 사용할 수도, 'use server' 를 사용한 server action 또한 사용할 수 없었다.
클라이언트 컴포넌트에서는 쿠키를 설정할 수 없는걸까? 🤔
정답은 반은 맞고 반은 틀리다. 클라이언트 측에서 쿠키를 설정 할 수는 있지만 보안적인 측면을 고려하여 httpOnly 가 적용된 쿠키를 설정하려고 했을때, 쿠키에 저장이 되지 않는 것을 발견했다. (아마도 보안상 서버측에서만 httpOnly 설정이 먹히는듯 하다..)
따라서 쿠키를 서버측에서 설정해줘야했는데 이는 아래의 Route Handler를 통해 해결하고자 했다.
2. Route Handler
Route Handler는 Nextjs Pages Router의 API Route와 같은 역할을 하는데, 백엔드에서 RESTful 한 API를 만드는 것 처럼 프론트 서버 단에서 API를 만들 수 있다. 서버측에서 사용할 수 있는 Nextjs의 headers, cookies 와 같은 동적 함수를 사용할 수 있다.
따라서 로그인을 진행할때 백엔드의 API를 바로 이용하는 것이 아니라 Route Handler를 한번 거치도록 설정하여 프론트 서버단에서 cookie 값을 설정해준다는 개념으로 로직을 작성하였다.
// api/token/route.ts
import axios from "axios";
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
// login API 요청
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/user/login`,
{ email, password }
);
const { authorization, refreshtoken } = response.headers;
// 토큰 정보 Cookie set
cookies().set("accessToken", authorization, { httpOnly: true });
cookies().set("refreshToken", refreshtoken, { httpOnly: true });
} catch (error) {
// 에러처리 로직...
}
}
3. 토큰 사용성에 대한 고민
- accessToken이 쿠키에 저장되어 있어 사용하기 불편한데 이를 어떻게 해결할 것인가? 🤔
- accessToken을 Route Handler를 통해 리턴값으로 내보낸후, 클라이언트 측에서 해당 값을 받았을때 localStorage에 저장한다. - refreshToken을 쿠키로 사용했을때 CSRF 공격은 어떻게 대응할 것인가? 🤔
- samesite 속성을 이용하여 배포시에 같은 origin 에서만 쿠키가 공유되도록 설정 (아직은 개발단계이기 때문에 아직 설정은 안해줬다.) - 데이터 prefetch 시에 accessToken을 어떻게 서버측에서 사용할 것인가? 🤔
- refreshToken을 이용해서 accessToken을 재발급 하여 토큰을 서버측에서 일회성으로 사용한다.
4. 그런데.. 사용자 정보를 이용한 데이터가 검색엔진에 잡히는것이 맞는가..?
위의 문제상황에서 초기 렌더링 성능에 너무 집중한 나머지, 기술 도입의 근본적인 이유를 잊었다고 했는데, 바로 이것 때문이다. 사용자 정보가 들어간 데이터가 검색 엔진에 잡힌다는 것 자체가 이상하다고 생각이들었다..😂
따라서 데이터 prefetch 는 진행하지 않기로하고, 인증인가가 필요한 페이지에서 토큰값만 받아 유효성 검사를 하는 방식으로 바꾸게 되었다. 쿠키에 저장된 refreshToken을 이용하여 accessToken을 발급받고, 이를 이용해 접근할 수 있는 토큰인지 검사를 한 후 정상적인 접근일때 페이지를 띄워준다. 만약 접근할 수 없는 토큰이라면, redirect 를 이용하여 로그인페이지로 이동시키도록 처리하였다.
3️⃣ 느낀점
토큰을 사용한 클라이언트측과 서버측의 사용 방식에 대해서 많이 고민했고, 토큰을 분리해서 저장함으로써 문제를 해결하였다. 항상 프론트 서버에 대해서 정확하게 알지 못하고 사용했었는데, 이번 트러블 슈팅을 통해서 제대로 알게되었다.
또한, 무조건 기술이 좋다고해서 적용부터 하고보는 습관도 고쳐야할것 같다고 느꼈다.. 😂 항상 해당 기술을 도입하려는 근본적인 이유를 되뇌이며 코드를 작성하는 것이 정말 중요하다고 생각했다.
(+ 공식문서를 열심히 잘 읽자..!! 🔥)