본문 바로가기

[Main Projects]/[SYNAPSE]

[SYNAPSE 개발일지 #4] Refresh Token 적용을 위한 API 구조 개선과 Axios 마이그레이션

728x90

[SYNAPSE 개발일지 #3] GraphRAG 파이프라인 구축 - 그래프 생성과 탐색, SSE 기반 실시간 통신까지

 

[SYNAPSE 개발일지 #3] GraphRAG 파이프라인 구축 - 그래프 생성과 탐색, SSE 기반 실시간 통신까지

[SYNAPSE 개발일지 #2] Redis Streams로 서버 간 의존도 낮추기 - 이벤트 기반 아키텍처 설계 [SYNAPSE 개발일지 #2] Redis Streams로 서버 간 의존도 낮추기 - 이벤트 기반 아키텍처 설계[SYNAPSE 개발일지 #1] Graph

blog.juyear.dev

이전 글 읽으러 가기!

 

👋 소개 및 회고

안녕하세요. 대학생 개발자 주이어입니다.

 

저번 글에서 SYNAPSE의 핵심 기능인 GraphRAG 파이프라인을 설계하고 구현하였습니다.

오늘은 주요 기능 개발에만 몰입하여 미루고 있던 Refresh Token을 활용한 자동 인증 갱신에 대해서 정리해 보려고 합니다.

  • Refresh Token 구현
  • fetch에서 axios로 마이그레이션한 이유
  • 자동 인증 갱신 구현

위 3가지를 중심으로 작성할 예정입니다.


🔐 Refresh Token 구현

Refresh Token 인증 갱신이 필요한 이유

Refresh Token 기반의 인증 갱신이 필요한 이유는 사용자 편리보안 때문이라고 생각합니다.

Access Token이 만료되었을 때 동작 과정

만약 Refresh Token이 없다면 사용자는 Access Token이 만료될 때 마다 서버에서 401 Error(인증 오류)와 함께 재인증 요구를 받게 됩니다.

즉, 사용자는 개발자가 정해둔 만료 시간이 지나면 다시 로그인을 해야하는 불편함이 추가됩니다.

 

보통의 사이트를 보면 한 번 로그인 이후에는 나갔다 들어오든, 시간이 지나든 다시 들어오면 자동으로 로그인 되는 경험이 좀 더 익숙합니다.

이러한 자동 인증 갱신을 도와주는 Token이 Refresh Token입니다.

Refresh Token의 자동 갱신 과정

Access Token이 만료됐을 때 Refresh Token을 통해 인증을 갱신하여 새로운 Access Token을 발급해줍니다.

그래서 일반적으로 Refresh Token은 Access Token보다 만료 시간을 길게 두어 지속적인 재발급을 도와줍니다.

 

아래는 이와 관련해서 생길 수 있는 의문점에 대해서 남겨두었습니다.

(빠르게 넘어가셔도 됩니다.)

 

의문 1. '그냥 Access Token을 길게 두면 되는거 아닌가?' 

Access Token을 길게 설정하지 않는 이유는 보안 때문입니다. Access Token 검증을 통해 서버 리소스에 접근할 수 있는데, 이러한 Access Token을 길게 설정하면 혹시나 Access Token이 탈취당했을 때 더 오랜 기간 탈취자는 서버 리소스에 접근할 수 있게 됩니다. 이러한 문제를 막기 위해 Access Token은 짧게 설정하는 것이 일반적입니다.

 

의문 2. 'Refresh Token도 탈취당하면 똑같은거 아닌가?'

맞습니다. Refresh Token이 탈취된다면 새로운 Access Token을 계속 발급받을 수 있고, 이는 탈취자가 오랜 기간 서버 리소스에 접근할 수 있는 방법을 제공합니다.
차이점은 Refresh Token은 서버에서 관리할 수 있다는 점입니다. Access Token은 일반적으로 서버에 저장하지 않는 stateless 방식으로 빠르게 검증이 가능한 대신 탈취당해도 개별적으로 무효화하기가 어렵습니다. 즉, Token이 만료되기 전까지는 사용할 수 있는 경우가 많습니다.
반면 Refresh Token은 서버에 저장하거나 관리하는 방식이 일반적입니다. 즉, Refresh Token이 털렸다는 것이 감지되면 서버에서 해당 Token을 삭제해 새로운 Access Token 발급과 서버 리소스 접근을 막을 수 있습니다.

결론적으로, Access Token은 짧은 수명으로 위험을 최소화하고, Refresh Token은 서버에서 관리하여 장기간 인증을 안전하게 유지하는 구조가 일반적입니다.

Refresh Token 구현

Refresh Token 발급 구현은 간단합니다.

이미 Access Token 발급이 구현되어 있다면 거의 바로 적용 가능합니다.

  login(user: User) {
    const payload = { sub: user.id, email: user.email };
    return {
      access_token: this.jwtService.sign(payload, { expiresIn: '1h' }),
      refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }),
    };
  }

기존에 Access Token을 발급해주던 부분에 Refresh Token만 같이 발급해 주면 됩니다.

여기서도 각 Token의 특징이 들어나는 것을 알 수 있습니다.

Access Token은 1h(1시간 후 만료), Refresh Token은 7d(일주일 후 만료)로 설정하였습니다.

발급 완료 사진

그 후 login을 하면 위와 같이 Access Token과 Refresh Token 2개가 Cookies에 저장되어 있는 것을 확인할 수 있습니다.

 

Refresh Token 발급을 구현했으니 이제 이 토큰을 이용하여 Access Token 재발급을 구현해 보겠습니다.

  async refresh(refreshToken: string) {
    try {
      const payload = this.jwtService.verify<{ sub: number; email: string }>(
        refreshToken,
      );

      const user = await this.usersService.findOneById(payload.sub);

      if (!user) {
        throw new UnauthorizedException();
      }

      return {
        access_token: this.jwtService.sign({
          sub: user.id,
          email: user.email,
        }),
      };
    } catch {
      throw new UnauthorizedException();
    }
  }

토큰 재발급도 크게 어렵지 않습니다.

Access Token을 검증하고 서버 리소스에 접근을 허용하듯이, Refresh Token을 검증하고 Access Token을 발급해 주면 됩니다.

위 코드를 보면, verify 함수를 통해 Refresh Token을 검증하고, 존재하는 유저인지 확인한 후, Access Token을 발급 및 반환해주는 것을 알 수 있습니다.

 

이렇게만 해주면 Refresh Token 발급과 Refresh Token을 이용한 Access Token 발급까지 구현이 끝납니다.

 

이제 client에서 401 Error를 받았을 때 재발급을 요청하는 코드만 구현하면 됩니다.


🛠️ axios로 마이그레이션

axios로 마이그레이션 한 이유

현재 구조의 문제점

그런데 여기서 한 가지 문제가 발생했습니다.

현재 저는 fetch 기반으로 데이터가 필요한 페이지나 컴포넌트에서 직접 요청하는 방식으로 개발 중 이었습니다.

즉, 모든 fetch 요청마다 401 Error를 받았는지 확인하고 재발급을 요청하는 로직을 넣어야 했습니다.

fetch wrapper를 만들어 API 요청 정리

이를 해결하기 위해서 가장 쉬운 방법은 기존에 사용하던 fetch를 유지하는 방법이었습니다.

fetch wrapper를 만들어 요청을 한 곳에서 처리하도록 만들면, 재발급 요청도 한 곳에서 처리할 수 있었습니다.

하지만 fetch는 이러한 공통 처리 기능을 기본적으로 제공하지 않기 때문에 별도의 wrapper를 직접 구현해야 했습니다.

물론 충분히 구현 가능한 방법이지만, 이를 구현하기 위해서는 요청 처리와 인증 오류 처리 로직이 하나의 함수에 작성되어야 했고, 프로젝트가 커질수록 책임이 섞인다는 점이 마음에 들지 않았습니다.

axios를 사용했을 때 오류 처리 과정

반면 axios는 공식적으로 interceptor라는 기능을 지원했고, 이는 API요청과 별개로 Error가 발생했을 때 중간에서 응답을 가로채 공통 로직을 처리할 수 있는 기능을 제공했습니다.

즉, API요청은 요청대로 분리하면서도, Error처리와 토큰 재발급 로직은 interceptor로 분리할 수 있었습니다.

API Layer를 적용한 API 통합 관리

또한 axios는 fetch와 달리 기본적으로 API Client를 구성하기 위한 다양한 기능을 제공했습니다.

Base URL 설정, 공통 Header 관리, Credentials 설정 등 프로젝트 규모가 커질수록 필요한 기능들을 하나의 인스턴스에서 관리할 수 있었습니다. 그렇기 때문에 API Layer를 통한 API 통합 관리 구조도 쉽게 설계할 수 있었습니다.

 

결과적으로 페이지와 컴포넌트는 API 함수를 호출하는 역할만 담당하고, 오류 처리와 토큰 재발급은 axios client가 담당하도록 역할을 분리할 수 있었습니다.

 

이번 마이그레이션의 목적은 단순히 fetch를 axios로 교체하는 것이 아니라, 인증 오류 처리와 API 통합 관리를 고려한 Trade-off의 결과였습니다.

axios로 마이그레이션하기

폴더 구조

우선 저는 api 폴더를 따로 만들어 통합 관리 구조를 설계했습니다.

api.ts 파일에는 위에서 설명한 공통 처리 로직이 들어가고, 나머지는 도메인 기준으로 나눠 api 호출을 처리하도록 설계하였습니다.

import axios from "axios";

export const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
  withCredentials: true,
});

