이번 포스팅에서는 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