서론
Next.js를 쓰면서 가장 고민이 되었던 것은 바로 서버 사이드 렌더링 시, 데이터를 외부 API에서 가져와야 하는데
jwt와 같이 유저가 필요한 요청일 경우 어떻게 토큰을 가져오고 만약에 요청 중 토큰이 만료 됐을 경우였다.
나의 정보력이 부족했던 것이었는지, 아니면 진짜 정보가 없었던 것인지 모르겠다만, 내가 구글링, AI를 통해 얻은 정보들에는 내가 원하는 부분은 없었다.
httpOnly 쿠키
내가 예전에 쓴 글 중에 이런 망언을 했었다.
“”나는 개인프로젝트하면 토큰을 로컬스토리지에 저장을 한다.
딱히 쿠키의 장점을 모르겠고, 유저의 사용허가를 받아야한다는 점에서 로컬스토리지가 더 이점이 많아보였다.
하지만 Next.js의 서버컴포넌트에서는 로컬스토리지를 사용하지 못하기에 로컬스토리지에서 쿠키를 사용하게 되었다.
지금 다시 보면 도대체 무슨 생각이었는지 모르겠다.
이 글은 이 전전 글에서 발췌해왔는데, 이때 당시에는 httpOnly 쿠키의 존재를 알지 못했다.
HttpOnly 쿠키는 자바스크립트로 설정, 삭제, 변경할 수 없으며 오직 서버의 응답을 통해서만 설정, 삭제, 변경이 가능하다. 따라서 XSS(교차 사이트 스크립팅) 공격으로부터 쿠키 탈취를 방지할 수 있고, SameSite 옵션 설정을 통해 CSRF(사이트 간 요청 위조) 공격도 예방할 수 있다.
그래서 갑자기 얘가 왜 나오냐, 바로 내가 이번 서버 사이드 렌더링을 공부하면서 같이 공부한 녀석이기 때문이다.
그 이유는 다음 챕터에서 알 수 있다.
대소고의 관례
우리 학교의 거의 모든 프로젝트에서 로그인 기능을 살펴보면 서버가 응답 body에 jwt를 담아서 클라이언트에 전송해주면, 클라이언트가 토큰 저장소에 jwt를 저장했다. 나 역시 이러한 환경에 있다보니 자연스럽게 그게 당연한 줄 알고 있었지만, 얼마 전에 유튜브 제X초님의 영상 중 토큰 저장소 관련 영상을 보고 httpOnly 쿠키의 존재를 알게되었다.
그런데 내가 httpOnly 쿠키를 알게되었다고 해서 우리 학교의 모든 사람들이 알게되는 것 뿐만이 아니라, 앱 프로젝트도 함께 진행하는 프로젝트의 특성상 쿠키를 사용해 토큰을 보내주는 방식은 교내 프로젝트에 적용하기 매우 어려운 것이 현실이다. 따라서 외부 서버를 바꾸지 않고 클라이언트에서의 보안을 챙길 방법을 모색하는 것이 나의 최대 관심사가 되었다.
Next.js API Route
최근 Next.js를 이용해 개인 풀스택 프로젝트를 많이 하다보니 자연스럽게 또 쓰게 되었다.
앞서 말한 httpOnly 쿠키는 서버에서만 설정, 삭제가 가능하다. 그 말인 즉슨, Next.js의 API Route또한 클라이언트의 쿠키 설정이 가능하다는 뜻이다. Next.js API Route에서 외부 API의 jwt응답을 받아 클라이언트에 쿠키를 보내주면 외부 서버를 바꾸지 않고도 httpOnly 쿠키를 설정 할 수 있다.
동작 흐름
- 클라이언트는 로그인, 회원가입 요청을 Next.js API Route에 전송한다.
- Next.js API Route는 axios를 통해 외부 API 서버와 통신하여 로그인, 회원가입을 처리한다.
- 외부 API 서버에서 응답 body에 담아 보내준 jwt를 Next.js API Route가 수신한다.
- 수신한 jwt를 next/header의 cookies()를 통해 httpOnly 쿠키로 설정한다.
본론
서버 사이드 렌더링시 토큰을 삽입하는 법
간단하다. API Route에서 사용한 cookies()를 통해 서버 사이드 컴포넌트에서 쿠키를 읽어올 수 있다.
이 쿠키를 fetch의 header에 담아주면 쿠키 데이터를 요청에 삽입 할 수 있다. 클라이언트 컴포넌트에서는 cookies()를 쓰지 못하지만(클라이언트에서는 httpOnly 쿠키 접근 조차 못함), 클라이언트의 fetch는 자동으로 쿠키를 삽입해주기 때문에 상관없다.
문제
우리 학교 프로젝트의 서버들은 모두 토큰을 헤더의 Authorization 필드에 넣는다.
서버 사이드 렌더링시에는 쿠키 내부 값까지 접근이 가능하기 때문에 상관이 없지만, 클라이언트 요청에서는 손도 못대고 쿠키에 담아야한다. 그렇기 때문에 이제는 오히려 클라이언트 컴포넌트에서 API 요청 시 문제가 생겨버렸다.
해결시도 1
로그인, 회원가입 처럼 API서버의 엔드포인트에 하나씩 대응해서 쿠키를 받고 헤더의 Authorization에 넣어주는 방식이다.
딱 들어도 굉장히 비효율적이다. 이럴거면 그냥 프로젝트 전체를 Next.js하나로 관리하는게 낫다.
기각!
해결시도 2
시도 1과 비슷하지만 next.js app router의 미친 기능인 Catch-all Routes를 사용하는 것이다.
해결시도 1의 대부분의 기능을 차용했지만 모든 엔드포인트에 대응할 수 있다. /api/[...segments]를 보내면
[...segments] 부분에 들어간 경로들을 배열을 참조하는 방식으로 사용이 가능하다.
예를 들면 /api/users/123을 요청하면 segments에는 ['users', '123'] 형태로 저장된다.
export async function GET(req: Request, { params }: { params: { segments: string[] } }) {
const path = params.segments.join("/");
// fetch(`https://api.example.com/${path}`)
}.join("/")를 통해 배열을 users/123으로 변경해주고, /api부분만 떼서 외부 API에 보내면 되기 때문이다.
추가로 토큰 만료시 재발급도 해주었다. NextResponse나 cookies()를 이용하면 똑같이 가능하다.
마지막 보스
이제 나의 최종 목표였던 서버 사이드 렌더링 상태에서 토큰 재발급에 도전한다.
시도
앞서서 말했던 /api/[...segments]를 서버 사이드 렌더링에서도 사용하려 했지만,,,,
서버 사이드 렌더링 요청에서는 클라이언트가 보낸 쿠키는 읽을 수 있지만, 서버 사이드 렌더링 중간에 쿠키를 변경해도 그 변경사항은 현재 요청에 반영되지 않는다.
그 이유는 다음과 같다.
- 서버 사이드 렌더링은 브라우저에서 응답을 받는 것이 아닌 서버에서 응답을 받는다. 따라서 외부 서버가 아무리 쿠키를 설정해도 브라우저에는 영향을 미치지 않는다.
- 서버 사이드 렌더링 중간에 쿠키를 새로 설정하더라도, 브라우저로 보내질 응답 헤더가 이미 생성된 후라면 클라이언트에 전달되지 않기 때문에 변경사항이 적용되지 않는다.
이 사실을 이번에 처음 알게되었다.
그래서 멘붕이 왔다. 그럼 쿠키를 어떻게 변경하지??
해결방안
Next.js에도 미들웨어(!!!!)가 있다. Next.js 환경 내부에서 일어나는 모든 요청을 먼저 받아 변경할 수 있다.
나의 문제점은 서버 사이드 렌더링 환경에서의 요청을 클라이언트 사이드 렌더링에서의 요청과 비슷하게 생각했다는 점이다. (쿠키가 변경되지 않는 다는 걸 몰랐으니깐...)
그래서 나의 최종 해결 방안은 다음과 같다.
- 미들웨어를 페이지 요청 시에만 감지하도록 한다 -> 클라이언트에서 보내는 /api/~~ 요청까지는 감지하면 안되기 때문.
- 미들웨어에서 페이지 요청시 저장된 쿠키의 accessToken의 payload속 exp(만료시간)을 현재와 비교하여 만료되었는지 확인한다.
- 토큰이 만료되었다면 reissue 요청을 보내 토큰을 재발급 받은 후, cookies()를 이용해 쿠키를 재설정 한다. -> 이때는 페이지가 아직 렌더링되기 전이기 때문에 쿠키가 변경된 후 페이지가 브라우저로 전송된다. 따라서 쿠키(토큰)이 정상적으로 재발급된다.
클라이언트 요청에서도 변경된 쿠키를 똑같이 공유할 수 있기 때문에 별도의 쿠키 관련 로직을 작성하지 않아도 된다.
나의 무식했던 전전 글에서는 커스텀 엑시오스 어쩌고 하고 있었지만, 이제 다 필요 없어졌다. ㅋㅋ
나의 10시간에 걸친 삽질이 드디어 빛을 보았다.
후기
Next.js는 알다가도 모르겠다. 왜 사람들이 Next.js는 초기 설계와 목적에 비해 너무 어려워졌다고 하는지 알것 같은 경험이었다. 그래도 이번 경험에서 얻은게 정말 많다. httpOnly 쿠키 관련 지식과 서버 사이드 렌더링에 대한 지식 수준이 한 계단 상승한 것 같아 뿌듯했다. 덤으로 나의 Next.js 템플릿도 완성시켰다. ㅋㅋ
좀 더 Next.js 스러운 웹을 만들 수 있게 된 것 같아서 앞으로 템플릿을 애용할 예정이다.
내 템플릿
cli로 설치가 가능하다.
pnpm create cher1sh-next-app또는
npx create-cher1sh-next-app로 사용가능하다.
소스코드는 깃허브를 참고해주면 좋을 것 같다.
훈수는 언제나 환영
