개발자일기/리액트 이야기

[프론트엔드] JWT, RefreshToken과 AccessToken

뫙뭉 2024. 11. 5. 11:01
반응형

로그인 상태 유지를 위한 다양한 방법과 JWT의 필요성

많은 웹사이트에서 사용자 정보를 기반으로 특정 데이터를 조작해야 하는 경우, 로그인 기능이 필수입니다. 일반적으로 아이디와 비밀번호를 이용한 로그인 방식을 많이 사용하며, 프론트엔드에서는 이 과정을 간단하게 input 입력방식으로 구현할 수 있습니다. 사용자가 로그인에 성공하면 추가적인 정보를 얻거나 특정 기능을 사용할 수 있게 됩니다. 예를 들어, 로그인 전에는 게시글 작성 권한이 없지만, 로그인 후에는 게시판을 작성할 수 있는 권한을 얻는 방식입니다.

기본적인 로그인 로직은 클라이언트에서 아이디와 비밀번호를 서버로 전송하여 로그인 여부를 확인하고, 성공적으로 응답을 받은 경우 클라이언트의 로그인 상태를 변경해 주는 것입니다. 그러나 이렇게 상태만 변경하는 방식에는 여러 가지 문제점이 발생할 수 있습니다.

 

기존 방식의 문제점

 

  1. 메모리(state)에서만 처리하면, 새로고침이나 브라우저 또는 탭을 닫는 경우 로그인 상태가 초기화되어 사용자 경험에 문제가 발생합니다.
  2. 동일한 사이트를 여러 탭에서 열어놓은 경우, 하나의 탭에서 로그인 상태가 변경되면 다른 탭에서도 동일한 상태가 유지되어야 합니다. 하지만 메모리에서만 상태를 관리하면 탭 간 동기화가 불가능하여 원하는 방식으로 로그인 상태가 유지되지 않습니다.
  3. 보안 검증이 불확실합니다. 클라이언트 측에서 로그인 상태를 조작할 수 있다면, 서버는 로그인 상태를 확인하지 못하고 클라이언트에서만 UI가 변하는 상황이 발생할 수 있습니다. 이는 악의적인 사용자가 로그인한 척 UI를 조작하여 다양한 정보를 확인할 가능성을 높입니다.

이러한 문제를 해결하기 위한 방법이 바로 JWT(JSON Web Token)입니다. JWT는 로그인 상태를 유지하면서도 보안 검증을 강화하는 인증 방식으로, 서버의 부하를 줄이면서도 확장성과 보안을 동시에 제공하기 때문에 널리 사용되고 있습니다.

오늘은 이 JWT 방식이 등장하게 된 배경과, 프론트엔드에서의 간단한 사용법에 대해 설명해보고자 합니다.

 

기존 로그인 상태 유지를 위한 다양한 방식들

위에서 말했던 여러 문제를 해결하기 위해 JWT 토큰 방식만 존재하는 것은 아닌데요, 개발자들은 여러 방식의 해결책들을 내놓았습니다.

 

1. 세션 기반 인증

세션 기반 인증은 서버에서 생성된 세션 ID를 통해 사용자의 로그인 상태를 유지하는 방식입니다. 사용자가 로그인하면 서버는 세션 ID를 생성하고, 이 세션 ID를 쿠키에 저장하여 클라이언트로 전달합니다. 이후 사용자는 요청 시마다 이 세션 ID를 서버로 보내며, 서버는 세션 ID를 통해 사용자 인증을 처리합니다.

  • 장점: 서버에서 세션 정보를 관리하므로 보안성이 높으며, 클라이언트 측 데이터 조작 방지가 가능합니다.
  • 단점: 서버에서 세션을 유지해야 하므로 서버 자원이 많이 소모되며, 분산 서버 환경에서 세션 동기화가 필요하여 복잡성이 증가합니다.

 

2. 쿠키 기반 인증

