직전 글도 마찬가지인거 같지만 백엔드 얘기 안하는 백엔드 개발자 이번에는 Next.js, Remix, 그리고 세션에 관한 이야기다. 당연히 Next.js, Remix의 전문가도, 일하면서 써본적도 없으니 잘 걸러서 읽을 것. Next.js에서 Iron Session을 사용해본 이야기, 그 방식을 Remix에 적용해보려고 했던 이야기이다.
일은 백엔드, 강의도 백엔드, 약간의 UI를 위한 프런트를 하는 입장에서 당연히 Next.js를 현업에서 써본 일은 없었다. 다만 항상 관심은 있었다. 워낙에 평가가 좋았던 것도 있었고, React.js를 비롯한 라이브러리에서 어떻게 SSR까지 가는지 늘 궁금했으니까. 특히 프런트의 대부분을 Vue.js로 만든 상황에서 SSR을 적용해보려고 하다가 "그렇게 하는거 아닌데" 소리를 들었을 때의 허탈함은 다시 겪고 싶지 않은 경험이었다.
아래에 설명하지만, 그 과정에서 Iron Session이란 것을 사용하였다. 백엔드만 공부하는 상황에서는 세션과 JWT등에 대해서 이야기할 때 상태를 저장하는지 여부(Statelessness)에 대한 이야기를 많이 하게 된다. 그런데 Next.js만 만들고 있는 상황에서도 상태를 저장하느냐에 대한 생각이 필요하게 될줄은 몰랐다. 어쨋든 그 과정에서 도입된 라이브러리였고, 생각보다 개발 경험이 좋았다. 여전히 Stateless를 유지하면서도 비교적 간단하게 구현이 가능했었다. 이걸로 만든게 지금 접속은 되지만 사용은 안되는 개인 토이 온라인 저지 사이트이다. 회원가입 해도 자동으로 직접 데이터베이스에서 승인해줘야 하며, 내가 확인하지 않기 때문에 지금은 나만 쓸 수 있는 사이트. 언젠간 열겠지
그리고 몇달 뒤 다른 프로젝트를 진행했는데, 이 프로젝트에는 Remix를 도입해 보았다. 어쩌다 들은 이야기인지는 모르겠는데, 최근에 떠오르는 프레임워크라고 듣고 한번 해보자는 생각이었다. 진짜 출처가 하나도 기억나지 않는다 굳이 따지자면 ChatGPT가 Next.js에서 Remix로 넘어갔다는 이야기를 어딘가에서 들었고, 그때 궁금해서 찾아보고 해보자 생각한게 맞는거 같다. 간단한 첫인상은, Next.js와는 다르지만 익숙해지면 굉장히 직관적인 형태(이게 말이 되나)인 듯.
다만 한가지 문제점은, Next.js에서는 괜찮게 사용했던 Iron Session의 방식이 Remix에서는 좀 잘 안되었다는 점이다.
실제 현업에서는 어떤 식으로 Next.js를 사용하는지는 잘 모른다. 다만 프런트와 백엔드라는 개념 자체가 웹 개발이 전문화 되면서 둘의 역할을 잘 분리하여 독립된 발전을 추구하며 생겼다는 관점에서, 난 백엔드를 먼저 만들기 시작했다. 당시 가장 친근했던 Spring Boot를 사용하면서, 아직 완전히 익숙하지 않은 Kotlin을 이용했다. 인증은 앞서 언급된 내용에서 유추할 수 있듯, JWT를 사용하도록 구현했다.
그런데 Next.js를 사용하려고 보았더니, 사실상 Next.js는 사실상 인증, 데이터베이스를 다 할 수 있는 풀스택 프레임워크였다. 그래서 백엔드 서버가 약간 벙찌게 되었다는 것.
JWT는 Stateless Authentication, 즉 서버에서 요청을 보낸 사람을 기억하지 않기 위해 사용하는 인증 방식의 가장 대중적인 형태이지 않을까 생각된다. 잘 구현된 JWT는 기본적으로 위조 변조가 어렵고, 안에 포함시킬 내용은 개발자가 얼마든지 조정할 수 있는 만큼 필요한 정보를 안전하게 주고받기 좋와서 많이 사용된다. 또한 JWT 자체에 모든 정보가 있기 때문에, 서버에서 추가적인 자원을 활용해 JWT가 정상적인지 기록할 필요가 없어서, Stateless의 특징을 가져갈 수 있다. 다만 기본적으로 내용을 확인하는것 자체는 어렵지 않기 때문에, 토큰이 탈취되지 않도록 신경써야 한다.
필자가 Next.js에 관심을 가진 이유는 결국 SSR 때문이었다. Server Side Rendering은 사용자에게 완성된 HTML을 전달하여 초기 로딩 속도를 빠르게 해주며, 서치 엔진에 좀더 친화적이다.
여기서 문제가 발생했다. SSR의 형태로 HTML을 만드려고 보니 서버에서 인증 정보가 필요했는데, 이걸 저장하게 된다면 JWT를 사용할 이유가 사라지는 거나 다름 없었다. 로그인이 필요 없는 서비스나 페이지라면 상관이 없었지만, 이 프로젝트는 아니었다. 그리고 토이 프로젝트니까 경험적 측면에서라도 한번 해보자는 생각도 있었고.
브라우저로 전달되는 코드에서 fetch
를 쓰려고 하면 SSR이 불가....한지는 모르겠다. 이론적으로 보자면 저 말이 맞는거 같다. 아마 대부분의 공통 UI는 서버에서 렌더가 되고, fetch
를 통해 데이터를 가져와야 되는 부분은 클라이언트에서 렌더가 될태니 일종의 하이브리드라고 생각할 수 있으려나? 물론 어차피 로그인이 필요한 기능이라면 검색이 되면 안되야 되는 것도 있을테니까 그 부분에 대하여 SSR을 포기하는 생각도 할 수 있겠지만. 실제로 처음에는 그런식으로 만들었는데, 서버에서 데이터를 로드하는 코드랑 클라이언트에서 데이터를 로드하는 코드 두가지를 다 작성해야 한다는 부분이 그렇게 맘에 들지는 않았다.
그래서 문서를 돌려보다가 Session Management Libraries 항목에 적혀있는 Iron Session을 찾아봤다.
Iron Session은 기본적으로 쿠키를 사용해 간단하게 세션을 관리하는 방법이고, JWT 처럼 완전히 Stateless한 방식이다. 아래와 같은 코드로 사용한다.
export const sessionOptions: SessionOptions = {
password: "password",
cookieName: "sessionid",
};
type SessionData = {
username: string;
isLoggedIn: boolean;
}
// cookies()는 Next.js의 함수이다.
// https://nextjs.org/docs/app/api-reference/functions/cookies
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
자신이 세션에 저장하고 싶은 정보를 SessionData
와 같이 타입으로 정의하고, (Next.js의 경우) cookies()
를 통해 쿠키를 전달하면 사용할 수 있다. 또한 Next.js의 경우 API 루트, 루트 핸들러, 서버 액션 등 상황을 가리지 않고 활용할 수 있다.
쿠키를 사용하지만 일반적인 (Django 기본 인증 모듈이라던지) 인증 방식과 달리 상태를 저장하지 않는다. 쿠키에 서버에서 대조, 조회를 하기 위한 Session Key를 저장하는 것이 아닌, JWT와 유사하게 쿠키 자체에 데이터를 저장하기 때문이다. 그리고 JWT와는 다른 방식으로 쿠키 내용 자체를 암호화하기 때문에, 비밀키를 알지 못하면 클라이언트에서 데이터를 해독할 수 없다. JWT가 위변조에 대한 우려를 줄이는 반면, Iron Session은 데이터 자체를 읽지 못하게 하는 방식. 즉, Iron Session을 사용하고 있는 상황이라면, Sticky Session과 같은 세션 관리를 하지 않는다고 하더라도, 여러 Next.js 인스턴스(맞나?)가 같은 세션에 대한 정보를 조회할 수 있다는 것이다.
이 문단에서는 Iron Session에서 제공하지 않는 기능인줄 알고 삽질한 기록이 있다. 나중에야 해당 기능을 발견했지만, 지금 적용해볼 염두가 안나서 알고만 있는 상태이다...
이후 다음 프로젝트에서는 프런트를 Remix로 진행하기로 마음먹고, Next.js에서 경험을 바탕으로 진행했다.
백엔드는 (WebSocket 만들기 좀더 편한지 볼라고) 다른 프레임워크를 썼지만 JWT로 인증하는건 마찬가지 였으니, 비슷한 순서로 구현하면 되지 않을까 했다. 문제는 Iron Session의 구체적인 구현 방식은 Remix와는 잘 맞지 않았다는 점.
Iron Session의 깃헙에 보면, getIronSession
의 대표적 사용법 두가지가 나와있다. 하나는 Express 등에서 사용하는 Request
, Response
객체를 사용하는 방법,
type SessionData = {
// Your data
}
const session = await getIronSession<SessionData>(req, res, sessionOptions);
또다른 하나는 위에도 나왔던 Next.js의 cookies()
를 사용하는 방법이다.
type SessionData = {
// Your data
}
const session = await getIronSession<SessionData>(cookies(), sessionOptions);
이 방식들을 살펴보면, 기본적으로 사용자가 직접 쿠키를 조작하는 과정은 전혀 살펴보기 어렵다. 첫번째 예시만 보았다면, 관심없는 개발자는 쿠키를 쓴다는 사실도 유추하기 애매한 부분이다(물론 sessionOptions
를 보면 쿠키관련 설정이 있어서 알수 있긴 하다).
근데 이제 Remix에서 쿠키를 조작하려면 무엇을 해야 하는지 살펴보자.
// loader는 해당 경로에 GET 요청이 오면 실행되고,
export async function loader({
request,
}: LoaderFunctionArgs)
const cookieHeader = request.headers.get("Cookie");
const cookie =
(await userPrefs.parse(cookieHeader)) || {};
// (이 예시의 경우) 반환한 데이터를 바탕으로 컴포넌트를 렌더한다.
return json({ showBanner: cookie.showBanner });
}
// action은 해당 경로에 POST 요청이 오면 실행되고,
export async function action({
request,
}: ActionFunctionArgs) {
const cookieHeader = request.headers.get("Cookie");
const cookie =
(await userPrefs.parse(cookieHeader)) || {};
const bodyParams = await request.formData();
if (bodyParams.get("bannerVisibility") === "hidden") {
cookie.showBanner = false;
}
// 다양한 방식으로 응답을 보낸다.
return redirect("/", {
headers: {
// 그리고 상황에 따라 Set-Cookie 헤더를 써서 쿠키를 설정한다.
"Set-Cookie": await userPrefs.serialize(cookie),
},
});
}
페이지가 로드되기 전 서버에서 실행되는 loader
함수에서는, request
의 헤더 중 Cookie
를 찾고, 그걸 바탕으로 미리 저장해둔 데이터가 있는지 확인하는 과정이 필요했다. Form 제출 등의 상황에서 실행되는 action
함수에서 응답을 통해 클라이언트에게 쿠키를 전달하고 싶은 경우, 해당 데이터를 직렬화 해서 직접 Set-Cookie
헤더에 설정을 해주는 과정이 필요했다. 즉, 쿠키의 값을 해석하거나 만드는건 Remix가 해주지만, 그걸 사용하기 위해서 개발자가 직접 헤더를 설정해야 한다.
당연히 Remix에는 Next.js의 cookies()
같은 함수는 없기 때문에 Iron Session의 2번 방법은 불가능 했으며, req, res
를 사용하는 1번 방법의 경우도 결국 세션 정보를 받아오기 위해 아직 만들어지지 않은 응답을 참조해야 한다는 문제가 있었기 때문에 사용이 불가능했다.
위 예시 코드의
userPref
의 경우 Remix가Set-Cookie
헤더의 값을 좀더 편하게 만들거나 쿠키 값에서 데이터를 쉽게 회수할 수 있도록 하기 위해 만들어진 객체이다.createCookie()
함수로 만든다.import { createCookie } from "@remix-run/node"; // or cloudflare/deno export const userPrefs = createCookie("user-prefs", { maxAge: 604_800, // one week });
결국 내가 선택한 방법은 "Iron Session이 내부적으로 하는 것을, 내가 그냥 코드로 짜자" 였다. 그래서 README를 잘 살펴보고, 코드를 살펴보며, 내부적으로 사용하고 있는 iron-webcrypto 라이브러리를 발견할 수 있었다. Iron Session의 문서에서도 언급되듯, @hapi/iron
을 표준 웹 API로 구현한 라이브러리였다. 다행히 사용법이 크게 어렵지 않았다.
Iron Session이 쿠키 관리까지 다 해주는 라이브러리라면, iron-webcrypto는 암호화 복호화를 담당하는 라이브러리라 볼 수 있겠다. Iron.seal
과 Iron.unseal
메서드를 활용한다. 또한 각 메서드에 현재 사용하고 있는 런타임(node
, deno
등)에서 제공하는 암호화 방식에 대한 Wrapper인 crypto
인스턴스를 제공해야 한다.
import * as crypto from "node:crypto";
import * as Iron from 'iron-webcrypto'
// ...
const seal = async (cookie: string) =>
await Iron.seal(crypto, { cookie }, SECRET, Iron.defaults);
const unseal = async (cookieValue: string) : Promise<string | null> =>
(await Iron.unseal(crypto, cookieValue, SECRET, Iron.defaults)).cookie;
이 두가지 메서드를 Next.js에서 쓴것과 비슷하게 loader
함수에서 호출해서 쓰는 getSession
함수를 만들었다.
// 긴 쿠키 값에서 암호화된 부분만 자르는 함수
const getSealedId = (cookie: string) => {
const splitStr = cookie.split("sessionid=");
const semiIdx = splitStr[1].indexOf(";");
return semiIdx !== -1 ? splitStr[1].slice(0, splitStr[1].indexOf(";")) : splitStr[1];
}
const getSession = async (request: Request) => {
const cookie = request.headers.get("Cookie");
// 내가 만든 쿠키가 없는것 같으면 그냥 평소처럼 처리한다.
if (!cookie || cookie.indexOf("sessionid=") === -1) return cookieSessionStorage.getSession(cookie);
// 암호화된 부분만 가져온다.
const value = getSealedId(cookie);
// 복호화 한다.
const unsealed = await unseal(value);
// 안되면 에러고...
if (!unsealed) throw Error("cannot decrypt cookie");
// 이걸 다시 원래 쿠키의 모습으로 만들어준다.
const composite = `sessionid=${unsealed}`;
// 그리고 Remix에게 전달해서 객체로 만들어주자.
return await cookieSessionStorage.getSession(composite);
}
...되게 바보같은데, request
객체에서 쿠키를 가져오고, 내가 찾는 키가 쿠키에 없다면 원래대로 처리하고, 있으면 복호화해서 Remix에 전달하는 것이다. 즉 클라이언트 - Remix 사이에 쿠키 전처리기를 하나 둔 셈이다.
약간 우려의 상황은 sessionid
가 있을 때 쿠키의 나머지 부분이 버려지는 것처럼 보이는데...이 메서드 자체가 쿠키의 다른 부분을 사용하기 위한게 아니고 오로지 내가 원하는 세션에 대한 정보를 회수할때만 사용하는 것이니 괜찮지 않을까 싶다뭐?. unsealed
에 실제로 뭐가 들었는지 시간이 될때 확인할 필요는 있을 듯.
지금 회고하면서 드는 생각이 어차피 이딴식으로 할거였으면 그냥 Remix의 쿠키 다루는 방식을 배제하는것도 나쁘지 않았을 것 같기도...
updateSession
업데이트는 좀더 까다로웠는데, 여전히 다른 쿠키들도 존재하며, Set-Cookie
에 담긴 정보는 쿠키의 값 외에 maxAge
등 다른 정보도 존재했기 때문. 그래서 먼저 세션을 Remix의 방법을 이용해 저장하고, 만들어진 Set-Cookie
에서 세션과 관련된 정보만 다시 암호화 하는 방식을 사용했다. 이는 Remix에서 응답에 직접 Set-Cookie
를 설정하지 않고, Set-Cookie
에 넣어줄 값만 만들어주기 때문에 가능한 방법이었다.
// Set-Cookie의 일부분만 암호화한다.
const refineCommited = async (commited: string) => {
// 내 쿠키를 기준으로 나누고,
const splitStr = commited.split("sessionid=");
// 앞쪽 저장하고
const preCookie = splitStr[0];
// 암호화할 부분 저장하고
const value = splitStr[1].slice(0, splitStr[1].indexOf(";"));
// 뒤쪽 저장하고
const postCookie = splitStr[1].slice(splitStr[1].indexOf(";"));
// 합치면서 다시 암호화한다.
return `${preCookie}sessionid=${await seal(value)}${postCookie}`;
}
const updateSession = async (session: Session, data: SessionData) => {
if (data.jwt) session.set("jwt", data.jwt);
if (data.username) session.set("username", data.username);
if (data.signedIn) session.set("signedIn", data.signedIn);
if (data.updatedAt) session.set("updatedAt", data.updatedAt);
// 이 메서드가 `Set-Cookie`에 들어갈 값을 만들어준다.
const commited = await cookieSessionStorage.commitSession(session)
return await refineCommited(commited);
}
원리 자체는 백준 기준으로 브론즈 문제 급.
그리고 의외로 잘 된다!
먼저 getSession
의 경우 로그인이 필요한 다양한 상황에서 loader
에서 사용했다.
export const loader = async ({
request
}: LoaderFunctionArgs) => {
const session = await getSession(request);
if (!session.get("signedIn")) return redirect("/signin");
const response = await fetch("http://localhost:8080/cameras", {
headers: {
"Authorization": `Bearer ${session.get("jwt")}`,
}
});
// 후략
그리고 updateSession
의 경우 로그인 처리같은 상황에서 활용하였다.
// 전략
return redirect("/", {
headers: {
"Set-Cookie": await updateSession(session, {
jwt,
username: userInfo.email,
signedIn: true,
updatedAt: Date.now(),
})
}
});
}
사실 이제와서 드는 생각이, 여전히 내려놓는 법을 덜 배웠다는 것. 말하자면 데이터를 어떻게 저장하든 기능 자체를 만들려고 했다면 좀더 수월하게 진행했을지도 모르겠다. JWT를 쿠키기반 세션에 저장해서 사인만 하면 어쨋든 사용은 가능하니까. 그리고 나중에 보니 iron-webcrypto의 있는 그대로 노출하는 sealData
같은 기능도 Iron Session에 있었다. 그래도 한번쯤 삽질도 해보고 해야 다음에 삽질을 덜하지 라는 생각으로 끝까지 달려왔던것 같다.
개인적으로 Remix에 대해서 좋았던 점은, 하나의 경로에서 GET
이 왔을 때 일어나는 일과 컴포넌트의 격리가 잘되어있다는 점이었다. Next.js는 "use client"
라던지 Hydration 라던지 좀 처음 봤을 때 복잡해 보이는 다양한 개념이 많이 있었는데, Remix는 그걸 상당히 단순화 했다는 느낌이었다. 즉 loader
와 action
이 언제 실행되는지를 알면 나머지는 상대적으로 날것의 코드 다루기에 가까웠던 것 같다. 그래서 useRef
같은 훅을 쓰면서도 신경쓸 부분이 Next.js에 비해 적었다고 생각했다. 그리고 단점은, 지금과 같이 좀 날것의 웹개발의 향기가 가끔 난다는 점이었다. Next.js에서는 함수 하나로 가져올 수 있는 쿠키를 직접 뜯어내야 한다는 것 같은...
여기서 언급된 프로젝트들은 각각 깃헙에 올려놨다.