Troubleshooting: RedisCache 직렬화 역직렬화 문제 - takeoff-26/logistics-service GitHub Wiki

RedisCache 직렬화 역직렬화 문제

문제 인식

허브의 findById 부분에서 일어난 문제이며 아래 문제 되는 코드 들에서 공통된 문제가 발생했다.

@Override
@Transactional(readOnly = true)
@Cacheable(value = "hubs", key = "#result != null ? #result.hubId : 'defaultKey'")
public GetHubResponseDto findByHubId(UUID hubId) {
    Hub hub = getHub(hubId);
    return GetHubResponseDto.from(hub);
}


@Override
@Cacheable(value = "hubSearch", key = "'search:' + #requestDto.toCacheKey() + '-hubs:' + #result.getHubIds()")
public PaginatedResultDto<SearchHubResponseDto> searchHub(SearchHubRequestDto requestDto) {
    return PaginatedResultDto.from(hubRepository.searchHub(requestDto.toSearchCriteria()));
}

@Override
@Cacheable(value = "hubsRoute", key = "#hubIdsDto.toHubId() + '-' + #hubIdsDto.fromHubId()")
public List<GetRouteResponseDto> findByToHubIdAndFromHubId(HubIdsDto hubIdsDto) {

    return hubRepository.findByIdIn(
            List.of(hubIdsDto.toHubId(), hubIdsDto.fromHubId()))
        .stream()
        .map(GetRouteResponseDto::from)
        .toList();
}
025-03-24T12:22:24.529+09:00 ERROR 49104 --- [hub] [io-19042-exec-7] [67e0cff00dde131a58253f32ba3e1570-58253f32ba3e1570] t.l.m.c.e.GlobalExceptionHandler         : Could not read JSON:Unexpected token (START_OBJECT), expected VALUE_STRING: need String, Number of Boolean value that contains type id (for subtype of java.lang.Object)
 at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); line: 1, column: 2] 

org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Unexpected token (START_OBJECT), expected VALUE_STRING: need String, Number of Boolean value that contains type id (for subtype of java.lang.Object)

문제 접근

레디스에서 직렬화와 역 직렬화를 하는 부분에서 문제

image

직렬화와 역직렬화에 대한 구현체를 지정한 Config에서 문제인가?라는 생각이 들었다.

기존 사용하던 설정에서 직렬화에 대해 RedisSerializer를 사용 중이었다.
기존 RedisSerializer에 대해 Default 적용되는 JdkSerializationRedisSerializer 이었고 간편하지만 Serializable을 구현한 클래스만 직렬화/역직렬화 될 수 있다. 해결 방안인 직렬화로 GenericJackson2JsonRedisSerializer를 선택하게 되었다. 선택한 이유는 여러 DTO를 사용하기 때문에 별도의 Class Type을 지정하지 않아도 자동으로 직렬화와 역직렬화를 해주기 때문이다.
마지막에 다루겠지 MSA에서 맞지 않는 이유도 있다.

별도로 여러 Value에 대해 컬렉션을 저장할 때 {"@class"::"~"}가 아닌 {{"@class":" ~"}}와 같은 형식으로 저장되기 때문에 패키지를 찾지 못하는 이슈가 발생한다. 이를 해결하기 위해 컬렉션으로 반환되는 값들을 1급 컬렉션으로 만들어주어야 한다.


문제 해결

@EnableCaching
@Configuration
public class RedisConfig {

    @Bean
    @Primary
    public CacheManager hubCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new
                StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
                RedisSerializer.json()))
            .entryTtl(Duration.ofHours(1L));
        return RedisCacheManager
            .RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(redisCacheConfiguration)
            .build();
    }
    @Bean
    public CacheManager hubListCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration cacheConfig = RedisCacheConfiguration
            .defaultCacheConfig()
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(redisConnectionFactory)
            .cacheDefaults(cacheConfig)
            .build();
    }

}

config에서 빈이 충돌 나지 않게 primary를 달아주었으며 캐시를 사용하는 곳에서 각 캐시 매니저를 받아와 사용하게끔 구성해 보았다.

@Service
@RequiredArgsConstructor
@Transactional
public class HubServiceImpl implements HubService {

    private final HubRepository hubRepository;

    @Override
    public PostHubResponseDto saveHub(PostHubRequestDto requestDto) {
        return PostHubResponseDto.from(hubRepository.save(requestDto.toEntity()));
    }

    @Override
    @Caching(evict = {
        @CacheEvict(value = "hubs", key = "#hubId", cacheManager = "hubCacheManager")
    })
    public PatchHubResponseDto updateHub(UUID hubId, PatchHubRequestDto requestDto) {
        Hub hub = getHub(hubId);
        hub.modifyHubName(requestDto.hubName());
        return PatchHubResponseDto.from(hub);
    }

