열공스토리

[Spring] Redis 사용 해 보기 본문

Spring

[Spring] Redis 사용 해 보기

열쩡열쩡열쩡_ 2023. 7. 28. 14:19

이번 포스팅에서는 최근 Security+JWT를 이용한 로그인 인증을 구현한 프로젝트에서 로그아웃 기능과 refresh token 기능을 도입할 때 사용했던 Redis에 대해 글을 적어보았습니다.

Redis란?

Redis는 인메모리 데이터 구조(In-memory Data Structure) 형태를 가지는 오픈 소스 기반의 비관계형 데이터 베이스 관리 시스템(DBMS)입니다. 데이터는 "Key-Value" 형식의 비정형 데이터로 관리가 되고 인메모리 특성에 의해 데이터가 휘발적인 특징을 갖게 되고 빠른 데이터 접근에 용이합니다.

 

Redis는 Cache, Database, 메시지 브로커 등으로 사용됩니다.

Redis를 선택한 이유

로그아웃 기능과 refresh token 기능에 Redis를 선택한 가장 큰 이유는 데이터의 휘발성 때문입니다.

 

로그아웃 기능에서는 블랙리스트라는 개념을 채택하여 로그아웃 유저의 요청을 차단하게 되는데 자세히 설명하면 로그아웃 시, 로그아웃 하는 유저의 access token을 블랙리스트에 올려 다음 번 요청이 들어올 때 블랙리스트에 해당 access token이 존재하면 요청이 차단되는 방식입니다.

 

이 때, Redis에 블랙리스트 token을 관리하게 되면 access token의 남은 유효시간만큼 Redis에 저장시간을 설정하여 이 후에 토큰이 자동 삭제가 되며 블랙리스트 token을 편리하게 관리 할 수 있습니다.

 

RDB등의 저장소에 블랙리스트 token을 관리하는 방법도 있지만 스케쥴러 등을 통해 주기적으로 데이터 삭제를 해야하는 부분이 비효율적이고 번거로운 작업이여서 Redis를 선택하였습니다.

 

refresh token 또한 같은 이유로 Redis로 관리하는 것이구요.

Redis 설정

그럼 우선 Redis 설정부터 해보겠습니다.

build.gradle

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

gradle 파일에 Redis 사용을 위한 dependency를 추가 해 줍니다.

application.yml

spring:
  redis:
    host: localhost
    port: 6379

yml 파일에 host와 port를 설정해 줍니다. redis port는 디폴트 값이 6379로 설정 되어 있습니다.

RedisConfig.java

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

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

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

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

RedisConfig라는 Redis 설정 파일을 하나 만들어서 Redis Repository를 사용하겠다는 @EnableRedisRepositories 어노테이션을 명시 해 주고 위 yml 파일에서 설정한 host와 port 값을 각 변수에 주입 해 줍니다.

 

그리고 Redis 연결 정보를 LettuceConnectionFactory()객체에 host와 port를 담아 빈에 등록 해 줍니다.

 

그런 다음 RedisTemplate 객체의 <Key, Value> 값을 <String, String>으로 설정하고 추가적인 설정을 더하여 마찬가지로 Bean에 등록 해 줍니다.

RedisDao.java

@Component
public class RedisDao {

    private final RedisTemplate<String, String> redisTemplate;

    public RedisDao(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void setValues(String key, String data) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValues(String key, String data, Duration duration) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    public String getValues(String key) {
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        return values.get(key);
    }

    public void deleteValues(String key) {
        redisTemplate.delete(key);
    }
}

RedisDao 클래스에서는 빈에 등록한 RedisTemlate 객체를 이용한 데이터 CRUD가 이루어집니다.

 

RedisDao 클래스는 필수로 구현해야 하는 것은 아니고 Redis와의 상호작용에 좀 더 다양한 기능이 필요할 때 구현하면 됩니다.

Redis 설정을 마쳤으니 이제 로그아웃 기능과 refresh token 기능에서 Redis가 쓰이는 부분을 보겠습니다.

로그아웃

로그아웃 기능에서는 위에서 말했듯이, 블랙리스트 token을 관리할 때 쓰인다고 했죠? 다음에서 확인 해 보겠습니다.

AuthService.java

