Remix Session으로 JWT 토큰 관리하기

Remix 프레임워크로 만들어진 웹에서 API 서버와 JWT로 인증을 처리해야 한다면, 그 토큰은 어떻게 관리되어야 할까요? Remix Session을 활용해서 SSR, CSR 두 시점에서 인증이 필요한 API를 호출할 수 있게 하는 방법을 소개합니다.

2024-09-19에 씀

사이드 프로젝트를 진행하면서 인증을 구현해야 하게 됐다. 인증 방식은 JWT를 사용하며, accessToken과 refreshToken을 사용해 인증을 처리하기로 했다. 이 경우 기본적인 플로우는 아래와 같다.

  1. 클라이언트가 로그인 요청을 보낸다
  2. 서버는 로그인 요청을 검증하고, 올바른 요청이라면 accessToken과 refreshToken을 넘겨준다
  3. 클라이언트는 두 토큰을 어딘가에 보관하고, 인증이 필요한 요청을 보낼 때 요청의 Authorization 헤더에 accessToken을 넣어 보낸다
  4. accessToken이 만료되면, 다시 로그인하지 않아도 refreshToken으로 새로운 accessToken을 발급받을 수 있다

문제는 이 토큰을 보관할 적절한 위치를 찾는 것이다. 이 프로젝트에서 웹 프론트엔드는 Remix 프레임워크 기반의 SSR 방식으로 구성되어 있는데, 이 상황에서 토큰을 저장할 수 있는 곳은 크게 두 가지로, Remix 노드 서버와 웹 브라우저가 있다.

처음에는 일반적인 방식대로 웹 브라우저의 localStorage에 accessToken을 저장하고, httpOnly 쿠키로 refreshToken을 저장했다. 그런데 이렇게 하면 Remix 서버에서는 localStorage에 접근할 수 없으므로, 토큰에 접근할 수 없다. 따라서 인증이 필요한 API는 SSR 시점에 호출할 수 없게 된다. 서비스의 페이지 대부분이 로그인 유저에게 제공되고 있기 때문에, SSR 시점에 API를 호출할 수 없다면 SSR을 하는 의미가 없어진다.

따라서 토큰은 Remix 노드 서버가 관리해야 한다. 그런데 서버는 클라이언트의 상태를 저장하지 않기 때문에, 클라이언트의 상태를 저장할 방법과 클라이언트를 식별할 방법이 필요하다. 클라이언트의 정보를 저장하기 위해 서버의 세션 스토리지를 사용하고, 클라이언트를 식별하기 위해 쿠키를 사용할 수 있다.

세션 스토리지 만들기

1type AuthSessionData = {
2 accessToken: string;
3 refreshToken: string;
4 expiredAt: string;
5};
6
7type SessionFlashData = {
8 error: string;
9};
10
11export type AuthSession = Session<AuthSessionData, SessionFlashData>;
12
13const { getSession, commitSession, destroySession } = createCookieSessionStorage<AuthSessionData, SessionFlashData>({
14 cookie: {
15 name: '__session', // 쿠키에 저장할 세션 저장소의 이름
16 httpOnly: true, // http 요청에서만 접근 가능
17 maxAge: 60 * 60 * 24 * 10, // 쿠키의 수명 (초)
18 path: '/',
19 sameSite: 'lax', // 동일 사이트 요청이거나, 다른 사이트에서 a, link, img 태그를 통해 전송된 요청
20 secrets: ['s3cret1'], // 쿠키 서명. 변조 방지를 위한 비밀 키
21 secure: process.env.NODE_ENV !== 'development', // https를 통해서만 전송할 것인지
22 },
23});
24
25// request 객체에서 세션을 바로 가져오는 함수
26const getAuthSession = async (request: Request) => {
27 return getSession(request.headers.get('Cookie'));
28};
29
30export { getAuthSession, commitSession, destroySession };

쿠키 세션을 사용할 경우, 세션의 데이터가 쿠키 자체에 저장된다. 이 경우 추가적인 데이터베이스가 없어도 된다는 장점이 있지만, 브라우저 상에서 쿠키의 최대 크기를 4kb로 제한하고 있다는 한계가 있다. 브라우저 쿠키에 데이터가 저장되기 때문에 값을 추적하거나 고유 ID를 부여하는 것도 어렵다.

