Spring Data Redis를 사용해보며, RedisRepository와 RedisTemplate를 어떻게 구성해야 Redis 내에서 깔끔한 형태를 지닐 수 있는지 학습하였습니다. 해당 과정에서 알게 된 내용들을 정리하여 작성합니다.
본 글에서 사용되는 코드들은 해당 git에 업로드되어 있습니다.
Spring Data Redis에서는 Redis와의 협력을 위해 2가지의 방법을 제안합니다. RedisRepository와 RedisTemplate는 사용자의 요구에 맞게 데이터를 삽입하는데, 두 방식의 이모저모를 확인해 보겠습니다.
JpaRepository처럼 interface로 구성할 수 있으며, Redis의 Hash를 이용하여 기본적인 crud를 수행합니다.
RedisRepository를 사용하기 위해선 @RedisHash
를 추가한 클래스가 필요합니다. 또한 해당 클래스는 JPA Entity처럼 @NoArgsConstructor
가 필요합니다.
RedisRepository 형식을 사용할 경우, SimpleKeyValueRepository가 사용됩니다.
조회 등의 작업 시 KeyValueOperations의 구현체인 KeyValueTemplate를 통해 연산이 진행되며, 해당 과정에서 RedisKeyValueAdapter를 사용합니다.
Adapter는 MappingRedisConverter와 연계되며, converter가 instance를 생성할 때 기본 생성자를 사용합니다.
@Getter
@RedisHash(value = "human-info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Human {
@Id
private Long id;
private String name;
private int age;
}
@RedlsHash
어노테이션의 value는 해당 객체가 Redis에 저장될 때 어떠한 Prefix를 가질 것인지 결정합니다.
@Id는 해당 Prefix 뒤에 붙으며({Prefix}:{Id}
), 객체를 판별할 수 있도록 합니다.
Repository는 CrudRepository를 확장하는 형태로 구성됩니다.
public interface HumanRepository extends CrudRepository<Human, Long> {
}
매우 간단하게 설정이 완료되었습니다.
Repository를 동작시켜보며 어떠한 명령어가 수행되는지, 내부 데이터는 어떠한 형태인지 확인해 보도록 하겠습니다.
Human human = new Human(1L, "eora", 21);
humanRepository.save(human);
JpaRepository와 동일하게 사용 가능합니다.
1714909780.801681 [0 192.168.65.1:36660] "HELLO" "3"
1714909780.807714 [0 192.168.65.1:36660] "CLIENT" "SETINFO" "lib-name" "Lettuce"
1714909780.807756 [0 192.168.65.1:36660] "CLIENT" "SETINFO" "lib-ver" "6.3.2.RELEASE/8941aea"
1714909780.832943 [0 192.168.65.1:36660] "DEL" "human-info:1"
1714909780.834503 [0 192.168.65.1:36660] "HMSET" "human-info:1" "_class" "eora21.demo.redis.repository.Human" "age" "21" "id" "1" "name" "eora"
1714909780.835399 [0 192.168.65.1:36660] "SADD" "human-info" "1"
DEL로 기존의 값을 아예 삭제한 후 새로운 Hash를 작성하고, SADD를 통해 Set을 추가하는 것을 확인할 수 있습니다.
이는 키가 일치하는 모든 Hash들을 쉽게 관리하기 위함으로 보입니다.
Human foundHuman = humanRepository.findById(1L)
.orElseThrow();
이 역시 간단하게 사용 가능합니다.
1714909892.711393 [0 192.168.65.1:36660] "HGETALL" "human-info:1"
humanRepository.deleteById(1L);
714910025.200952 [0 192.168.65.1:36853] "HGETALL" "human-info:1"
1714910025.202727 [0 192.168.65.1:36853] "DEL" "human-info:1"
1714910025.203787 [0 192.168.65.1:36853] "SREM" "human-info" "1"
1714910025.205070 [0 192.168.65.1:36853] "SMEMBERS" "human-info:1:idx"
1714910025.206169 [0 192.168.65.1:36853] "DEL" "human-info:1:idx"
해당하는 데이터가 존재하는지 조회 후, del로 해시를 지우고 Set에서도 값을 지우는 등 생각보다 많은 명령어가 발생하는 것을 확인할 수 있습니다.
humanRepository.deleteAll();
1714910260.664711 [0 192.168.65.1:37047] "DEL" "human-info"
1714910260.669163 [0 192.168.65.1:37047] "KEYS" "human-info:*"
1714910260.670769 [0 192.168.65.1:37047] "DEL" "human-info:1"
생성했던 Set을 지우고, KEYS를 통해 h3-index의 키들을 확인 후 하나씩 지우는 것처럼 보입니다.
만약 해당하는 KEY가 매우 많다면 성능 이슈가 생길 수 있을 것 같습니다.
1) "_class"
2) "eora21.demo.redis.repository.Human"
3) "age"
4) "21"
5) "id"
6) "1"
7) "name"
8) "eora"
class 정보와 각 필드의 값이 hash로 구성된 것을 확인할 수 있습니다.
아주 간단한 설정만으로도 Redis에 데이터를 작성할 수 있었고, 해당하는 형태도 redis 내에서의 연산을 수행하기 좋은 형태인 것으로 보입니다.
다만 Spring만을 위한 클래스의 정보가 기입되는 점이나 @NoArgsConstructor가 강제된다는 점, 사용자가 의도하지 않은 명령어가 발생할 수 있다는 점, hash를 제외한 나머지 타입 사용을 지정할 수 없다는 점 등도 확인할 수 있었습니다.
만약 다른 타입을 사용해야 한다거나 저장되는 필드 형식을 변경하고 싶다면, RedisTemplate를 사용하는 게 좋아 보입니다.
RedisTemplate는 각종 Operation 객체를 통해 redis의 사용을 손쉽게 합니다. 많은 예시가 존재하기 때문에, 해당 글에서는 Redis 내부에 저장되는 데이터들의 형태에 초점을 맞추겠습니다.
객체를 저장해야 하므로, 간단하게 Json 형태의 Serializer를 사용해보도록 하겠습니다.
편의 상 RedisTemplate<K, V>에서 V를 예시로 사용할 클래스 타입으로 맞추도록 하겠습니다.
public record Mutant(Long id, String name, int age) {
}
@Bean
public RedisTemplate<String, Mutant> jsonRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Mutant> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
@Autowired
RedisTemplate<String, Mutant> jsonRedisTemplate;
@Test
@DisplayName("일반 json 조회")
void jsonTemplateGet() throws Exception {
// given
Mutant deadpool = new Mutant(1L, "deadpool", 47);
ValueOperations<String, Mutant> valueOps = jsonRedisTemplate.opsForValue();
valueOps.set("mutant:" + deadpool.id(), deadpool);
// when
Mutant findMutant = valueOps.get("mutant:" + 1);
// then
Assertions.assertThat(findMutant).extracting(Mutant::id, Mutant::name, Mutant::age)
.containsExactly(1L, "deadpool", 47);
}
1714912603.412684 [0 192.168.65.1:38957] "HELLO" "3"
1714912603.425803 [0 192.168.65.1:38957] "CLIENT" "SETINFO" "lib-name" "Lettuce"
1714912603.425832 [0 192.168.65.1:38957] "CLIENT" "SETINFO" "lib-ver" "6.3.2.RELEASE/8941aea"
1714912603.441498 [0 192.168.65.1:38957] "SET" "\xac\xed\x00\x05t\x00\bmutant:1" "{\"@class\":\"eora21.demo.redis.template.Mutant\",\"id\":1,\"name\":\"deadpool\",\"age\":47}"
1714912638.569061 [0 192.168.65.1:38957] "GET" "\xac\xed\x00\x05t\x00\bmutant:1"
지정한 명령만 간단하게 나오는군요. 이처럼 RedisTemplate를 쓰면 원하는 명령어만 콕 집어 동작시킬 수 있습니다.
opsForHash().putAll()을 사용했어도 HMSET, HGETALL로 알맞는 명령어가 나왔을 거에요.
따라서 이후 내용에서는 어떤 명령어가 발생하는지 굳이 확인하지는 않겠습니다.
다만, 명령어를 보면 key가 좀 이상한 것 같네요. 키를 확인해 보겠습니다.
1) "\xac\xed\x00\x05t\x00\bmutant:1"
텍스트가 깨진 것 같네요. get 명령어를 사용해 보겠습니다.
GET \xac\xed\x00\x05t\x00\bmutant:1
(nil)
제대로 확인이 되지 않고 있습니다. 큰따옴표로 묶어서 확인해야 할 것 같네요.
GET "\xac\xed\x00\x05t\x00\bmutant:1"
"{\"@class\":\"eora21.demo.redis.template.Mutant\",\"id\":1,\"name\":\"deadpool\",\"age\":47}"
이제야 보이는군요. 다행히 value가 json으로 잘 들어갔지만, key가 작성한 대로 이쁘게 들어갔으면 좋겠습니다.
기존의 key가 이쁘지 않았던 이유는, keySerializer를 설정하지 않았기 때문입니다.
RedisTemplate는 기본 Serializer로 JdkSerializationRedisSerializer를 사용합니다.
public void afterPropertiesSet() {
super.afterPropertiesSet();
if (defaultSerializer == null) {
defaultSerializer = new JdkSerializationRedisSerializer(
classLoader != null ? classLoader : this.getClass().getClassLoader());
}
...
}
따라서 key가 이쁘게 들어가도록 하기 위해, 아래와 같이 작성해 줍시다.
@Bean
public RedisTemplate<String, Mutant> beautyKeyJsonRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Mutant> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer()); // 추가
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return redisTemplate;
}
그 후 코드를 동작시켜 보겠습니다.
@Autowired
RedisTemplate<String, Mutant> beautyKeyJsonRedisTemplate;
@Test
@DisplayName("이쁜 key를 가진 json 조회")
void beautyKeyJsonTemplateGet() throws Exception {
// given
Mutant wolverine = new Mutant(2L, "wolverine", 55);
ValueOperations<String, Mutant> valueOps = beautyKeyJsonRedisTemplate.opsForValue();
valueOps.set("mutant:" + wolverine.id(), wolverine);
// when
Mutant findMutant = valueOps.get("mutant:" + 2);
// then
Assertions.assertThat(findMutant).extracting(Mutant::id, Mutant::name, Mutant::age)
.containsExactly(2L, "wolverine", 55);
}
GET mutant:2
"{\"@class\":\"eora21.demo.redis.template.Mutant\",\"id\":2,\"name\":\"wolverine\",\"age\":55}"
원하는 키 형태로 잘 반영되어 들어간 것을 확인할 수 있었습니다.
다만 또 걸리는 게 있다면, value가 너무 번잡하게 들어가는 것 같습니다.
RedisRepository는 key와 value가 정말 이쁘게 들어갔었는데, RedisTemplate는 json으로 작성되어 들어가다 보니 이쁘지가 않네요.
두 가지 방법이 있을 것 같습니다.
1번은 RedisRepository의 저장 형태처럼 클래스 형태를 판단하고 그에 맞는 값으로 구성해보는 것입니다. 다만 제가 Redis에 들어가는 데이터 크기를 최대한 줄이는 방법을 찾아보며 해당 글을 작성하는 것이기에, 2번을 선택하여 글을 작성하도록 하겠습니다.
(아마 1번은 Reflection을 사용하면 가능하겠으나, deseiralize의 난이도가 꽤 높을 것 같네요.)
데이터 크기를 줄여서, 간단하게 id/name/age
형태로 생성할 수 있으면 좋겠습니다.
해당하는 결과를 만들기 위해서는 Serializer를 직접 작성해야 합니다.
@Bean
public RedisTemplate<String, Mutant> beautyKeyValueRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Mutant> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new MutantSerializer());
return redisTemplate;
}
private static final class MutantSerializer implements RedisSerializer<Mutant> {
@Override
public byte[] serialize(Mutant mutant) throws SerializationException {
if (Objects.isNull(mutant)) {
throw new IllegalArgumentException();
}
String mutantText = new StringJoiner("/")
.add(String.valueOf(mutant.id()))
.add(mutant.name())
.add(String.valueOf(mutant.age()))
.toString();
return mutantText.getBytes(StandardCharsets.UTF_8);
}
@Override
public Mutant deserialize(byte[] bytes) throws SerializationException {
if (Objects.isNull(bytes) || bytes.length == 0) {
return null;
}
String value = new String(bytes);
String[] mutantInfo = value.split("/");
if (mutantInfo.length != 3) {
throw new IllegalArgumentException();
}
long id = Long.parseLong(mutantInfo[0]);
String name = mutantInfo[1];
int age = Integer.parseInt(mutantInfo[2]);
return new Mutant(id, name, age);
}
}
코드가 이쁘진 않지만, 사용해보도록 하겠습니다.
@Autowired
RedisTemplate<String, Mutant> beautyKeyValueRedisTemplate;
@Test
@DisplayName("serializer를 사용한 mutant 조회")
void beautyKeyValueRedisTemplateGet() throws Exception {
// given
Mutant yukio = new Mutant(3L, "yukio", 31);
ValueOperations<String, Mutant> valueOps = beautyKeyValueRedisTemplate.opsForValue();
valueOps.set("mutant:" + yukio.id(), yukio);
// when
Mutant findMutant = valueOps.get("mutant:" + 3);
// then
Assertions.assertThat(findMutant).extracting(Mutant::id, Mutant::name, Mutant::age)
.containsExactly(3L, "yukio", 31);
}
GET mutant:3
"3/yukio/31"
데이터가 아주 간결하게 잘 나왔군요!
다만 이는 어디까지나 redis에서 데이터가 차지하는 용량을 줄이기 위함이라 많은 단점이 있습니다.
json은 어떻게든 해석할 수 있다곤 해도, 지금과 같은 결과는 어떠한 방식으로 구성된 것인지 미리 알고 있어야만 올바른 의미 파악이 가능할 것입니다.
만약 Mutant 클래스에 변화가 생긴다면 어떨까요? 필드가 추가되거나 삭제된다면 기존의 데이터를 불러와 사용하기 힘들 것입니다. 기존 데이터와 새로운 데이터를 판별할 방법이 준비되어야 할 것이고, Serializer도 변화가 일어나야 할 거에요.
혹은 기존 클래스에서 필드가 추가된/제거된 새로운 데이터를 만들고 관리하는 방법이 좋을 수도 있겠습니다.
public record NewMutant(Long id, String name, int age, int power) {
private static final String DELIMITER = "/";
public byte[] serialize() {
String mutantText = new StringJoiner(DELIMITER)
.add(String.valueOf(id))
.add(name)
.add(String.valueOf(age))
.toString();
return mutantText.getBytes(StandardCharsets.UTF_8);
}
public static NewMutant deserialize(byte[] bytes) {
String value = new String(bytes);
String[] mutantInfo = value.split(DELIMITER);
if (mutantInfo.length != 4) {
throw new IllegalStateException();
}
long id = Long.parseLong(mutantInfo[0]);
String name = mutantInfo[1];
int age = Integer.parseInt(mutantInfo[2]);
int power = Integer.parseInt(mutantInfo[3]);
return new NewMutant(id, name, age, power);
}
}
@Bean
public RedisTemplate<String, NewMutant> newMutantRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, NewMutant> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new NewMutantSerializer());
return redisTemplate;
}
private static final class NewMutantSerializer implements RedisSerializer<NewMutant> {
@Override
public byte[] serialize(NewMutant newMutant) throws SerializationException {
if (Objects.isNull(newMutant)) {
throw new IllegalArgumentException();
}
return newMutant.serialize();
}
@Override
public NewMutant deserialize(byte[] bytes) throws SerializationException {
if (Objects.isNull(bytes) || bytes.length == 0) {
return null;
}
return NewMutant.deserialize(bytes);
}
}
serialize, deserialize 로직을 NewMutant로 옮겼습니다(객체의 책임을 생각하며 한번 옮겨봤는데, 큰 반향은 없을 것 같습니다).
모든 데이터가 변경되어야 한다면, 기존의 Mutant 데이터가 조회되었을 때 해당 데이터를 NewMutant 형식으로 변경 후 기존 Mutant 삭제 및 NewMutant 추가하는 방식으로 로직이 구성되어야 할 것 같네요. 작성하지는 않았습니다.
아니면 추가된 필드에 디폴트 값을 지정해주거나, 필드 개수가 다르면 분기문으로 나눈다거나.. 방법은 많을 것 같습니다만 이거다! 하는 해결책은 없는 것 같습니다. 상황에 맞게 적절한 해결책을 고민해봐야 할 듯 하네요.
잠깐 해당 고민을 멈추고, 다시 데이터를 살펴볼까요?
GET mutant:3
"3/yukio/31"
음, 우리는 조회하면서 이미 id가 3이라는 것을 알고 있습니다.
헌데 value에도 id가 추가되어 있습니다. value에서 id가 꼭 필요할까요?
해당 데이터도 없애고 싶다면 어떻게 해야 할까요?
아쉽게도 RedisTemplate의 Serializer를 사용하는 경우에는 제약이 있습니다. id 필드 자체는 클래스 내부에 속하므로, Value Serializer가 원하는 객체를 뱉게 하기 위해서는 해당 데이터가 존재해야 합니다.
혹은 id가 빈 객체를 만들고, 나중에 id를 추가해주는 방식도 있겠지만.. 현재 record를 기반으로 사용하고 있으므로 불가능하겠군요.
그렇다면 Serializer 자체를 고수하지 말고, 객체를 만들고 조회할 때 RedisTemplate를 사용하도록 해 봅시다.
public record XForce(Long id, String name, int age) {
}
@Component
@RequiredArgsConstructor
public class XForceWriter {
public static final String PREFIX = "x-force#";
public static final String DELIMITER = "/";
private final RedisTemplate<String, String> redisTemplate;
public void write(XForce xForce) {
ValueOperations<String, String> valueOps = redisTemplate.opsForValue();
valueOps.set(keyGenerator(xForce.id()), valueGenerator(xForce));
}
private String keyGenerator(Long id) {
return PREFIX + id;
}
private String valueGenerator(XForce xForce) {
return new StringJoiner(DELIMITER)
.add(xForce.name())
.add(String.valueOf(xForce.age()))
.toString();
}
}
@Component
@RequiredArgsConstructor
public class XForceReader {
private final RedisTemplate<String, String> redisTemplate;
public Optional<XForce> read(Long id) {
ValueOperations<String, String> valueOps = redisTemplate.opsForValue();
String redisXForceValueText = valueOps.get(XForceWriter.PREFIX + id);
if (Objects.isNull(redisXForceValueText)) {
return Optional.empty();
}
XForce xForceFromText = createXForceFromText(id, redisXForceValueText);
return Optional.of(xForceFromText);
}
private XForce createXForceFromText(Long id, String redisXForceValueText) {
String[] xForceFieldValues = redisXForceValueText.split(XForceWriter.DELIMITER);
if (xForceFieldValues.length != 2) {
throw new IllegalStateException();
}
String name = xForceFieldValues[0];
int age = Integer.parseInt(xForceFieldValues[1]);
return new XForce(id, name, age);
}
}
Redis에 저장되는 데이터는 모두 String 기반이라는 것에 중점을 두고, 데이터를 쓸 때나 읽을 때 이를 활용하도록 했습니다.
@SpringBootTest
class XForceOperatorTest {
@Autowired
XForceReader xForceReader;
@Autowired
XForceWriter xForceWriter;
@Test
@DisplayName("operator를 통한 조회")
void OperatorRead() throws Exception {
// given
XForce vanisher = new XForce(1L, "vanisher", 60);
xForceWriter.write(vanisher);
// when
XForce readXForce = xForceReader.read(vanisher.id())
.orElseThrow();
// then
Assertions.assertThat(readXForce).extracting(XForce::id, XForce::name, XForce::age)
.containsExactly(1L, "vanisher", 60);
}
}
GET x-force#1
"vanisher/60"
id가 value 내에서 제거됨을 확인할 수 있습니다.
물론 이 역시 단점이 있습니다.
중복되는 데이터를 하나 제거했을 뿐, 변화에 민감하다는 단점은 해결하지 못 했습니다.
팀원 중 누군가가 RedisTemplate를 함부로 사용하지 않도록 해야 합니다. 멀티모듈을 구성하여 api로만 redis 내의 값을 읽거나 쓰도록 해야 할 것입니다. 다만 XForce 모듈 내에서 발생하는 문제는 막을 수 없을 것이므로, 같이 작업하는 팀원분들과 많은 대화를 나눠야 할 것 같아요.
RedisRepository를 사용하면 데이터가 이쁘게 잘 저장되지만, 사용자가 인지하지 못 한 명령어가 발생하게 됩니다. 만약 특정 객체를 crud만 한다면 RedisRepository가 오히려 더 간편하고 좋을 수 있습니다.
반면 hash 말고 다른 데이터타입을 사용해야 한다거나 불변 객체를 사용해야 하는데 redis에는 많은 신경을 쓰지 않아도 된다면 RedisTemplate를 사용하시면 될 것 같습니다.
그래도 redis에 조금 이쁘게 저장되는 걸 원하신다면 key는 String, value는 Jacson Serializer를 적용해주면 좋을 것 같습니다.
객체가 크게 바뀔 일이 없다 + 저장되는 데이터 크기를 중요하게 생각하신다면 Serializer를 직접 생성하거나 Writer, Reader 형식을 고민해보셨으면 합니다.
또한 RedisRepository와 RedisTemplate, Writer & Reader 중 하나를 콕 선택할 필요는 없을 것 같습니다. 각자 프로젝트 구성에 맞게 여러 가지를 종합적으로 채택할 수도 있을 것 같아요.
이 외에 더 좋은 방향성이 있다면 공유해주세요! 저도 더 배우고 싶습니다!