데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      Spring Boot 성능 개선 사례 공유 (1) - Redis 및 Local 캐싱 활용

      dragonpipe 25.01.03
      4,745 8 5
      DEVOTEE 요약
      본 블로그에서는 Redis와 Local 캐시를 활용하여 데이터 조회 성능을 개선한 사례를 설명합니다. 조회 성능을 개선하기 위해 Redis와 로컬 메모리를 활용하여 데이터를 캐싱하고 업데이트 구조를 개선하여 조회 시 로컬 메모리의 캐시 데이터만 활용하도록 설계하였습니다. 이는 10000개의 요청이 몰렸을 경우 2배 이상의 성능 개선을 이루었으며, 향후 캐시 버전 관리와 더 효율적인 데이터 관리 체계를 도입하려고 계획하고 있습니다.
      DEVOTEE 추천 블로그

      안녕하세요, dragonpipe입니다.

      이 번에는 Redis와 Local 캐시를 활용하여 조회 성능을 개선한 사례를 공유드려보려고 합니다.


      1. 개선 배경

      성능을 개선할 때 중요한 것은 왜, 어떤 목적으로 성능을 개선하는지 라고 생각하는데요.

      저의 경우에는 상점에서 팔고 있는 다양한 상품(코스튬, 꾸미기 아이템 등)들의 정보(이미지url, 가격, 할인정책 등)를 DB를 통해 조회하고 있었고

      (일부 Redis 캐시를 활용하고 있었으나 동일한 parameter로 조회했을 때만 사용) 조회가 몰릴 경우엔 Slow Query로 인한 부하가 발생하여 서버가 정상 동작하지 않는 이슈가 있었습니다.

      이를 개선하기 위해 조회할 때는 오직 로컬 메모리에 로드 된 캐시만을 활용하고 DB에서 데이터가 변경되었을 때

      Redis(글로벌캐시) 캐시 또는 Local 캐시(메모리에 load된 캐시)를 업데이트할 수 있는 구조에 대해 고민하였습니다.

      Disk 기반 DB와 In-Memory DB 비교



      2. 개선 전략

      위에서도 언급했듯이 기존에도 일부 Redis 캐시를 사용하고 있었습니다.

      다만 parameter 단위로 쪼개서 캐싱을 하고 있어서 조회 조건이 변경될 경우 DB를 다시 조회한 뒤 Redis에 캐싱하고 사용할 수 있었습니다.

      이번에는 전체 데이터를 Redis에 올리고, 그 데이터를 로컬 메모리에 로드해서 조회가 일어날 때 로컬 메모리에 load된 cache 데이터로만 조회할 수 있도록 변경하였습니다.

      1. Admin에서 마켓 데이터 변경 시, Cache API 서버로 캐시 갱신 API 호출

      2. Cache API에서는 변경된 데이터를 DB에서 읽은 후, Redis에 저장된 캐시를 삭제하고 다시 올림

      3. Redis 캐시 갱신에 성공했다면, Redis에 event publish 요청 (이 때 변경된 데이터에 해당하는 topic에만 publish 요청)

      4. 해당 topic을 subscribe 하고 있는 API 서버는 publish한 event 요청을 받음

      5. event 요청을 받았다면 Redis에 캐싱된 데이터가 변경되었다는 의미이므로, Redis를 조회해서 데이터를 내려받은 후 local 메모리에 load

      6. 만약 Redis 장애가 발생하였을 경우, DB에서 데이터를 조회해서 local 메모리에 load

      7. 혹시라도 event pub/sub이 정상적으로 수행되지 않을 수 있으므로 배치 스케줄러로 N분(예: 60분)마다 캐시 갱신 API를 호출

      8. API로 조회 요청이 들어올 경우 로컬에 로드된 데이터로 조회해서 결과 응답 전송


      3. 상세 개발 내용

      이를 구현하기 위해서 개발적으로 고민한 포인트 들에 대해서 같이 공유드리려고 합니다.

      1) Redis 자료구조를 Hash구조로 구성

      • Admin에서 데이터가 변경되었을 때 Redis에 캐싱된 데이터도 업데이트 해주어야 합니다. 그러나 보통 데이터는 아주 일부만 변경됩니다.

        만약 Redis에 데이터를 올려둘 때 전체 리스트를 한 번에 올려둔다면 (예: key:value <- list전체) 변경한 데이터만 반영할 수 없습니다.

        따라서 실제로 갱신한 몇 개의 데이터만 갱신할 수 있도록 Redis의 자료구조를 Hash 구조로 구성했습니다.

      • DB에서의 PK를 Hash구조의 Key로 사용하였고, 데이터가 변경되었을 때 PK로 Redis에 캐싱된 Hash 데이터를 조회해서 key에 매핑된 데이터를 업데이트(삭제 & 추가)하도록 구현하였습니다.

      • Redis에 올려둔 뒤 event pub/sub 할 때도 message를 통하여 변경된 데이터의 PK를 전달하였고, Redis에서 Local로 내려받을 때 변경된 데이터만 받아서 업데이트 하도록 구현하였습니다.

      @Autowired
      private HashOperations<String, String, Object> hashOperations;
      
      @Autowired
      private RedisPublisher redisPublisher;
      
      private static final String REDIS_HASH_KEY = "store-item";
      
      public void updateMarketData(StoreItem storeItem) {
          // Redis Hash에 업데이트
          hashOperations.put(REDIS_HASH_KEY, storeItem.getStoreItemNo(), storeItem);
      
          // 변경된 PK를 Pub/Sub로 전파
          redisPublisher.publish(storeItem.getStoreItemNo());
      }


      2) RedisTemplate과 RedisMessageListener 분리

      • RedisTemplate은 주로 데이터를 읽고 쓰는 역할을 수행합니다. 키-값 쌍을 관리하고 Redis 명령어를 실행하는데 사용됩니다.

        이에 반해 RedisMessageListenerContainer는 주로 Pub/Sub 메시지 구독 역할을 수행합니다.

        Redis의 SUBSCRIBE 명령을 사용하여 특정 채널을 구독하고 메시지를 리스닝합니다.

      • 메시지 리스닝은 지속적으로 블로킹(bloaking) 상태로 작동하기 때문에, 메시지를 리스닝하고 있는 과정에서 Redis 명령어(GET/PUT 등)를 실행할 경우 에러가 발생할 수 있습니다.

        따라서 아래 소스코드 처럼 RedisTemplate과 RedisMessageLister용 Connection Factory를 분리해서 생성하여 서로 독립적인 Bean 클래스를 만들 수 있도록 구성하였습니다.

      // RedisTemplate Bean 생성 (redisConnectionFactory)
      @Bean
      public RedisTemplate<String, Object> redisTemplate() throws UnknownHostException {
          RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
          redisTemplate.setConnectionFactory(`redisConnectionFactory`());
          redisTemplate.setKeySerializer(new StringRedisSerializer());
          redisTemplate.setValueSerializer(new StringRedisSerializer());
          redisTemplate.setHashKeySerializer(new StringRedisSerializer());
          redisTemplate.setHashValueSerializer(new StringRedisSerializer());
          return redisTemplate;
      }
      
      // RedisMessageListenerContainer Bean 생성 (redisNoClusterTopologyFactory)
      @Bean
      RedisMessageListenerContainer redisContainer() throws UnknownHostException {
          RedisMessageListenerContainer container = new RedisMessageListenerContainer();
          container.setConnectionFactory(`redisNoClusterTopologyFactory`());
          for (String topic : topics){
              ChannelTopic channelTopic = new ChannelTopic(topic);
              container.addMessageListener(messageListener(), channelTopic);
          }
          return container;
      }


      3) Local Memory Load In Singleton

      • local memory에 load하는 데이터의 양이 꽤 크기 때문에 매번 조회할 때마다 인스턴스를 생성하는 것은 성능 상 좋지 않다고 생각했습니다.

        따라서 데이터가 singleton으로 존재할 수 있도록 고민하였고, 다양한 개발 방법 (static 활용 등) 중 enum을 사용하여 singleton 객체를 만드는 방법을 채택하였습니다.

      • Singleton 패턴은 클래스의 인스턴스를 오직 하나만 생성하도록 보장하는 디자인 패턴입니다.

        일반적으로 static 변수를 사용해서 구현하지만 리플렉션(Reflection) 공격, 직렬화(Serialization)에 취약하고,

        멀티스레드 환경에서 Double-Checked Locking과 같은 복잡한 구현이 필요하다는 단점도 있습니다.

      • 이를 개선하기 위해 Enum으로 Singleton 객체를 만들었습니다. 아래 예시에서 INSTANCE는 Enum 값이며,

        해당 Enum이 JVM에서 유일하게 하나의 객체로 보장되어 Singleton 객체처럼 사용할 수 있습니다.

        이를 통해 synchronized, Double-Checked Locking 등의 복잡한 로직 없이 Singleton 객체를 만들 수 있었습니다.

      • 추가로 Enum에 대한 접근은 특정 인터페이스에만 오픈하고 싱글톤으로 관리되는 Enum 데이터들은 오직 그 특정 인터페이스를 통해서만 생성/조회할 수 있도록 개발하였습니다.

        이를 통해 객체 생성 코드를 클라이언트 코드와 분리하여 유연성과 확장성을 높일 수 있었습니다.

      public enum SingletonEnum {
          INSTANCE;
      
          public void doSomething() {
              System.out.println("SingletonEnum is working!");
          }
      }
      
      public class SingletonTest {
          public static void main(String[] args) {
              SingletonEnum singleton = SingletonEnum.INSTANCE;
              singleton.doSomething();
          }
      }


      4) Circular dependencies 이슈로 ApplicationEventPublisher 사용

      • MessageListener를 구현한 RedisMessageSubscriber라는 Class를 만들어서 Redis 캐시가 갱신되었다는 event를 Subscribe할 수 있도록 구현하였습니다.

        이벤트를 받으면 로컬 메모리에 로드하기 위해 인터페이스를 호출해야 하는데 해당 인터페이스를 사용하기 위해선 의존성 주입을 해야했고

        의존성 주입을 하기 위해선 최초 생성자로 생성할 때 인터페이스를 파라미터로 같이 넘겨줬어야 했습니다.

        이 때 아래와 같이 Circular dependencies 문제가 발생하여 해당 방식으로 개발할 수 없었습니다.

         activeController defined in file 
            ↓
         activeService defined in file 
      ┌─────┐
      |  cacheService defined in file 
      ↑     ↓
      |  redisComponent defined in file 
      ↑     ↓
      |  redisConfig defined in file 
      └─────┘
      • 이를 보안하기 위해 적용한 것이 ApplicationEventPublisher인데요.

        ApplicationEventPublisher는 Spring 프레임워크에서 애플리케이션 이벤트를 발행(publish)하고 리스너(listener)가 해당 이벤트를 수신하도록 하는 역할을 합니다.

        Redis를 통해서 사용했던 pub/sub과 같은 기능을 Application 내부에서도 사용할 수 있는 것입니다.

      • 이벤트를 발행할 때는 applicationEventPublisher.publishEvent()메소드를 호출하여 발행시키고 수신할 때는 onApplicationEvent() 메소드로 수신하도록 구현하였습니다.

        저는 ApplicationListener<?> interface를 구현(implement)한 뒤 onApplicationEvent() 메소드를 Override했는데

        Spring 4.2 이상 부터는 @EventListener 어노테이션을 적용하여 동일하게 구현이 가능하다고 하니 참고하시면 좋을 것 같습니다.

      // 발행(publish)
      public class RedisMessageSubscriber implements MessageListener {
          public static List<String> messageList = new ArrayList<String>();
          private final ApplicationEventPublisher applicationEventPublisher;
      
          public void onMessage(Message message, byte[] pattern) {
              messageList.add(message.toString());
              applicationEventPublisher.publishEvent(new RedisEvent(this, message.toString()));
          }
      }
      
      // 리스너(listener)
      @Override
      public void onApplicationEvent(RedisEvent event) {
          log.info("==== onApplicationEvent 시작 ====");
          String message = event.getMessage();
          log.info("applicationEvent received : {}", message);
      }


      5) Local Memory에 Load된 데이터로 조회하는 방법 (Stream)

      • Local 메모리로 load하기 전에는 JPA 또는 Mybatis를 활용하여 DB에 호출하는 SQL Query에 parameter를 전달하여 where 조건과 paging 쿼리를 사용하여 데이터를 가져왔습니다.

        하지만 이제 로컬 메모리에 데이터 전체가 다 올라가 있기 때문에 쿼리 없이 로컬 데이터에서 조회할 수 있어야 합니다.

      • 이를 위해 우선 싱글톤 객체에 데이터를 담을 때 부터 type에 맞게 데이터를 조회할 수 있도록 Map<String, List> 형식으로 데이터를 담았습니다.

      • 그리고 추가적으로 필요한 filter처리, sort처리, 페이징 처리 등을 Stream과 Java 연산 등을 활용하여 구현하였습니다.

      // Singleton에서 타입에 맞는 list 꺼내오기
      List<StorItemEntity> list = null;
      if (COSTUME_MRKTCLCD.equals(mrktClCd)) list = instance.getCostumeListByMenuNo().get(itemMenuNo);
      else if (MOTION_MRKTCLCD.equals(mrktClCd)) list = instance.getMotionListByMenuNo().get(itemMenuNo);
      else if (DECORATE_MRKTCLCD.equals(mrktClCd)) list = instance.getDecorateListByMenuNo().get(itemMenuNo);
      
      // Filter 처리
      if ("DEFAULT".equals(productCd)) list = list.stream().filter(s -> s.getItemStlmTpCd().equals("01")).collect(Collectors.toList());
      else if ("PNT".equals(productCd)) list = list.stream().filter(s -> s.getItemStlmTpCd().equals("03")).collect(Collectors.toList());
      else if ("STN".equals(productCd)) list = list.stream().filter(s -> s.getItemStlmTpCd().equals("02")).collect(Collectors.toList());
      
      // Sort 처리
      if ("01".equals(filter)) list.sort(Comparator.comparing(StorItemEntity::getFstRegDttm));
      else if ("03".equals(filter)) list.sort(Comparator.comparing(StorItemEntity::getSortVal).reversed());
      else if ("04".equals(filter)) list.sort(Comparator.comparing(StorItemEntity::getSortVal));
      
      // 랜딩 시 해당 데이터 최상위 처리
      if (storItemNo != null) {
          int index = list.stream().filter(x -> storItemNo.equals(x.getStorItemNo())).findFirst().map(list::indexOf).orElse(-1);
          if (index > -1) {
              StorItemEntity s = list.get(index);
              list.remove(index);
              list.add(0, s);
          }
      }
      
      // 페이징 처리
      if (page != null && size != null) list = getPage(list, page, size);
      
      // 페이징 처리 static 메소드
      public static <T> List<T> getPage(List<T> sourceList, int page, int pageSize) {
          if(pageSize <= 0 || page <= 0) {
              log.info("invalid value : page["+page+"], size["+pageSize+"]");
              return sourceList;
          }
      
          int fromIndex = (page - 1) * pageSize;
          if(sourceList == null || sourceList.size() <= fromIndex){
              return Collections.emptyList();
          }
      
          // toIndex exclusive
          return sourceList.subList(fromIndex, Math.min(fromIndex + pageSize, sourceList.size()));
      }


      4. 결과

      성능 개선 결과 10000개의 요청이 몰렸을 경우 2배 이상의 성능을 보여주는 것을 확인했습니다.

      이전에도 같은 parameter일 때는 redis 캐시를 사용해서 데이터를 반환했다는 점에서 조회하는 parameter를 변경하면서 조회했으면 더 극적인 성능 차이를 보일 수 있었을 것 같습니다.

      사실상 Redis 캐시를 활용하는 것에서 Local 캐시를 활용하는 것으로 변경하는 것만으로도 이 정도의 성능 차이가 발생할 수 있다는 것을 이번 작업을 통해 알 수 있었습니다.

      image.png


      마치는 글

      이번에는 조회 성능을 개선한 사례를 정리해 보았습니다. 앞으로의 향후 계획은 아래와 같습니다.

      1. 캐시 버전 관리: 지금은 Redis에 올려둔 최신 데이터 하나로만 버전을 관리하고 있는데 추후 캐싱된 데이터의 버전 정보를 관리하여 다수의 버전으로 관리하면 더 좋겠다는 생각을 했습니다.

        만약 버전으로 관리했을 경우엔 이번에 갱신한 캐시 데이터의 버전 정보를 publish 할 때 같이 전달하고, 서버 쪽에서는 로컬에 캐싱된 데이터의 버전 정보와 subscribe한 버전 정보를 비교한 뒤,

        이미 그 버전까지 갱신이 되어 있으면 데이터를 받지 않고 이미 받은 버전보다 높은 버전이면 갱신할 수 있도록 구현하면 더 효율적일 것 같다는 생각을 했습니다.

      2. 데이터 관리 체계: 조회 성능을 개선할 수 있는 데이터가 더 있는지 확인하여 DB로 관리하던 데이터를 캐시로 관리할 수 있는지 확인할 예정입니다.

        이 때 어떤 데이터를 캐싱할지에 대한 기준이 필요할 것이라고 생각했고, 데이터의 변경은 많이 일어나지 않고 조회가 자주 일어나는 데이터를 기준으로 선정하여 조회 성능을 조금 더 높여볼 예정입니다.

      긴 글 읽어주셔서 감사합니다.

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      dragonpipe 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기