그 외에는 파일 백업, Cloudflare Workers KV, Amazon DynamoDB 등을 사용할 수 있도록 해주는 Remix 기본 제공 함수가 있다. 세션 저장을 위한 진실의 원천을 두면, 쿠키에는 세션의 ID 값만 저장하면 된다.

이 글에서는 일단 쿠키에 세션 데이터를 저장하는 createCookieSessionStorage를 사용해 세션을 관리한다. 이 함수가 반환하는 commitSession, destroySession 은 응답 헤더의 Set-cookie 에 넣어줄 값을 만들기 위한 함수이다. AuthSession을 만들어서 클라이언트 쿠키에 세션을 커밋해 주었다면, 쿠키는 브라우저에 아래 스크린샷과 같이 저장된다.

로그인

인증 정보를 생성하려면 유저가 로그인을 시도해야 한다. /auth?code={code} 경로로 진입하면 해당 페이지의 loader가 API 서버에 로그인 요청을 보내도록 했다. 응답으로 토큰을 받으면 그 토큰 데이터로 세션을 만들고, 클라이언트에 보내주는 응답 헤더에 쿠키를 설정해 준다.

1// src/app/routes/auth.tsx
2
3// /auth 경로로 진입 시 실행할 loader
4export const loader: LoaderFunction = async ({ request }) => {
5 const searchParams = new URL(request.url).searchParams;
6 const code = searchParams.get('code');
7
8 // code 파라미터가 없다면 잘못된 접근이다
9 if (!code) {
10 throw new Response('', {
11 status: 404,
12 statusText: '',
13 });
14 }
15
16 try {
17 // 백엔드 API를 호출해서 JWT 토큰을 받아온다
18 const { data } = await loginKakao({ code });
19
20 // request headers에서 session을 가져와 수정해준다
21 const session = await getAuthSession(request);
22 session.set('accessToken', data.accessToken);
23 session.set('refreshToken', data.refreshToken);
24 session.set('expiredAt', generateExpiredDate());
25
26 // 로그인에 성공했다면 메인 페이지로
27 return redirect('/', {
28 headers: {
29 // 클라이언트의 쿠키에 세션을 설정해준다
30 'Set-Cookie': await commitSession(session),
31 },
32 });
33 } catch (e) {
34 console.error(e);
35 return redirect('/login');
36 }
37};

앞으로 쿠키가 만료될 때까지 브라우저가 Remix 노드 서버에 보내는 요청에는 session 정보가 포함된다. 노드 서버에서는 쿠키에 접근해서 세션 정보를 알아올 수 있다.

SSR 시점에 토큰 사용하기

요청 헤더의 쿠키에 세션 정보가 담겨 있으므로, 리믹스 서버에서는 이 정보를 사용해서 accessToken을 가져올 수 있다. 이 토큰이 있으면 리믹스 서버에서도 인증이 필요한 API를 호출할 수 있다.

accessToken을 안전하게 가져오기 위해 authenticate 함수를 만들었다.

1// request로부터 accessToken을 가져오는 것을 시도해 본다
2export const authenticate = async (request: Request) => {
3 const session = await getAuthSession(request);
4 const expiredAt = session.get('expiredAt');
5 const accessToken = session.get('accessToken');
6
7 // 세션에 아예 토큰이 없으면, 로그인 페이지로
8 if (!accessToken) {
9 throw redirect('/login', {
10 headers: {
11 // 세션을 제거하고, 헤더에서도 제거해준다
12 'Set-Cookie': await destroySession(session),
13 },
14 });
15 }
16
17 try {
18 // 만료일을 지났다면, refreshToken을 사용해서 accessToken을 다시 발급받아 본다
19 if (!expiredAt || isDateExpired(expiredAt)) {
20 const { data } = await requestRefreshToken(request);
21
22 return { accessToken: data.accessToken, refreshToken: data.refreshToken };
23 }
24 } catch (e) {
25 // refreshToken 발급 도중 에러가 발생했다면, 로그인 페이지로 redirect
26 throw redirect('/login', {
27 headers: {
28 'Set-Cookie': await destroySession(session),
29 },
30 });
31 }
32
33 // 별 문제 없다면, accessToken을 반환한다
34 // 이 토큰은 사용처에서 알아서 사용하면 된다
35 return { accessToken };
36};