    @Override
    @Transactional(readOnly = true)
    @Cacheable(value = "hubs", key = "#hubId", cacheManager = "hubCacheManager")
    public GetHubResponseDto findByHubId(UUID hubId) {
        Hub hub = getHub(hubId);
        return GetHubResponseDto.from(hub);
    }


    @Override
    @Cacheable(value = "hubSearch",
        key = "'search:' + #requestDto.hubName() + "
            + "'-adress:' + #requestDto.address() +"
            + "'-isAcs:' + #requestDto.isAsc() +"
            + "'-sortBy:' + #requestDto.sortBy() +"
            + "'-page:' + #requestDto.page() +"
            + "'-size:' + #requestDto.size() ",
        cacheManager = "hubListCacheManager"
    )
    public PaginatedResultHubResponseDto searchHub(SearchHubRequestDto requestDto) {
        return PaginatedResultHubResponseDto.from(PaginatedResultDto.from(hubRepository.searchHub(requestDto.toSearchCriteria())));
    }

    @Override
    @Cacheable(value = "hubsRoute",
        key = "'fromHub:' + #hubIdsDto.fromHubId() + "
            + "'-toHub: ' + #hubIdsDto.toHubId()",
        cacheManager = "hubListCacheManager"
    )
    public HubToHubResponseDto findByToHubIdAndFromHubId(HubIdsDto hubIdsDto) {

        return HubToHubResponseDto.from(hubRepository.findByIdInAndDeletedAtIsNull(
                List.of(hubIdsDto.toHubId(), hubIdsDto.fromHubId()))
            .stream()
            .map(GetRouteResponseDto::from)
            .toList());
    }

    @Override
    @Cacheable(value = "hubs", key = "'allHubs'", cacheManager = "hubCacheManager")
    public List<GetAllHubsDto> findAllHub() {
        return hubRepository.findByDeletedAtIsNull()
            .stream()
            .map(GetAllHubsDto::from)
            .toList();
    }

    @Override
    @Caching(evict = {
        @CacheEvict(value = "hubs", key = "'allHubs'", cacheManager = "hubCacheManager"),
        @CacheEvict(value = "hubs", key = "#hubId", cacheManager = "hubCacheManager"),
        @CacheEvict(value = "hubsRoute", allEntries = true, cacheManager = "hubListCacheManager"),
        @CacheEvict(value = "hubSearch", allEntries = true, cacheManager = "hubListCacheManager")
    })
    public void deleteHub(UUID hubId, Long userId) {
        Hub hub = getHub(hubId);
        hub.delete(userId);
    }

    private Hub getHub(UUID hubId) {
        return hubRepository.findByIdAndDeletedAtIsNull(hubId)
            .orElseThrow(() -> HubBusinessException.from(HubErrorCode.HUB_NOT_FOUND));
    }
}

image

컬렉션에 대한 값을 1급 컬렉션으로 만들어 안정성을 높이고 직렬화 구현체를 바꿔주어 안정성 있게 직렬화와 역직렬화를 함으로 문제를 해결하게 되었다.


해결 이후

직렬화와 역직렬화를 제대로 하지 못해서 캐싱이 제대로 되지도, 캐싱 된 값을 반환하지도 못한 오류에 대해 현재 해결 방법을 알게 되었고 키와 밸류의 저장, 읽기, 쓰기에 따라 시스템 안정성과 흐름이 결정되고 외부 인프라와 관련된 부분은 구현체에 따라 작동하게 되는 기능이 시스템에 큰 영향을 미친다는 것을 알게 되었다.

MSA에서 사용하기엔 GenericJackson2JsonRedisSerializer는 적절하지 않다.
왜냐하면 GenericJackson2JsonRedisSerializer은 클래스 타입으로 패키지 경로를 지정해서 캐싱하게 되는데 이 때 캐싱되는 값에 대한 패키지 경로를 접근자가 알아야 한다는 문제가 있고 이는 MSA에서는 사실 맞지 안다. 이번에 적용한 이유로는 모노레포를 통한 프로젝트기도 하고 문제가 있는 부분도 사용해보아야 나중에 체감이나 내가 기술을 사용할 때 더 논리적으로 설득도 하는 경험을 쌓을 수 있을 것 같아서이다.
마지막으로 방금 말한 것에 대한 대처법도 간략히 적고 끝내겠다.

Jackson2JsonRedisSerializer으로 제네릭 형식으로 저장해 ObjectMapper로 직렬화, 역직렬화하게 하면 더 안정성 있고 분리된 환경에서 작동하게 될 것 같다.