🥞 BE
home

Redis Hash List 직렬화 오류

Date
2025/02/19
Category
DB
Tag
Redis
Detail
컨텐츠 등록 시, List로 요청값을 받아 그대로 Redis에 저장하는 과정에서, Redis가 List 데이터를 제대로 저장하지 못하고 클래스 값만 받아오는 상황이 생겼다.
11) "playerContents.[1]._class" 12) "java.util.ArrayList"
Plain Text
복사
위와 같이 java.util.ArrayList로 저장되고, hget roomId:~ playerContents.[1] 명령어로 조회를 시도하면 (nil) 값이 반환되었다.
// 컨텐츠 등록 @Override public void registerContents(ContentSelectReq request) { Room room = findRoomById(request.roomId()); if (request.contentIds().size() > 5) { throw new RoomException(ExceptionMessage.CONTENTS_OVER); } room.getPlayerContents().put(request.userId(), request.contentIds()); roomRepository.save(room); log.info("contentIds : {}", room.getPlayerContents().get(request.userId())); }
Java
복사
로그를 찍어 get 명령어로 조회해오는 과정에서는 제대로 저장된 값을 반환하였다. 이건 그냥 JVM에 값 캐싱된 것 때문인 것 같고.. roomRepository.save(room) 메서드가 제대로 작동하지 않아 저장과정에서 문제가 발생하는 거라 판단했다.
결론은 redis 저장 시, 직렬화/역직렬화가 제대로 수행되지 않아 해당 데이터를 못 불러오는 상황이었다.
Wrapper 클래스를 만들자니.. 저거 하나 쓰자고 만들었다가 나중에 비슷하게 List를 통째로 저장해야하는 일이 발생하면 그때마다 만들어주기는 싫고 해서 다양한 방법을 시도해보았다.

1. 필드 단위 @JsonTypeInfo 사용

직접 해당 필드에 어노테이션을 붙여서 직렬화 시 타입 정보를 포함하도록 할 수 있다.
@Getter @Setter @RedisHash(value = "room", timeToLive = 36000) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Room { @Id private String roomId; private String roomName; private Status status; private Long hostId; private int maxPlayers; private int totalRounds; private List<Long> currentPlayers; @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class") private Map<Long, List<String>> playerContents; private Map<Long, Boolean> readyStatus;
Java
복사
그래서 이런식으로 @JsonTypeInfo 어노테이션을 붙여서 사용해봤다. 결론은 저장조차 되지 않았다.
@JsonTypeInfo 어노테이션은 객체 직렬화/역직렬화 시 클래스 타입 정보를 포함시킬 수 있도록 돕지만, 단일 값 타입인 Map<Long, List<String>>에는 개별 항목의 타입 정보를 포함하여 저장하는 설정은 아니다.

2. ObjectMapper의 기본 타입 활성화

RedisRepository에서 사용하는 Jackson 직렬화 과정에서 전체 객체에 대해 타입 정보를 포함하도록 ObjectMapper를 설정했다.
GenericJackson2JsonRedisSerializer는 객체를 JSON 형태로 직렬화한다. 내부적으로는 ObjectMapper를 사용하는데,파라미터로 전달되는 ObjectMapper가 있을 경우에는 이를 활용하고 없다면직접 ObjectMapper를 생성하여 사용한다.
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); // 글로벌 기본 타입 활성화 설정 적용 ObjectMapper objectMapper = new ObjectMapper(); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); redisTemplate.setValueSerializer(genericJackson2JsonRedisSerializer); redisTemplate.setHashValueSerializer(genericJackson2JsonRedisSerializer); redisTemplate.afterPropertiesSet(); return redisTemplate; }
Java
복사
그래서 ObjectMapper를 따로 빼서 DefaultType를 설정했다. 이를 통해 모든 엔티티/필트에 대해 일괄적으로 타입 정보를 포함하게 설정했다. 하지만 이 방법도 결국 값이 아니라 타입 정보만 저장하는 것은 똑같았다.