사용처에서는 반환된 accessToken을 요청 header의 Authorization에 넣어 사용하면 된다. 단, refreshToken이 함께 전달된 경우에는 응답 헤더에 세션 쿠키를 넣어줘야 한다.

1// src/app/routes/_index.tsx
2export const loader = async ({ request }: LoaderFunctionArgs) => {
3 const { accessToken, refreshToken } = await authenticate(request);
4
5 const { data } = await getAllInfo({
6 headers: {
7 Authorization: `Bearer ${accessToken}`,
8 },
9 });
10
11 return json({ profileList: data }, {
12 headers: {
13 ...(refreshToken && { 'Set-Cookie': await commitSession(updateSessionToken(request, { accessToken, refreshToken }))})
14 }
15 });
16};
17
18const updateSessionToken = async (request: Request, { accessToken, refreshToken }: { accessToken: string; refreshToken: string; }) => {
19 const session = await getAuthSession(request);
20
21 session.set('accessToken', data.accessToken);
22 session.set('refreshToken', data.refreshToken);
23 session.set('expiredAt', generateExpiredDate());
24
25 return session;
26}

CSR 시점에 토큰 사용하기

간혹 클라이언트 측에서 API 서버에 직접 요청을 보내야 하는 경우가 있다. 그런데 쿠키를 httpOnly로 저장했으므로 클라이언트 측에서 쿠키에 담긴 세션의 정보를 알 수 없다.

보안을 위해 httpOnly는 풀 수 없고, 만약에 쿠키에 담긴 세션에 접근한다고 해도 데이터를 쿠키가 아닌 다른 곳에 저장해서 쿠키에는 세션 ID만 담겨 있다면 accessToken에는 접근 불가능하다.

이 상황에서 accessToken을 사용하기 위해 시도한 방법은 두 가지가 있다.

  1. loader가 accessToken을 클라이언트에 넘겨준다
  2. 리믹스 서버가 API 서버의 게이트웨이 역할을 한다. 즉, 모든 API 요청은 리믹스 서버를 거친다

여기서는 2번 방법을 사용해서 해결해봤다. api.$.tsx 네이밍으로 라우트 파일을 생성하면, 리믹스 서버에 /api 경로로 보내지는 요청을 받을 수 있다. loader와 action 함수를 각각 아래와 같이 생성하면 리믹스 서버가 API 서버에 대한 프록시 서버 역할을 하게 만들 수 있다. 클라이언트 측에서는 API 서버에 바로 요청을 보내는 것이 아니라 리믹스 서버로 먼저 요청을 보내게 된다.

1// /app/routes/api.$.tsx
2
3const apiURL = new URL(process.env.API_BASE_URL ?? '');
4
5// GET 요청 프록시
6export const loader: LoaderFunction = async (args) => {
7 const { accessToken, refreshToken } = await authenticate(args.request);
8
9 const url = new URL(args.request.url);
10 url.protocol = apiURL.protocol;
11 url.host = apiURL.host;
12 url.port = apiURL.port;
13
14 const response = await fetch(
15 url.toString(),
16 new Request(args.request, {
17 redirect: 'manual',
18 headers: {
19 Authorization: `Bearer ${accessToken}`,
20 },
21 }),
22 );
23 return json(await response.json(), {
24 headers: {
25 ...(refreshToken && { 'Set-Cookie': await commitSession(updateSessionToken(request, { accessToken, refreshToken }))})
26 },
27 });
28};
29
30// POST 요청 프록시
31export const action: ActionFunction = async (args) => {
32 const { accessToken, refreshToken } = await authenticate(args.request);
33
34 const url = new URL(args.request.url);
35 url.protocol = apiURL.protocol;
36 url.host = apiURL.host;
37 url.port = apiURL.port;
38
39 const response = await fetch(
40 url.toString(),
41 new Request(args.request, {
42 redirect: 'manual',
43 headers: {
44 'Content-Type': args.request.headers.get('Content-Type') ?? '',
45 Authorization: `Bearer ${accessToken}`,
46 },
47 }),
48 );
49 return json(await response.json(), {
50 headers: {
51 ...(refreshToken && { 'Set-Cookie': await commitSession(updateSessionToken(request, { accessToken, refreshToken }))})
52 },
53 });
54};

