Spring @Cacheable Hateoas 관련 오류 해결

개발 환경

  • Spring boot 2.6.6

  • Redis

  • hateoas 2.6.4

구현 코드 및 오류

Redis Cache 설정

@Configuration
@EnableCaching
public class RedisConfig {
    private final RedisProperties redisProperties;
    
	@Bean(name = "connectionFactory")
    @Profile({"local", "test"})
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    @Bean(name = "cacheManager")
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheManager.RedisCacheManagerBuilder builder= RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory);
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues() 
                .entryTtl(Duration.ofDays(30)) 
                .computePrefixWith(CacheKeyPrefix.simple()) 
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new JdkSerializationRedisSerializer()));

        return builder.cacheDefaults(configuration).build();
    }

}

1. Hateoas 관련 오류

아래와 같이 org.springframework.hateoas의 PagedModel 또는 RepresentationModel를 상속받은 객체를 cache하게 되면 읽을 때 deserialize 오류가 발생하게 된다.

  • 서비스 코드
@Cacheable(cacheNames = "CACHE_DELIVERY_NOTICE",
           cacheManager = "cacheManager",
           key = "T(String).valueOf(#params.btnSearch)+#params.viewType+#pageable.pageNumber",
           condition="T(org.springframework.util.StringUtils).hasText(#params.searchText) == false",
           unless="#result == null")
@Transactional(readOnly = true)
public PagedModel<NoticeModel> findAllByParams(NoticeSearchParams params, Pageable pageable) {
    return this.noticeModelAssembler.toPageModel(noticeRepository.findAllByParams(params, pageable));
}
  • 오류 메시지
org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Could not resolve type id 'org.springframework.hateoas.Links' as a subtype of `java.util.List<org.springframework.hateoas.Link>`: Not a subtype
...
 at [Source: (byte[])"{"@class":"org.springframework.hateoas.PagedModel","links":["org.springframework.hateoas.Links",[{"@class":"org.springframework.hateoas.Link","rel":["org.springframework.hateoas.StringLinkRelation","self"],"href"
...
at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:156)
  • 원인

    GenericJackson2JsonRedisSerializer가 org.springframework.hateoas.Links type을 deserialize 하지 못하여 발생하는 오류.

  • 해결

    서비스 코드에서 hateoas 관련 Type을 제거하고 일반 객체로 리턴한 후 Controller에서 Convert하여 Response하도록 변경.

    (최종 해결 소스는 마지막에..)

2. PageImpl 오류

Hateoas 관련 오류를 해결하기 위해 아래와 같이 Page<T> 객체로 리턴하도록 만들었지만, PageImpl 관련 오류가 또 발생하였습니다.

  • 서비스 코드
@Cacheable(cacheNames = "CACHE_DELIVERY_NOTICE",
           cacheManager = "cacheManager",
           key = "T(String).valueOf(#params.btnSearch)+#params.viewType+#pageable.pageNumber",
           condition="T(org.springframework.util.StringUtils).hasText(#params.searchText) == false",
           unless="#result == null")
@Transactional(readOnly = true)
public Page<NoticeResponse> findAllByParams(NoticeSearchParams params, Pageable pageable) {
    return noticeRepository.findAllByParams(params, pageable);
}
  • 오류 메시지
nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `org.springframework.data.domain.PageImpl`
  • 원인

​ org.springframework.data.domain.PageImpl에 기본생성자가 존재하지 않아 발생.

  • 해결

​ PageImpl를 Wrapper 객체를 만들어 사용.

@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public RestPage(@JsonProperty("content") List<T> content,
                    @JsonProperty("number") int page,
                    @JsonProperty("size") int size,
                    @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }

    public RestPage(Page<T> page) {
        super(page.getContent(), page.getPageable(), page.getTotalElements());
    }
}
@Cacheable(cacheNames = "CACHE_DELIVERY_NOTICE",
           cacheManager = "cacheManager",
           key = "T(String).valueOf(#params.btnSearch)+#params.viewType+#pageable.pageNumber",
           condition="T(org.springframework.util.StringUtils).hasText(#params.searchText) == false",
           unless="#result == null")
@Transactional(readOnly = true)
public Page<NoticeResponse> findAllByParams(NoticeSearchParams params, Pageable pageable) {
    return new RestPage<>(noticeRepository.findAllByParams(params, pageable));
}

최종 해결 소스

  • 서비스
@Cacheable(cacheNames = "CACHE_DELIVERY_NOTICE",
           cacheManager = "cacheManager",
           key = "T(String).valueOf(#params.btnSearch)+#params.viewType+#pageable.pageNumber",
           condition="T(org.springframework.util.StringUtils).hasText(#params.searchText) == false",
           unless="#result == null")
@Transactional(readOnly = true)
public Page<NoticeResponse> findAllByParams(NoticeSearchParams params, Pageable pageable) {
    return new RestPage<>(noticeRepository.findAllByParams(params, pageable));
}
  • 컨트롤러
@GetMapping("/query")
public ResponseEntity<PagedModel<NoticeModel>> findAllByParams(@Valid NoticeSearchParams params,
	Pageable pageable, Errors errors) {
    
	if (errors.hasErrors()) {
		throw new InvalidParameterException("입력 값이 올바르지 않습니다.", errors);
	}

	return ResponseEntity.ok()
		.body(this.noticeModelAssembler.toPageModel(this.noticeService.findAllByParams(params, pageable)));
}
  • Page Wrapper
@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public RestPage(@JsonProperty("content") List<T> content,
                    @JsonProperty("number") int page,
                    @JsonProperty("size") int size,
                    @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }

    public RestPage(Page<T> page) {
        super(page.getContent(), page.getPageable(), page.getTotalElements());
    }
}
  • ModelAssembler
@Component
@RequiredArgsConstructor
public class NoticeModelAssembler implements RepresentationModelAssembler<NoticeModel, NoticeModel> {
    private final PagedResourcesAssembler<NoticeModel> pagedResourcesAssembler;
    
    @Override
    public NoticeModel toModel(NoticeModel model) {
        return model
                .add(getSelfLink(model.getNoticeSeq()))
            	.add(getUpdateLink(entity.getNoticeSeq()))
                .add(getDeleteLink(entity.getNoticeSeq()));
    }

    @Override
    public CollectionModel<NoticeModel> toCollectionModel(Iterable<? extends NoticeModel> entities) {
        return RepresentationModelAssembler.super.toCollectionModel(entities);
    }

    public PagedModel<NoticeModel> toPageModel(Page<NoticeResponse> entities) {
        Page<NoticeModel> models = entities
                .map(NoticeResponse::toModel);

        return pagedResourcesAssembler.toModel(models, this);
    }
    
    ...
}

댓글남기기