[TIL] Redis 활용

김건우·2024년 8월 8일

[TIL]

목록 보기
11/25

1. 세션 클러스터링

간단하게 세션 클러스터링을 구성해본다.

RedisConnectionFactory 를 통해 application.yml 파일에서 설정한 redis 세팅을 RedisTemplate에 적용시키고, 직렬화 옵션도 설정해준다.

간단하게 HttpSession 을 통해 session의 정보를 받아올 수 있다.
기본 설정은 tomcat에서 제공하는 JSESSION 을 이용하지만,

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'

다음 의존성을 추가시켜주면 큰 설정없이 HttpSession을 Redis에 저장할 세션으로 변경할 수 있다.

service 코드는 간단하게, hash 형식으로 modifyCart 에서는 hashOps.increment 를 통해 item의 수량이 들어올 때 마다 증가시켜주게 만들고, getCart 에서는 해당 sessionId를 통해 정보를 가져오는 코드이다.

세션클러스팅을 확인하기위해 8080 포트로 띄운 환경에서 상품 정보를 저장하고, 8081 포트로 띄운 환경에서 해당 세션 아이디로 찾는 과정이 정상적으로 이루어지는 것으로 보인다.

또한 Redis를 확인해 봤을 때, 장바구니의 정보와 session에 대한 정보가 남아있는 걸로 보아 2 서버에서 제대로 세션을 통해 정보를 공유하고 있음을 확인할 수 있다.


2. 캐싱

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public RedisCacheManager cacheManager(
            RedisConnectionFactory redisConnectionFactory
    ) {
        RedisCacheConfiguration configuration = RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(120))
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeValuesWith(
                        SerializationPair.fromSerializer(RedisSerializer.java())
                );

        RedisCacheConfiguration individual = RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()
                .entryTtl(Duration.ofSeconds(20))
                .enableTimeToIdle()
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeValuesWith(
                        SerializationPair.fromSerializer(RedisSerializer.json())
                );

        return RedisCacheManager
                .builder(redisConnectionFactory)
                .cacheDefaults(configuration)
                .withCacheConfiguration("storeCache", individual)
                .build();
    }
}

spring 에서 캐시 설정을 하는 CacheConfig 클래스이다.
RedisConnectionFactory 를 통해 기본 설정값을 받아옴을 알 수 있고,
기본 configuration 설정은 null 값 불가, 만료 시간을 120초, 캐시 키의 접두사를 simple 옵션으로 생성하고, 기본 Java 직렬화를 사용한다.

individual 옵션은 "storeCache" 에만 사용하고, TTI 설정을 통해 마지막 접근 시간에 기반해서 다시 만료 시간을 업데이트 시키는 옵션을 사용했다. 또한 JSON 형식으로 직렬화를 설정한다.

@Slf4j
@Service
public class StoreService {
    private final StoreRepository storeRepository;
    public StoreService(StoreRepository storeRepository) {
        this.storeRepository = storeRepository;
    }

    // 새로 만든 상점은 다음 검색에서 등장할 수 있게끔 기존의 전체 조회 캐시 삭제
    // 인지도가 높지 않을 가능성이 있으므로 생성시 추가 캐시 생성은 하지 않는다.
    @CacheEvict(cacheNames = "storeAllCache", allEntries = true)
    public StoreDto create(StoreDto dto) {
        return StoreDto.fromEntity(storeRepository.save(Store.builder()
                .name(dto.getName())
                .category(dto.getCategory())
                .build()));
    }