Logout

클라이언트가 로그아웃을 요청하면, 세션에 저장된 인증 정보를 지워주면 된다. 헤더에 Set-cookie 를 넣어줄 때, destroySession 이 반환하는 값을 넣어주면 된다.

1// /app/routes/logout.tsx
2export const loader = async ({ request }: LoaderFunctionArgs) => {
3 const session = await getAuthSession(request);
4 return redirect('/login', {
5 headers: {
6 // 세션을 제거하고, 헤더에서도 제거해준다
7 'Set-Cookie': await destroySession(session),
8 },
9 });
10};

API 응답 에러 처리하기

세션의 expiredAt 시점을 넘기지 않았을 때에도, 서버의 변경으로 인해 accessToken이 만료되었을 수도 있다. 이 경우 인증이 필요한 요청을 보내게 되면 401 에러가 발생한다. 이 경우, 사용자에게 로그인을 요청하지 않고도 refreshToken을 사용해 새로운 accessToken 발급을 요청해 볼 수 있다.

/refresh 경로가 아닌 요청에서 401 에러가 발생했거나, accessToken을 발급받은지 하루가 지났다면 아래 함수를 호출해서 refresh 요청을 보낸다.

1export const requestRefreshToken = async (request: Request) => {
2 const session = await getAuthSession(request);
3 try {
4 // 세션의 토큰으로 refresh 요청을 보내 본다
5 const { data } = await refreshToken({
6 accessToken: session.get('accessToken')!,
7 refreshToken: session.get('refreshToken')!,
8 });
9
10 return { data };
11 } catch (e) {
12 // refresh 요청에 실패했다면, 쿠키의 세션을 없애고 로그인 페이지로 redirect 시킨다
13 // 쿠키에 세션이 남아 있으면 로그인한 유저로 판단될 수 있다
14 throw redirect('/login', {
15 headers: {
16 'Set-Cookie': await destroySession(session),
17 },
18 });
19 }
20};

에러가 발생할 경우 그 에러를 한 곳에서 일괄적으로 처리하고 싶어서 Axios에서 제공하는 interceptor를 활용했다. Axios 설정은 entry.server.ts 파일에서 처리해줬다.

1// entry.server.ts
2
3// axios 설정을 해준다
4axios.defaults.baseURL = process.env.API_BASE_URL;
5axios.defaults.withCredentials = true;
6axios.interceptors.response.use(
7 (response) => {
8 return response;
9 },
10 async (e) => {
11 if (e.status !== 401 || e.request.url.includes('refresh') || e.request.config.sent || !e.config) return;
12
13 const accessToken = await requestRefreshToken(e.request);
14 e.config.headers.Authorization = `Bearer ${accessToken}`;
15 // 최대 2번까지만 요청을 보내기 위해 플래그를 설정해 둔다
16 e.config.sent = true;
17 return axios(e.config);
18 },
19);
20
21export default async function handleRequest() {
22 // ...
23}

의도대로 동작하긴 하지만 사실 완벽한 해결책은 아니라고 생각하는데, entry.server.ts 파일 전역 스코프에서 실행하는 코드가 서버 초기화 시점에 실행되는 코드는 아니기 때문이다.

Remix 서버가 실행되는 코드를 보면, entry.server.ts 에서 내보내는 handleRequest를 가져오기 위해 entry.server.ts 를 불러오는데, 이 과정에서 파일 전역에 작성한 코드가 실행되는 사이드 이펙트가 발생하는 것 같다. 더 확실하게 해결하기 위해선 커스텀 서버를 만들어서 서버 초기화 시점에 axios 설정 코드를 실행하게 하는게 좋을 것 같다.

