BlueNyang
[멀티캠퍼스] 풀스택 개발자 아카데미 (29) - Spring Boot & JWT (2)
Java 풀스택 아카데미
· 멀티캠퍼스 JAVA 풀스택 개발자 아카데미 6회차 (29편)

[멀티캠퍼스] 풀스택 개발자 아카데미 (29) - Spring Boot & JWT (2)

BlueNyangBlueNyang
·
·
약 3분
·
# 부트캠프후기# 멀티캠퍼스it부트캠프# [현대이지웰] JAVA 풀스택 개발자 아카데미 6회차# refresh-token# axios-interceptor# token-reissue
시리즈·멀티캠퍼스 JAVA 풀스택 개발자 아카데미 6회차(30개의 글)
  • ···
  • 28.[멀티캠퍼스] 풀스택 개발자 아카데미 (28) - Spring Boot & JWT(1)
  • 29.[멀티캠퍼스] 풀스택 개발자 아카데미 (29) - Spring Boot & JWT (2)현재
  • 30.[멀티캠퍼스] 풀스택 개발자 아카데미 (30) - Deployment

지난 글에서는 Spring Boot 환경에서 JWT를 발급하고 검증하는 기본 로직을 구현했다. 하지만 Access Token의 유효 기간을 짧게(30분) 설정했기 때문에, 사용자는 30분마다 로그인이 풀리는 불편함을 겪게 된다.

이번 글에서는 보안과 편의성 두 마리 토끼를 잡기 위한 Refresh Token 도입과, 이를 React(Axios + Redux) 프론트엔드와 매끄럽게 연동하는 방법을 알아본다.

1. Refresh Token 전략이란?

Access Token은 탈취되었을 경우를 대비해 수명을 짧게 가져간다. 대신, 수명이 긴(7일~14일) Refresh Token을 하나 더 발급하여, Access Token이 만료되었을 때 사용자가 다시 로그인할 필요 없이 "투명하게(Silent)" 새로운 Access Token을 발급받도록 하는 전략이다.

  • Access Token: 리소스 접근용. 유효기간 짧음 (30분~1시간).
  • Refresh Token: 재발급 검증용. 유효기간 김 (1주~2주). DB나 Redis에 저장하여 관리.
Access Token을 발급해주는 흐름도
Access Token을 발급해주는 흐름도

2. Backend: 토큰 재발급 API 구현

2.1. Refresh Token 저장소 (Entity)

Refresh Token은 서버가 상태를 관리해야 하므로 DB에 저장해야 한다. (실무에서는 TTL 기능이 있는 Redis를 주로 사용하지만, 여기서는 편의상 RDB(JPA)를 사용한다.)

java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
    @Id
    @Column(name = "rt_key")
    private String key; // 사용자 ID

    @Column(name = "rt_value")
    private String value; // Refresh Token 값

    @Builder
    public RefreshToken(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}
Refresh Token 테이블 구조가 보이는 ERD 다이어그램
Refresh Token 테이블 구조가 보이는 ERD 다이어그램

2.2. 토큰 재발급 (Reissue) 서비스 로직

사용자가 보낸 Access Token과 Refresh Token을 검증하고 새 토큰을 내려주는 로직이다.

java
// AuthService.java
@Transactional
public TokenDto reissue(TokenRequestDto tokenRequestDto) {
    // 1. Refresh Token 검증
    if (!jwtTokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
        throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
    }

    // 2. Access Token 에서 User ID 가져오기
    Authentication authentication = jwtTokenProvider.getAuthentication(tokenRequestDto.getAccessToken());

    // 3. 저장소에서 User ID 를 기반으로 Refresh Token 값 가져옴
    RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
            .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));

    // 4. Refresh Token 일치하는지 검사
    if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
        throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
    }

    // 5. 새로운 토큰 생성
    TokenDto tokenDto = jwtTokenProvider.generateTokenDto(authentication);

    // 6. 저장소 정보 업데이트
    RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
    refreshTokenRepository.save(newRefreshToken);

    // 토큰 발급
    return tokenDto;
}

2.3. Controller 작성

java
@PostMapping("/token/refresh")
public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
    return ResponseEntity.ok(authService.reissue(tokenRequestDto));
}
Postman으로 새로운 토큰을 응답받는 테스트 성공 화면
Postman으로 새로운 토큰을 응답받는 테스트 성공 화면

3. Frontend: React + Axios Interceptor 연동

프론트엔드에서는 매번 API 요청을 보낼 때마다 헤더에 토큰을 넣어야 하고, 토큰 만료 에러(401)가 발생하면 자동으로 재발급 요청을 보내야 한다. 이 복잡한 과정을 컴포넌트마다 작성할 수 없으므로 Axios Interceptor를 사용한다.

3.1. Axios 인스턴스 생성

기본 axios 객체 대신 설정을 커스텀한 인스턴스를 만든다.

javascript
// src/api/axiosInstance.js
import axios from "axios";

// 환경변수에서 API 기본 URL 가져오기 없으면 로컬호스트 사용
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";

