[SpringBoot, Redis] Redis를 활용한 Caching 기본 및 활용 예제

3Beom's 개발 블로그·2023년 6월 29일
2
post-custom-banner

Cache

  • 한번 읽은(처리된) 데이터를 임시로 저장하고, 필요에 따라 전송, 갱신, 삭제하는 기술이다.
  • 보통 서버의 메모리를 사용하는 경우가 많다.
  • 메모리를 사용하기 때문에 매번 Disk로부터 데이터를 조회하는 것보다 훨씬 빠른 I/O 성능을 얻을 수 있다.
  • 하지만 서버가 다운되거나, 재부팅되는 경우 데이터가 사라지는 휘발성의 성격을 갖고 있다.
  • 따라서 영속적으로 보관할 수 없는, 임시적으로 보관하고 빠르게 해당 정보에 접근하기 위한 용도로 사용되어야 한다.
    • Redis의 경우, 주기적으로 Disk에 백업해 둘 수 있다.

Cache 장점

  • 서버 간 불필요한 트래픽을 줄일 수 있다.
  • 웹어플리케이션 서버의 부하를 감소시킨다.
  • 어플리케이션의 빠른 처리성능(조회)을 확보해 궁극적으로 고객에게 쾌적한 서비스 경험을 제공한다.

Cache의 대상이 되는 데이터

  • 단순한, 또는 단순한 구조의 데이터 : 정보의 단순성
  • 반복적으로 동일하게 제공되어야 하는 데이터 : 빈번한 동일 요청의 반복
  • 변경 주기가 빈번하지 않고 단위처리 시간이 오래걸리는 정보 : 높은 단위처리 비용
  • 정보의 최신화가 반드시 실시간으로 이루어지지 않아도 서비스 품질에 영향을 거의 주지 않는 정보

→ 해당 조건들 중 2개 이상 포함되는 성격의 데이터이면 Cache 적용을 적극적으로 고려해 볼 수 있다.

  • 예시
    • 검색어, 인기 상품, 베스트 셀러 등
    • 방문자 수, 조회 수, 추천 수
    • 1회성 인증정보 (SMS 본인인증, IP 정보 등)

Cache 사용 시 주의할 점

  • 캐싱할 정보의 선택
  • 캐싱할 정보의 유효기간 (TTL, Time To Live) 설정
  • 캐싱할 정보의 갱신 시점

→ 서비스 설계 시, 특히 백엔드의 경우 API 서비스의 기능 설계 단계부터 Cache 정책을 수립해두는게 좋다.

→ 어떤 정보를 Cache로 적용할지?, 해당 정보들을 어떤 시점에 어떤 주기로 갱신, 삭제할 지? ⇒ 캐싱 전략


Redis

  • key-value 저장소
  • List, Hash, Set, Sorted Set 등 여러 형식의 자료구조를 지원하는 NoSQL
  • 메모리에 상주하며 RDBMS의 캐시 솔루션으로 주로 사용된다.
  • 라인, 삼성전자, 네이버, StackOverflow 등 여러 IT 대기업에서도 사용하는 검증된 오픈소스 솔루션.