    public void logout(LogoutRequest request) {
        String atk = request.getAccessToken();

        if (!tokenProvider.validateToken(atk)) {
            throw new IllegalArgumentException("잘못된 JWT 서명입니다.");
        }

        Authentication authentication = tokenProvider.getAuthentication(atk);

        if (redisDao.getValues(authentication.getName()) != null){
            redisDao.deleteValues(authentication.getName());
        }

        Long expiration = tokenProvider.getExpiration(atk);
        redisDao.setValues(atk,"logout", Duration.ofMillis(expiration));
    }

로그아웃 비즈니스 로직입니다.

 

controller에서 request body로 받은 accessToken의 남은시간을 가져와서 Key를 accessToken 값으로 지정하고 Value에 "logout" 상태로 지정하여 남은시간과 함께 Redis에 저장하는 것을 볼 수 있습니다.

JwtFilter.java

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String token = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {

            if (Objects.isNull(redisDao.getValues(token))) {
                Authentication authentication = tokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
            } else {
                log.info("condition : " + redisDao.getValues(token));
            }

        } else {
            log.info("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

이 후, 같은 유저에게서 새로운 요청이 들어오면 서블릿 Filter 단계에서 header 담긴 access token이 Redis 메모리에 key값으로 존재 하는지 조건을 걸어 만약 존재한다면 SecurityContext에 사용자 정보가 저장이 되지 않게 되어 결국 exception과 함께 요청이 차단됩니다.

Refresh Token

refresh token도 간단합니다. 자, 같이 보겠습니다.

TokenProvider.java

    public String provideAccessToken(Authentication authentication) {
        return createToken(authentication, this.accessTokenValidityInMilliseconds);
    }

    public String provideRefreshToken(Authentication authentication) {
        String rtk = createToken(authentication, this.refreshTokenValidityInMillisecond);

        redisDao.setValues(authentication.getName(), rtk, Duration.ofMillis(this.refreshTokenValidityInMillisecond));
        return rtk;
    }

    private String createToken(Authentication authentication, long validity) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        return Jwts.builder()
                .claim("email", authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(new Date(now + validity))
                .compact();
    }

우선, 사용자가 로그인을 요청하면 비즈니스 로직에서 TokenProvider의 provideAccessToken()과 provideRefreshToken() 메소드를 Authentication 객체와 함께 호출하는데 위에서 볼 수 있듯이 access token은 그대로 반환하지만 refresh token은 반환하기 전, Redis에 저장한 다음 반환하는 것을 볼 수 있습니다.

 

이 때, Authentication의 getName()(저의 경우에는 email을 반환하도록 설정했습니다.)을 Key로 지정하고 refresh token을 Value로 지정하고 refresh token의 유효기간과 함께 저장하고 있습니다.

AuthService.java

    public ReissueResponse reissue(ReissueRequest request) {
        String email = tokenProvider.getAuthentication(request.getRefreshToken()).getName();

        String rtk = redisDao.getValues(email);

        if (!tokenProvider.validateToken(rtk)) {
            throw new RuntimeException("잘못된 JWT 서명입니다.");
        }

        Authentication authentication = tokenProvider.getAuthentication(rtk);
        String atk = tokenProvider.provideAccessToken(authentication);

        return ReissueResponse.of(atk);
    }

이 후에 access token 재발급 요청이 들어오면, request body로 refresh token을 받아 비즈니스로직에서 refresh token의 사용자 email을 이용하여 Redis에서 refresh token을 가져와서 유효성 검사를 진행하고 통과되면 새로운 access token을 발급하여 반환합니다.

 

이 때, Redis에 저장 되어 있던 refresh token이 유효기간 설정에 의해 데이터가 삭제되어 null이 반환 될 수 있습니다.


정리

이번 포스팅에서는 Redis에 대해서 알아보았습니다.

저는 이번 기회에 Redis에 대해 공부 해 보면서 in-memory 저장소와 RDB의 차이점 이라던가 장단점 등 얄팍하게 알고있던 부분들을 좀 더 자세히 알게 되었던 것 같습니다.

또, 처음 Redis를 프로젝트에 적용하면서 놓친 부분들 또는 생각지도 못한 부분들이 다시금 상기되어 더 꽉찬 공부가 된 것 같아 정말 뜻 깊은 시간이었습니다.

끝!