PROJECT/개발일지

Redis활용해 Refresh Token 관리하기 (spring boot, elastiCache)

Ray123 2023. 9. 2. 20:35

현재 내가 소마에서 진행 중인 프로젝트는 jwt를 활용중이며, rdb에 refresh token을 저장하고 재발급에 활용하고 있다. 시퀀스 다이어그램으로 보면 다음과 같은 구조다.

 

(참고 : refresh token을 db에 저장한 뒤 클라이언트가 재발급 요청 시 보내진 refresh token과 대조하는 이유 = 악의적 사용자가 지 맘대로 만든 refresh token으로 재발급 요청해서 access token을 받으려 하는걸 막을 수 있고, 악의적 사용자가 refresh token을 탈취했다고 해도 우리 쪽에서 db에 있는 refresh token을 없애준다든가 하는 식으로 조치를 취해줄 수도 있고,, 로그아웃 구현 시에도 편리하고.. 등등)

 

그러나 이 방법의 단점을 꼽자면 다음과 같은 부분들이 있다

 

  1. rdb에서 refresh token을 가져오는데 시간이 좀 걸린다
  2. rdb에 저장된 토큰데이터들 중 만료기간이 지난 토큰들을 개발자가 코드를 작성하든 뭐 하든 해서 직접 삭제시켜줘야한다

 

이를 인메모리 데이터베이스인 redis를 사용하면, 해당 단점들을 개선할 수 있다

 

  1. 우선 메모리에 저장하기 때문에 rdb대비 토큰을 가져오는 속도가 빠를 것이고
  2. ttl을 설정해주면 만료기간 지난 토큰들은 알아서 없어진다

 

따라서 refresh token을 관리하던 방법을 rdb에서 redis로 바꾸기로 했다. 물론 인메모리 데이터베이스인 만큼 꺼지면 다 날라가긴 하지만, refresh token은 날라가도 크리티컬한 피해는 없다. 번거로울 수 있지만 다시 로그인하면 되는 거니까.

 


우선 AWS에서 ElastiCache로 Redis를 쓸 거다. 문제는 ElastiCache는 같은 VPC에서만 접속하는걸 허용해서 로컬에서 접근할 수 없다는 것. 그러나 ssh 터널링 등을 통한 방법으로 로컬에서도 ElastiCache에 접근이 가능하다. 이건 내가 쓴 글이 있으니 링크를 달겠다.

 

https://jofestudio.tistory.com/110

 

[AWS] SSH 포트포워딩 및 SSM을 통해서 로컬에서 Private Subnet에 있는 RDS 접근하기

소마 프로젝트를 진행하면서, 인프라 쪽을 내가 어느 정도 담당하고 있어서(사실 담당한다고 하기에도 민망할 정도로 기본적인 부분들만 담당하긴 하지만) RDS를 띄워보기로 했다. Private Subnet에

jofestudio.tistory.com

 

Bastion Host를 통해 접근하는 방식이므로 ElastiCache에 달아주는 보안그룹에는 Bastion Host 쪽에서 오는 6379포트에 대한 인바운드를 열어줘야 한다.

 

스프링부트에선 다음과 같은 의존성을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

그 다음 application.properties에 다음과 같이 작성해준다(application.yml을 사용한다면 그에 맞춰 수정하면 됨)

spring.data.redis.host=localhost
spring.data.redis.port=6379

 

참고로 로컬에서 ssh 포트포워딩을 통해 elastiCache에 접속하는 중이므로 host는 localhost로 한 것이다.

그리고 다음과 같은 Configuration Class를 만들어준다.

 

@Configuration
public class RedisConfig {
    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate =  new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // 일반적인 key:value의 경우 시리얼라이저
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

 

