어느날, 로그인이 풀리는 이슈가 발생하였고 리팩토링을 진행하며 기존의 인증방식에 대한 흐름을 파악하며 겪었던 문제들과 개발팀 회의를 통해 세션 인증 방식으로 변경하게 된 과정을 기록으로 남기기로 하였습니다.
쿠키란 웹사이트가 사용자의 브라우저에 저장하는 작은 데이터 조각으로 기본적으로 Key-Value 형태로 구성되어 있으며 4KB의 크기 제한이 있으며, 만료 시간을 설정할 수 있습니다.
또한 HTTP 요청시 따로 설정하지 않아도 자동으로 전달된다는 특징이 있습니다.
쿠키의 가장 큰 단점은, 자동으로 전달되는 특징 때문에 보안에 취약하다는 점이며,
이러한 단점을 보완하기 위해 보통 다음과 같은 옵션을 설정합니다.
HttpOnly
Secure
세션은 쿠키의 단점을 보완하는 방식으로, 클라이언트의 민감한 정보를 서버측에서 저장하고 관리합니다.
클라이언트가 사용자 정보를 서버로 보내 로그인 요청을 하면, 서버는 인증된 사용자 정보를 세션 저장소에 저장하여 SessionID를 발급하여 클라이언트에게 보내주는 방식입니다.
실제로는 세션 방식 인증 또는 토큰 인증 방식을 사용합니다
JWT토큰 인증 방식으로 설명합니다
세션의 경우 성능 문제나 확장성이 어렵다는 단점이 있어 세션 방식을 보완하는 토큰 인증 방식이 등장했습니다.
토큰은 클라이언트 측에 저장하고, 서버는 이 토큰을 검증하는 방식으로 JWT 토큰은 시크릿키(SecretKey)를 사용하여 JWT토큰을 만들고, 시크릿키를 사용하여 인증과정을 거치는 방식으로 JWT 자체는 해독하기 쉽기 때문에 민감한 정보를 담지 않아야 합니다.
토큰의 경우 탈취를 당할 수 있고, 해독하기가 쉽기 때문에 토큰의 유효기간을 짧게 설정하는 경우가 많습니다.
Authorization 헤더
에 추가하여 서버에 전송Authorization: Bearer <token>
저희 회사는 위의 인증 방식 중 토큰 인증 방식을 선택하여 구현하였습니다.
accessToken
이 만료된다면 refreshToken
으로 accessToken을 갱신해야 하는데, 갱신이 되지 않아서 로그인이 풀리는 문제가 발생하였고, 로그인 흐름을 따라가면서 문제를 파악했습니다.
일단, 처음 초창기때 api를 호출하는 파일은 아래와 같았습니다.
이후 저희는 리팩토링을 통해 아래의 두가지 파일이 더 추가됩니다.
이미 기존에 만들었던 파일들을 사용하는 로직이 많았기 때문에, 리팩토링한 파일로 모두 변경할 시간적 여유가 없었습니다. 그래서 저희는 앞으로 개발하거나 리팩토링을 할 때는 새로운 파일을 적용하는 방식으로 진행하며 개발을 해오고 있었습니다.
그리고 이때, accessToken이 만료될 경우 refreshToken으로 갱신하는 로직이 새로 만든 파일에는 적용되어 있었지만, 기존의 레거시 파일에는 적용되어 있지 않는 원인을 파악했습니다.
문제점을 파악한 후, 레거시 코드에서 토큰 갱신 로직을 추가해줌으로써 토큰 갱신 문제는 해결했습니다.
이제 토큰이 정상적으로 잘 갱신되어서 로그인이 풀리는 이슈는 모두 해결되었습니다.
하지만 개발팀 회의에서 이번 이슈를 공유하고 의견을 나누는 과정에서 JWT 토큰 방식이 아닌 세션 인증 방식으로 마이그레이션하기로 결정했습니다.
JWT 토큰의 경우 탈취 위험이 있지만 accessToken의 만료기간을 짧게 설정하는 것으로 해결 할 수 있습니다.
하지만 내부에서 사용자 편의성을 위해 로그인 유지 기간을 길게 유지하면 어떠냐는 의견이 나왔으나 토큰 인증방식의 경우 만료기간이 길 경우 보안 이슈와, 기존 로직에서도 문제점이 발견되어서 세션 인증 방식으로 마이그레이션하기로 결정되었습니다.
이제 세션 인증 방식으로 마이그레이션을 진행해보겠습니다.
세션 인증 방식의 Secure 옵션을 사용하기 때문에 https로 도메인 맞추는 작업이 필요해졌습니다.
이전 로컬에서 개발을 할 때에는 http://localhost:3000
으로 개발을 진행했는데요, Secure 옵션을 사용함으로 http 에서는 쿠키가 전송되지 않기 때문에 클라이언트는 서버에서 보낸 쿠키를 전달받지 못하는 문제가 발생합니다.
그래서 로컬환경에서도 쿠키를 잘 전달받을 수 있도록, 도메인 변경작업을 먼저 시작합니다.
또한, IOS 앱 심사에서는 애플 로그인이 필수로 들어가야 하며 callbackUrl이 카카오, 구글, 네이버와는 다르게 애플의 경우 https로 적용되어야 하는 문제점도 있기 때문에 https로 도메인을 적용해줍니다.
https://local.domain.com
로컬 환경에서 https로 도메인 적용하는 방법은 이 블로그를 참고해주세요
React & Vite 로컬 환경에서 도메인 & HTTPS 적용하기
도메인을 맞춰준 후 axios와 fetch API에서 쿠키를 전달 받을 수 있도록 설정해줍니다.
axios에서는 서버에서 보내주는 쿠키를 브라우저에서 저장하기 위해 withCredentials
옵션 설정을 해줍니다.
export const apiClient = axios.create({
withCredentials: true,
});
withCredentials
: axios가 브라우저의 쿠키(세션 쿠키 포함)를 요청과 함께 전송하도록 하기 위한 설정
withCredentials
을 설정해줌으로써 axios가 쿠키에 저장된 사용자의 SessionID를 서버로 요청합니다
또한 서버가 Set-Cookie 헤더를 포함해서 쿠키를 설정했을 때, withCredentials: true
가 설정되어 있어야 브라우저가 이 쿠키를 저장할 수 있습니다.
FetchAPI에서는 credentials: 'include’
옵션을 설정합니다.
const response = await fetch(`/api/blog`, {
headers,
credentials: 'include',
});
FetchAPI에서는 include 옵션을 설정하지 않으면, 쿠키가 자동으로 포함되지 않기 때문에 필수로 설정해줍니다.
omit
(기본값): 쿠키를 절대 포함하지 않음same-origin
: 같은 출처(same-origin)에서는 쿠키를 포함하지만, 다른 출처(CORS) 요청에서는 쿠키 포함 안 함include
: 출처에 관계없이 항상 쿠키를 포함이렇게 axios와 fetch API 옵션 설정도 되었으니, 금방 문제가 해결될 줄 알았습니다..
하지만 Next.js 14버전을 사용하면서 생각지 못했던 트러블 슈팅들이 발생했고 그 과정을 기록으로 남기기로 했습니다.
서버 컴포넌트에서 간단한 테스트 중 쿠키를 전송 받지 못하는 이슈가 발생했습니다.
클라이언트의 쿠키에 SESSIONID가 저장되어있고, 모든 요청이 정상적으로 되어있었지만 서버는 쿠키를 전달받지 못한 이슈였습니다.
그 이유는 path
설정이 /api
로 되어있기 때문이였습니다.
path 설정을 /api로 지정했기 때문에 /api
경로에 대해서만 쿠키를 전달받을 수 있게 설정이 되어있는데, 서버 컴포넌트의 경우 페이지를 접근할 때 /posts
와 같은 경로이기 때문에 서버에서는 쿠키를 전달받지 못한 상황이였습니다.
이후 백엔드에서 api 설정을 / 로 풀어줌으로서 해결할 수 있었습니다.
path 설정을 /api로 지정했던 이유는 회사 내부 사정이 있었습니다.
두번째로 FetchAPI에 credentials: 'include’
를 설정했지만 쿠키가 전달되지 않는 문제가 발생했습니다.
서버 컴포넌트도 서버라는 것을 공부할 때는 기억하고 있어도 막상 개발을 할 때에는 잊는 경우가 있는데 이번 경우도 그 문제였습니다.
서버 컴포넌트는 브라우저 환경이 아닌 Node.js 환경에서 실행됩니다.
즉, cookies()
API를 통해 쿠키에 접근할 수 있지만, 자동으로 쿠키가 전송되지 않기 때문에 명시적으로 헤더에 포함시켜야합니다.
클라이언트 컴포넌트에서 사용자가 로그인하면, 백엔드 서버가 SESSIONID를 포함한 쿠키를 응답으로 보냄.
브라우저는 해당 쿠키 SESSIONID를 자동으로 저장하며, 이후 같은 도메인으로 요청할 때 자동으로 쿠키를 포함해서 요청을 보냄.
사용자가 서버 컴포넌트가 포함된 페이지를 요청
브라우저는 Next.js 서버(서버 컴포넌트)에 페이지 요청을 보낼때 SESSIONID 쿠키를 자동으로 포함해서 요청보낸다.
GET /my-page HTTP/1.1
Host: local.domain.com
Cookie: SESSIONID=abcdef12345
브라우저가 자동으로 보낸 SESSIONID를 서버 컴포넌트는 cookies() API를 통해 쿠키를 읽는다.
import { cookies } from 'next/headers';
export async function getSessionFromCookies() {
const nextCookies = cookies();
// 브라우저가 보낸 세션 쿠키를 가져온다
const sessionId = nextCookies.get('SESSIONID')?.value;
return sessionId;
}
서버 컴포넌트가 API요청을 할 때 쿠키를 직접 헤더에 추가한다.
즉, 서버 컴포넌트는 브라우저가 보낸 HTTP 요청의 Cookie 헤더에서 SESSIONID 가져옵니다.
const headers: Record<string, string> = {};
// JWT 토큰이 있으면 Authorization 헤더 추가
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 세션 쿠키가 있으면 Cookie 헤더 추가
if (sessionID) {
headers['Cookie'] = sessionID;
}
이렇게 직접 헤더에 쿠키를 포함시키면서 문제를 해결할 수 있었습니다.
간단하게 나열했지만, 실제로는 더 많은 과정을 거쳐서 진행했습니다.
JWT 토큰을 유지하면서 세션 인증 방식으로 변경하는 과정이나, 버셀의 도메인 설정, QA 진행을 하면서 발견된 이슈들 등등 제가 생각했던 것보다 범위가 많았습니다.
그럼에도 이번 트러블 슈팅이 의미있던 점은, 토큰 방식부터 세션 인증 방식으로 마이그레이션을 직접 해보면서 로그인 구현 흐름을 잘 파악할 수 있게 되었고, 서버 컴포넌트에 대해서도 다시 한번 배울 수 있었습니다.
다음번 과제로는 한번도 경험해보지 못한 과제를 할당받아서 조금 막막하지만 재미있을 것 같아서 다음 과제도 진행하고 나면 블로그로 작성해보도록 하겠습니다.
좋은 글 잘 읽었습니다:)