[JWT] MariaDB에서 Redis로 Refresh Token 관리 전환과 이전 방식 문제점 정리

Yu Seong Kim·2024년 12월 2일

개요

요기조기 프로젝트의 RefreshToken을 MariaDB에서 관리하였습니다. 즉, MariaDB로 관리를 해도 되지만, 추후 서비스가 커짐에 따라 속도 문제, 자동 만료 부재 등 다양한 이유로 MariaDB로 관리하는 것에 대한 문제점을 예측하고 그에 대한 해결방안을 생각했습니다.

Refresh Token 이란?

예전에 포스팅한 jwt토큰을 이용한 로그인 방식은 access를 위한 토큰이라 볼 수 있습니다. 그래서 사람들은 보통 이 토큰을 access token이라 지정하는 경우도 많습니다.만약 이 토큰이 탈취당하면 매우 리스크가 큰데 이러한 해결 방안은 토큰의 유효시간을 짧게 설정하여 보완하면 됩니다.
유효시간이 짧으면 토큰을 탈취 당해도 금방 만료되기 때문에 유효성 통과를 빠르게 할 수 없습니다. 그러나 이러한 방식의 문제점은 사용자는 짧은 시간 사이에 계속 로그인을 수행해야 하는 불편한 점이 생깁니다.

이러한 불편한 점을 해결하기 위해서 RefreshToken을 같이 생성합니다.

출처 : https://nowgnas.github.io/posts/refreshtoken/

RefreshToken 사용 과정

  1. user가 로그인 api를 통해 서버에 로그인하도록 요청합니다.

  2. 만약 DB에 사용자가 있는 지 확인되면, access 토큰과 refresh 토큰을 발급해줍니다.

  3. 이제 user는 서버에 api를 호출할 때 마다 access토큰을 헤더에 담아 메시지를 전송합니다.

  4. 서버에서는 이 access토큰을 파싱해서 실제 유저인지, 권한을 갖는 지 인증, 인가 작업을 거칩니다.

  5. 만약 토큰이 유효하면, api를 실행합니다.

  6. 만약 유효기간이 지나 유효하지 않다면, 만료되었다는 것을 서버가 클라이언트에 알립니다.

  7. 만료되었다는 것을 알게 되면, 클라이언트에서 서버로 refresh 토큰을 전송해 다시 access토큰을 재발급 하도록 요청합니다.

  8. 서버가 재발급 요청을 받으면, refresh 토큰을 검증해 유효한지 확인하고, 만약 refresh 토큰도 유효하지 않으면, 사용자는 결국 재 로그인 해야합니다.

  9. 만약 refresh 토큰이 유효하면, access토큰을 새로 생성해서 클라이언트에게 전송하고, 사용자는 재발급된 토큰으로 다시 api호출이 가능해집니다.

Refresh token 저장 위치

access 토큰 같은 경우 클라이언트에게 보내면 클라이언트 로컬 스토리지 같은 곳에 저장되어 사용하지만 refresh 토큰은 access 토큰의 탈취를 염려해 사용하는 것이기에, 서버에 저장되는 경우가 많습니다. 또한 refresh 토큰도 access 토큰보다는 길지만 유효기간이 존재하고, 결국 언젠가 사라져야 하며, 이 과정은 개발자가 직접 삭제 해줘야 합니다. refresh 토큰을 서버에서 자동으로 삭제할 방법으로 MariaDB에 저장하여 스케쥴링 작업을 진행하거나 Redis를 사용하여 key - value 형태로 데이터를 저장하는 noSQL 인 메모리 데이터베이스로 데이터에 유효기간을 부여하여 관리하면 됩니다.

MariaDB로 RefreshToken 관리할 때의 단점

  • 속도 문제
    -> MariaDB는 디스크 기반의 관계형 데이터베이스로, 읽기/쓰기 작업이 Redis 같은 메모리 기반 저장소에 비해 상대적으로 느립니다.
    -> 고빈도 읽기/쓰기 작업에서 성능 병목이 발생할 수 있습니다.

    고빈도 읽기/쓰기 작업이란 데이터 저장소(데이터베이스, 캐시 등)에서 특정 데이터에 대해 매우 자주 읽고 쓰는 작업을 말합니다.

  • 자동 만료 관리 부재
    -> Refresh Token은 일정 시간이 지나면 만료되어야 하지만, MariaDB는 TTL(Time-To-Live) 기능이 없어서 만료된 토큰을 직접 관리해야 합니다.
    -> 이를 위해 주기적으로 삭제 작업(CRON 또는 스케줄링 작업)을 설정해야 합니다.
  • 데이터베이스 부하
    -> 서비스가 커진다면 RefreshToken은 빈번히 조회되고, 갱신 되어야 하기 때문에, 다른 사용자 데이터와 동일한 디비에 저장한다면 전체 DB 성능에 부정적인 영향을 미칩니다.
  • 스케일링의 어려움
    -> MariaDB는 수평 스케일링(데이터 분산)을 지원하지 않아, 대규모 트래픽을 처리할 때 병목현상이 발생할 수 있습니다.

    병목현상은 시스템의 여러 구성 요소 중 하나가 다른 부분의 처리 속도를 제한하거나 느리게 만드는 상황을 말합니다.

  • 복잡한 쿼리 관리
    -> 토큰 만료, 갱신, 조회 등에 대한 SQL 쿼리를 직접 작성하고 관리해야 하지만 Redis에서는 간단한 키-값 기반 명령으로 처리가 가능합니다.

