Ayden's journal

AsyncLocalStorage를 활용한 서버사이드 쿠키 자동 주입

최근 프로젝트에서 유저 인증 로직을 백엔드가 쿠키에 JWT 토큰을 심는 방식으로 처리하기로 결정했다. 클라이언트에서는 fetch나 React Query 호출 시 credentials: "include"만 설정하면 브라우저가 자동으로 쿠키를 보내주기 때문에, 사실상 별다른 작업 없이 인증이 적용된다.

하지만 Next.js를 쓰면서 서버 사이드에서 이루어지는 로직, 예를 들어 getServerSideProps나 API 라우트에서 데이터를 불러오는 과정에서는 이야기가 달라진다. 페이지 라우팅마다 fetcher 호출이 발생하고, 요청 단위로 쿠키를 전달해야 하는 상황이 반복적으로 등장한다. 매번 쿠키를 직접 fetch 옵션에 넣어주거나, 요청 객체를 계속 전달해야 하는 번거로움이 존재한다.

이런 반복적인 귀찮음을 줄이면서 서버 사이드 요청 단위로 컨텍스트를 안전하게 유지할 수 있는 방법을 찾던 중, Node.js의 AsyncLocalStorage를 알게 되었다. AsyncLocalStorage를 활용하면 요청 단위로 컨텍스트를 만들어 비동기 함수 체인 전체에서 동일한 데이터를 안전하게 공유할 수 있다. 이를 이용하면 서버 사이드에서 쿠키를 fetcher에 자동으로 주입하거나, 트랜잭션 ID나 로깅 정보를 요청 단위로 관리하는 구조를 깔끔하게 구현할 수 있다.

 

AsyncLocalStorage

서버사이드에서 요청 단위로 데이터를 안전하게 관리하고 싶을 때, Node.js에서 제공하는 AsyncLocalStorage를 활용할 수 있다. AsyncLocalStorage는 요청마다 별도의 컨텍스트를 생성하고, 비동기 함수 체인 전체에서 동일한 데이터를 접근할 수 있는 저장소를 제공한다. 쉽게 말하면 Node.js에서의 Thread-Local Storage와 비슷한 개념으로, 각 요청마다 독립적인 데이터 공간을 가지므로 전역 변수를 사용하는 것보다 안전하다.

간단한 예제를 보면 이해가 쉽다. 먼저 AsyncLocalStorage 인스턴스를 생성하고, run 메서드를 통해 새로운 컨텍스트를 생성한다. 그 안에서 어떤 비동기 함수가 실행되더라도, getStore()를 통해 항상 동일한 데이터를 접근할 수 있다.

import { AsyncLocalStorage } from 'node:async_hooks';

interface RequestContext {
  cookies?: string;
}

const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();

asyncLocalStorage.run({ cookies: 'jwt=abc123' }, async () => {
  console.log(asyncLocalStorage.getStore()?.cookies); // 'jwt=abc123'

  await new Promise(resolve => setTimeout(resolve, 100));
  console.log(asyncLocalStorage.getStore()?.cookies); // 여전히 'jwt=abc123'
});

 

위 예제에서 볼 수 있듯, 컨텍스트 안에서 실행되는 모든 비동기 함수는 동일한 cookies 값을 안전하게 참조할 수 있다. 이를 활용하면, 서버사이드에서 요청마다 JWT 토큰이나 트랜잭션 ID 같은 정보를 AsyncLocalStorage에 저장하고, 여러 계층의 fetcher나 미들웨어에서 동일한 데이터를 재사용할 수 있다.

AsyncLocalStorage를 사용하는 장점은 명확하다. 첫째, 반복적으로 fetcher에 데이터를 전달할 필요가 없어 코드가 훨씬 간결해진다. 둘째, Promise나 setTimeout 등 비동기 흐름에서도 안전하게 요청 단위 데이터를 유지할 수 있다. 셋째, 서버사이드에서 요청마다 독립적인 컨텍스트를 가지므로, 여러 요청이 동시에 처리되더라도 데이터 충돌 없이 안정적인 처리가 가능하다.

 

데코레이터를 이용한 SSR 쿠키 관리

Next.js 서버사이드에서는 요청마다 fetcher 호출 시 쿠키를 일일이 전달해야 하는 번거로움이 존재한다. 예를 들어 getServerSideProps에서 여러 fetcher 메서드를 호출하는 경우, 매번 fetchOptions.headers.cookie에 JWT 토큰을 넣어주는 코드를 작성해야 한다. 이러한 반복적인 코드는 코드의 가독성을 떨어뜨리고, 실수 가능성을 높인다.