  • RedisConnectionFactory : Redis 서버와의 연결을 만들고 관리하는 빈
  • RedisTemplate : Redis와의 상호작용을 돕는 빈. 즉 얘를 핸들링하여 레디스와 짝짝꿍하는 것
  • Serializer설정 : RedisTemplate의 디폴트 Serializer는 JdkSerializationRedisSerializer인데, 문제는 redis-cli를 통해 개발자가 직접 redis안에 있는 애들을 볼 때 Jdk직렬화된 애들은 그 값을 도무지 알아먹을 수가 없어서, 편의를 위해 StringRedisSerializer로 설정(이렇게 하면 String값 그대로 Redis에 저장됨)

 

참고 : 직렬화(seriailize) = 객체 등을 바이트 스트림 형태의 연속적인 데이터로 변환하는 것. 반대는 역직렬화(deserialize)

 

그리고 다음과 같은 RefreshTokenService를 만들어준다.

@RequiredArgsConstructor
@Service
public class RefreshTokenService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Long REFRESH_TOKEN_EXPIRE_TIME = 60 * 60 * 24 * 30L;

    public void saveRefreshToken(String email, String refreshToken) {
    	// 이메일을 key로, 토큰값을 value로
        redisTemplate.opsForValue().set(email, refreshToken, REFRESH_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
    }

    public String getRefreshToken(String email) {
        Object refreshToken = redisTemplate.opsForValue().get(email);
        return refreshToken == null ? null : (String) refreshToken;
    }

    public void deleteRefreshToken(String email) {
        redisTemplate.delete(email);
    }
}

 

나는 Refresh Token의 유효기간을 1달로 잡았기 때문에, 60 * 60 * 24 * 30 = 30일을 유효기간으로 설정해 저장하도록 구현했다. 분이 아니라 다른 시간 단위로도 저장 가능하니, 본인 취향따라 하면 된다.(TimeUnit.SECONDS 이쪽)

 

이제 이를 활용해 원래 rdb를 사용하던 로직을 바꿔줬다.

 