그 외에도, 모든 클라이언트 요청에 대해 하나의 Axios 설정을 공유하게 되기 때문에 Axios 인스턴스가 특정 클라이언트의 정보를 기억하지 않도록 해야 한다는 주의점이 있다.

그 외 다양한 시도들…

더 좋은 방법이 있을까 해서 다른 방법들을 시도해 봤는데, 결론적으로는 더 나쁜 방법들이었다(…) 그렇지만 덕분에 Remix 동작에 대해 더 깊게 알게 된 것 같아 추가로 남겨두었다.

loader, action을 고차 함수로 감싸기

API 서버로 보내는 요청은 모두 loader나 action 함수를 거치게 되었으니, loader와 action에서 발생하는 에러를 전부 캐치하는 무언가가 있으면 에러를 일괄적으로 처리할 수 있겠다고 생각했다.

loader와 action을 감싸서 함수 실행 이전에 accessToken을 가져오고, 함수 실행 도중에 발생하는 에러는 캐치해서 401 에러가 발생한 경우에 refresh 요청을 보내는 고차 함수를 작성했다.

1// 사용처
2export const loader = withAuthenticated(async ({ params }, accessToken) => {
3 const { key } = params;
4
5 const { data } = await getInfo(key, {
6 headers: {
7 Authorization: `Bearer ${accessToken}`,
8 },
9 });
10
11 return { profile: data };
12});
13
14// == 고차 함수 구현 ==
15// loader 혹은 action 함수의 타입
16type Handler<T> = (args: LoaderFunctionArgs | ActionFunctionArgs) => Promise<T>;
17
18// loader나 action을 감쌀 함수의 타입
19type WithAuthenticated = <T>(
20 callback: (args: LoaderFunctionArgs | ActionFunctionArgs, accessToken: string) => Promise<T>,
21) => Handler<T>;
22
23// 고차 함수
24export const withAuthenticated: WithAuthenticated = <T>(
25 callback: (args: LoaderFunctionArgs | ActionFunctionArgs, accessToken: string) => Promise<T>
26) => {
27 return async (args: LoaderFunctionArgs | ActionFunctionArgs) => {
28 const { request } = args;
29 const session = await getAuthSession(request);
30 const accessToken = session.get('accessToken');
31
32 if (!accessToken) {
33 throw redirect('/login');
34 }
35
36 try {
37 // accessToken을 넘겨주며 loader나 action을 호출한다
38 return await callback(args, accessToken);
39 } catch (e) {
40 // loader나 action을 실행하던 도중 401 에러가 발생했다면, refreshToken을 시도해본다
41 if (e instanceof AuthorizationError) {
42 ...
43 // refresh에 성공했고, GET 요청이라면, 다시 요청을 시도해 본다
44 if (request.method === 'GET')
45 throw redirect(request.url, {
46 headers: {
47 'Set-Cookie': await commitSession(session),
48 },
49 });
50 throw e;
51 }
52
53 throw e;
54 }
55 };
56};

그런데 이런 식으로 loader나 action을 고차 함수로 감싸는 형태는 Remix 공식 문서에도 명시되어 있는 서버 안티 패턴 중 하나이다.

리믹스 라우트 파일은 하나의 파일에 서버 코드와 클라이언트 코드가 공존한다. 그러나 실제 클라이언트 코드를 빌드할 때에는 서버 코드가 모두 제거되어야 하므로, 리믹스는 클라이언트 코드를 만들기 위해 라우트 파일의 프록시 모듈을 만든다.

1export { meta, default } from './routes/post.tsx'

만약 모듈의 전역 스코프에 실행되는 코드가 있다면, 모듈을 import 하는 것만으로도 코드가 실행된다.

1// import 시점에 실행됨! 클라이언트 코드에서 process에 접근하게 되어 오류가 발생한다
2console.log(process.env.NODE_ENV);
3
4export const loader = () => {
5 // 서버 코드 ...
6}
7
8export default function Page() {
9 return <div>page</div>;
10}

고차함수도 마찬가지로 loader와 action에 값을 할당하는 시점에 함수가 실행되기 때문에, 클라이언트 코드를 만드는 시점에 서버 코드가 실행되는 사이드 이펙트가 발생할 수 있다.

