본문 바로가기

Project/DB

Redis / RedisTemplate 실험

프로젝트의 RefreshToken과 1:1채팅의 읽음 처리를 구현하기 위해 사용자가 소켓에 접속했는지 저장하기 위해 Redis를 사용하기로 했다.

사용자가 소켓에 접속했는지 관리하려면 소켓에 접속하고 나갈 때 마다 계속 데이터를 저장하고 삭제해줘야 하는데 RDBMS 보다 Redis에 저장하는 것이 더 효율적이라고 판단했으며 이전부터 Redis 라는 기술을 사용해보고 싶었다.

 

기존엔 구현하기 쉽다는 이유로 CrudRepository를 이용해 간단하게 구현했다. 하지만 CrudRepository를 사용하니 실제 Redis에 저장된 값들이 직관적이지 못 했으며 Transaction을 지원하지 않았다.

@Getter
@RedisHash(value = "refreshToken", timeToLive = 604800)
public class RefreshToken {

    @Id
    private String id;
    private String ip;
    private Set<String> authorities;
    @Indexed
    private String refreshToken;
    @Indexed
    private String username;

    @Builder
    public RefreshToken(String ip, Set<String> authorities, String refreshToken, String username) {
        this.ip = ip;
        this.authorities = authorities;
        this.refreshToken = refreshToken;
        this.username = username;
    }
}

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {

    RefreshToken findByRefreshToken(String refreshToken);
    RefreshToken findByUsername(String username);
}

RefreshToken 클래스에 @RedisHash 어노테이션을 붙이고 CrudRepository를 이용해 쉽게 Redis에 저장할 수 있었다. 하지만

@Test
void saveRepositoryTest() {
    // given
    Set<String> roles = new HashSet<>();
    roles.add("ROLE_USER");

    RefreshToken refreshToken = RefreshToken.builder()
            .ip("mockIp")
            .refreshToken("mockRefreshToken")
            .username("mockUsername")
            .authorities(roles)
            .build();

    // when
    RefreshToken savedRefreshToken = refreshTokenRepository.save(refreshToken);
    RefreshToken findRefreshToken = refreshTokenRepository.findById(savedRefreshToken.getId()).orElseThrow(
            () -> new IllegalArgumentException()
    );

    // then
    assertThat(findRefreshToken.getId()).isEqualTo(savedRefreshToken.getId());
    assertThat(findRefreshToken.getRefreshToken()).isEqualTo(savedRefreshToken.getRefreshToken());
    assertThat(findRefreshToken.getUsername()).isEqualTo(savedRefreshToken.getUsername());
    assertThat(findRefreshToken.getAuthorities()).isEqualTo(savedRefreshToken.getAuthorities());
    assertThat(findRefreshToken.getIp()).isEqualTo(savedRefreshToken.getIp());
}

해당 테스트를 실행한 뒤 실제 Redis에 저장된 값을 보니

위와 같이 저장되었으며 값들의 타입이 모두 달라 Redis에서 내가 입력한 값이 잘 저장되었는지 확인하기 어려웠다. 이러한 이유들로 인해 난 CrudRepository가 아닌 RedisTemplate을 사용하기로 결정했다. 

 

아래는 RedisTemplate을 테스트해보며 알아낸 사실들이다.

@Configuration
public class RedisConfig {

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

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

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

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        // redisTemplate를 받아와서 set, get, delete를 사용
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        // setKeySerializer, setValueSerializer 설정
        // redis-cli을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        redisTemplate.setEnableTransactionSupport(true);

        return redisTemplate;
    }

}

위 코드를 이용해 RedisTemplate을 빈으로 등록하며 redisTemplate.setEnableTransactionSupport를 true로 설정해 transaction이 동작하도록했다.

@Test
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);
    redisTemplate.opsForHash().putAll(username, refreshToken);

    redisTemplate.expire(username, 7L, TimeUnit.DAYS);
    if (username.equals("username")) {
        throw new RuntimeException();
    }
}

말도안되는 테스트코드 이지만 redisTemplate.opsForHash().putAll()을 통해 username 값을 키로 토큰값을 Redis의 해쉬 자료구조를 이용해 저장하고 강제로 Exception을 발생시킨다.

그러나 예외를 발생시켜 테스트가 실패했음에도 Redis에는 값이 저장된다.

@Test
@Transactional
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);
    redisTemplate.opsForHash().putAll(username, refreshToken);

    redisTemplate.expire(username, 7L, TimeUnit.DAYS);
    if (username.equals("username")) {
        throw new RuntimeException();
    }
}

그러나 @Transactional 어노테이션을 테스트 메서드에 붙여주면

테스트는 똑같이 실패하지만 Redis에 값이 들어가지 않았다. Transaction이 잘 적용됐음을 알 수 있다.

 

아래는 RedisTemplate을 이용해 여러가지 테스트를 실행했을때의 결과들이다.

 

1.

@Test
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);
    redisTemplate.opsForHash().putAll(username, refreshToken);

    redisTemplate.opsForHash().put(username, "additional", "value");
}

opsForHash를 이용해 username을 키로 Map의 값을 넣은 뒤 같은 키로 서브키를 추가시키면 서브키의 값이 정상적으로 추가됨을 볼 수 있다.

 

2. 

@Test
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);
    redisTemplate.opsForHash().putAll(username, refreshToken);

    redisTemplate.opsForHash().put(username, "role", "value");
}

opsForHash를 이용해 username을 키로 Map의 값을 넣은 뒤 같은 uername 키에 이미 존재하는 서브키의 값을 넣으면 기존의 값이 덮어써짐을 볼 수 있다.

 

3. 

@Test
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);
    redisTemplate.opsForHash().putAll(username, refreshToken);

    redisTemplate.opsForValue().set(username, "value");
}

opsForHash를 이용해 username을 키로 Map의 값을 넣은 뒤 opsForValue를 이용해 같은 키로 값을 넣으면 해쉬 형식으로 저장됐던 값들이 뒤에 저장한 String 타입으로 덮어써졌음을 볼 수 있다.

 

4.

@Test
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);

    redisTemplate.opsForValue().set(username, "value");

    redisTemplate.opsForHash().putAll(username, refreshToken);
}

 

3. 과 반대로 opsForValue로 먼저 값을 넣은 뒤 opsForHash로 값을 넣으면 덮어써지는 것이 아닌 오류를 뱉는다. Redis에는 먼저 값을 넣은 String 형식의 값이 저장되어 있다.

 

5.

@Test
void redisTest() {
    // given
    String token = "refreshToken";
    String username = "username";
    String role = "role";
    String ip = "ip";
    Map<String, String> refreshToken = RefreshTokenMapper.refreshTokenMap(token, username, role, ip);

    redisTemplate.opsForHash().putAll(username, refreshToken);
    redisTemplate.expire(username, 7L, TimeUnit.DAYS);
}

마지막으로 redisTemplate.expire을 이용하면 Redis에 저장기간을 설정할 수 있다. (expire을 설정하지 않으면 Redis에 ttl이 -1로 저장되어 영구 저장된다.)