[spring] Redis 캐싱 적용기

구동현·2024년 7월 15일

땅땅땅 서비스의 유저테스트 피드백을 살펴본 결과,
메인 페이지에서 경매글 목록을 조회하는 로직이 가장 느리다는 의견이 존재했습니다.
저희팀은 EHCache와 RedisCache를 적용해보고 나온 결과와 여러가지 트레이드 오프에 대해 이번글에서 서술해보겠습니다.

[Before] 경매글 전체 조회 메인 로직


    public Slice<AuctionListResponseDto> getAuctions(
        ...
    ) {
        User user = userService.findUserOrElseThrow(userId);
        List<Long> townList = user.getTown().getNeighborIdList();

        return auctionRepository
        	.findAllByFilters(townList, status, title, pageable)
            .map(auction -> new AuctionListResponseDto(...));
    }
  • 기존의 땅땅땅 서비스에서는 캐싱을 적용하지 않고, 유저의 인근 지역 리스트와 필터링 조건들을 통해 직접 repository에 조회하는 과정을 거쳤습니다.
  • 처리량은 10.3TPS으로 상대적으로 느렸고, 같은 내용의 피드백을 유저에게 받았습니다.
  • 저희는 반복되는 요청이 많고, 다른 API보다 상대적으로 느린 경매글 전체 조회 메소드에 캐싱서비스를 적용하기로 했습니다.

[1차시도] Ehcache 적용

  • 저희는 캐싱을 적용하기 위해 Spring에서 간단하게 사용할 수 있는 JAVA 기반의 오픈소스 라이브러리인 Ehcache를 적용하기로 했습니다.
  • Redis와 달리 별도의 데몬을 가지지 않고, 로컬에서 돌아가기 때문에 성능적인 장점을 가져갈 수 있다는 판단이 있었습니다.
  • 또한, 라이프사이클이 Spring application과 동일하게 가져가기때문에 관리에 이점도 있었습니다.

의존성 추가

    // ehcache
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.ehcache:ehcache'

