얼리테이블 - 트러블슈팅 : 자동 로그인의 필요성

take_the_king·2025년 2월 4일

1. 문제

초기에는 엑세스토큰만을 사용하여 로그인을 관리했는데, 엑세스토큰의 만료 시간이 짧아 사용자가 자주 로그인을 다시 해야 하는 불편함이 발생했습니다.

즉, 사용자가 로그인한 후에도 짧은 시간마다 토큰 만료로 인해 인증이 끊기고, 다시 로그인을 해야 하는 상황이 문제였습니다.


2. 원인

  • 엑세스토큰 만료 시간 엑세스토큰은 보통 보안상의 이유로 만료 시간이 짧게 설정됩니다. 이로 인해, 사용자가 서비스를 이용하는 도중에 토큰이 만료되면 인증 상태가 풀려 다시 로그인을 요구하게 됩니다.
  • 엑세스토큰 단독 관리 기존에는 엑세스토큰만 관리하다 보니, 토큰 갱신 로직이 없어서 만료되면 새 토큰을 발급받지 못하고 로그인이 끊기는 문제가 발생했습니다.

3. 해결책

해결책으로는 리프레쉬 토큰(Refresh Token)을 도입하는 것입니다.

  • 리프레쉬 토큰 발급: 사용자가 로그인할 때 서버는 엑세스토큰과 함께 리프레쉬 토큰을 발급합니다.
  • 리프레쉬 토큰 저장: 리프레쉬 토큰은 보안상의 이유로 HttpOnly 쿠키로 저장합니다. 이 쿠키는 JavaScript에서 접근할 수 없으므로, XSS 공격에 안전합니다.
  • 엑세스토큰 저장: 엑세스토큰은 브라우저의 로컬스토리지에 저장하여 클라이언트 측에서 사용합니다.
  • 자동 토큰 갱신: 클라이언트에서는 엑세스토큰을 사용하다가 만료되면, HttpOnly 쿠키에 저장된 리프레쉬 토큰을 사용하여 서버에 새 엑세스토큰 발급을 요청합니다. 이 과정은 axios 인터셉터 등을 통해 자동으로 처리할 수 있습니다.

4. 적용 (코드 예시)

4.1. 서버에서 RefreshToken 추가 발급

@PostMapping("/login")
    public ResponseEntity<JwtAuthResponse> loginUser(@Valid @RequestBody UserLoginRequestDto requestDto,
                                                     HttpServletResponse response) {

        String accessToken = userService.loginUser(requestDto);

				// 쿠키에 refresh token 담기
        response.addCookie(userService.craeteCookie(requestDto.getEmail()));

        return ResponseEntity.status(HttpStatus.OK).body(new JwtAuthResponse(AuthenticationScheme.BEARER.getName(), accessToken));
    }
  • refresh Token 을 생성해서 쿠키에 담는 메서드 (UserService)
/**
     * refresh Token 을 쿠키에 담기
     *
     * @return Cookie
     */
    public Cookie craeteCookie(String email) {

        String cookieName = "refreshToken";
        String cookieValue = jwtProvider.generateRefreshToken(email); // 쿠키벨류엔 글자제한이 이써, 벨류로 만들어담아준다.

        // refreshToken db 저장
        refreshTokenService.saveRefreshToken(email, cookieValue);

        Cookie cookie = new Cookie(cookieName, cookieValue);
        // 쿠키 속성 설정
        cookie.setHttpOnly(true);  //httponly 옵션 설정
        // cookie.setSecure(true); //https 옵션 설정
        cookie.setPath("/"); // 모든 곳에서 쿠키열람이 가능하도록 설정
        cookie.setMaxAge(60 * 60 * 24); //쿠키 만료시간 설정
        return cookie;

    }
  • Redis에 refresh Token을 저장 (만료 기간 30일)
@Service
public class RefreshTokenService {

    private final RedissonClient redissonClient;

    public RefreshTokenService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    // RefreshToken 저장
    public void saveRefreshToken(String username, String refreshToken) {
        RBucket<String> bucket = redissonClient.getBucket("refreshToken:" + username);
        bucket.set(refreshToken, Duration.ofDays(30));
    }

    // RefreshToken 가져오기
    public String getRefreshToken(String username) {
        RBucket<String> bucket = redissonClient.getBucket("refreshToken:" + username);
        return bucket.get();
    }

    // RefreshToken 삭제
    public void deleteRefreshToken(String username) {
        RBucket<String> bucket = redissonClient.getBucket("refreshToken:" + username);
        bucket.delete();
    }

