본문 바로가기
Back-end/Project

[Cache] 다수 key로 CacheEvict, Page 직렬화 (CacheResolver, Mixin, DefaultTyping, Custom Se/Deserializer)

by whatamigonnabe 2023. 6. 16.

목차

  1. 해결 과제: 한 번에 다수의 캐시를 Evict 하기, Page 직렬화하기
  2. 문제: @CacheEvict는 복수의 key 불가
    1. 해결. SpEL과 CacheResolver를 통해 해결
    2. 개선. RedisCacheMange 설정들
  3. 문제: 직렬화 실패
    1. 해결Java8부터 추가된 LocalDateTime -> JavaTimeModule 모듈 추가
    2. Lazy Loading으로 인한 Hibernate Proxy -> Mixin을 등록하여 직렬화 무시
  4. 문제: 역직렬화 실패
    1. LinkedHashMap으로 역직렬화 -> ObjectMapper에 DefaultTyping 설정
    2. 기본 생성자가 없어 역직렬화 불가 ->  Customize Serializer / Deserializer
  5. 성능 테스트 with JMeter

 

해결 과제

배달 서비스 개발 중, 음식점 리스트 조회 기능에 Cache를 도입하여 성능을 개선해보고자 했습니다. 음식점 리스트 조회 기능은, 현재 위치에 배달이 가능한 음식점들 중 입력한 카테고리에 해당하는 음식점들을 조회하는 기능입니다. 배민 같은 서비스의 경우에는 같은 동네에 사는 유저들이 많고, 해당 유저마다 같은 결과 값을 리턴하게 되며, 음식점 정보 갱신이 잦지 않기 때문에 캐시 도입이 적절하다고 판단했습니다.

캐시 적용 대상 메서드(JpaRepository)

캐시를 적용할 메서드는 아래와 같습니다.

파라미터는 카테고리ID, 배달지역ID, 그리고 요청 페이지 정보를 담은 Pageable이고, 리턴 값은 가게 정보를 담은 Page입니다.

그리고 가게 정보가 갱신되는 아래 메서드(save)에서 @CacheEvict를 적용하려합니다. 그리고  Store 엔티티는 한 개 이상의 카테고리와 한 개 이상의 배달지역을 가지고 있습니다.

 

따라서 크게 해결해야할 과제는 이렇습니다.

  1. 음식점 리스트를 조회하면, 카테고리ID/배달지역ID/페이지정보를 Key로 하여 Cache 저장 / 조회하기
  2. Store 객체를 save()하면, Store의 다수의 카테고리와 다수의 배달지역을 조합하여 Evcit하기
  3. 음식점 리스스트 조회의 Return Type인 Page<T> 객체를 직렬화/역직렬화하기

Cache 저장소 : Redis

캐시 저장소로써 Redis를 사용했습니다. 아래에 Spring이 지원하는 여러 DB들이 있는데, 성능이 중요하다면 Caffeine or Hazelcast가 추천되지만, 화장성이나 영속화와 같은 기능들 그리고 SpringBoot의 지원이 좋은 Redis를 선택했습니다.

https://docs.spring.io/spring-boot/docs/2.1.6.RELEASE/reference/html/boot-features-caching.html

Serializer : GenericJackson2JsonRedisSerializer

Cache에 저장될 때 직렬화를 해서 Redis에 저장되고, Cache에서 불러올 때는 역직렬화 과정을 거칩니다. 따라서 Serializer를 사용해야 하며, Spring에서는 Redis의 Serializer로 다음과 같은 옵션이 있습니다.

  1. JdkSerializationRedisSerializer
    디폴트 값. 자바 직렬화사용
  2. StringRedisSerializer
    문자열을 직렬화하는 데 사용. 객체는 불가능. 문자만 다룰 때는 사용하는 옵션.
  3. OxmSerializer
    객체를 XML로 매핑
  4. Jackson2JsonRedisSerializer
    JSON format으로 변환함. 직렬화 대상의 타입을 알고 있어야 함.
  5. GenericJackson2JsonRedisSerializer
    JSON format으로 변환함. 직렬화 대상의 타입을 몰라도 됨. 따라서 다양한 타입으로 변환할 때 적합함.

저는 위의 옵션 중에 GenericJackson2 JsonRedisSerializer를 사용했습니다.

우선 JdkSerializationRedisSerializer는 자바 직렬화의 단점에 때문에 사용하지 않았습니다. 자바 직렬화는 Serializable만 구현하면 별다른 작업 없이 직렬화할 수 있는 장점이 있지만, 불필요하게 많은 메타 데이터까지 함께 저장하여 용량 문제가 있고, byte[]로 변환되기 때문에 저장된 정보를 사람이 이해하기가 어렵습니다.

또한 캐시의 대상이 되는 객체가 위의 page<Store>뿐 아니라 다양한 곳에서 사용되기 되고, 사람이 이해하기 쉬운 장점이 있기 때문에 GenericJackson2JsonRedisSerializer를 선택했습니다.

앞으로 위의 기능에 Cache를 도입하면서, 직면한 문제와 해결방안에 대해 설명하겠습니다.

 

문제. @CacheEvict는 복수의 key를 허용하지 않는다.

우선 @Cacheable은 해당 메서드에 캐시를 적용하여, 설정한 key 값으로 캐시 메모리를 탐색해 값이 있으면 이것을 대신 리턴하고, 그렇지 않으면 해당 메서드를 호출한 후 결과 값을 캐시에 저장합니다. 그리고 @CacheEvict는 설정한 key 값에 해당하는 캐시를 삭제합니다.

저의 경우에는 가게리스트를 조회하는 메서드에 @Cacheable을 , 그리고 가게 정보를 저장 및 갱신하는 메서드(save)에 @CacheEvict를 적용했습니다.

