요기조기 프로젝트의 RefreshToken을 MariaDB에서 관리하였습니다. 즉, MariaDB로 관리를 해도 되지만, 추후 서비스가 커짐에 따라 속도 문제, 자동 만료 부재 등 다양한 이유로 MariaDB로 관리하는 것에 대한 문제점을 예측하고 그에 대한 해결방안을 생각했습니다.
예전에 포스팅한 jwt토큰을 이용한 로그인 방식은 access를 위한 토큰이라 볼 수 있습니다. 그래서 사람들은 보통 이 토큰을 access token이라 지정하는 경우도 많습니다.만약 이 토큰이 탈취당하면 매우 리스크가 큰데 이러한 해결 방안은 토큰의 유효시간을 짧게 설정하여 보완하면 됩니다.
유효시간이 짧으면 토큰을 탈취 당해도 금방 만료되기 때문에 유효성 통과를 빠르게 할 수 없습니다. 그러나 이러한 방식의 문제점은 사용자는 짧은 시간 사이에 계속 로그인을 수행해야 하는 불편한 점이 생깁니다.
이러한 불편한 점을 해결하기 위해서 RefreshToken을 같이 생성합니다.

출처 : https://nowgnas.github.io/posts/refreshtoken/
user가 로그인 api를 통해 서버에 로그인하도록 요청합니다.
만약 DB에 사용자가 있는 지 확인되면, access 토큰과 refresh 토큰을 발급해줍니다.
이제 user는 서버에 api를 호출할 때 마다 access토큰을 헤더에 담아 메시지를 전송합니다.
서버에서는 이 access토큰을 파싱해서 실제 유저인지, 권한을 갖는 지 인증, 인가 작업을 거칩니다.
만약 토큰이 유효하면, api를 실행합니다.
만약 유효기간이 지나 유효하지 않다면, 만료되었다는 것을 서버가 클라이언트에 알립니다.
만료되었다는 것을 알게 되면, 클라이언트에서 서버로 refresh 토큰을 전송해 다시 access토큰을 재발급 하도록 요청합니다.
서버가 재발급 요청을 받으면, refresh 토큰을 검증해 유효한지 확인하고, 만약 refresh 토큰도 유효하지 않으면, 사용자는 결국 재 로그인 해야합니다.
만약 refresh 토큰이 유효하면, access토큰을 새로 생성해서 클라이언트에게 전송하고, 사용자는 재발급된 토큰으로 다시 api호출이 가능해집니다.
access 토큰 같은 경우 클라이언트에게 보내면 클라이언트 로컬 스토리지 같은 곳에 저장되어 사용하지만 refresh 토큰은 access 토큰의 탈취를 염려해 사용하는 것이기에, 서버에 저장되는 경우가 많습니다. 또한 refresh 토큰도 access 토큰보다는 길지만 유효기간이 존재하고, 결국 언젠가 사라져야 하며, 이 과정은 개발자가 직접 삭제 해줘야 합니다. refresh 토큰을 서버에서 자동으로 삭제할 방법으로 MariaDB에 저장하여 스케쥴링 작업을 진행하거나 Redis를 사용하여 key - value 형태로 데이터를 저장하는 noSQL 인 메모리 데이터베이스로 데이터에 유효기간을 부여하여 관리하면 됩니다.
고빈도 읽기/쓰기 작업이란 데이터 저장소(데이터베이스, 캐시 등)에서 특정 데이터에 대해 매우 자주 읽고 쓰는 작업을 말합니다.
병목현상은 시스템의 여러 구성 요소 중 하나가 다른 부분의 처리 속도를 제한하거나 느리게 만드는 상황을 말합니다.
*- 일시적 데이터 관리
-> Refresh Token은 영구적으로 저장할 필요가 없는 일시적 데이터이므로, Redis의 비영구적 저장 방식과 잘 맞습니다.
- MariaDB 단점: 속도 문제, TTL 부재, 데이터베이스 부하 증가, 관리 복잡성.
- Redis 장점: 빠른 성능, TTL 지원, 간단한 관리, 대규모 트래픽 처리 능력.
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Value("${spring.redis.password}")
private String redisPassword;
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory(){
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(redisHost);
redisStandaloneConfiguration.setPort(redisPort);
redisStandaloneConfiguration.setPassword(redisPassword);
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public RedisTemplate<String,Object> redisTemplate () {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
//레디스 연결을 관리
redisTemplate.setConnectionFactory(redisConnectionFactory());
//레디스 키의 직렬화 방식, 키를 문자열로 직렬화
redisTemplate.setKeySerializer(new StringRedisSerializer());
//레디스 값의 직렬화 방식. 값을 문자열로 직렬화
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@RedisHash(value = "refreshToken", timeToLive = 60*60*24)
public class RefreshToken {
@Id
private String email;
private String refreshToken;
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
여기서 주의할 점은 Redis는 RDBMS가 아니기 때문에 @Entity를 사용하지 않습니다.
RefreshTokenRepository
public interface RefreshTokenRepository {
void save(RefreshToken refreshToken);
Optional<RefreshToken> findByUsername(String email);
void delete(String username);
}
RefreshTokenRepositoryImpl
@Repository
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
private final RedisTemplate redisTemplate;
@Override
public void save(RefreshToken refreshToken) {
// opsForValue -> 스트링을 위한 것.
ValueOperations<String,String> valueOperations = redisTemplate.opsForValue();
// 만약 이미 유저네임이 존재하면 업데이트를 위한 기존 유저네임 삭제
if(!Objects.isNull(valueOperations.get(refreshToken.getEmail()))){
redisTemplate.delete(refreshToken.getEmail());
log.info("refreshToken Repository save Update -> 업데이트를 위한 기존 key 삭제");
}
// 레디스에 키-값 저장
valueOperations.set(refreshToken.getEmail(),refreshToken.getRefreshToken());
// 만료 시간 24시간
redisTemplate.expire(refreshToken.getEmail(),60 * 60 * 24, TimeUnit.SECONDS);
}
@Override
public Optional<RefreshToken> findByUsername(String username) {
ValueOperations<String,String> valueOperations = redisTemplate.opsForValue();
String refreshToken = valueOperations.get(username);
if(refreshToken == null){
return Optional.empty();
}else{
return Optional.of(new RefreshToken(username,refreshToken));
}
}
@Override
public void delete(String username) {
redisTemplate.delete(username);
}
jwtProvider는 예전 jwt를 정리할 때, 포스팅 했기 때문에 RefreshToken 부분만 작성하겠습니다.
@Value("${springboot.jwt.refresh-secret}")
private String refreshSecretKey;
private final long refreshTokenValidTime = 1000 * 60 * 60 * 24 * 7; // 7일
public String createRefreshToken(String email) {
Claims claims = Jwts.claims().setSubject(email);
Date now = new Date();
String refreshToken =Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + refreshTokenValidTime))
.signWith(SignatureAlgorithm.HS256, refreshSecretKey)
.compact();
return refreshToken;
}
public String getUsernameFromRefreshToken(String refreshToken) {
return Jwts.parser()
.setSigningKey(refreshSecretKey)
.parseClaimsJws(refreshToken)
.getBody()
.getSubject();
}
public boolean validRefreshToken(String refreshToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(refreshSecretKey).parseClaimsJws(refreshToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
@Override
public SignInResultDto SignIn(String email, String password) {
Member member = memberRepository.getByEmail(email);
if (member == null) {
throw new RuntimeException("Member not found");
}
if(!passwordEncoder.matches(password, member.getPassword())) {
throw new RuntimeException("Invalid credentials");
}
log.info("[getSignInResult] 패스워드 일치");
// 토큰 생성
String accessToken = jwtProvider.createToken(
String.valueOf(member.getEmail()),
member.getTeamMembers().stream()
.map(TeamMember::getRole)
.collect(Collectors.toList())
);
String refreshToken = jwtProvider.createRefreshToken(member.getEmail());
refreshTokenRepository.save(new RefreshToken(email,refreshToken));
log.info("[refreshToken] : {}",refreshToken);
// SignInResultDto 작성 및 반환
SignInResultDto signInResultDto = new SignInResultDto();
signInResultDto.setToken(accessToken);
signInResultDto.setRefreshToken(refreshToken);
signInResultDto.setDetailMessage("로그인 성공");
setSuccess(signInResultDto);
return signInResultDto;
}
String refreshToken = jwtProvider.createRefreshToken(member.getEmail());
String refreshToken = jwtProvider.createRefreshToken(member.getEmail());
refreshTokenRepository.save(new RefreshToken(email,refreshToken));
를 추가하여 RefreshToken을 생성하고, Redis에 저장합니다.
이 과정을 Auth 소셜로그인 코드에서도 동일하게 적용합니다.
@Service
@Slf4j
@RequiredArgsConstructor
public class RefreshTokenServiceImpl implements RefreshTokenService {
private final JwtProvider jwtProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final MemberDao memberDao;
@Override
public RefreshTokenResponseDto reIssue(String refreshToken, HttpServletRequest request) {
log.info("reIssue ==> refresh 토큰 통한 토큰 재발급 시작");
// 유효기간 검증
if(!jwtProvider.validRefreshToken(refreshToken)) {
throw new IllegalArgumentException("재로그인 필요");
}
log.info("reIssue ==> refresh 토큰 검증 성공");
String email = jwtProvider.getUsernameFromRefreshToken(refreshToken);
// 레디스에 그 유저에게 발급된 refresh토큰 있는지 확인
RefreshToken findRefreshToken = refreshTokenRepository.findByUsername(email).orElseThrow(
()-> new ResponseStatusException(HttpStatus.BAD_REQUEST, "로그아웃된 사용자"));
log.info("reIssue ==> DB에 사용자 이름과 refresh 토큰 존재 확인");
if(!findRefreshToken.getRefreshToken().equals(refreshToken)) {
throw new IllegalArgumentException("redis의 RefreshToken과 일치 하지 않음");
}
log.info("reIssue ==> Redis의 RefreshToken과 일치 확인");
Member member = memberDao.findMemberByEmail(email);
if(member == null) {
throw new IllegalArgumentException("존재 하지 않은 사용자 입니다.");
}
String newAccessToken = jwtProvider.createToken(
String.valueOf(member.getEmail()),
member.getTeamMembers().stream()
.map(TeamMember::getRole)
.collect(Collectors.toList())
);
String newRefreshToken = jwtProvider.createRefreshToken(email);
findRefreshToken.updateRefreshToken(newRefreshToken);
refreshTokenRepository.save(findRefreshToken);
RefreshTokenResponseDto refreshTokenResponseDto = new RefreshTokenResponseDto(
newAccessToken,newRefreshToken);
return refreshTokenResponseDto;
}
}
이 코드의 동작 과정은 아래와 같습니다.
1. refresh 토큰이 유효한 지 검증한다. 유효하지 않다면, 결국 사용자는 다시 로그인해야 합니다.
DB에 사용자 아이디를 key로 저장한 데이터가 있는 지 확인한다. 없다면, 역시나 유효기간이 지났으니 재로그인 해야합니다.
DB에 사용자 아아디(key)와 refresh token(value) 데이터가 존재한다면, 사용자가 보낸 refresh token과 저장된 refresh token이 같은 지 비교합니다.
위 과정에서 문제가 없으면, refresh 토큰이 이상이 없다는 뜻이니 새로운 access token을 사용자에게 발급해줍니다.
이후 그 사용자의 refresh 토큰을 업데이트해서 redis에 적용합니다.
일반 로그인

토큰 재발급

이렇게 레디스를 이용하여 RefreshToken을 관리하고, 새로 발급하는 API까지 만들어 보았습니다. 이를 통해서 사용자는 매번 로그인할 필요가 없기 API를 지속적으로 호출하여 로그인 상태를 이어나갈 수 있습니다. 또한 마리아 디비로 관리하는 단점도 함께 알아보았습니다.