    // RefreshToken 검증
    public boolean validateRefreshToken(String username, String refreshToken) {
        String storedToken = getRefreshToken(username);
        return storedToken != null && storedToken.equals(refreshToken);
    }
}

4.2. Axios 인터셉터를 활용한 자동 토큰 갱신

import axios from "axios";

// 🔹 Axios 인스턴스 생성
const instance = axios.create({
  baseURL: "http://localhost:8080", // Spring Boot 서버 주소
  withCredentials: true, // 쿠키 포함 요청
});

// 🔹 액세스 토큰을 가져오는 함수
const getAccessToken = () => localStorage.getItem("accessToken");

// 🔹 요청 인터셉터: 헤더에 액세스 토큰 추가
instance.interceptors.request.use(
  (config) => {
    const accessToken = getAccessToken();
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 🔹 리프레시 토큰을 사용해 새로운 액세스 토큰을 가져오는 함수
const refreshAccessToken = async () => {
  try {
    const response = await instance.post(
      "/users/refresh",
      {},
      {
        headers: { "Content-Type": "application/json" },
        withCredentials: true, // HttpOnly 쿠키 포함
      }
    );

    const newAccessToken = response.data.accessToken;
    if (!newAccessToken) throw new Error("새로운 액세스 토큰 없음");

    localStorage.setItem("accessToken", newAccessToken);
    return newAccessToken;
  } catch (error) {
    console.error("❌ 리프레시 토큰 만료: 로그인 페이지로 이동");
    localStorage.removeItem("accessToken");
    window.location.href = "/login"; // 로그인 페이지로 리디렉션
    return null;
  }
};

// 🔹 응답 인터셉터: 401 응답 처리 (액세스 토큰 갱신 후 요청 재시도)
instance.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // 재시도 방지 플래그 설정

      const newAccessToken = await refreshAccessToken();
      if (newAccessToken) {
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
        return instance(originalRequest); // 요청 재시도
      }
    }

    return Promise.reject(error);
  }
);

export default instance;

4.3 서버에서 엑세스 토큰 재발급 (UserService)

/**
     * refreshToken 확인 및 accessToken 재발급
     *
     * @param email
     * @return accessToken
     */
    public String refresh(String email, String refreshToken) {

        if(!refreshTokenService.validateRefreshToken(email, refreshToken)) {
            throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
        }

        return jwtProvider.generateAccessToken(email);
    }

5. 자동 로그인 플로우 설명

  1. 로그인 시 토큰 발급
    • 사용자가 로그인하면 서버는 엑세스토큰과 리프레쉬 토큰을 함께 발급합니다.
    • 엑세스토큰은 로컬스토리지에 저장되고, 리프레쉬 토큰은 HttpOnly 쿠키에 저장됩니다.
  2. 요청 시 엑세스토큰 사용
    • 클라이언트는 axios 인터셉터를 통해 로컬스토리지의 엑세스토큰을 HTTP 헤더에 포함하여 요청합니다.
  3. 엑세스토큰 만료 시
    • 서버로부터 401 Unauthorized 응답을 받으면 axios 응답 인터셉터가 동작합니다.
  4. 토큰 갱신 요청
    • 인터셉터에서 /users/refresh API를 호출하여 리프레쉬 토큰을 사용해 새 엑세스토큰을 발급받습니다.
  5. 새 토큰으로 재시도
    • 발급받은 새 엑세스토큰을 로컬스토리지에 저장하고, 원래의 요청을 재시도하여 자동 로그인을 유지합니다.
  6. 갱신 실패 시
    • 새 토큰 발급에 실패하면 사용자를 로그인 페이지로 리디렉션하여 다시 로그인하도록 합니다.

결론

엑세스토큰의 짧은 만료 시간 문제를 해결하기 위해 리프레쉬 토큰을 도입하였습니다.

리프레쉬 토큰은 HttpOnly 쿠키에 안전하게 저장되고, 엑세스토큰은 로컬스토리지에 저장되어 사용됩니다.

axios 인터셉터를 활용해 401 에러 발생 시 자동으로 리프레쉬 토큰을 사용해 새 엑세스토큰을 발급받아 원래의 요청을 재시도하도록 구현하여, 사용자가 자주 로그인할 필요 없이 자동 로그인이 유지되도록 해결했습니다.

profile
개발을 좋아하는 taketheking 입니다.

0개의 댓글