쿠키 기반 인증은 클라이언트의 브라우저에 사용자 상태를 저장하고, 이를 통해 인증을 처리하는 방식입니다. 서버가 HttpOnly  쿠키에 로그인 정보를 저장하여 사용자의 인증 상태를 유지하는 방식입니다.

  • 장점: 서버 자원을 많이 사용하지 않으며, HttpOnly 옵션을 통해 클라이언트에서 쿠키에 접근할 수 없도록 하여 보안성을 강화할 수 있습니다.
  • 단점: 브라우저에만 종속되어 있어 모바일 앱에서 사용이 어려우며, 쿠키 자체의 크기 제한으로 많은 정보를 담기 어렵습니다.

 

JWT 방식의 등장과 채택 이유

JWT는 기존 방식들에서 발생하던 여러 문제를 해결하기 위해 고안되었습니다. 서버에 상태를 저장하지 않고 토큰만으로 인증을 관리할 수 있기 때문에, 확장성과 관리 측면에서 장점이 많아 널리 사용되고 있습니다.

 

  • 서버 부하 감소: JWT는 토큰 자체에 모든 인증 정보를 담아 클라이언트에 저장합니다. 서버는 각 요청마다 클라이언트의 토큰을 검증할 뿐, 세션을 관리할 필요가 없으므로 서버 자원을 절약할 수 있습니다.
  • 확장성: 분산 서버 환경에서 서버 간 세션 동기화 없이도 인증을 유지할 수 있으므로, 분산 환경이나 마이크로서비스 아키텍처에서 특히 유리합니다.
  • RESTful API와의 호환성: JWT는 HTTP 헤더에 포함하여 전송되며, RESTful API와 자연스럽게 호환됩니다.
  • 유연한 보안 관리: 토큰을 암호화하거나 서명하여 위변조를 방지할 수 있으며, 유효기간을 통해 보안을 강화할 수 있습니다.

 

JWT의 원리

JWT는 기본적으로 Header, Payload, Signature의 세 부분으로 구성되어 있으며, 각각의 부분이 특정 기능을 수행합니다.

 

  • Header (헤더): JWT의 타입과 해싱 알고리즘을 포함하여, JWT의 기본적인 구조와 인증 방식을 정의합니다.
  • Payload (페이로드): 사용자 ID, 역할, 유효 기간 등 JWT에 저장할 정보를 담고 있습니다. 이 정보는 인코딩된 형태로 전달됩니다.
  • Signature (서명): 서버가 토큰의 유효성을 검증할 수 있는 서명으로, 헤더와 페이로드를 결합한 후 비밀 키로 서명하여 생성됩니다. Signature를 통해 데이터 위변조를 방지할 수 있습니다.

 

이를 간단하게 보기좋게 표로 나타내보자면

 

부분 내용 목적
Header JWT 타입 정보와 알고리즘 토큰의 구조 파악
Payload 사용자 정보와 기타 데이터 전달할 데이터 포함
Signature 데이터 위변조 방지 서명 보안 및 데이터 무결성 검증

 

 

JWT는 인증과 세션 관리를 위해 Access TokenRefresh Token 형태로 활용됩니다. Access Token은 클라이언트가 인증된 상태로 서버 자원에 접근할 수 있도록 해주는 짧은 수명의 토큰이고, Refresh Token은 만료된 Access Token을 갱신할 수 있도록 해주는 장기 수명의 토큰입니다. 이 두 토큰은 서로 보완적인 역할을 하며, 보안과 사용자 편의를 동시에 제공하는데 필수적인 요소입니다.

JWT를 Access Token과 Refresh Token으로 나누어 사용하는 이유와 각 토큰의 특징을 알아보겠습니다.

 

Access Token과 Refresh Token의 차이점