api.ts에서는 BaseURL과 Credentials 설정을 해주었습니다.

axios.create는 axios기반의 api호출 틀을 생성한다고 생각하시면 됩니다.

import { api } from "./api";

export const getCategories = () => api.get("/categories");

export const createCategory = (title: string, color: string) =>
  api.post("/categories", { title, color });

export const updateCategory = (
  categoryId: number,
  title: string,
  color: string,
) => api.patch(`/categories/${categoryId}`, { title, color });

export const deleteCategory = (categoryId: number) =>
  api.delete(`/categories/${categoryId}`);

그 후 api 호출 처리는 어떻게 하는지 category를 대표로 보여드리겠습니다.

위와 같이 api.ts에서 생성한 api호출 틀을 가지고 경로와 필요한 데이터를 넣어 호출을 보내게 됩니다.

const { data: response } = await deleteCategory(updateCategory.id);

이렇게 완성되면, 컴포넌트에서는 위와 같이 단순하게 함수를 호출하듯 사용할 수 있습니다.

const response = await fetch(
  `${process.env.NEXT_PUBLIC_BACKEND_URL}/categories/${updateCategory?.id}`,
  {
    method: "DELETE",
    credentials: "include",
  },
).then((result) => result.json());

기존에 fetch를 사용하여 처리하던 방식과 비교하면 훨씬 간단하고 직관적인 것을 알 수 있습니다.