3. 커스텀 Serializer/Deserializer

다음으로 선택한 방법은 커스텀 Serializer, Deserializer 클래스를 만들어서 @JsonSerialize, @JsonDeserialize어노테이션과 함께 직접 적용하는 방법이었다.
public class Long2StringListMapSerializer extends JsonSerializer<Map<Long, List<String>>> { @Override public void serialize(Map<Long, List<String>> value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); for (Map.Entry<Long, List<String>> entry : value.entrySet()) { String keyStr = entry.getKey().toString(); gen.writeObjectField(keyStr, entry.getValue()); } gen.writeEndObject(); } }
Java
복사
public class Long2StringListMapDeserializer extends JsonDeserializer<Map<Long, List<String>>> { @Override public Map<Long, List<String>> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { ObjectMapper mapper = (ObjectMapper) p.getCodec(); JsonNode node = mapper.readTree(p); Map<Long, List<String>> result = new HashMap<>(); Iterator<Map.Entry<String, JsonNode>> fields = node.fields(); while (fields.hasNext()) { Map.Entry<String, JsonNode> field = fields.next(); Long key = Long.valueOf(field.getKey()); List<String> list = mapper.convertValue(field.getValue(), new TypeReference<List<String>>() {}); result.put(key, list); } return result; } }
Java
복사
다음과 같이 Map<Long, List<String>> 타입에 대한 직렬화, 역직렬화만 수행하는 코드이다.
@JsonSerialize(using = PlayerContentsSerializer.class) @JsonDeserialize(using = PlayerContentsDeserializer.class) private Map<String, List<String>> playerContents;
Java
복사
이것도 문제가 해결되지 않았다. 같은 Map 자료형인 Map<Long, Boolean> readyStatus는 잘 직렬화 되어 저장되는데, Map 내부의 List자료형을 제대로 직렬화하지 못하는 것 같아 결국 마지막으로 선택한 방식은 List를 없애고, Map<Long, String> playerContents로 아예 리스트를 모두 문자열로 바꿔 저장하는 방식이었다. 이렇게 하면 저장 시 String으로 변환하는 로직 하나가 추가되고, 원했던 데이터 형식은 아니지만 직렬화/역직렬화 과정에서 문제는 발생하지 않았다.
// 컨텐츠 등록 @Override public void registerContents(ContentSelectReq request) { Room room = findRoomById(request.roomId()); if (request.contentIds().size() > 5) { throw new RoomException(ExceptionMessage.CONTENTS_OVER); } try { ObjectMapper objectMapper = new ObjectMapper(); String jsonContentList = objectMapper.writeValueAsString(request.contentIds()); room.getPlayerContents().put(request.userId(), jsonContentList); roomRepository.save(room); log.info("컨텐츠 등록됨 | userId: {}, contentIds:{}", room.getPlayerContents().keySet(), room.getPlayerContents().values()); } catch (JsonProcessingException e) { throw new RuntimeException("Json 직렬화 오류", e); } }
Java
복사
이런식으로 ObjectMapper의 writeValueAsString() 메서드를 활용해서 문자열로 바꿔주고 직렬화가 잘 되는지 확인해본 결과, 아래와 같이 문자열 형태로 저장되는 것을 확인할 수 있었다. 물론 조회도 잘 됨
11) "playerContents.1" 12) "[\"53\", \"54\", \"55\"]"
Plain Text
복사

결론

RedisRepository를 활용해 Map 내부에서 List 자료형을 쓸경우, 제대로 직렬화 되지 않으며 커스텀 시리얼라이저, 어노테이션과 같은 방법도 적용되지 않았다.
이후 Redis를 활용할 일이 있으면 초기 설정과 CRUD 로직 생성에 시간이 걸리더라도 좀 더 데이터를 다루기 쉬운 RedisTemplate로 진행하는게 좋겠다는 생각이 들었다.

Reference