Access TokenRefresh Token은 인증을 유지하기 위해 사용되는 두 가지 토큰이며, 각기 다른 역할을 합니다.

 

  • Access Token: 사용자의 인증 정보를 담고 있으며, 짧은 유효기간을 가집니다. 주로 API 요청 시 헤더에 포함하여 서버에 인증 정보를 전달하며, 사용자가 특정 리소스에 접근할 권한이 있는지를 검증하는 데 사용됩니다. 짧은 유효기간을 통해 보안을 강화할 수 있지만, 만료되면 새롭게 갱신이 필요합니다.
  • Refresh Token: Access Token이 만료되었을 때, 새로운 Access Token을 발급받기 위해 사용됩니다. 유효기간이 길며, 이를 통해 사용자에게 자동 로그인 또는 세션 유지 경험을 제공합니다. Refresh Token은 클라이언트가 서버에 추가 인증을 요청할 때만 사용됩니다.

 

Access Token과 Refresh Token 관리 방법

보안성과 사용성 측면에서 최적의 토큰 관리 방식은 Access Token은 상태 관리 라이브러리(MobX 등)에 저장하고, Refresh Token은 HttpOnly 쿠키에 저장하는 것입니다. 각 토큰의 저장 방식에 대해 설명하겠습니다.

 

1. Access Token을 상태 관리 라이브러리(MobX)에서 관리

 

Access Token은 유효기간이 짧아 주기적으로 갱신해야 하므로, 상태 관리 라이브러리(MobX)를 통해 메모리에 저장합니다. 이 방식은 페이지가 새로고침되면 초기화되지만, 클라이언트 측에서 보안을 강화할 수 있습니다. 만약 Access Token이 만료되면, 상태 관리 라이브러리에 저장된 Refresh Token을 사용해 자동으로 갱신하도록 구현할 수 있습니다.

해당 글에서는 jwt에서 만료 시간을 추출하기 위해 jwt_decode 라이브러리를 활용한 코드입니다.

 

// AuthStore.js
import { makeAutoObservable } from 'mobx';
import axios from 'axios';
import jwt_decode from 'jwt-decode'; // JWT 토큰을 디코딩하기 위한 라이브러리

class AuthStore {
  accessToken = null;
  expiryTime = null; // 만료 시간을 저장할 변수

  constructor() {
    makeAutoObservable(this);
  }

  setAccessToken(token) {
    this.accessToken = token;
    
    // JWT 토큰에서 만료 시간 추출
    const decodedToken = jwt_decode(token);
    this.expiryTime = decodedToken.exp; // 만료 시간 설정 (Unix timestamp)
  }

  async login(username, password) {
    try {
      const response = await axios.post('https://example.com/api/login', {
        username,
        password,
      });

      const { accessToken } = response.data;
      
      this.setAccessToken(accessToken); // 토큰과 만료 시간 설정
      axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
    } catch (error) {
      console.error('로그인 실패', error);
    }
  }

  async refreshAccessToken() {
    try {
      const response = await axios.post('https://example.com/api/refresh');
      const { accessToken } = response.data;
      
      this.setAccessToken(accessToken); // 새 토큰과 만료 시간 설정
      axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
      return accessToken;
    } catch (error) {
      console.error('Access Token 갱신 실패', error);
      this.logout();
    }
  }

  logout() {
    this.accessToken = null;
    this.expiryTime = null; // 만료 시간도 초기화
    delete axios.defaults.headers.common['Authorization'];
  }
}

export const authStore = new AuthStore();

 

 

 

위의 코드는 다음과 같이 사용하여 AccessToken이 없는 경우나 페이지를 새로고침 하였을 경우 미리 갱신하는 로직을 구현할 수 있습니다.

 

 

// App.tsx
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { authStore } from './authStore'; // MobX로 관리되는 authStore
import axios from 'axios';