entry.server.ts에 handleError 구현하기

entry.server.tshandleError 함수를 정의하면, 각 route의 loader나 action에서 에러를 던졌을 때 여기에서 그 에러를 처리할 수 있다.

1// loader나 action에서 에러가 던져지면 이 함수가 실행된다
2export function handleError(e) {
3 console.error(e)
4}

문제는 이 함수는 동기로만 실행된다는 점이다. 즉, 이 안에서 비동기 로직을 실행해도 loader나 action은 기다려주지 않는다. 애초에 이 함수는 에러가 발생했을 때 어딘가 로깅을 하는 정도의 역할이기 때문에, 에러를 캐치해서 재요청을 보내는 동작은 여기에서 처리하기에 맞지 않다.

Axios 인스턴스를 가져오는 함수

이 프로젝트에서는 Orval 라이브러리를 사용해 스웨거에서 내보내는 API 스펙을 토대로 호출 함수와 타입을 자동 생성하고 있다. 이때, 서버 요청과 클라이언트 요청 모두 Axios를 감싼 customInstance라는 함수를 사용해서 HTTP 요청을 보내고 있다.

1// orval이 자동 생성한 함수
2export const saveInfo = (
3 saveInfoRequest: SaveInfoRequest,
4 params: SaveInfoParams,
5 options?: SecondParameter<typeof customInstance>,
6) => {
7 return customInstance<string>(
8 {
9 url: `/api/v1/info/save`,
10 method: 'POST',
11 headers: { 'Content-Type': 'application/json' },
12 data: saveInfoRequest,
13 params,
14 },
15 options,
16 );
17};

클라이언트와 서버는 Axios 설정을 다르게 해주어야 한다. 클라이언트 요청은 단순히 같은 도메인으로 요청을 보내게 하면 되지만, 서버 요청은 백엔드 API 서버로 요청을 보내도록 baseUrl을 설정해줘야 하고, API 서버 응답의 에러를 인터셉트해서 처리해줘야 한다.

따라서 Axios 인스턴스 자체가 분리되어야 하는데, 서버와 클라이언트는 같은 customInstance 함수를 사용해야 한다. 그래서 customInstance에서 Axios 인스턴스를 만들 때 동적 import를 하게 해봤다.

1export const customInstance = async <T>(
2 config: AxiosRequestConfig,
3 options?: AxiosRequestConfig,
4): Promise<AxiosResponse<T>> => {
5 const { createInstance } = await import(
6 typeof window === 'undefined' ? './server_instance.ts' : './client_instance.ts'
7 );
8 const instance = createInstance();
9 const promise = instance({
10 ...config,
11 ...options,
12 });
13
14 return promise;
15};
16
17// ./client_instance.ts
18export const createInstance = () => {
19 return axios.create();
20};
21
22// ./server_instance.ts
23export const createInstance = () => {
24 const instance = axios.create({
25 baseURL: process.env.API_BASE_URL,
26 withCredentials: true,
27 });
28 instance.interceptors.response.use(
29 (response) => {
30 return response;
31 },
32 async (e) => {
33 if (e.status !== 401 || e.request.url.includes('refresh') || e.request.config.sent || !e.config) return;
34
35 const accessToken = await requestRefreshToken(e.request);
36 e.config.headers.Authorization = `Bearer ${accessToken}`;
37 e.config.sent = true;
38 return axios(e.config);
39 },
40 );
41 return instance;
42};

문제는 이렇게 하면 server_instance.ts 파일이 클라이언트 빌드에 포함된다. 이 코드에는 서버에서만 실행될 수 있는 코드가 포함돼있기 때문에 클라이언트 측에서 실행되면 에러가 발생하게 된다.

만약에 빌드에 포함하지 않을 수 있다고 하더라도 클라이언트와 서버 코드가 섞이면서 흐름이 복잡해질 수 있어 좋지 않은 방법이라고 판단해서 이 방법도 사용하지 않았다.

참고

프로필 사진

조예진

이전 포스트
비슷한 동작을 하는 Jotai Atom을 관리하는 방법
다음 포스트
⟦TypeScript〛DistributedOmit - 유틸리티 타입의 타입 분배