또한 API 호출과 공통 로직 처리를 API Layer(api 폴더)에서 한 번에 관리하기 때문에 유지보수나 코드 재활용 면에서도 유리합니다.


⚡자동 인증 갱신 구현

이제 모든 기반이 마련되었으니 자동 인증 갱신은 axios의 interceptor을 활용하여 구현만 해주면 됩니다.

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        await api.post("/auth/refresh");

        return api(originalRequest);
      } catch (refreshError) {
        window.location.href = "/login";
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  },
);

저는 위와 같이 구현해 주었습니다.

interceptors.response는 모든 API 응답을 가로채는 역할을 합니다.

이를 통해 응답이 정상이라면 값을 그대로 반환하고, 401 Error가 발생하면 /auth/refresh로 재발급 요청을 보냅니다.

이후 재발급이 성공하면 기존 요청을 자동으로 다시 실행하고, Refresh Token까지 만료되었다면 로그인 페이지로 이동시켜 재인증을 유도합니다.

 

* 추가

추가적으로 _retry를 사용하여 무한 재요청을 방지하였습니다.

api(originalReqeust)를 통해 기존 요청을 재시도 합니다.


😊 마무리

✅ Refresh Token 기반 재발급 구현
✅ axios 마이그레이션
✅ 자동 인증 갱신 구현
➡️ 테스트 코드 작성하기

[진행 상황 정리]

 

이번 글에서는 Refresh Token 기반 토큰 재발급에 대해서 정리해보았습니다.

또한 이를 구현하면서 생긴 다양한 문제를 어떤 방식으로 해결하고 해당 방식을 적용하기 위해 어떤 설계와 고민들을 했는지 정리해보았습니다.

 

저도 처음에 Refresh Token 기능을 개발하기 시작했을 때는 금방 끝날 '기능 하나' 정도로 생각했습니다.

하지만 현재 코드 구조에 맞춰 설계를 하다 보니 점점 단순히 토큰을 재발급하는 문제가 아니라, 인증 로직을 어디에서 관리할 것인지, API를 어떤 구조로 관리할 것인지, fetch를 그대로 유지할 것인지 등 다양한 고민으로 이어졌습니다.

 

결국 이번 작업은 Refresh Token을 적용하는 것보다 프로젝트의 API 구조를 한 단계 개선하는 의미있는 과정이었습니다.

 

그럼 지금까지

읽어주셔서 감사드리며, 다음에 더 좋은 글로 돌아오도록 하겠습니다.

by. 대학생 개발자 주이어

 

https://discord.gg/8Hh8WgM4zp

 

KYT CODING COMMUNITY Discord 서버에 가입하세요!

Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 28명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.

discord.com

KYT CODING COMMUNITY 가입하기!

728x90