Next.js 미들웨어에서 백엔드 API로부터 발급받은 쿠키를 활용하기

Nowon LeeNowon Lee
4 min read

쿠키 이슈

Next.js에서 서버 컴포넌트는 서버에서 실행되고 렌더링되는 컴포넌트이다. 다음과 같은 특징이 있다.

  • 서버에서만 동작하기 때문에 데이터베이스, 파일 시스템에도 접근할 수 있다.

  • 서버에서 미리 렌더링된 UI를 그대로 클라이언트로 전달하기 때문에 자바스크립트 번들 사이즈를 줄일 수 있다.

  • 브라우저에서 실행되지 않기 때문에 useState, useEffect와 같은 리액트 API를 호출할 수 없다.

별도의 백엔드 API가 개발되어있는 상황에서 Next.js로 프론트엔드를 개발할 때 API를 요청하는 케이스는 여러가지가 될 수 있다.

  • Next.js 클라이언트 컴포넌트를 웹 브라우저에서 렌더링할 때 백엔드 API 요청

  • Next.js 클라이언트 컴포넌트를 서버 측 렌더링할 때 백엔드 API 요청

  • Next.js 서버 컴포넌트, 서버 액션에서 백엔드 API 요청

각각의 도메인이 프론트엔드는 frontend.com, 백엔드는 backend.com으로 되어있을 때 백엔드 API에서 쿠키를 발급받았음에도 프론트엔드에서 네트워크 요청할 때 쿠키가 보이지 않는 문제가 있었다.

  1. 로그인 페이지에서 필요한 데이터를 입력 후 백엔드 API로 로그인 요청하면 백엔드에서는 JWT 토큰이 담긴 쿠키를 발급하게 된다.
  1. 웹 브라우저에서는 backend.com의 쿠키를 통해 API를 요청하여 사용자 데이터를 받을 수 있다.

  2. Next.js의 서버 렌더링 환경에서는 프론트의 서버 도메인과 쿠키의 도메인과 서로 달라서 쿠키의 값을 읽을 수 없고 사용자 데이터를 요청할 수 없다.

쿠키의 도메인 속성은 도메인을 따로 명시하지 않으면 현재 도메인으로만 쿠키를 전송할 수 있다. 만약 도메인 속성에 특정 도메인을 지정하면 서브도메인에서 쿠키를 전송 가능하도록 할 수 있다.

예를 들어 프로젝트를 진행할 때 프론트엔드를 example.com, 백엔드를 backend.example.com으로 도메인을 설정하고 쿠키의 도메인을 example.com로 지정하면 쿠키를 양 쪽에서 사용하도록 할 수 있다.

다음과 같은 예시에서는 Next.js 렌더링 서버, 미들웨어에서도 쿠키를 받을 수 있고, 백엔드 API에서도 쿠키를 받을 수 있다.

  • 웹 페이지가 렌더링되면 백엔드의 /auth API로 요청을 보내서 쿠키를 발급받는다.

  • 특정 버튼을 클릭하여 백엔드 /admin으로 요청을 보냈을 때 쿠키의 유무에 따라 true, false 응답을 받게 된다.

  • 프론트엔드의 미들웨어에서도 쿠키가 있는지의 여부에 따라서 웹 페이지의 응답 헤더의 Has-Auth의 값이 결정된다.

개발환경에서 쿠키의 SameSite 속성을 Strict로 설정했다. backend.example.com, example.com 최상위 도메인이 같으므로 SameSite로 간주될 수 있다. SameSite를 Strict로 설정해야 타 사이트에서 쿠키를 가져가는 것을 막을 수 있다.

// Hono Backend
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { poweredBy } from "hono/powered-by";

const app = new Hono();

app.use(poweredBy());
app.use(logger());
app.use(
  "*",
  cors({
    origin: ["http://example.com:3000", "http://backend.example.com:3003"],
    allowHeaders: [
      "X-Custom-Header",
      "Upgrade-Insecure-Requests",
      "Set-Cookie",
    ],
    allowMethods: ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"],
    maxAge: 600,
    credentials: true,
  })
);

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

app.get("/auth", (ctx) => {
  const date = new Date();
  const expires = new Date(date.getTime() + 24 * 60 * 60 * 1000);
  setCookie(ctx, "Authorization", "THIS_IS_PASSKEY", {
    path: "/",
    secure: false,
    httpOnly: false,
    sameSite: "Strict",
    expires,
    domain: "example.com",
  });
  return ctx.json({ message: "ok" });
});

app.get("/admin", (ctx) => {
  ctx.header;
  const passKey = getCookie(ctx, "Authorization");
  if (passKey && passKey.length > 0) {
    return ctx.json({ message: true });
  }
  return ctx.json({ message: false });
});

const port = 3003;

serve({
  fetch: app.fetch,
  port,
});
// app/page.tsx
// NEXT_PUBLIC_API_URL = http://backend.example.com
"use client";

import { useEffect } from "react";

export default function Home() {
  useEffect(() => {
    fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth`, {
      credentials: "include",
    }).then((response) => {
      console.log(response);
    });
  }, []);
  const onClick = () => {
    fetch(`${process.env.NEXT_PUBLIC_API_URL}/admin`, {
      credentials: "include",
    })
      .then((resp) => resp.json())
      .then((data) => {
        console.log(data);
      });
  };
  return (
    <div className="w-full h-full">
      <h1 className="text-2xl font-semibold">Hello World!</h1>
      <button onClick={onClick}>Admin</button>
    </div>
  );
}
// middleware.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export async function middleware(request: NextRequest) {
  const cookies = request.cookies;
  const auth = cookies.get("Authorization");

  const res = NextResponse.next();
  res.headers.set("Has-Auth", auth ? "true" : "false");

  return res;
}

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

로컬에서 도메인 세팅

로컬에서 프로젝트를 개발할 때 localhost 외에도 특정 도메인을 세팅하여 각 프로젝트에 필요한 도메인을 적용할 수 있다.

/etc/hosts 파일은 PC에서 도메인에 대해 IP 주소를 찾을 때 조사하는 파일이다. 파일을 열어보면 localhost가 127.0.0.1 아이피에 연결되어있는 것을 확인할 수 있다. 127.0.0.1 아이피는 루프백 아이피로 자기 자신을 가리키는 IP이다.

localhost 외에도 다른 도메인을 루프백 IP에 매핑시키면 도메인으로 로컬 프로젝트에 접속하게 할 수 있다.

127.0.0.1       example.com
127.0.0.1       backend.example.com

SameSite에 관하여

모든 서브도메인이 Same Site인 것은 아니다.

.com 또는 .net과 같은 최상위 도메인의 경우 최상위 도메인과 그 앞 부분의 조합을 Site라고 부를 수 있다. 예를 들어 example.com의 경우 example.com의 서브도메인은 전부 Site가 같다고 할 수 있다.

반면 co.kr의 경우 최상위 도메인이 .kr라고 해서 app1.co.krapp2.co.kr가 같은 Site라고 하기 힘들 수 있다.

임의의 도메인 접미사(Suffix)에 대해 도메인을 등록할 수 있는지 알고리즘 상으로 판단하기 힘들다. 누군가가 도메인을 등록할 수 있는 접미사를 유효 최상위 도메인, eTLD라고 부르며 이 목록을 모아놓은 곳을 공용 접미사 목록(Public Suffix List)라고 부른다.

eTLD+1은 eTLD과 그 다음 부분을 포함하며, eTLD+1 도메인과 그 서브도메인은 전부 Site가 같다고 할 수 있다.

0
Subscribe to my newsletter

Read articles from Nowon Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nowon Lee
Nowon Lee