src/main/resources/ehcache.xml 추가

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns="http://www.ehcache.org/v3"
  xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
  xsi:schemaLocation="
            http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd
            http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">

  <cache alias="trashcanList"> <!-- @Cacheable의 value 값으로 사용됨 -->
    <!--  key 타입 지정 -->
    <key-type>java.lang.String</key-type>
    <!-- value 타입 지정 -->
    <value-type>java.util.List</value-type>

    <expiry>
      <ttl unit="seconds">600</ttl> <!-- ttl 설정 -->
    </expiry>

    <listeners>
      <listener>
        <!-- 리스너 클래스 위치 -->
        <class>com.twoez.zupzup.config.cache.CacheEventLogger</class>

        <!-- 비동기 방식 사용, 캐시 동작을 블로킹하지 않고 이벤트를 처리, SYNCHRONOUS와 반대 -->
        <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
        <!-- 이벤트 처리 순서 설정 X, ORDERED와 반대 -->
        <event-ordering-mode>UNORDERED</event-ordering-mode>

        <!-- 리스너가 감지할 이벤트 설정(EVICTED, EXPIRED, REMOVED, CREATED, UPDATED) -->
        <events-to-fire-on>CREATED</events-to-fire-on>
        <events-to-fire-on>UPDATED</events-to-fire-on>
        <events-to-fire-on>EXPIRED</events-to-fire-on>
      </listener>
    </listeners>

    <resources>
      <!-- JVM 힙 메모리에 캐시 저장 -->
      <heap unit="entries">100</heap>

      <!-- off-heap(외부 메모리)에 캐시 저장 -->
      <offheap unit="MB">10</offheap>
    </resources>
  </cache>

  <cache alias="googlePublicKeys"> <!-- @Cacheable의 value 값으로 사용됨 -->
    <expiry>
      <ttl unit="seconds">600</ttl> <!-- ttl 설정 -->
    </expiry>

    <listeners>
      <listener>
        <!-- 리스너 클래스 위치 -->
        <class>com.twoez.zupzup.config.cache.CacheEventLogger</class>

        <!-- 비동기 방식 사용, 캐시 동작을 블로킹하지 않고 이벤트를 처리, SYNCHRONOUS와 반대 -->
        <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
        <!-- 이벤트 처리 순서 설정 X, ORDERED와 반대 -->
        <event-ordering-mode>UNORDERED</event-ordering-mode>

        <!-- 리스너가 감지할 이벤트 설정(EVICTED, EXPIRED, REMOVED, CREATED, UPDATED) -->
        <events-to-fire-on>CREATED</events-to-fire-on>
        <events-to-fire-on>EXPIRED</events-to-fire-on>
      </listener>
    </listeners>

    <resources>
      <!-- JVM 힙 메모리에 캐시 저장 -->
      <heap unit="entries">100</heap>

      <!-- off-heap(외부 메모리)에 캐시 저장 -->
      <offheap unit="MB">10</offheap>
    </resources>
  </cache>

  <cache alias="kakaoPublicKeys"> <!-- @Cacheable의 value 값으로 사용됨 -->
    <expiry>
      <ttl unit="seconds">600</ttl> <!-- ttl 설정 -->
    </expiry>

    <listeners>
      <listener>
        <!-- 리스너 클래스 위치 -->
        <class>com.twoez.zupzup.config.cache.CacheEventLogger</class>

        <!-- 비동기 방식 사용, 캐시 동작을 블로킹하지 않고 이벤트를 처리, SYNCHRONOUS와 반대 -->
        <event-firing-mode>ASYNCHRONOUS</event-firing-mode>
        <!-- 이벤트 처리 순서 설정 X, ORDERED와 반대 -->
        <event-ordering-mode>UNORDERED</event-ordering-mode>

        <!-- 리스너가 감지할 이벤트 설정(EVICTED, EXPIRED, REMOVED, CREATED, UPDATED) -->
        <events-to-fire-on>CREATED</events-to-fire-on>
        <events-to-fire-on>EXPIRED</events-to-fire-on>
      </listener>
    </listeners>

    <resources>
      <!-- JVM 힙 메모리에 캐시 저장 -->
      <heap unit="entries">100</heap>

      <!-- off-heap(외부 메모리)에 캐시 저장 -->
      <offheap unit="MB">10</offheap>
    </resources>
  </cache>
</config>

CacheConfig.java 추가

@Configuration
@EnableCaching
public class CacheConfig {
	// default로 사용되는 cachemanager
    @Bean
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager(“…”);
    }
}
  • 처리량은 72.6TPS으로 기존대비 약 6배가 향상되었다.

[2차시도] Redis 캐싱

  • 저희는 고가용성을 위해 2개 이상의 서버를 운영중에 있습니다.
  • 이런 환경에서, Ehcache로 캐싱한다면 데이터 부정합성이 발생할 우려가 있었습니다.
  • 고가용성과 속도 등 여러가지 트레이드 오프를 고민한 결과, 데이터 부정합 문제는 무시할 수 없기에, Redis Cache를 사용하기로 결정했습니다.
  • 또 저희는 Redis pub/sub, keyspace, 분산락을 사용해서 이미 Redis 관련 인프라를 구축한 상태여서 Redis를 사용하는것에서 이점을 가져갈 수 있었습니다.

Bean 등록

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory cf) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()))
            .entryTtl(Duration.ofMinutes(4L));

        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
            .cacheDefaults(redisCacheConfiguration).build();
    }

어노테이션 적용

    @Cacheable(value = "auctions", cacheManager = "cacheManager")
   public List<AuctionListResponseDto> getAuctions(
       Long userId,
       StatusEnum status,
       String title
   ) {
       User user = userService.findUserById(userId);
       List<Long> townList = user.getTown().getNeighborIdList();

       return auctionRepository.findAllByFilters(townList, status, title);
   }

이런한 과정을 통해 캐싱을 적용하였고, 처리량은 54.9TPS으로 기존 대비 약 4.3배 향상한 결과를 볼 수 있었습니다.

결론

이런 결과를 얻을 수 있었고, 가장 빠른 것은 Ehcache였지만 데이터 부정합 문제를 해결하기 위해 Redis Cache를 적용하게 되었습니다.

profile
개발합시다

0개의 댓글