ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 대용량 트래픽에서 DB부하를 줄이고 성능 튜닝을 위한 캐싱 적용
    토이프로젝트/hi-bus-go 2020. 8. 27. 02:19

    배경

    버스 터미널 어플을 개발하던 도중 조회성 정보중 거의 변하지 않는 정보를 많은 사용자가 매번 조회하기 위해 RDB에 접근하다 보면 DB 서버의 리소스를 사용하고 디스크 I/O가 자주 발생하여 DB 부하가 늘어날 것이라고 생각하였습니다. 또한 RDB에 최대한 적게 접근하면서 빠른 조회 응답을 얻는 방법 중에 캐시(Cache)가 있다는 것을 알게 되었습니다.

     

    그렇다면 여기서 말하는 캐시(Cache)가 무엇인지 알아보도록 하겠습니다.

     

    이전 글에서 인메모리 데이터 베이스 중 하나인 redis를 이용하여 대용량 트래픽을 처리하기 위한 세션 구조에 대해 글을 쓴 적이 있습니다. redis는 인메모리 데이터 베이스이지만 캐시 서버와 같은 기능을 하기도 합니다.

     

    Cache(캐시)란 자주 시용하는 데이터 값을 미리 복사해 놓는 임시 장소를 가리킵니다. 캐시를 시스템 상으로 분류하면,

    • CPU L1 Cache
    • CPU L2 Cache
    • Disk Cache

     

    이 캐시들은 os 및 하드웨어에서 제공하는 캐시를 말합니다. 즉 시스템의 성능을 높이는 데 사용되는 것들입니다.

    앞으로 말하는 캐시는 하드웨어가 아닌 서버의 기준으로 다음과 같이 분류할 수 있습니다.

     

    로컬 캐시(Local Cache)

     

    글로벌 캐시(Global Cache)

    로컬 캐시(Local Cache)

    • Local 서버 내에서만 사용되는 캐시
    • 서버의 리소스를 이용한다.

     

    글로벌 캐시(Global Cache)

    • 여러 서버에서 Cache Server에 접근하여 사용하는 캐시

     

    캐시를 적용하기에 적합한 데이터

    • 반복적이고 동일한 결과가 나오는 기능의 반환 값
    • 업데이트가 자주 발생하지 않는 데이터
    • 자주 조회되는 데이터
    • 날아가도 상관없는 데이터

     

    구분 장점 단점
    로컬 캐시 같은 JVM내에 혹은 같은 장비에서 데이터를 가져오므로 성능이 좋다. 하나의 장비내에 있는 데이터만 동기화가 이루어 진다.
    글로벌 캐시 데이터 동기화가 실시간으로 이루어지기 때문에 모든 사용자가 동일한 데이터를 가질수 있다. 네트워크를 통해 데이터를 전달하기 때문에 로컬 캐시에 비해 상대적으로 느리다.

     

    버스 터미널 조회 정보와 같은 자주 조회되면서 거의 변하지 않는 정보는 어떤 캐시 전략을 적용해야 할까요?.

    여러 서버를 사용 시에 서버 간 데이터를 동기화를 하면서, 모든 사용자가 동일한 데이터를 조회하기 위해서는 글로벌 캐시 전략을 적용하기로 했습니다.

     

    글로벌 캐시을 적용하기 위한 데이터는 어떤 데이터가 적합할까요?.

    • 여러 서버에서 접근하여 동기화가 이루어져 동일한 데이터를 가져야 할 때
    • 서버에 정상적으로 작동하지 않을 때에도 데이터를 유지해야 될 때

     

    1분 뒤에 날아가도 되는 데이터나 RDBMS에서 데이터를 가져와도 되지만 매우 자주 사용되면서 캐시에 없으면 DB에서 가져오면 되는 것들에 적용하면 큰 효과를 볼 수 있습니다.

     

     

     

    히트율(Hit Rate)

    매우 자주 사용되는 데이터를 사용하여 캐시에 요청한 데이터가 존재하는 경우를 캐시 히트(Hit)라고 합니다. 반대로 자주 사용되는 데이터가 캐시에 존재하지 않는 경우를 캐시 미스(Miss)라고 합니다.

     

    캐싱이 성능에 많은 영향을 미치는 만큼 자주 사용되는 데이터가 캐시에 얼마나 존재하는지를 의미하는 캐시 히트율이 높을수록 캐싱 성능이 향상됩니다. 또한 자주 사용되는 데이터이지만 캐시를 초기화하여 캐시에 존재하지 않을 경우 캐시 미스(Miss)가 발생하기도 합니다.

     

    자주 조회되며, 변하지 않는 정보일수록 캐시 히트율이 높아 캐싱 성능 향상에 큰 영향을 미칩니다. 캐시 히트율이 높다는 의미는 디스크 I/O가 적게 발생하는 만큼 데이터 베이스 접근하는 빈도가 줄어들어 데이터 베이스 서버의 리소스를 적게 사용하게 됩니다. 또한 캐싱 성능 향상뿐 아니라 서버의 리소스를 적게 사용하여 서버 전체 성능 향상에도 영향을 미칩니다.

     

    하지만 캐시 크기를 40에서 50으로 늘릴 경우 히트율의 증가가 캐시 크기를 20에서 20으로 또는 30에서 40으로 늘릴 경우에 비해 적다는 것을 알 수 있습니다. 이는 캐시 크기의 비례하여 히트율이 증가하지 않다는 것을 보여줍니다. 캐시 히트율이 높다고 무조건 성능이 비례하여 증가하지 않기 때문에 알맞은 캐시 크기는 애플리케이션에 따라 달라지며 적정 수준에서 캐시 크기를 정하는 것이 좋습니다.

     

    캐싱 적용을 위한 설정

    글로벌 캐시 전략을 적용하기 위해서 인메모리 데이터 베이스이면서 캐싱 처리에 적합한 Redis을 적용해보겠습니다.

    Spring에서 redis를 이용하여 캐시 설정을 해보도록 하겠습니다.

     

    @EnableCaching 어노테이션 추가 - 스프링 부트 애플리케이션에게 캐시 사용하겠다는 의미로 추가

    /**
     * @EnableCaching : 서비스 추상화한 CacheManager 인터페이스를 구현한 Bean을 등록한다.
     * 어플리케이션 내에 캐싱 어노테이션에 있는지 스캔후에 발견되면, 프록시가 자동으로 생성되어
     * 메소드 호출을 가로 채고 그에 따라 캐싱 동작을 처리한다.
     */
    @EnableCaching
    @SpringBootApplication
    public class HiBusGoApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(HiBusGoApplication.class, args);
        }
    
    }

     

    SpringCacheManager 설정

    RedisConfig라는 별도의 파일을 생성하여 CacheManager 인터페이스를 구현하는 RedisCacheManager를 Bean으로 등록하여 사용한다.

    @Configuration
    public class RedisConfig {
    
      /**
       * Redis Connection Factory 설정
       * connectionFactory는 connection 객체를 생성하여 관리하는 인터페이스입니다.
       * Java의 Redis client는 Jedis와 Lettuce 2가지가 있습니다.
       *
       * Jedis - 멀티쓰레드환경에서 쓰레드 안전을 보장하지 않는다.
       * - Connection pool을 사용하여 성능, 안정성 개선이 가능하지만 Lettuce보다 상대적으로 하드웨어적인 자원이 많이 필요하다.
       * - 비동기 기능을 제공하지 않는다.
       *
       * Lettuce - Netty 기반 redis client library
       * - 비동기로 요청하기 때문에 Jedis에 비해 높은 성능을 가지고 있다.
       * - TPS, 자원사용량 모두 Jedis에 비해 우수한 성능을 보인다는 테스트 사례가 있다.
       */
      @Bean
      public RedisConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
      }
    
      /**
       * RedisTemplate은 키,값 및 해시 키/값에 직렬화/역직렬화하기 위한 템플릿 제공 및 설
       * 직렬화는 java object를 redis에 byte로 직렬화한다.
       * 시리얼 라이저를 설정하지 않으면 기본으로 JdkSerializationRedisSerializer 사용
       * JdkSerializationRedisSerializer : redis에 키값을 \xac\xed\x00\x05t\x00\x03key와 같은
       * 바이너리 값으로 저장되기 때문에 문자열로 키값을 저장하기 위해 StringRedisSerializer 설정
       * GenericJackson2JsonRedisSerializer : 객체를 json 형태로 직렬화/json을 객체로 역직렬화한다.
       */
      @Bean
      public RedisTemplate<Object, Object> redisTemplate() {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
    
        //객체를 json 형태로 깨지지 않고 받기 위한 직렬화 작업
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return redisTemplate;
      }
    
      /**
       * Redis Cache를 사용하기 위한 cache manager 등록 및 설정
       * entryTtl - 캐시의 TTL(Time To Live)를 설정한다.
       */
      @Bean
      public CacheManager redisCacheManager() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext
                .SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext
                .SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .entryTtl(Duration.ofMinutes(5L));
    
        RedisCacheManager cacheManager = RedisCacheManager
            .RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory())
            .cacheDefaults(redisCacheConfiguration)
            .build();
    
        return cacheManager;
      }
    
    }

     

    캐시 등록 및 조회 - @Cacheable

    버스 터미널 목록이 매우 자주 사용되며 거의 변경되지 않는 데이터이므로 캐시 등록

       /**
         * @Cacheable : 동일 값이 Cache에 있는 경우 Cache에서 데이터를 return합니다.
         * 만약 동일 key 값이 없을 경우 메소드를 실행하고 반환된 결과 값을 Cache에 저장합니다.
         */
        @Cacheable(value = "terminals.name", key = "#name", cacheManager = "redisCacheManager")
        public Optional<BusTerminal> findByNameAndRegion(String name, String region) {
            return Optional.ofNullable(terminalMapper.findByNameAndRegion(name, region))
                .filter(o -> o.getStatus() == Status.DEFAULT);
        }
    
        @Cacheable(value = "terminals.region", key = "#region", cacheManager = "redisCacheManager")
        public List<BusTerminal> searchByRegion(String region) {
            return terminalMapper.searchByRegion(region);
        }
    
        @Scheduled(fixedDelay = 300000L) // 5분마다 캐시 갱신
        @Cacheable(value = "terminals.total", key = "'total'")
        public List<BusTerminal> searchTotal() {
            return terminalMapper.searchTotal();
        }

    검색 시 입력한 key 값으로 캐시를 검색합니다. key값이 없는 경우 고정된 키값(고정된 문자열)으로도 캐시 조회가 가능합니다. CacheManager 등록 시 설정했던 TTL(Time-to-live)@Cacheable에 사용하는 글로벌 설정이므로,

    별도의 만료시간 설정을 하고 싶다면 @Scheduled라는 어노테이션을 사용하여 매개변수가 없는 메서드에 적용 가능합니다.

     

    버스 터미널 캐시를 조회하는 Controller

     /**
       * 터미널 조회 메소드
       *
       * @param region 지역 이름
       * @param name 터미널 이름
       * @return BusTerminal
       */
      @GetMapping("/{region}/{name}")
      public ResponseEntity<?> getBusTerminal(@PathVariable String region, @PathVariable String name) {
        Optional<BusTerminal> busTerminal = busTerminalService.findByNameAndRegion(name, region);
    
        if (!busTerminal.isPresent()) {
          return RESPONSE_NOT_FOUND;
        }
    
        return ResponseEntity.ok().body(busTerminal.get());
      }
    
      /**
       * 터미널 조회 메소드
       * @param region 지역 이름
       * @return List<BusTerminal>
       */
      @GetMapping("/{region}")
      public ResponseEntity<?> getBusTerminals(@PathVariable String region) {
        List<BusTerminal> busTerminals = busTerminalService.searchByRegion(region);
    
        return ResponseEntity.ok().body(busTerminals);
      }
      
      /**
       * 터미널 전체 조회 메소드
       * @return List<BusTerminal>
       */
      @GetMapping()
      public ResponseEntity<?> getTotalBusTerminals() {
        List<BusTerminal> busTotalTerminals = busTerminalService.searchTotal();
    
        return ResponseEntity.ok().body(busTotalTerminals);
      }

     

    캐시 적용 전 버스 터미널 전체 조회

    캐시를 적용하지 않고 전체 조회를 한 경우 270ms

     

    캐시 적용 후 버스 터미널 전체 조회

    적용 전과 비교하여 270ms -> 7ms로 매우 빨라진 것을 확인할 수 있습니다.

     

    결론

    버스 터미널 어플을 개발하면서 매우 자주 사용되며 거의 변경되지 않는 데이터의 경우 조회 시간과 DB 부하를 줄이며 어떻게 빠른 조회가 가능할지에 대해 고민해봤습니다. 버스 터미널 목록은 거의 변하지 않으며 매우 자주 사용되는 데이터이며 자료의 크기가 크지 않아, 캐시에 저장하기에 적합하다고 생각했습니다. 이에 redis를 이용한 캐싱을 적용하였습니다.

     

    적용 결과 조회 응답 속도가 매우 빨라지는 성과를 얻었습니다. 대용량 트래픽을 고려해본다면 여러 대의 서버가 접근하는 글로벌 캐시를 이용하였기에 동기화하여 모든 사용자가 동일한 데이터를 조회할 수 있다고 생각합니다.

     

    이외에도 스프링에서 redis를 지원한다는 점에서 차이가 있었습니다. 스프링에서 지원을 한다는 점에서 개발 편의성과 지속성이 좋다고 할 수 있습니다.

    References

    자바 성능 튜닝 이야기 

    http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788966260928

     

    자바 성능 튜닝 이야기 - 교보문고

    『자바 성능 튜닝 이야기』는 고성능 애플리케이션을 위해 고려 해야 할 복잡한 요소들을 하나하나 짚어 주는 책이다. 장애를 일으키는 반복적인 코딩 이슈부터 시스템 진단, 튜닝 방법에 이르�

    www.kyobobook.co.kr

    https://spring.io/projects/spring-data-redis

     

    Spring Data Redis

    Spring Data Redis, part of the larger Spring Data family, provides easy configuration and access to Redis from Spring applications. It offers both low-level and high-level abstractions for interacting with the store, freeing the user from infrastructural c

    spring.io

    https://docs.aws.amazon.com/ko_kr/AmazonElastiCache/latest/red-ug/Strategies.html

     

    캐싱 전략 - Redis용 Amazon ElastiCache

    이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

    docs.aws.amazon.com

     

    Project Github URL

    버스 예매 프로젝트 github URL

    댓글

Designed by Tistory.