

이번 포스팅에서는 refresh 토큰 재발급 관련 로직을 리팩터링했던 과정에 관해 포스팅 해볼려고 합니다!
리팩토링 이전에는 MySQL을 데이터베이스로 사용하여 refresh token을 저장하고, AccessToken이 만료되면 refresh token을 활용해 재발급 과정을 진행했습니다.
AccessToken의 만료 시간을 30분으로 설정했기 때문에, 토큰 만료로 인한 401 에러 발생 시, 프론트엔드에서 accessToken을 서버로 보내 재발급을 요청합니다. 서버는 AccessToken으로 RefreshToken을 조회하고, 유효할 경우 새 AccessToken을 발급합니다.
프론트엔드에서 토큰을 헤더에 담아 인증 요청을 보냈을 때, 인증 실패 시 발생하는 쿼리 수를 살펴보겠습니다:
하지만 이것이 최선의 선택일까요?
RefreshToken 역시 유효기간이 있습니다. 이 프로젝트에서는 14일의 유효기간을 설정하고, 만료 시 삭제하는 방식을 채택했습니다. RDB 사용 시, 스케줄러와 같은 프로그램을 만들어 RefreshToken을 만료시키는 방법을 사용해야 합니다.
단일 쿼리라도 이는 사용자당 30분마다 한 번씩 실행됩니다. 예를 들어:
사용자 수가 증가할수록, DB에 지속적이고 과도한 부하를 주는 작업이 될 수 있습니다.
Redis 적용 시 얻을 수 있는 이점을 살펴보겠습니다:
자동 만료 처리: MySQL 사용 시 스케줄러로 데이터를 직접 삭제해야 하지만, Redis는 TTL(Time-To-Live) 기능으로 설정 시간 후 자동 삭제됩니다.
접근 횟수: 제가 설계한 로직에서 Redis 사용 시,
성능: 데이터베이스 접근 횟수는 한 번 늘었지만, Redis는 매우 빠른 읽기 속도를 자랑합니다. 캐싱용으로도 많이 사용되는 이유입니다.
부하 분산: MySQL은 다른 비즈니스 로직 처리를 위해 자주 사용되는데, RefreshToken 관리까지 맡기면 주기적인 요청으로 인한 부하가 우려됩니다.
이러한 이유로 Redis를 이용해 Refresh Token 관리 시스템을 리팩토링하기로 결정했습니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  redis:
    host: <public ip or localhost>
    port: 6379
@Getter
@AllArgsConstructor
@RedisHash(value = "jwtToken", timeToLive = 60 * 60 * 24 * 14)
public class RefreshToken implements Serializable {
    @Id
    private String id;
    @Indexed
    private String accessToken;
    private String refreshToken;
    public void updateAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }
}
@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
    Optional<RefreshToken> findByAccessToken(String accessToken);
}
@Service
@RequiredArgsConstructor
@Transactional
public class RefreshTokenService {
    private final RefreshTokenRepository repository;
    private final JwtTokenUtil jwtUtil;
    public void saveTokenInfo(String username, String accessToken, String refreshToken) {
        repository.save(new RefreshToken(username, accessToken, refreshToken));
    }
    public void removeRefreshToken(String accessToken) {
        RefreshToken token = repository.findByAccessToken(accessToken)
                .orElseThrow(IllegalArgumentException::new);
        repository.delete(token);
    }
    public String republishAccessToken(String accessToken) {
        // 액세스 토큰으로 Refresh 토큰 객체를 조회
        Optional<RefreshToken> refreshToken = repository.findByAccessToken(accessToken);
        // RefreshToken이 존재하고 유효하다면 실행
        if (refreshToken.isPresent() && jwtUtil.isJwtValid(refreshToken.get().getRefreshToken())) {
            // RefreshToken 객체를 꺼내온다.
            RefreshToken resultToken = refreshToken.get();
            // 권한과 아이디를 추출해 새로운 액세스토큰을 만든다.
            String newAccessToken = jwtUtil.generateAccessToken(resultToken.getId());
            // 액세스 토큰의 값을 수정해준다.
            resultToken.updateAccessToken(newAccessToken);
            repository.save(resultToken);
            // 새로운 액세스 토큰을 반환해준다.
            return newAccessToken;
        }
        return null;
    }
}
@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
    private final RefreshTokenService tokenService;
    @PostMapping("/token/logout")
    public void logout(@RequestHeader("Authorization") final String accessToken) {
        // 엑세스 토큰으로 현재 Redis 정보 삭제
        tokenService.removeRefreshToken(accessToken);
    }
    @PostMapping("/token/refresh")
    public ResponseEntity<TokenResponseStatus> refresh(@RequestHeader("Authorization") final String accessToken) {
        String newAccessToken = tokenService.republishAccessToken(accessToken);
        if (StringUtils.hasText(newAccessToken)) {
            return ResponseEntity.ok(TokenResponseStatus.addStatus(200, newAccessToken));
        }
        return ResponseEntity.badRequest().body(TokenResponseStatus.addStatus(400, null));
    }
}
보통 security+jwt를 사용하게 되면, 인증을 위한 필터를 사용할텐데 refresh 토큰 재발급을 위해 accessToken을 받고 필터를 수행하면 토큰의 값은 있지만 토큰이 유효하지 않을 수 있기 때문에 시큐리티 필터에서 해당 url을 건너뛰게 설정해줘야 합니다!!
        - id: plant-service
          uri: lb://PLANT-SERVICE
          predicates:
            - Path=/plant-service/token/refresh
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/plant-service/(?<segment>. *) , /$\{segment}
https://velog.io/@j3beom/Spring-Security-JWT-Redis-Refresh-Token-%ED%99%9C%EC%9A%A9-Token-%EC%9E%AC%EB%B0%9C%EA%B8%89-2-DB-%ED%99%9C%EC%9A%A9-RDBMS
https://velog.io/@ch4570/OAuth-2.0-JWT-Spring-Security%EB%A1%9C-%ED%9A%8C%EC%9B%90-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-Refresh-Token-%EC%9E%AC%EB%B0%9C%EA%B8%89
https://inkyu-yoon.github.io/docs/Language/SpringBoot/RefreshToken