특징

  • Key-Value Store
    • key-value 쌍으로 저장하는 거대한 Map 형태의 데이터 저장소이다.
    • 데이터를 쉽고 편하게 읽고 쓸 수 있어 익히기 쉽고 직관적이라는 장점이 있지만, Key-Value 형태로 저장된 데이터를 Redis 내에서 처리하는 것이 어렵다는 단점이 있다.
  • 다양한 데이터 타입
    • Value의 타입을 다양하게 지정할 수 있다.
    • List, String, Set, Sorted Set 등 여러 데이터를 저장하여 손쉽고 편하게 저장할 수 있다.
  • Persistence
    • 데이터를 메모리가 아닌 Disk에 저장할 수도 있다.
    • 따라서 서버가 shutdown 된 후에 재실행 하더라도 Disk에 저장해놓은 데이터를 다시 읽어 데이터가 유실되지 않는다.
    • Disk 저장 방식으로는 Snapshot, AOF 방식이 있다.
      • Snapshot
        • RDB에서도 사용하고 있는 방식이다.
        • 특정 시점의 데이터를 Disk에 옮겨담는 방식
        • Blocking 방식의 SAVE, Non-blocking 방식의 BGSAVE 방식이 있다.
      • AOF
        • Redis의 모든 write/update 연산 자체를 log 파일에 기록하는 방식이다.
        • 서버 재시작 시 write/update 연산을 순차적으로 재실행하여 데이터를 복구한다.
    • Redis 공식 문서에는 두 방식을 혼용해서 활용하는 것을 권장한다.
      • 주기적으로 snapshot으로 백업해두고, 다음 snapshot 까지 그 사이에 발생한 연산들을 AOF 방식으로 저장하는 것.
  • ANSI C
    • C언어로 작성된다.
    • 이에 따라 Java와 같이 가상머신 위에서 동작하는 언어에서 발생하는 성능 문제에서 자유롭다.
    • 가상 머신 위에서 인터프리터 된 언어로 가동하는 경우, Garbage Collection 동작에 따른 성능 문제가 발생할 수 있지만, C언어로 작성된 Redis는 이러한 이슈에 대해 자유롭다.
  • 서버 측 복제 및 샤딩을 지원
    • 읽기 성능 증대를 위한 서버 측 복제를 지원한다.
    • 쓰기 성능 증대를 위한 클라이언트 측 샤딩을 지원한다.

Java(Spring, SpringBoot)에서 Redis를 활용하는 이유

  • 추상화된 API와 어노테이션을 제공한다.
    • 어노테이션 사용만으로 일반 Service 메서드를 캐시 함수로 사용할 수 있다.
  • SpringBoot의 Auto Configuration 적용으로 인해 Cache 서버 설정이 간결해졌다.
    • SpringBoot Starter Kit을 기본적으로 제공한다.
      • spring-boot-starter-data-redis

SpringBoot에서 공식 지원하는 Third-Party Cache 라이브러리

  • Redis, Caffeine, EhCache, Hazelcate, Infinispan
  • 해당 라이브러리들 모두에 대해 상단에서 기재된 추상화된 API들 모두 사용할 수 있다.

SpringBoot - Redis 적용 과정

  • Redis 설치 (macOS 기준)
    • brew install redis
    • Redis 실행 : redis-server
    • Redis CLI 실행 : redis-cli
      • keys * : 전체 키 목록 확인
      • get [key] : 해당 키 데이터 확인
      • flushall : 전체 데이터 제거
  • SpringBoot 의존성 라이브러리 추가
    • build.gradle

      implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  • application.yml 설정
    • application.yml

      spring:
        datasource:
          url: jdbc:h2:tcp://localhost/~/redistest
          username: sa
          password:
          driver-class-name: org.h2.Driver
        jpa:
          hibernate:
            ddl-auto: create
          properties:
            hibernate:
              format_sql: true
        cache:
          type: redis
        redis:
          host: 127.0.0.1
          port: 6379
    • redis 기본 포트 : 6379

  • RedisConfig 파일 생성 및 Redis 사용 설정
    • RedisConfig.java
      package caching.redis.practice.config;
      
      import java.time.Duration;
      import org.springframework.beans.factory.annotation.Value;
      import org.springframework.cache.CacheManager;
      import org.springframework.cache.annotation.EnableCaching;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;
      import org.springframework.data.redis.cache.RedisCacheConfiguration;
      import org.springframework.data.redis.cache.RedisCacheManager;
      import org.springframework.data.redis.connection.RedisConnectionFactory;
      import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
      import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
      import org.springframework.data.redis.serializer.RedisSerializationContext;
      
      @Configuration
      @EnableCaching
      public class RedisConfig {
          @Value("${spring.redis.host}")
          private String host;
      
          @Value("${spring.redis.port}")
          private int port;
      
          @Bean
          public RedisConnectionFactory redisConnectionFactory() {
              return new LettuceConnectionFactory(host, port);
          }
      
          @Bean
          public CacheManager cacheManager() {
              RedisCacheManager.RedisCacheManagerBuilder builder =
                      RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
      
              RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                      .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) // Value Serializer 변경
      				.disableCachingNullValues()
                      .entryTtl(Duration.ofMinutes(30L));
      
              builder.cacheDefaults(configuration);
      
              return builder.build();
          }
      }
      • @EnableCaching 어노테이션을 통해 캐싱 기능 사용 등록
      • .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) : JSON 형태로 Serialization 적용
      • .disableCachingNullValues() : 데이터가 null일 경우 캐싱하지 않음
      • .entryTtl(Duration.ofMinutes(30L)) : 유효기간 설정