@Cacheable을 적용할 메서드의 리턴 값은 카테고리ID, 배달지역ID, Page의 size와 number 값에 따라 달라집니다. 그런데 @CacheEvict를 적용할 메서드인 save에서는 Store 가게 객체를 입력을 받고 Store는 한 개 이상의 카테고리ID와 한 개 이상의 배달지역ID를 가집니다. 따라서, 간단히만 봐도, 한 번에 save() 메서드 호출에서 다수의 Evict를 해야 하는 상황입니다.

그런데 문제는 @CacheEvict는 다수의 Key를 허용하지 않습니다.

@CacheEvict의 key

대안

  1. Store를 갱신(저장)할 때마다 Store 캐시를 모두 Evict 한다.
    @CacheEvict에는 allEntries 속성이 있어서, 이를 true로 하면 지정한 캐시를 모두 비울 수 있습니다.
  2. CacheResolver를 Customize 해서 필요한 부분만 캐시를 지운다.

첫 번째 옵션은 간단하게 구현할 수 있지만, 비효율적입니다. 예를 들면, 서울 어느 구 A동 B동에 배달을 하는 치킨집을 오픈했다고 해서 전국의 가게 캐시를 모두 지우게 됩니다.

두 번째 옵션은 구현이 더 복잡하지만, 캐시의 효율성을 위해서 선택했습니다. 위와 같은 예시라면 A동/치킨집, B동/치킨집 캐시만 삭제를 하면 됩니다.

@CacheEvict의 allEntries

해결방향. 카테고리/배달지역을 cacheName으로 페이지/사이즈를 key로, allEntries = true

우선 정확히 key들이 어떤 모습 이어야 하는지 살펴보겠습니다.

    

카테고리1/배달지역1/페이지1/사이즈10

카테고리1/배달지역1/페이지2/사이즈10

카테고리1/배달지역1/페이지3/사이즈10

카테고리2/배달지역2/페이지1/사이즈10

카테고리3/배달지역2/페이지1/사이즈10

카테고리4/배달지역1/페이지1/사이즈10

    

그리고 카테고리 1과 2를 포함하고 배달지역 1과 2를 포함하는 가게가 추가된다면, 위의 key들 중 아래의 키들은 제거가 되어야 합니다.

    

카테고리1/배달지역1/페이지1/사이즈10

카테고리1/배달지역1/페이지2/사이즈10

카테고리1/배달지역1/페이지3/사이즈10

카테고리2/배달지역2/페이지1/사이즈10

    

즉, 가게의 카테고리와 배달지역의 조합에 해당하는 모든 키를 제거해야 하는 것입니다.

 

이 문제를 CacheNamekey그리고 allEntries 속성을 이용해서 이 문제를 해결했습니다.

@Cacheable이나 @CacheEvict 모두 cacheNamekey를 요구하는데, 이는 단순히 cacheName과 key를 조합하여 Redis 등 DB의 key를 만듭니다. 예를 들어, cacheName이 "store"이고 key가 "category1"이라면, 실제로 Redis에 저장되는 key는 "store::category1"입니다. ("::"는 cacheName과 key를 나누는 구분자인데, 필요하다면 따로 설정을 할 수 있습니다. ) 따라서, 사실상 '실제 key'는 cacheName과 key의 조합인 것이죠. 그리고 allEntries속성은 지정한 cacheName에 해당하는 모든 값들을 삭제를 하며, key와 달리 다수의 cacheName을 받을 수 있습니다.

 

이러한 속성을 이용해서, 카테고리와 배달지역을 합쳐서 cacheName으로 페이지와 사이즈를 합쳐서 key로 만들고, @CacheEvict에서 allEntries = true를 설정하면, 이 문제를 해결할 수 있습니다!

해결 1. Key : SpEL 사용

우선 keySpEL 사용하여 아래와 같이 구현했습니다.

SpEL을 사용하면 파라미터에 쉽게 접근할 수 있습니다.

해결 2. CacheNames: Customize CacheResolver

key에서는 SpEL을 지원하여 동적으로 값을 입력할 수 있지만, cacheNames에서는 SpEL을 지원하지 않고, Evict에서는 카테고리와 배달지역의 모든 조합을 만들어야 하기 때문에, CacheResolver를 Customize 할 필요가 있습니다.

CacheResolver는, Reflection을 통해서 메서드, 메서드의 클래스, 파라미터 정보를 얻어와서 적절한 Cache들을 리턴합니다. (Cache는 cacheName에 해당하는 cache를 표현합니다.)

기본적인 구현 방법은 AbstractCacheResolver를 참고하여 배운 후, SimpleCacheResolver를 상속받아서 구현했습니다.

AbstractCacheResolver의 resolveCaches 메서드

public class  StoreCacheResolver extends SimpleCacheResolver {
  public StoreCacheResolver(CacheManager cacheManager) {
    super(cacheManager);
  }

  /**
   * ResolveCaches for Store
   *
   * This is available for both @Cacheable and @CacheEvict.
   * If method signature is changed, findCacheForCacheable() and findCacheForEvict should be changed along.
   * 
   * @param context the context of the particular invocation
   */
  @Override
  public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {
    List<String> cacheNames = makeCacheNames(context);
    Collection<Cache> result = new ArrayList<>(cacheNames.size());
    for (String cacheName : cacheNames) {
      Cache cache = getCacheManager().getCache(cacheName);
      if (cache == null) {
        throw new IllegalArgumentException("Cannot find cache named '" +
            cacheName + "' for " + context.getOperation());
      }
      result.add(cache);
    }
    return result;
  }

  private List<String> makeCacheNames(CacheOperationInvocationContext<?> context) {
    Object[] args = context.getArgs();
    List<String> cacheNames = new ArrayList<>();
    delegate(args, context.getMethod().getDeclaredAnnotations(), cacheNames);
    if (cacheNames.size() == 0)
      throw new IllegalCallerException("No appropriate resolve method found");
    return cacheNames;
  }

  private void delegate(Object[] args, Annotation[] annotations, List<String> cacheNames) {
    for (Annotation annotation : annotations) {
      if (annotation.annotationType() == Cacheable.class) {
        findCacheForCacheable(args, cacheNames);
        break;
      }
      if (annotation.annotationType() == CacheEvict.class) {
        findCacheForEvict(args, cacheNames);
        break;
      }
    }
  }

  private void findCacheForCacheable(Object[] args, List<String> cacheNames) {
    if (args.length < 2) throw new IllegalCallerException("Caller method must have at least 2 parameters.");
    Long categoryId = (Long) args[0];
    Long deliveryAreaId = (Long) args[1];
    String cacheName = makeCacheName(categoryId, deliveryAreaId);
    cacheNames.add(cacheName);
  }

  private void findCacheForEvict(Object[] args, List<String> cacheNames) {
    Store store = (Store) args[0];
    List<Long> categoryIds = new ArrayList<>();
    for (CategoryStore cs : store.getCategoryStores()) {
      categoryIds.add(cs.getCategory().getCategoryId());
    }
    List<Long> deliveryAreaIds = new ArrayList<>();
    for (DeliveryAreaStore das : store.getDeliveryAreaStores()) {
      deliveryAreaIds.add(das.getDeliveryArea().getDeliveryAreaId());
    }
    for (Long categoryId : categoryIds) {
      for (Long deliveryAreaId : deliveryAreaIds) {
        cacheNames.add(makeCacheName(categoryId, deliveryAreaId));
      }
    }
  }

  private String makeCacheName(Long categoryId, Long deliveryAreaId) {
    return "storeCa" + categoryId + "De" + deliveryAreaId;
  }
}

이후 Bean으로 등록하고, @Cacheable@CacheEvict에 등록해 주면 됩니다.

개선. RedisCacheManager 설정

설정 1. KEYS 명령 대신 SCAN 사용

Cache가 어떻게 동작하는지 확인하기 위해, 여러 테스트를 해보던 중 CacheResolver에서 Cache를 찾는 과정에서 Redis의 Keys 명령어를 찾는다는 것을 발견했습니다.

keys는 O(N)의 시간 복잡도를 가지는 명령어이기 때문에, 조심해서 사용해야 합니다. 물론 Redis 자체가 In-Memory DB이기 때문에 매우 빠른 속도로 처리하긴 합니다. 그럼에도 KEYS를 남발하면 문제가 생길 수 있기 때문에, redis에서는 대안으로써 scan을 제안합니다. 이는 전체를 한 번에 순회하지 않고, 지정한 수만큼 잘라서 수행하기 때문에 redis 서버를 오랜 기간 block 하는 문제를 방지할 수 있습니다.

Redis 공식 페이지의 SCAN 명령어 설명 중 일부

KEYS 명령어 대신 SCAN을 사용하는 방법을 아래와 같습니다.

RedisCacheWriter는 low level에서 Redis와 통신하는 객체입니다. 이는 전략 패턴으로 scan 명령을 사용할지, keys를 사용할지 정할 수 있습니다. 위처럼 RedisCacheManager을 빈으로 등록할 때, CacheWriter 설정을 하면 keys 대신 scan을 사용하게 됩니다.

설정 2. non-blocking CacheWriter 사용

RedisCacheWriter는 locking과 non-locking으로 나뉩니다. Redis가 Single Thread임으로 원자성이 보장되고 항상 thread-safe 하고 생각할 수 있지만, 그것은 Redis 입장이고 Spring 등 client의 입장에서 Redis 명령을 한 개 이상 사용한다면 명령 하나하나는 원자적이지만, 전체적으로는 그렇지 않습니다. 밑에 설명에서도 나와있듯이, putIfAbsent와 같은 명령은 overlapping이 발생할 수도 있습니다.

그러므로, 구현한 cache의 기능이 두 개 이상의 redis 명령으로 이뤄져 있다면, overlapping이 발생할 가능성이 있기 때문에, 구현의 문제 발생 가능성, 캐시 정확도의 중요성, 성능을 고려하여 선택해야 합니다.

저의 경우는, 위에서도 보이듯이 CacheEvict의 명령이 여러 개의 Redis 명령으로 이뤄져 있어 Overlapping이 발생할 가능성이 있있지만 같은 동네 & 같은 카테고리의 가게가 동시에 갱신되는 경우가 드물기 때문에 발생 가능성은 '하'이고, 캐시의 정확도는 결제와 관련이 없기 때문에 중요도는 '중'이고, 성능 중요성은 '상'이라고 판단했습니다. 따라서, non-blocking 하는 CacheWriter를 채택했습니다.

DefaultRedisCacheWriter에 대한 설명

설정 3. TransactionAware

이 설정은 RadisCache의 put이나 evict가 Spring의 transaction과 동기화하는 것입니다. @CacheEvict는 데이터 계층(JpaRepository)에 붙어있음으로, 비즈니스 계층에서 예외처리를 모두 거친 상태입니다. 따라서 이 과정이 실패하는 원인은 DB의 문제일 것이고, 이러한 확률은 낮다고 생각하여 transactionAware false(디폴트)로 설정했습니다.

설정해야 한다면 RedisCacheMangerBuilder 또는 RedisCacheManger에서 설정할 수 있습니다.

한계. 복수의 key 사용 불가로 인해, 복수의 SCAN 명령 사용

Scan 명령어를 대신 사용하게 했지만, 여전히 전체 Redis를 순회해야 하는 것은 마찬가지입니다. Cache라는 객채로 동일 cacheName을 가지는 모든 key 값끼리 묶어서 관리를 하고 있음에도, 해당 cache를 지우기 위해서는 scan 명령어를 사용해야 합니다. 심지어 저의 코드는 한 번에 Evict는 해당 Store객체가 가지고 있는 (카테고리수 x 배달지역수) 만큼 scan 명령어를 보내야 합니다.

사실 cacheName와 key 중 page number와 size로 이뤄져 있는 key는(ex. "storeCa1De10::page1size10" 에서 storeCa1De10이cacheName, page1size10가 key) 정해져 있습니다. 배민 앱을 보면 size는 고정되어 있고, page도 많아봐야 10개 이하입니다. 따라서, key를 중복으로 사용할 수 있다면 SCAN 대신 DEL 명령을 사용해서 (카테고리수 * 배달지역수 * 10)번의 DEL 명령으로 단축할 수 있을 것입니다.

spring-data-redis 깃헙을 보니 저와 같은 개선사항을 지적한 적이 있지만, "중복으로 key를 사용하지 않는 상황을 만드는 것이 바람직하다"라는 것이 이 커뮤니티의 방향인 것 같아 아쉬웠습니다.

 

문제. 직렬화 실패

원인 1. ObjectMapper는 Java8부터 추가된 LocalDateTime 등의 date/type은 직렬화가 불가능하다.

Store 클래스의 멤버 변수에 createdAt은 LocalDateTime 타입이고, 이를 직렬화 못하는 문제가 발생했습니다. 그 원인을 파악하기 위해 GenericJackson2JsonRedisSerializer이 어떠한 방법으로 직렬화를 수행하고 있는지 살펴보았고, 내부적으로 Jasckon ObjectMapper를 사용하고 있는 것을 발견했습니다.

해결 1. JavaTimeModule 모듈 추가

그리고 ObjectMapper에 대해 찾아보니 기본적으로 이 타입을 지원하고 있지 않지만, 직렬화 모듈을 추가하면 가능했습니다.

추가로 WRITE_DATES_AS_TIMESTAMP 기능을 disable 하면, 날짜 정보가 string으로 저장됩니다.

원인 2. Hibernate Proxy 직렬화 실패

아래와 같이, Lazy Loading 하는 Hibernate Proxy를 직렬화하다가, 영속성 콘텍스트가 끝난 프록시를 로딩하려다 보니 예외가 발생했습니다.

문제의 연관관계 필드들

대안

우선 프록시로 되어있는 객체들은 해당 메서드에서 필요하지 않았기 때문에, 이를 직렬화할 때 무시할 수 있는 방법이 필요했습니다. 이를 위한 방법은 아래와 같습니다.

  1. 클래스에 직접 작성하는 방법
    1. @JsonIgnoreProperties을 클래스 레벨에 붙여 제외할 필드명을 작성
    2. @JsonIgnore을 제외할 필드 위에 작성
    3. Filter: 제외할 프로퍼티를 담은 필터를 작성한 후, 해당 클래스 위에 @JsonFilter("myFilter") 작성
  2. 별도로 예외를 작성하여 적용하는 방법
    1. Jackson mixins : 제외할 필드를 담은 클래스를 작성하여 objectMapper에 등록

1번 방법은 기존의 소스 코드에 변화를 줘야 하고 다른 ObjectMapper에서도 영향을 받기 때문에, 소스 코드에 변화를 주지 않고 현재 사용하는 Object Mapper에만 적용시킬 수 있는 Mixin 방법을 채택했습니다.

해결 2: Mixin 작성하여 등록

Mixin 클래스는 적용 대상 클래스가 상속할 수 있는 형태여야 합니다.(class or interface) 이후 대상 객체에 override 하여 어노테이션들을 적용할 수 있습니다.

Mixin class

ObejctMapper에 아래처럼 등록합니다.

역직렬화 실패

문제. LinkedHashMap으로 역직렬화

위와 같이 ObejctMapper로 수정하고 나니, 역직렬화하는 과정에서 Page가 LinkedHashMap로 역직렬화되는 문제가 생겼습니다.

class java.util.LinkedHashMap cannot be cast to class org.springframework.data.domain.Page (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; org.springframework.data.domain.Page is in unnamed module of loader 'app')
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class org.springframework.data.domain.Page (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; org.springframework.data.domain.Page is in unnamed module of loader 'app')

원인. default configure 된 ObectMapper가 역직렬화할 Type을 모른다.

위의 문제는 ObejctMapper가 역직렬화하는 과정에서, 정확히 어떤 Type으로 역직렬화해야 하는지 모르는 경우 LinkedHashMap으로 변환을 하게 되는데, 이를 Page 객체로 cast 하지 못해서 나타나는 문제입니다.

하지만 GenericJackson2JsonRedisSerializer는 타입을 모르더라도 역직렬화가 가능하다고 했는데, 왜 이런 문제가 발생했을까요?

이 원인을 파악하기 위해서 GenericJackson2JsonRedisSerializer의 내부 코드를 살펴보니 이유를 파악할 수 있었습니다.

기본 생성자로 객체를 만들면, 내부에서 ObejctMapper를 Custom하는 반면, 개발자가 Custom한 ObjectMapper를 매개변수로 받는 생성자는, 추가적인 과정을 거치지 않고 그 ObejctMapper를 사용합니다. 그리고 GenericJackson2JsonRedisSerializer 내부에서 기본적으로 Custom하는 ObjectMapper는 DefaultTyping을 사용하고 있습니다. 이것은 ObejctMapper가 타입정보를 포함하여 직렬화하는 옵션입니다. 이를 사용하면 ObejctMapper에게 따로 어떤 타입으로 역직렬화할지 알려주지 않더라도 정확한 타입으로 직렬화할 수 있습니다.

위에서 적용된 ObjectMapper 설정들

  • registerNullValueSerializer() : Null로 된 필드도 타입을 포함하여 직렬화
  • ObjectMapper.DefaultTyping.EVERYTHING : 모든 프로퍼티를 직렬화. 이외에도 어떤 프로퍼티를 직렬화할지 설정할 수 있습니다.
  • mapper.getPolymorphicTypeValidator() : 역직렬화할 문자열에 적혀있는 어떤 객체든 역직렬화가 가능하기 떄문에 보안적인 이슈가 있습니다. 따라서 defaulTying을 필수적으로 Validator가 필요합니다.
  • builder.init(JsonTypeInfo.Id.CLASS, null) : 객체의 타입 정보를 어떻게 표현할지를 결정하며, 여기서는 클래스 이름을 사용하여 타입정보를 표현합니다.
  • builder.inclusion(JsonTypeInfo.As.PROPERTY) : 객체의 타입 정보를 어디에 표현할지를 결정합니다. 해당 설정은 타입 정보를 JSON 속성으로 표현합니다.

해결. ObjectMapper DefualtTyping 설정

GenericJackson2JsonRedisSerializer의 기본 설정을 참고해서, DefualtTyping을 활성화한 ObejctMapper를 구성했습니다.

개선점. GenericJackson2JsonRedisSerializer 생성자에 Module을 받는다면 어떨까?

GenericJackson2JsonRedisSerializer는 객체로 만들어지고 난 후에는 ObjectMapper에 접근할 수 없도록 하고 있는데, 중간에 직렬화/역직렬화 매커니즘이 바뀌면 전체 무결성이 회손될 가능성 때문에 이렇게 한 것 같습니다. 하지만, 저의 경우 처럼 단순히 JavaTimeModule만 등록할 일을 불필요하게 크게 만드는 문제가 있습니다. 때문에 GenericJackson2JsonRedisSerializer 생성자에 Module을 파라미터로 넘겨받아서, 기존 ObjectMapper에 추가하도록 하면 좋겠다는 생각을 했습니다. 그래서 spring-data-redis Github에 Issue를 남겼습니다.

문제. Page는 기본생성자가 없어 역직렬화가 불가능하다.

이번에는 Return type인 Page가 역직렬화가 안 되는 문제가 발생했습니다.

원인: ObejctMapper는 역직렬화에 기본생성자를 사용한다.

ObjectMapper에 대해 살펴보니, 기본 생성자가 있어야만 역직렬화를 할 수 있었습니다. 추가적으로 대상 클래스의 멤버 필드에 접근할 수 있는 방법(public 또는 setter)가 있어야 직렬화가 되지만, spring boot에서는 자동으로 ObejctMappe에 ParameterNames 모듈을 추가해서 private 멤버에도 접근할 수 있도록 합니다.

대안

1. 기본 생성자를 포함하여 Page의 구현체인 PageImpl을 감싸는 Wrapper Class를 구현한다.

2. Object Mapper가 Page를 Serialize하는 방법을 커스텀한다.

대안 1. Page를 감싸는 Wrapper Class 구현

1번은 간단하다는 장점이 있지만, Repository에 직접 @Cacheable을 붙일 수가 없고 불필요해 보이는 코드가 추가로 생기는 단점이 있습니다. 왜냐하면 JpaRepostiory의 쿼리 메서드의 리턴타입을 Custom Page로 변경을 해도, 내부적으로는 PageImpl 타입으로 우선 생성한 후 cast 하기 때문입니다. 따라서 코드가 아래처럼 바뀝니다.

JpaRepository로부터 곧바로 CustomPage로 return 받을 수 없기 때문에 JpaRepository에는 @Cacheable을 붙일 수 없고, 위와 같이 우선 PageImpl 구현체로 받은 후 CustomPage를 생성해줘야 합니다.

이때 CustomPage는 아래처럼 구현할 수 있습니다.

  • @JsonIgnoreProperties : 직렬화에서 제외할 프로퍼티를 설정할 수 있습니다. ignoreUnknown을 true로 설정하면, 역직렬화하는 과정에서 알 수 없는 프로퍼티(setter나 그것을 표현하는 생성자가 없는) 이 있을 경우 무시합니다. 또한 pageable이라는 이름의 프로퍼티를 무시하도록 했습니다. 왜냐하면 PageImpl은 Pageable 필드를 가지고 있는데, 이것 또한 기본생성자가 없기 때문에 직렬화할 수 없기 때문입니다.
  • @JsonCreator: 기본 생성자 대신에 지정한 생성자를 사용하도록 하며, @JsonProperty에 지정한 이름과 매치되도록 역직렬화를 수행합니다.

대안 1의 문제. 동일 클래스에서 호출한 메서드에는 Cache가 동작하지 않는다.

이는 Cache 나 Transaction 등의 기능들은 AOP를 통해서 작동합니다. 즉, 프락시를 통해서 이뤄지는 것이죠. 그런데 동일 클래스에서 메서드를 호출하게 되면 프록시 객체를 타지 않기 떄문에 Cache가 동작하지 않는 것입니다. 이를 해결하기 위해서는 다른 클래스에서 호출하도록 구조를 바꾸거나, 또는 프록시 객체를 호출하는 방법이 있습니다. 하지만, 이러한 방법은 기존 로직의 변화를 많이 가져오게 됩니다.

대안 2. Serializer를 Customize

이러한 단점들이 있기 때문에, 두 번째 대안인 Serialier를 Custom 하기로 했습니다. 이 방법을 사용하면, Page를 직접 역직렬화할 수 있기 때문에, 기존 코드를 변화시킬 필요가 없고 JpaRepository에 바로 적용할 수 있습니다.

주의..!

Page Serializer에 관해 검색해 보면 시도한 사람은 많은데 결국 위의 대안 1을 선택하고 성공한 케이스를 찾을 수 없었습니다. 왜냐하면, Serializer를 커스텀하는 것에 대한 공식문서가 잘 되어있지 않고, 라이브러리의 코드를 따라가면서 원리를 이해해야 하기 때문입니다. 저는 어떻게 결과는 만들었지만, 그 과정에 대해서는 더 훌륭한 방법이 있을 테니 참고 바랍니다.

해결 방향. Page Serializer / Deserializer Customize

Page를 Serialize 하는 것의 핵심은 Page가 품고 있는 객체의 타입을 Serialized 된 문자열을 읽고 판단할 수 있어야 하는 것입니다. 왜냐면, 지금 저희는 GenericJackson2JsonRedisSerializer를 사용하고 있기 때문에 코드 상에서 명시적으로 Type을 알려주면 안 됩니다.

저는 디폴트로 직렬화된 것과, 디버깅을 통해 메커니즘을 뜯어가면서 단서를 찾았습니다.

밑에 두 직렬화된 값을 비교해 보면, defaultTyping은 "@class"를 이름으로 클래스경로를 포함한 클래스 이름을 값으로 하는 필드를 추가하고 있습니다. 바로 이 값을 통해 타입을 추론하는 것입니다.

이번엔 desrializer가 어떻게 작동하는지 보시죠.

실험 코드

먼저 GenericJackson2JsonRedisSerializer입니다. 아래를 보시면, 우선적으로 Object 타입으로 역직렬화를 합니다.

그리고 Object.class에 해당하는 Deserializer인 UnTypedObjectDeserializer를 찾아서 역직렬화를 요청합니다.

여기서 deserializeWithType은 default typing을 사용할 때 호출되는 메서드이고, 그렇지 않은 경우는 deserialize라는 메서드를 호출합니다. JsonParser 객체를 통해서 한 조각씩 직렬화된 값들을 읽게 되고, TypeDeserializer를 통해서 Object 객체 안의 한 필드의 type을 읽어서 직렬 하라는 요청을 합니다.

이제 이곳이 핵심입니다. AsPropertyTypeDeserializer의 deserializeTypedFromObject 메서드의 일부입니다.

필드의 이름이 "@class"이고 이것의 다음 토튼 값, 즉 클래스 이름을 가지고 TypeId를 찾게 됩니다. 그리고 아래처럼 이것을 가지고 deserializer를 찾은 후 이것에게 역직렬화를 요청합니다. 이후로 PageImpl은 기본생성자가 없기 때문에 역직렬화에 실패합니다.

즉, 우선 역직렬화 과정에서 커스텀한 Deserializer를 찾을 수 있도록 해야 합니다. 그렇게 하기 위해선 직렬화한 JSON Object 이 첫 필드가 "@class" : "org.springframework.data.domain.PageImpl"이 되어야 합니다.

그리고 이 과정 후에, 적합한 deserializer가 없다면, BeanDeserializer에게 요청을 하고 객체를 생성한 뒤 객체에 값을 하나씩 담습니다. 이 과정에서 deserializeWithType 메서드를 사용하게 되는데, 이는 타입을 추론할 수 있는 TypeDeserializer를 사용하게 됩니다.

그러나 해당 타입에 맞는 적절한 Deserializer를 찾았을 경우에는 Type을 안다는 가정이 들어갔는지, TypeDeserializer를 사용할 수 없습니다.

JsonDeserialier abstract class
JsonDeserialier abstract class

하지만 Page는 내부의 content 필드가 Generic Type을 가지는 List이기 때문에 Type을 반드시 추론할 수 있어야 합니다.

 

정리해 보면,

1) Custom Deserializer를 사용할 수 있도록, Json Object 상단에 PageImpl의 클래스 네임을 필드로 싣어야 한다.

2) TypeDeserializer를 사용할 수 없기 때문에, List의 내부 클래스 정보를 직접 파싱 해서 처리해야 한다.

3) TypeDeserializer를 사용할 수 없으면, DeserializationContext의 readValue 메서드를 사용해야 하는데, 이 또한 deserializeWithType이 아닌 deserialize 메서드를 사용함으로 Json 내부에 클래스 정보를 필드로 실은 형태는 역직렬화할 수 없다.

 

위의 세 가지 조건을 충족시키기 위해서 아래의 방향을 설정했습니다.

  1. Custom Serializer를 만들어서
    1. PageImpl만 클래스 정보를 담도록 한다.
    2. Page에 담긴 클래스 정보를 따로 담는다.
    3. Page의 모든 필드는 defaultTyping을 사용하지 않고 직렬화한다.
  2. Custom Deseiralizer를 만들어서
    1. Page에 담긴 클래스 정보를 파싱 한다.
    2. 이것을 가지고 content 필드를 defaultTyping 없이 역직렬화한다.
    3. 나머지도 defaultTyping 없이 역직렬화한다.

해결 1. Customize Serializer

  • StdSerializer를 상속합니다.
  • serializeWithType() 기본적으로 DefualtTyping이 적용된 ObjectMapper를 사용하기 때문에, serializeWithType 메서드가 호출됩니다.
  • 이곳에서 Page 클래스 정보만 TypeSerializer를 이용해 write 합니다.
  • writeSubClassInPage() : 매뉴얼 하게 "class"를 필드 이름으로, Page의 클래스 이름을 필드의 값으로 하여 필드를 작성합니다.
  • writeFields(): JsonGenerator를 이용해서 DefualtTyping를 이용하지 않고 직렬화합니다.
public class PageSerializer extends StdSerializer<Page> {

  public PageSerializer() {
    super(Page.class);
  }

  /**
   * Serialize Page in a custom way.
   * 
   * 1. Only class info of Page is serialized using defaultTyping.
   * 2. Generic Class of List is serialized manually.
   * 3. Fields are serialized with no type info.
   * 
   * @param value Value to serialize; can <b>not</b> be null.
   * @param gen Generator used to output resulting Json content
   * @param serializers Provider that can be used to get serializers for
   *   serializing Objects value contains, if any.
   * @param typeSer Type serializer to use for including type information
   * @throws IOException
   */

  @Override
  public void serializeWithType(Page value, JsonGenerator gen, SerializerProvider serializers, TypeSerializer typeSer) throws IOException {
    WritableTypeId id = typeSer.writeTypePrefix(gen, typeSer.typeId(value, JsonToken.START_OBJECT));
    writeSubClassInPage(value, gen);
    writeFields(value, gen, serializers);
    typeSer.writeTypeSuffix(gen, id);
  }


  @Override
  public void serialize(Page value, JsonGenerator gen, SerializerProvider provider) throws IOException {
    throw new IllegalCallerException("This method is not supposed to be called.");
  }

  private void writeFields(Page value, JsonGenerator gen, SerializerProvider provider) throws IOException {
    if (value.getContent() != null && value.getContent().size() > 0) {
      provider.defaultSerializeField("content", value.getContent(), gen);
    }
    gen.writeNumberField("size", value.getSize());
    gen.writeNumberField("number", value.getNumber());
    gen.writeNumberField("totalElements", Long.valueOf(value.getTotalElements()));
  }

  private void writeSubClassInPage(Page value, JsonGenerator gen) throws IOException {
    if (value.getContent() == null || value.getContent().size() == 0) return;
    Class subClass = value.getContent().get(0).getClass();
    gen.writeStringField("class", subClass == null ? "null" : subClass.getName());
  }
}

해결 2. Customize Deserializer

  • 기본적으로 deserialize()가 호출됩니다.
  • JsonParser를 통해 필드의 이름이 "class"인 것을 찾아 reflection으로 Class를 얻어 JavaType을 만듭니다.
  • 만든 JavaType과 DeserializationContext의 readValue()를 통해 List의 역직렬화를 수행합니다.
  • 나머지도 DeserializationContext의 readValue()를 통해 List의 역직렬화를 수행합니다.
  • 역직렬화한 값들을 통해 PageImpl를 생성하여 리턴합니다.
public class PageDeserializer extends StdDeserializer<PageImpl> {
  private static final String CONTENT = "content";
  private static final String NUMBER = "number";
  private static final String SIZE = "size";
  private static final String TOTAL_ELEMENTS = "totalElements";
  private static final String CLASS = "class";

  public PageDeserializer() {
    super((JavaType) null);
  }

  @Override
  public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException, JacksonException {
    throw new IllegalCallerException("This method is not supposed to be called.");
  }

  /**
   * Deserialize Page. This is matched with PageSerializer.
   *
   * @param p Parsed used for reading JSON content
   * @param ctxt Context that can be used to access information about
   *   this deserialization activity.
   *
   * @throws IOException
   * @throws JacksonException
   */
  @Override
  public PageImpl deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
    CollectionType valuesListType = null;
    List<?> list = new ArrayList();
    int pageNumber = -1;
    int pageSize = -1;
    long total = -1L;

    String propName = p.getCurrentName();
    do {
      p.nextToken();
      switch (propName) {
        case CLASS:
          String className = ctxt.readValue(p, String.class);
          Class clazz = getClass(className);
          valuesListType = ctxt.getTypeFactory().constructCollectionType(List.class, clazz);
          break;
        case CONTENT:
          list = ctxt.readValue(p, valuesListType);
          break;
        case NUMBER:
          pageNumber = ctxt.readValue(p, Integer.class);
          break;
        case SIZE:
          pageSize = ctxt.readValue(p, Integer.class);
          break;
        case TOTAL_ELEMENTS:
          total = ctxt.readValue(p, Integer.class);
          break;
        default:
          p.skipChildren();
          break;
      }
    } while (((propName = p.nextFieldName())) != null);

    validate(pageNumber, pageSize, total, p);
    return new PageImpl<>(list, PageRequest.of(pageNumber, pageSize), total);
  }

  private void validate(int pageNumber, int pageSize, long total, JsonParser p) throws JsonParseException {
    if (pageNumber == -1 || pageSize == -1 || total == -1L) {
      throw new JsonParseException(p, "Invalid JSON format.");
    }
  }

  private Class getClass(String className) {
    try {
      return Class.forName(className);
    } catch (ClassNotFoundException e) {
      throw new RuntimeException(e);
    }
  }
}

테스트

직렬화/역직렬화 테스트