    // 조회된 상점은 일단 캐시에 둔다.
    // TTL은 줄이면서 TTI를 설정함으로서,
    // 자주 조회되지 않는 상점들은 캐시에서 빠르게 제거하고
    // 자주 조회되는 상점은 캐시에 유지하도록 설정하였다.
    @Cacheable(cacheNames = "storeCache", key = "args[0]")
    public StoreDto readOne(Long id) {
        return storeRepository.findById(id)
                .map(StoreDto::fromEntity)
                .orElseThrow(() ->
                        new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    // 전체 조회는 일반적인 서비스에서 가장 많이 활용된다.
    @Cacheable(cacheNames = "storeAllCache", key = "methodName")
    public List<StoreDto> readAll() {
        return storeRepository.findAll()
                .stream()
                .map(StoreDto::fromEntity)
                .toList();
    }

    // 상점이 갱신될 경우 해당 내용을 갱신해 주어야 한다.
    // 단일 캐시는 갱신, 전체 캐시는 제거한다.
    @CachePut(cacheNames = "storeCache", key = "#result.id")
    @CacheEvict(cacheNames = "storeAllCache", allEntries = true)
    public StoreDto update(Long id, StoreDto dto) {
        Store store = storeRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        store.setName(dto.getName());
        store.setCategory(dto.getCategory());
        return StoreDto.fromEntity(storeRepository.save(store));
    }

    // 삭제될 경우 단일 캐시, 전체 캐시 전부 초기화.
    @Caching(evict = {
            @CacheEvict(cacheNames = "storeCache", key = "args[0]"),
            @CacheEvict(cacheNames = "storeAllCache", allEntries = true)
    })
    public void delete(Long id) {
        storeRepository.deleteById(id);
    }
}

@CacheEvict, @Cacheable, @CachePut 각각 어노테이션이 어떤 역할을 하는지 알면 쉽게 이해할 수 있다. 또한 key에서 methodName 으로 실제 메서드 이름으로 설정할 수 있고, #result.id 같은 해당 메서드의 반환값의 필드로 설정할 수도 있고, agrs[0] 처럼 메서드의 입력값 중 1번째 인자로 키 이름을 설정할 수도 있다.

또한 @CacheEvict 을 여러개 사용해야 하는 상황이라면 @Caching 을 통해 선언할 수 있다.

간단하기에 실제 실행 결과는 제외한다.


3. 리더보드 기능과 Write-Back 패턴 구현

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, ItemDto> rankTemplate(
            RedisConnectionFactory redisConnectionFactory
    ) {
        RedisTemplate<String, ItemDto> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(RedisSerializer.string());
        template.setValueSerializer(RedisSerializer.json());
        return template;
    }

    @Bean
    public RedisTemplate<String, ItemOrderDto> orderTemplate(
            RedisConnectionFactory redisConnectionFactory
    ) {
        RedisTemplate<String, ItemOrderDto> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(RedisSerializer.string());
        template.setValueSerializer(RedisSerializer.java());
        return template;
    }
}

Redis 설정부터
리더보드 관련 설정하는 rankTemplate은 ItemDto 라는 값을 value로, json 형식으로 직렬화한다.
orderTemplate은 ItemOrderDto를 value로, java로 직렬화한다.

캐시 설정은 위의 default 설정과 같다.

@Slf4j
@Service
public class ItemService {
    private final ItemRepository itemRepository;
    private final OrderRepository orderRepository;
    private final ZSetOperations<String, ItemDto> rankOps;
    private final RedisTemplate<String, ItemOrderDto> orderTemplate;
    private final ListOperations<String, ItemOrderDto> orderOps;

    public ItemService(
            ItemRepository itemRepository,
            OrderRepository orderRepository,
            RedisTemplate<String, ItemDto> rankTemplate,
            RedisTemplate<String, ItemOrderDto> orderTemplate
    ) {
        this.itemRepository = itemRepository;
        this.orderRepository = orderRepository;
        this.rankOps = rankTemplate.opsForZSet();
        this.orderTemplate = orderTemplate;
        this.orderOps = this.orderTemplate.opsForList();
    }
    
    @Cacheable(
            cacheNames = "itemSearchCache",
            key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
    )
    public Page<ItemDto> searchByName(String query, Pageable pageable) {
        return itemRepository.findAllByNameContains(query, pageable)
                .map(ItemDto::fromEntity);
    }

    public void purchase(ItemOrderDto dto) {
        Item item = itemRepository.findById(dto.getItemId())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        /*ItemOrderDto.fromEntity(orderRepository.save(ItemOrder.builder()
                .item(item)
                .count(1)
                .build()));*/
        orderOps.rightPush("orderCache::behind", dto);
        rankOps.incrementScore(
                "soldRanks",
                ItemDto.fromEntity(item),
                1
        );
    }

    @Transactional
    @Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
    public void insertOrders() {
        boolean exists = Optional.ofNullable(orderTemplate.hasKey("orderCache::behind"))
                .orElse(false);
        if (!exists) {
            log.info("no orders in cache");
            return;
        }
        // 적재된 주문을 처리하기 위해 별도로 이름을 변경하기 위해
        orderTemplate.rename("orderCache::behind", "orderCache::now");
        log.info("saving {} orders to db", orderOps.size("orderCache::now"));
        orderRepository.saveAll(orderOps.range("orderCache::now", 0, -1).stream()
                .map(dto -> ItemOrder.builder()
                        .itemId(dto.getItemId())
                        .count(dto.getCount())
                        .build())
                .toList());
        orderTemplate.delete("orderCache::now");
    }

    public List<ItemDto> getMostSold() {
        Set<ItemDto> ranks = rankOps.reverseRange("soldRanks", 0, 9);
        if (ranks == null) return Collections.emptyList();
        return ranks.stream().toList();
    }
}

전체 코드를 중심으로 각각의 동작을 간단하게 설명하도록 하겠다.