SpringBoot - Redis 활용

(JPA와 H2 DB로 최대한 간단하게 구현했다.)

  • Member Entity
    package caching.redis.practice.domain;
    
    import javax.persistence.Column;
    import javax.persistence.Entity;
    import javax.persistence.GeneratedValue;
    import javax.persistence.Id;
    import lombok.Getter;
    import lombok.Setter;
    
    @Entity
    @Getter
    @Setter
    public class Member {
    
        @Id
        @GeneratedValue
        @Column(name = "MEMBER_ID")
        private Long id;
    
        @Column(name = "NAME")
        private String name;
    }
  • RedisTestRepository, RedisTestRepositoryImpl
    package caching.redis.practice.dao;
    
    import caching.redis.practice.domain.Member;
    
    public interface RedisTestRepository {
        Member save(Member member);
        Member findOne(Long memberId);
        void remove(Member member);
    }
    package caching.redis.practice.dao;
    
    import caching.redis.practice.domain.Member;
    import javax.persistence.EntityManager;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Repository;
    
    @Repository
    @RequiredArgsConstructor
    public class RedisTestRepositoryImpl implements RedisTestRepository {
    
        private final EntityManager em;
    
        @Override
        public Member save(Member member) {
            if (member.getId() == null) {
                em.persist(member);
            } else {
                Member findMember = em.find(Member.class, member.getId());
                findMember.setName(member.getName());
            }
    
            return member;
        }
    
        @Override
        public Member findOne(Long memberId) {
            return em.find(Member.class, memberId);
        }
    
        @Override
        public void remove(Member member) {
            em.remove(member);
        }
    }
  • RedisTestService, RedisTestServiceImpl
    package caching.redis.practice.service;
    
    import caching.redis.practice.domain.Member;
    
    public interface RedisTestService {
        void joinMember(Member member);
        Member updateMember(Member member, Long memberId);
        Member getMemberInfo(Long memberId);
        void removeMember(Long memberId);
    }
    package caching.redis.practice.service;
    
    import caching.redis.practice.dao.RedisTestRepository;
    import caching.redis.practice.domain.Member;
    import lombok.RequiredArgsConstructor;
    import org.springframework.cache.annotation.CacheEvict;
    import org.springframework.cache.annotation.CachePut;
    import org.springframework.cache.annotation.Cacheable;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    @Service
    @RequiredArgsConstructor
    @Transactional(readOnly = true)
    public class RedisTestServiceImpl implements RedisTestService {
    
        private final RedisTestRepository redisTestRepository;
    
        @Override
        @Transactional
        public void joinMember(Member member) {
            redisTestRepository.save(member);
        }
    
        @Override
        @CachePut(value = "Member", key = "#memberId", cacheManager = "cacheManager")
        @Transactional
        public Member updateMember(Member member, Long memberId) {
            return redisTestRepository.save(member);
        }
    
        @Override
        @Cacheable(value = "Member", key = "#memberId", cacheManager = "cacheManager", unless = "#result == null")
        public Member getMemberInfo(Long memberId) {
            return redisTestRepository.findOne(memberId);
        }
    
        @Override
        @CacheEvict(value = "Member", key = "#memberId", cacheManager = "cacheManager")
        @Transactional
        public void removeMember(Long memberId) {
            Member member = redisTestRepository.findOne(memberId);
            redisTestRepository.remove(member);
        }
    }
  • RedisTestController
    package caching.redis.practice.controller;
    
    import caching.redis.practice.domain.Member;
    import caching.redis.practice.service.RedisTestService;
    import java.util.Map;
    import lombok.RequiredArgsConstructor;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.DeleteMapping;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.PutMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @RequestMapping("/")
    @RequiredArgsConstructor
    public class RedisTestController {
    
        private final RedisTestService redisTestService;
    
        @GetMapping("/{memberId}")
        public ResponseEntity<?> getMemberInfo(@PathVariable("memberId") Long memberId) {
            return ResponseEntity.ok(redisTestService.getMemberInfo(memberId));
        }
    
        @PostMapping("")
        public ResponseEntity<?> joinMember(@RequestBody Map<String, String> memberInfo) {
            Member member = new Member();
            member.setName(memberInfo.get("name"));
            redisTestService.joinMember(member);
            return ResponseEntity.ok("가입 완료");
        }
    
        @PutMapping("")
        public ResponseEntity<?> updateMember(@RequestBody Map<String, String> memberInfo) {
            System.out.println(memberInfo);
            Member member = new Member();
            member.setId(Long.parseLong(memberInfo.get("id")));
            member.setName(memberInfo.get("name"));
            redisTestService.updateMember(member, member.getId());
            return ResponseEntity.ok("수정 완료");
        }
    
        @DeleteMapping("/{memberId}")
        public ResponseEntity<?> deleteMember(@PathVariable("memberId") Long memberId) {
            redisTestService.removeMember(memberId);
            return ResponseEntity.ok("삭제 완료");
        }
    }