이 문제를 해결하기 위해 AsyncLocalStorage를 활용하면 요청 단위 컨텍스트를 안전하게 유지할 수 있다. 서버 사이드에서 각 요청마다 AsyncLocalStorage 컨텍스트를 생성하고, JWT 쿠키를 저장해두면 비동기 함수 체인 어디에서든 동일한 쿠키를 안전하게 접근할 수 있다. 여기에 데코레이터를 적용하면, fetcher 메서드 호출 시 자동으로 쿠키를 주입할 수 있어, 반복적인 쿠키 전달 코드를 제거할 수 있다.

데코레이터는 클래스 메서드의 동작을 감싸서, 서버사이드일 경우 AsyncLocalStorage에서 쿠키를 가져와 fetch 옵션에 추가하고, 클라이언트에서는 브라우저 쿠키를 그대로 사용하도록 처리한다. 아래 예시는 이를 구현한 @withCookies() 데코레이터이다.

// lib/asyncContext.ts
import { AsyncLocalStorage } from 'node:async_hooks';

interface RequestContext {
  cookies?: string;
}

// 서버에서만 AsyncLocalStorage 생성
export const asyncLocalStorage =
  typeof window === 'undefined' ? new AsyncLocalStorage<RequestContext>() : null;

/**
 * 현재 요청의 쿠키를 반환
 * 서버 사이드에서만 동작하며, 클라이언트에서는 undefined를 반환
 */
export function getCookies(): string | undefined {
  if (typeof window === 'undefined') {
    return asyncLocalStorage?.getStore()?.cookies;
  }
  return undefined; // 클라이언트에서는 접근하지 않음
}
import { getCookies } from './asyncContext';

export function withCookies() {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      let cookies: string | undefined;

      if (typeof window === 'undefined') {
        cookies = getCookies();
      }

      if (args[0] && typeof args[0] === 'object') {
        args[0] = {
          ...args[0],
          fetchOptions: {
            ...(args[0].fetchOptions || {}),
            headers: {
              ...(args[0].fetchOptions?.headers || {}),
              ...(cookies ? { cookie: cookies } : {}),
            },
            credentials: 'include', // 서버와 클라이언트 모두 안전
          },
        };
      }

      return originalMethod.apply(this, args);
    };

    return descriptor;
  };
}

 

이 데코레이터를 클래스 메서드에 적용하면, 서버사이드와 클라이언트 양쪽에서 쿠키를 안전하게 처리할 수 있다. 예를 들어 TechQuestionClient 클래스에서 메서드에 적용하면 다음과 같다.

@thisBind
export class TechQuestionClient extends Fetcher {
  @withCookies()
  public getTags({ fetchOptions }: { fetchOptions?: any } = {}) {
    return this.queryOptions({
      queryKey: ["tech-question", "tags"],
      queryFn: () => this.fetcher.get("tech-question/tags", {}, fetchOptions),
    });
  }

  @withCookies()
  public getTechAnswerList({ questionId, fetchOptions }: { questionId: string; fetchOptions?: any }) {
    return this.queryOptions({
      queryKey: ["tech-question", "answer", questionId],
      queryFn: () => this.fetcher.get("tech-question/{questionId}/answer", { path: { questionId } }, fetchOptions),
    });
  }
}

 

마지막으로 서버사이드에서 AsyncLocalStorage 컨텍스트를 생성하고 사용하면, getServerSideProps에서 fetcher 호출 시 자동으로 쿠키가 주입된다. 이렇게 하면 SSR 요청마다 반복적으로 쿠키를 전달할 필요가 사라지며, 클래스 기반 구조와도 자연스럽게 통합된다.

export const getServerSideProps = async (context) => {
  return asyncLocalStorage.run({ cookies: context.req.headers.cookie || '' }, async () => {
    const client = new TechQuestionClient();
    const tags = await client.getTags(); // @withCookies 덕분에 자동 쿠키 주입
    return {
      props: { tags },
    };
  });
};

 

이러한 구조를 통해 Next.js 서버사이드에서 JWT 쿠키 인증 로직을 깔끔하게 통합할 수 있으며, 클라이언트 사이드에서도 브라우저 쿠키를 그대로 활용할 수 있어 서버와 클라이언트 모두 안전하게 인증을 처리할 수 있다.

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기