    @Cacheable(
            cacheNames = "itemSearchCache",
            key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
    )
    public Page<ItemDto> searchByName(String query, Pageable pageable) {
        return itemRepository.findAllByNameContains(query, pageable)
                .map(ItemDto::fromEntity);
    }

검색 기능으로 Page 형식을 통해 반환하고 있다.
Pageable 객체에 크게 pageNumber와 pageSize가 존재하기에 해당 정보 또한 캐시 이름에 추가해준다.
page, size 에 따라 나누어 주지 않는다면, 당연하게도 이상한 값이 반환될 수 있다.

public void purchase(ItemOrderDto dto) {
        Item item = itemRepository.findById(dto.getItemId())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        /*ItemOrderDto.fromEntity(orderRepository.save(ItemOrder.builder()
                .item(item)
                .count(1)
                .build()));*/
        orderOps.rightPush("orderCache::behind", dto);
        rankOps.incrementScore(
                "soldRanks",
                ItemDto.fromEntity(item),
                1
        );
    }

Write-Back 패턴을 위해 기존 DB로의 저장을 삭제하고, redis에 rightPush를 통해 적재하는 과정을 볼 수 있다.
또한, 리더보드 기능을 위해 incrementScore 메서드를 사용하는 모습도 확인 가능하다.

캐싱 전략에 대해 궁금하다면 이전에 작성한 글을 참조하길 바란다.
https://velog.io/@kimgunwooo/%EC%84%B1%EB%8A%A5-%EA%B0%9C%EC%84%A0-%EA%B3%BC%EC%A0%95-feat.-Redis-MongoDB

    @Transactional
    @Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
    public void insertOrders() {
        boolean exists = Optional.ofNullable(orderTemplate.hasKey("orderCache::behind"))
                .orElse(false);
        if (!exists) {
            log.info("no orders in cache");
            return;
        }
        // 적재된 주문을 처리하기 위해 별도로 이름을 변경하기 위해
        orderTemplate.rename("orderCache::behind", "orderCache::now");
        log.info("saving {} orders to db", orderOps.size("orderCache::now"));
        orderRepository.saveAll(orderOps.range("orderCache::now", 0, -1).stream()
                .map(dto -> ItemOrder.builder()
                        .itemId(dto.getItemId())
                        .count(dto.getCount())
                        .build())
                .toList());
        orderTemplate.delete("orderCache::now");
    }

    public List<ItemDto> getMostSold() {
        Set<ItemDto> ranks = rankOps.reverseRange("soldRanks", 0, 9);
        if (ranks == null) return Collections.emptyList();
        return ranks.stream().toList();
    }

마지막으로, 스케쥴러를 통해서 일정 시점(여긴 20초)마다 Redis에 있는 해당 데이터가 존재한다면, key 이름을 변경해주고, 모든 데이터를 Redis -> DB 로 벌크성으로 저장하는 작업을 진행하게 된다.

여기서 이름을 변경해주는 이유는 저장하는 순간에도 데이터가 계속 쌓일 수 있기 때문에 구분지어주기 위함이다. Redis 특성상 해당하는 key가 존재하지 않으면 새로 생성해서 적재하기에 쉽게 구분지어 줄 수 있다.

마지막은 간단하게 리더보드를 출력하는 과정이다.
reverseRange를 통해 판매량이 가장 높은 것 부터 10개를 뽑아서 List 형식으로 반환한다.


마무리

간단하게 Redis를 복습하는 시간을 가졌다.
세션 기반 로그인은 구현해 봤지만, Redis를 활용해 클러스터링을 구현해본 적은 없었는데, 되게 간단했다.
실제 구현 시에도 조금의 처리만 추가하면 쉽게 적용할 수 있을 것 같다.

사실 캐싱은 프로젝트가 다 끝나고, 실제 사용자의 사용 패턴을 파악하면서 적용하는 부분이라 현재는 적용 하는 방법과, 고려할 부분 정도만 파악해보았다.

사실 Redis 는 pub/sub 을 통해 채팅방처럼 사용할 수도 있고, hyperloglog 를 사용해 대규모 처리도 가능하다.
현재는 사용해본 적 없지만, 추후에 pub/sub 기능이 필요하거나, 조회수 로직에 적용할 수 있을 듯 한데 그때, 다른 툴과 비교해보면서 적용을 고려해봐야겠다.

사용처를 넓게 알고있다면, 관련 기능을 구현함에 있어서 시야가 넓어지는 것 같다.

profile
공부 정리용

0개의 댓글