결과 확인

  • Redis 실행
    • redis-server Redis서버실행
  • Redis CLI 실행
    • redis-cli Redis CLI 실행
  • 처음 비어있는 상태 확인
    • keys*

(Postman 활용)

  • 데이터 삽입

    데이터 삽입

    데이터 삽입 쿼리

  • 데이터 조회 : Redis Cache 에 저장된다

    데이터 조회

    데이터 조회 쿼리

    → Redis 확인

    Redis 저장 확인

    • 이후 데이터 조회를 계속 해도 DB로 쿼리가 전송되지 않는다. : Redis 인메모리로부터 받아와 반환하기 때문!
  • 데이터 수정 : Redis Cache에 저장된 데이터 수정

    데이터 수정

    데이터 수정 쿼리

    → Redis 확인

    Redis 데이터 수정 확인

    → name 이 updatedname1 로 수정된 것을 확인할 수 있다.

    → 다시 조회 확인

    Redis 데이터 확인

    → 수정됨

    → DB로 SELECT 쿼리가 전송되지 않음도 확인

  • 데이터 삭제 : Redis Cache에 저장된 데이터도 삭제된다.

    데이터 삭제

    데이터 삭제 쿼리

    → Redis 확인

    Redis 데이터 삭제 확인

    → 데이터가 삭제된 것을 확인할 수 있다.

어노테이션 정리

  • @EnableCaching
    • SpringBoot에 캐싱 기능을 활용한다는 것 등록
    • SpringBoot Starter 클래스, 혹은 RedisConfig 클래스에 적용한다.
  • @Cacheable
    • DB로부터 애플리케이션으로 데이터를 조회한 후 Cache에 저장할 때 사용한다.
    • 조회 메서드에서 사용한다.
    • Redis 내에 어노테이션 파라미터로 전달된 key 값에 해당하는 value 데이터가 없을 경우 DB로 쿼리를 전송한다.
  • @CachePut
    • DB의 데이터가 수정되었을 경우, Redis Cache에 저장된 데이터에도 반영해주는 어노테이션이다.
  • @CacheEvict
    • DB의 데이터가 삭제되었을 경우, Redis Cache에 저장된 데이터도 삭제하는 어노테이션이다.

참고 자료

profile
경험과 기록으로 성장하기
post-custom-banner

0개의 댓글