const App: React.FC = observer(() => {
  useEffect(() => {
    // 페이지가 새로고침되거나 처음 로드될 때 실행되는 함수
    const checkAndRefreshToken = async () => {
      // accessToken이 없는 경우에는 로그인 페이지로 리다이렉트하거나 토큰을 발급받는 로직을 추가할 수 있습니다.
      if (!authStore.accessToken) {
        await authStore.refreshAccessToken();
      }

      // Access Token의 유효 기간을 체크하고, 만료 시간 이전에 갱신하는 로직 설정
      const refreshBeforeExpire = async () => {
        // Access Token의 만료 시점을 계산하는 로직 (예: 만료 5분 전 갱신)
        const tokenExpiryTime = authStore.tokenExpiryTime; // JWT 만료 시간(Unix timestamp)
        const currentTime = Math.floor(Date.now() / 1000); // 현재 시간 (초 단위)

        // Access Token이 만료되기 5분 전에 리프레시
        const refreshTimeThreshold = tokenExpiryTime - currentTime - 300;

        if (refreshTimeThreshold > 0) {
          // 5분 전에 갱신할 수 있도록 타이머 설정
          setTimeout(async () => {
            await authStore.refreshAccessToken();
          }, refreshTimeThreshold * 1000); // 밀리초로 변환
        } else {
          // Access Token이 이미 만료된 경우 갱신
          await authStore.refreshAccessToken();
        }
      };

      await refreshBeforeExpire();
    };

    // 페이지가 로드될 때마다 checkAndRefreshToken 함수 호출
    checkAndRefreshToken();
  }, []);

  return (
    <div>
      <h1>Welcome to My App</h1>
      {/* 앱 컴포넌트 로직 */}
    </div>
  );
});

export default App;

 

 

 

Access Token이 갱신될 때마다 이를 axios의 기본 헤더에 추가하여, 모든 API 요청에 자동으로 포함되도록 설정할 수 있습니다.

// axios 설정 파일
import axios from 'axios';
import { authStore } from './authStore';

// axios 기본 설정
axios.defaults.baseURL = 'https://example.com/api';
axios.defaults.headers.common['Content-Type'] = 'application/json';

// 요청 인터셉터: Access Token이 있는 경우 헤더에 추가
axios.interceptors.request.use(
  async (config) => {
    const token = authStore.accessToken;
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 응답 인터셉터: Access Token이 만료된 경우 Refresh Token을 사용해 갱신
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // Access Token 만료 시 401 오류 처리
    if (error.response && error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        await authStore.refreshAccessToken();
        originalRequest.headers['Authorization'] = `Bearer ${authStore.accessToken}`;
        return axios(originalRequest);
      } catch (refreshError) {
        authStore.logout();
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

 

2. Refresh Token을 HttpOnly 쿠키에 저장

 

Refresh Token은 민감한 정보이므로 HttpOnly 쿠키에 저장하여 관리합니다. HttpOnly 속성이 적용된 쿠키는 JavaScript에서 접근할 수 없으므로, XSS(크로스 사이트 스크립팅) 공격으로부터 안전하게 보호됩니다. 또한, HttpOnly 쿠키에 저장된 Refresh Token은 서버와의 통신 시 자동으로 전송되므로, 추가적인 작업 없이 안전하게 사용할 수 있습니다. 프론트엔드에서 쿠키를 직접 다루지 않아도 되므로 보안과 편의성을 동시에 확보할 수 있습니다.

 

이와 같은 방식으로 Access TokenRefresh Token을 관리하면 보안성과 사용자 경험을 모두 향상시킬 수 있습니다. Access Token은 상태 관리 라이브러리에서 관리하고, Refresh Token은 HttpOnly 쿠키에 저장하여 안전하고 효율적인 인증 관리를 구현할 수 있습니다.

 

 

마무으리

사실 로그인 상태관리는 대부분의 웹 어플리케이션에서 필수적으로 사용되는 기능입니다. 어찌보면 간단하고 당연하게 생각하는 기능이지만, 이를 위해 많은 개발자들이 노력하였고, 그리고 가져다 사용하는 방법도 생각보다는 복잡할 수 있습니다. JWT 방식 이외에도 많은 방식들이 있으니, 각 방식의 장단점을 파악하여 내 프로젝트에 더 맞는 방식을 채택하는 태도가 개발 실력 향상에 좋은 영향이 있을 거라 생각합니다. 오늘도 코드 치시느라 다들 고생 많으셨습니다ㅎㅎ

 
반응형