Redis로 RefreshToken 관리할 때의 장점

  • 빠른 성능
    -> Redis는 메모리 기반 NoSQL 데이터베이스로, 읽기/쓰기 속도가 매우 빠릅니다.
    -> JWT Refresh Token처럼 고빈도 읽기/쓰기 작업에 적합합니다.
  • TTL 지원
    -> Redis는 데이터 저장 시 TTL을 설정할 수 있어, 자동으로 만료된 Refresh Token을 제거가 가능합니다.
    ->주기적인 만료 처리 로직이 필요하지 않습니다.
  • 저장소의 가벼움
    -> Refresh Token은 간단한 키-값 구조이므로, 관계형 데이터베이스보다 Redis의 키-값 기반 저장소에 적합하고, 메몰 효율이 높습니다.
  • 수평 스케일링 지원
    -> Redis는 클러스터링 및 수평 스케일링을 지원하므로, 대규모 트래픽도 효과적으로 처리 가능합니다.
  • 간단한 관리
    -> Redis의 명령은 간단하고 직관적이며, 복잡한 SQL 쿼리를 작성할 필요가 없음.
    -> 예: SET, GET, EXPIRE 같은 기본 명령으로 대부분의 작업을 처리 가능.
  • 독립된 세션 데이터 관리
    -> Refresh Token을 Redis로 분리하여 관리하면, MariaDB의 주요 데이터베이스에 부담을 주지 않습니다.
    -> 데이터베이스의 안정성과 성능을 유지할 수 있습니다.

*- 일시적 데이터 관리
-> Refresh Token은 영구적으로 저장할 필요가 없는 일시적 데이터이므로, Redis의 비영구적 저장 방식과 잘 맞습니다.

Redis 관리로의 전환 요약

  • MariaDB 단점: 속도 문제, TTL 부재, 데이터베이스 부하 증가, 관리 복잡성.
  • Redis 장점: 빠른 성능, TTL 지원, 간단한 관리, 대규모 트래픽 처리 능력.

코드로 이해하기

RedisConfig.java
@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;
    }
  • redisConnectionFactory를 빈으로 등록할 때, 반환할 객체를 Lettuce로 정했습니다.
  • Lettuce는 RedisConnectionFactory를 구현한 구현체입니다.
  • Jedis라는 것도 있었는데, 동기 방식이기에 블로킹되거나 하는 이슈가 있고, 성능도 떨어진다는 이야기가 있었습니다.
  • 그래서 동기 비동기 다 지원하고 non-blocking을 지원하는 Lettuce를 선택했다.
    자세한 내용은?
    Jedis vs Lettuce
RefreshToken.java
@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와 RefreshTokenRepositoryImpl

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.java

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;
        }
    }

}
SignServieImpl.java
    @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 소셜로그인 코드에서도 동일하게 적용합니다.

RefreshTokenServiceIMpl.java
@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 토큰이 유효한 지 검증한다. 유효하지 않다면, 결국 사용자는 다시 로그인해야 합니다.

  1. DB에 사용자 아이디를 key로 저장한 데이터가 있는 지 확인한다. 없다면, 역시나 유효기간이 지났으니 재로그인 해야합니다.

  2. DB에 사용자 아아디(key)와 refresh token(value) 데이터가 존재한다면, 사용자가 보낸 refresh token과 저장된 refresh token이 같은 지 비교합니다.

  3. 위 과정에서 문제가 없으면, refresh 토큰이 이상이 없다는 뜻이니 새로운 access token을 사용자에게 발급해줍니다.

  4. 이후 그 사용자의 refresh 토큰을 업데이트해서 redis에 적용합니다.

Swagger 테스트 결과

일반 로그인

토큰 재발급

마무리

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

profile
Development Record Page

0개의 댓글