  • 로그인 후 jwt(access token, refresh token)을 생성하고 refresh token을 rdb에 저장하던 것 => refresh token을 redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다
  • 토큰 재발급 요청시 rdb에서 refresh token가져오던 것 => redis에서 가져온다
  • 토큰 재발급 후 재발급된 refresh token을 rdb에 저장하던 것 => redis로 저장. 이 때 redis에 기존에 쓰던 refresh token이 있으면 덮어씌운다

 

이제 로그인 요청, 인가인증이 필요한 요청, 토큰 재발급 요청에 대한 수행과정들을 시퀀스 다이어그램으로 표현하면 다음과 같아진다.

 

 

ppt로 힘들게 만드러따..

아 그리고 이 과정에서 추가적으로 고민했던 게, 토큰재발급 요청을 인증된 유저만 받을지였다. 구글링해서 나오는 다른 예제들을 보면 재발급 요청은 인증을 안 해도 가능하게끔 하는 예제가 대부분이었는데, 나는 고민 끝에 재발급 요청도 인증을 한 유저만 할 수 있게끔 했다. 왜냐하면, 당연히 보안을 위해서. 인증할 필요없이 refresh token만 덜렁 보내서 재발급받을 수 있다면, refresh token이 탈취당하면 너도나도 내 refresh token으로 재발급을 무료로 시도할 수 있기 때문이다. 

 

인증은 access token을 통해 이뤄지며, 재발급 요청은 우리 플젝에선 앱 접속시 자동로그인을 위한 재발급을 위할 때가 아니라면 access token이 만료됐을 때 이뤄질 거다. 만료된 토큰은 검증과정에서 ExpiredJwtException을 뱉을 텐데, 그러면 만료된 토큰에 대한 인증을 어떻게 할 수 있는가?

 

처음에 생각한 것은 임시 통행증(Authentication)을 끊어주는 거였다. 스프링 시큐리티는 ContextHolder에 Authentication이 들어있다면 인증됐다고 판단하므로, ExpiredJwtException이 터졌을 때 재발급경로로 요청이 온 것이라면(이는 request객체를 까서 판단 가능) 임시로 Authentication을 만들어 ContextHolder에 넣어주면 되는거다. 

try {
	jwtProvider.validateAccessToken(accessToken);
} catch (ValidTimeExpiredJwtException ex) {
	// access token이 만료됐지만 재발급하는 요청에 대한 처리 - 임시로 인증됐다고 처리한다
	if (request.getRequestURI().equals(REISSUE_PATH)) {
		setTemporaryAuthenticationToContextHolder();
		return;
	}
	request.setAttribute("exception", ex.getMessage());
	return;
} catch (CustomJwtException ex) {
	request.setAttribute("exception", ex.getMessage());
	return;
}
    private void setTemporaryAuthenticationToContextHolder() {
        // 임시 권한 생성
        List<GrantedAuthority> temporaryAuthorities = new ArrayList<>();
        temporaryAuthorities.add(new SimpleGrantedAuthority(ROLE_USER.name()));
        // 임시 통행증 발급
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                "temporaryAuthentication", "", temporaryAuthorities
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

 

이렇게 해도 잘 동작한다. 하지만! 나중에 소마 내 다른 연수생이 작성한 코드들을 몰래보다가, ExpiredJwtException의 인스턴스로부터 jwt클레임을 추출 가능하다는 놀라운 사실(!)을 깨달았다. 따라서 단순히 유효기간이 만료된 토큰이라면 정상적인 토큰에서 claim을 빼내는 것처럼 claim을 빼낼 수 있다.

    private String extractEmailFromToken(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(accessKey)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException ex) {
            return ex.getClaims().getSubject();
        }
    }

역시 남의 코드 보면서 배우는게 참 많아.. 암튼, 이를 통해 ExpiredJwtException이 터지면 재발급 요청인지를 판단하고, 재발급 요청이라면 기존 인증 절차와 마찬가지로 동작시킬 수 있다.

 


이런 과정들을 거쳐 redis를 도입했다. 그러면 속도는 얼마나 개선됐을까?

 

RDB(AWS RDS)만 사용했을 땐 평균 180ms정도가 걸렸으나,

 

Redis(AWS ElastiCache)를 사용하고나선 평균 100ms정도가 걸리게 됐다. 약 80ms정도 속도가 빨라진 것이다.

 

아직 학생 수준인 내게 80ms 정도라는 숫자가 의미가 있는지는 모르겠다. 그러나 트래픽이 많아질수록, 단축된 시간 X 요청횟수가 실제적인 성능 개선이라고 생각한다면 의미있는 개선을 이뤄낸게 아닐까싶다. okky에 문의한(?) 결과 운영자분께서 elastiCache를 통해 80ms 정도 개선한 것이면 잘 한 것이라고 해서 나름 뿌듯함.

 

굳이 레디스를 도입했어야 하는가? 라는 질문도 받을 수 있다. 맞는 말이다. 일단 배포를 안 한 만큼 재발급 요청에 대한 트래픽이 현재 없는 상태인데 굳이 80ms 높이겠다고 레디스? + 사용자 입장에서도 체감되는 개선인가? 라는 질문도 추가적으로 받을 수 있겠다. 한마디로, 지금 레디스를 도입하는 건 오버 엔지니어링이 아니겠냐는 말로 정리할 수 있겠다

 

그러나 속도 측면 말고도 유효기간이 만료된 토큰이 개발자가 뭘 하지 않아도 레디스에서 날라간다는 점에서 개인적으로 나름의 의미는 있다고 생각한다. 또한 학생 신분인 내가 레디스를 이렇게 처음 접하게 된 것도 의미가 충분한다. 마지막으로 돈 문제. ElastiCache 그거 돈 들지 않느냐! 굳이 레디스 필요없는데 왜 레디스 달아서 돈 나가게 하냐! 

 

당연히 실무라면, 혹은 내가 실제로 이 서비스를 운영하고 있는 단계면 어떻게든 돈 적게 쓸라고 쥐어짰겠지만, 난 지금 소마에서 지원금을 받으면서 플젝을 하고 있는 상태. 돈이 좀 드는 AWS 서비스들을 이것저것 써보면서 학습하는게 스스로한테 좋을 터. 그렇기 때문에 Redis를 이렇게 써보는게 의미가 있다고 생각한다. 허허헣