const axiosInstance = axios.create({
  baseURL: `${API_BASE_URL}/api`,
  headers: {
    "Content-Type": "application/json",
  },
});

export default client;
React 프로젝트 폴더 구조
React 프로젝트 폴더 구조

3.2. Request Interceptor: 헤더에 토큰 주입

요청이 전송되기 직전에 가로채서 Authorization 헤더를 심어준다. 토큰은 Redux Store나 LocalStorage에서 가져온다.

javascript
axiosInstance.interceptors.request.use(
  (config) => {
    const { accessToken } = store.getState().auth;
    // const token = null; // 임시로 토큰을 가져오는 부분을 비워둠

    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }

    return config;
  },
  (err) => {
    return Promise.reject(err);
  }
);

3.3. Response Interceptor: 401 에러 핸들링 (핵심!)

응답을 받았는데 401 Unauthorized 에러라면, 토큰이 만료된 것이다. 이때 사용자를 튕겨내는 대신 몰래 토큰을 재발급받고 원래 하려던 요청을 다시 실행한다.

javascript
axiosInstance.interceptors.response.use(
  (resp) => {
    return resp;
  },
  async (err) => {
    const originalRequest = err.config;
    if (err.response) {
      // 403 Forbidden 공통 처리 예시
      if (err.response.status === 403 && !originalRequest._retry) {
        originalRequest._retry = true;

        try {
          const refreshToken = localStorage.getItem("refreshToken");

          if (!refreshToken) {
            store.dispatch(logout());
            return Promise.reject(err);
          }

          const resp = await axios.post(`${API_BASE_URL}/api/v1/token/refresh`, {
            refreshToken: refreshToken,
          });

          const { accessToken, refreshToken: newRefreshToken } = resp.data;

          store.dispatch(
            tokenRefresh({
              accessToken: accessToken,
              refreshToken: newRefreshToken,
            })
          );

          if (newRefreshToken) {
            localStorage.setItem("refreshToken", newRefreshToken);
          }

          originalRequest.headers.Authorization = `Bearer ${accessToken}`;
          return axiosInstance(originalRequest);
        } catch (err) {
          store.dispatch(logout());
          toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");
          window.location.href = "/login";
          return Promise.reject(err);
        }
      }
      // ... 다른 상태 코드에 대한 공통 처리 추가 가능
      return Promise.reject(err.response.data);
    }
    return Promise.reject(err);
  }
);
Axios Interceptor의 흐름을 보여주는 다이어그램
Axios Interceptor의 흐름을 보여주는 다이어그램

3.4. Redux 로그인 액션 (참고)

로그인 성공 시 데이터를 저장하는 부분이다. (Redux Toolkit 예시)

javascript
// src/store/authSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axiosInstance from "../api/axiosConfig";

export const loginUser = createAsyncThunk(
  "auth/loginUser",
  async (loginData, { rejectWithValue }) => {
    try {
      const response = await client.post("/auth/login", loginData);
      // 로컬 스토리지 저장
      localStorage.setItem("accessToken", response.data.accessToken);
      localStorage.setItem("refreshToken", response.data.refreshToken);
      return response.data;
    } catch (error) {
      return rejectWithValue(error.response.data);
    }
  }
);
// ... Slice 로직 생략
로그인 페이지 코드의 일부
로그인 페이지 코드의 일부

4. 최종 테스트

이제 브라우저를 켜고 테스트를 진행해보자.

  1. 로그인을 하여 Access Token을 발급받는다.
  2. (테스트를 위해) DB나 로컬 스토리지의 Access Token 값을 임의로 변경하거나, 서버의 유효 시간을 1분으로 줄여본다.
  3. 로그인이 필요한 페이지에 접속하거나 새로고침을 한다.
  4. Network 탭을 확인한다.
  • 첫 번째 요청: 401 실패 (빨간색)
  • 두 번째 요청: /token/refresh 200 성공
  • 세 번째 요청: 원래 하려던 요청 200 성공

이 모든 과정이 사용자 눈에는 "그냥 로딩이 잘 되는 것"처럼 보인다. 이것이 바로 Seamless 한 UX다.

크롬 개발자 도구 Network 탭 스크린샷
크롬 개발자 도구 Network 탭 스크린샷

5. 마무리

두 번의 글에 걸쳐 Spring Boot 3.x + Spring Security 6 환경에서의 JWT 인증 시스템을 완벽하게 구축해 보았다.

  • 1편: JWT 발급, 검증 필터, Security 설정
  • 2편: Refresh Token, Axios Interceptor를 이용한 재발급 자동화

이 구조는 현업에서도 가장 많이 사용하는 표준적인 인증 패턴 중 하나이다. 이 코드를 베이스로 하여 OAuth2(소셜 로그인)를 붙이거나, Redis를 도입해 성능을 최적화하는 방식으로 확장해 나가면 된다.

이제 여러분의 프로젝트에 안전하고 편리한 로그인 시스템을 적용해 볼 수 있다.

'로그인 성공' 상태가 표시된 UI
'로그인 성공' 상태가 표시된 UI
BlueNyang
작성자BlueNyang
라이선스
CC BY NC
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus