1. 서비스 요구사항


캐시를 통한 읽기 성능 최적화


<aside>

현재 진행중인 프로젝트의 서비스는 큐레이션을 제공하고 해당 큐레이션은 관리자가 생성 및 수정할 수 있다.

즉, 큐레이션 서비스는 실시간으로 변경되는 것이 아닌 관리자 주도로 업데이트 되기 때문에 캐시가 성능에 엄청난 이점을 가져다 줄 수 있다. 그렇다면 이때 어떤 과정을 통해서 캐시 성능을 높였는지 보자.

</aside>

버전 필드를 통한 추가 최적화


<aside>

자주 업데이트하지 않는 데이터이기에 캐시를 사용한다면, 애초에 캐시된 데이터를 클라이언트 측에서 관리하는 것도 하나의 방안이 될 수 있다.

실제로 브라우저 캐시를 보면, 브라우저에 캐시된 정보가 있다면 이를 활용하기도 한다.

다만 중요한 점은 캐시가 변경되었는지 확인을 할 필요가 있다. 예를 들어 클라이언트가 캐시된 데이터를 가지고 있다고 해도 새로운 업데이트가 발생하면 이를 갱신해줘야 하는것이다.

그래서 캐시마다 버전 필드를 부여하고 이를 비교하는 방식으로 진행한다.

</aside>

<aside>

프로세스


  1. 클라이언트가 게시물 조회 요청을 보낸다.
  2. 서버는 버전 0의 캐시를 응답한다.
  3. 클라이언트는 버전 0의 캐시를 클라이언트 데이터베이스에 저장한다.
  4. 이후 클라이언트는 게시물 조회 요청을 보낼때 현재 자신이 가지고 있는 캐시 버전 정보를 보낸다.
  5. 서버는 캐시를 로드해서 버전을 비교한 후, 만약 버전이 변경되었다면 새로운 버전을 응답한다.
  6. 만약 버전이 변경되지 않았다면, Null은 반환한다.
  7. 이는 GET 요청을 줄여주지는 못하지만 네트워크 대역폭을 줄여주며 버전에 대한 정합성도 보장해줄 수 있다. </aside>

<aside>

네트워크 대역폭 기대효과


응답 DTO를 기반으로 캐시가 적용되는 대상의 클래스를 보자.

기본적으로 categoryList의 길이는 4, 각 CategoryResponseDto는 4개의 PostResponseDto를 가진다. 또한 각 PostResponseDto는 title은 30글자 content는 300글자 정도를 가진다.

즉 이를 계산했을때 아래 표와 같은 결론이 나온다.

사용자 수 절약 데이터 KB 환산 MB 환산
1명 6 566 바이트 6.42 KB 0.0063 MB
100명 656 600 바이트 641 KB 0.63 MB
10 000명 65 660 000 바이트 64 137 KB 62.7 MB
</aside>

1️⃣ 큐레이션 게시물에 대해 캐시를 적용한다.


<aside>

  1. 큐레이션 게시물 데이터를 조회할때 이를 캐시하자.
  2. Cacheable 어노테이션을 통해서 특정 키에 매핑되어 캐시를 저장할 수 있다.
/* 서브 아이템 캐시를 통해 조회 성능 개선 */
@Transactional(readOnly = true)
@Cacheable(
        value = "subItemCache",
        key = "'cached-sub-item-' + #type + '-' + #itemId",
        sync = true
)
public ItemResponseDto getCachedItem(Integer type, Long itemId) {
    return getItem(type, itemId);
}

</aside>

2️⃣ TTL을 적용하여 캐시를 관리하자.


<aside>

  1. 캐시 데이터는 우리가 삭제하지 않으면 계속 메모리 공간을 차지할 수 있다.
  2. 레디스는 메모리 데이터베이스이기 때문에 메모리 공간을 효율적으로 사용하는 것이 중요하다.
  3. 따라서 기본적으로 TTL 적용을 통해 시간 제한을 둔다.
@Bean
	public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
	    RedisCacheConfiguration cacheConfiguration =
	            RedisCacheConfiguration.defaultCacheConfig()
	                    .entryTtl(Duration.ofDays(7))
	                    .serializeValuesWith(
	                            RedisSerializationContext.SerializationPair
	                                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
	                    );
	
	    return RedisCacheManager.builder(redisConnectionFactory)
	            .cacheDefaults(cacheConfiguration)
	            .build();
  }

</aside>

3️⃣ 관리자 주도 업데이트를 실행한다.


<aside>

  1. 캐시에 대한 데이터가 변경될 수 있다.
  2. 이때 데이터베이스를 업데이트한 후, 캐시를 삭제하는것 이 아니라, 관리자가 직접 캐시를 업데이트하게 둔다.
  3. 이는 캐시를 삭제할때, 캐시 미스로 인한 Cache Stampede 문제를 해결할 수 있다.
  4. 즉, 관리자의 관리 아래 CachePut이 붙은 로직을 실행함으로 캐시를 갱신하는 것이다.
@Transactional(readOnly = true)
@CachePut(
        value = "pageCache",
        key = "'cached-page-' + #type + '-' + #period"
)
public PageResponseDto putCachedPage(Integer type, Integer period) {
    BabyPage babyPage = getPage(type, period);

    if (babyPage == null) {
        return null;
    }

    if (babyPage.getType() == TYPE_PREGNANCY_GUIDE
            || babyPage.getType() == TYPE_CHILDCARE_GUIDE) {
        List<CategoryResponseDto> categoryList = getPageItemWithPost(babyPage);
        return new PageResponseDto(babyPage, categoryList);
    }

    if (babyPage.getType() == TYPE_CHILDCARE_NUTRITION
            || babyPage.getType() == TYPE_PREGNANCY_NUTRITION) {
        List<CategoryResponseDto> categoryList = getPageItemWithNutrition(babyPage);
        return new PageResponseDto(babyPage, categoryList);
    }

    List<CategoryResponseDto> categoryList = getPageItemWithInspection(babyPage);
    return new PageResponseDto(babyPage, categoryList);
}

</aside>