@Cacheable / @Cacheable (빈 Page인 경우) / @CacheEvict

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class ServiceCacheTest {
  @Autowired
  private StoreRepository storeRepository;
  @Autowired
  private CategoryRepository categoryRepository;
  @Autowired
  private DeliveryAreaRepository deliveryAreaRepository;
  @Autowired
  private UserRepository userRepository;
  @Autowired
  private RedisTemplate<String, String> redisTemplate;
  @Autowired
  private RedisCacheManager cacheManager;
  private StoreSaveUtil storeSaveUtil;
  private List<Store> stores;

  @BeforeAll
  void saveStores() {
    storeSaveUtil = new StoreSaveUtil(storeRepository, categoryRepository,
        deliveryAreaRepository, userRepository);
    int numberOfStores = 30;
    int numberOfDeliveryAreas = 3;
    int numberOfCategoriesAStoreHas = 2;
    int numberOfDeliveryAreasAStoreHas = 2;

    stores = storeSaveUtil.saveStores(numberOfStores, numberOfDeliveryAreas,
        numberOfCategoriesAStoreHas, numberOfDeliveryAreasAStoreHas);
  }
  @BeforeEach
  void beforeClear() {
    CacheUtils.clearCache(redisTemplate);
    cacheManager.initializeCaches();
  }

  @Test
  @Order(100)
  void cacheableTest() {
    // given
    long categoryId = 1L;
    long deliveryAreaId = 1L;
    int size = 10;
    int page = 0;

    // when
    Page<Store> stores =
        storeRepository.findStoresByCategoryAndDeliveryArea(categoryId, deliveryAreaId, PageRequest.of(page, size));

    // then
    Set<String> cacheNames = (Set<String>) cacheManager.getCacheNames();
    assertThat(cacheNames.size()).isSameAs(1);
    if (cacheNames.size() != 1) return;
    Cache cache = null;
    for (String cacheName : cacheNames) cache = cacheManager.getCache(cacheName);
    String key = "page" + page + "size" + size;
    Page<Store> cachedStore = (Page<Store>) cache.get(key).get();
    assertThat(cachedStore.getTotalElements()).isEqualTo(stores.getTotalElements());
    assertThat(cachedStore.getNumber()).isEqualTo(stores.getNumber());
    assertThat(cachedStore.getSize()).isEqualTo(stores.getSize());
    assertThat(cachedStore.getContent().get(0).getAddress()).isEqualTo(stores.getContent().get(0).getAddress());
  }

  @Test
  @Order(200)
  void cacheableTest_EmptyResult() {
    // given
    long categoryId = 99999L;
    long deliveryAreaId = 99999L;
    int size = 10;
    int page = 0;

    // when
    Page<Store> stores =
        storeRepository.findStoresByCategoryAndDeliveryArea(categoryId, deliveryAreaId, PageRequest.of(page, size));

    // then
    Set<String> cacheNames = (Set<String>) cacheManager.getCacheNames();
    assertThat(cacheNames.size()).isSameAs(1);
    if (cacheNames.size() != 1) return;
    Cache cache = null;
    for (String cacheName : cacheNames) cache = cacheManager.getCache(cacheName);
    String key = "page" + page + "size" + size;
    Page<Store> cachedStore = (Page<Store>) cache.get(key).get();
    assertThat(cachedStore.getTotalElements()).isEqualTo(stores.getTotalElements());
    assertThat(cachedStore.getNumber()).isEqualTo(stores.getNumber());
    assertThat(cachedStore.getSize()).isEqualTo(stores.getSize());
    assertThat(cachedStore.getContent().size()).isEqualTo(0);
  }

  @Test
  @Order(300)
  void cacheEvictTest() {
    Collection<String> names = cacheManager.getCacheNames();
    // given -> make 3 Caches : storeCa1De1::page0size10, storeCa2De2::page0size10, storeCa3De3::page0size10
    for (int id = 1; id <= 3; id++) {
      long categoryId = id;
      long deliveryAreaId = id;
      int size = 10;
      int page = 0;

      storeRepository.findStoresByCategoryAndDeliveryArea(categoryId, deliveryAreaId, PageRequest.of(page, size));
    }

    List<Long> categoryIds = List.of(1L, 2L);
    List<Long> deliveryAreaIds = List.of(2L, 3L);

    // when -> evict 1 Cache : storeCa2De2::page0size10
    Store store = storeSaveUtil.createStore(categoryIds, deliveryAreaIds);
    storeRepository.save(store);

    // then
    for (String cacheName : cacheManager.getCacheNames()) {
      assertThat(cacheName).isNotEqualTo("storeCa2De2::page0size10");
    }
  }
}

성능 테스트 : Jmeter

Jmeter를 사용해 테스트를 진행했습니다.

설정

  • Application 설정
    • 카테고리 12개
    • 배달지역 20개 (서울 행정동 416개의 5% 수준)
    • 음식점 3000개 (19년 기준 배민 업체수 30만 * 전국 음식점 서울비중 약 20% = 배민 서울 업체 수 6만, 6만의 5% 수준)
    • 한 음식점 당 등록한 카테고리 2개, 한 음식점 당 등록한 배달지역 5개
  • 서버 설정
    • Docker를 이용하여 로컬에서 MySQL과 Redis 서버 사용
  • Jmeter 설정
    • 동시 접속자수 (Thread) : 150명
    • Ramp-up period : 0
    • Duration: 180초
    • 랜덤 변수를 사용해서 임의의 카테고리 & 배달지역 & Page Number 조합으로 호출
    • Page의 SIze는 10으로 고정

결과: With No Cache

Cache를 적용하지 않고 테스트해 보니, 평균 ReponseTime이 255ms로 집계 됐고, 아래의 Response Time Graph를 보니 비슷한 수준으로 유지되고 있는 것을 확인했습니다.

Summary Report
Response Time Graph

결과: With Cache

평균 Response Time 18ms로 집계 됐고, 그래프로 보니 캐시가 없어 직접 DB까지 I/O를 진행한 초기에는 느리다가 점차 빨라지는 양상을 보였습니다.

Cache를 사용하지 않았을 때보다, 응답시간을 90% 정도 개선하였습니다.

Summary Report
Response Time Graph

참조

Jackson Ignore Properties on Marshalling, Baeldung

Spring Cache custom resolver, Sping Cloud

Getting Started with Custom Deserialization in Jackson, Bealdung

Jackson: java.util.LinkedHashMap cannot be cast to X, Baeldung

Spring Boot: Customize the Jackson ObjectMapper, Baeldung

Intro to the Jackson ObjectMapper, Baeldung

Spring Cache Reference

Sping Redis Reference

'Back-end > Project' 카테고리의 다른 글

Fetch join와 Batch size를 이용한 N+1 문제 해결  (0) 2023.06.02