[AvAb] Redis로 조회수 Write-Back 전략 도입하기

엄기훈·2024년 2월 16일
0

AvAb

목록 보기
3/4
post-thumbnail

⚙️ RedisTemplate 설정하기

이전 게시글에서 RedisRepository를 사용하기로 결정 했었습니다.
하지만 구현 도중 RedisRepository를 이용하면 동시성 문제가 발생해 Redis에 누적 조회수가 정확히 반영되지 않았습니다.
결국 원자 단위의 명령을 내리기 위해 RedisTemplate를 도입하였습니다.

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public StringRedisTemplate redisTemplate() {
        StringRedisTemplate redisTemplate = new StringRedisTemplate();

        redisTemplate.setConnectionFactory(connectionFactory());

        return redisTemplate;
    }
}

RedisTemplate를 사용하기 위해서는 LettuceConnectionFactoryRedisTemplate을 Bean으로 등록해야 합니다.

LettuceConnectionFactory를 생성할 때 Redis 엔드포인트 주소와 포트번호를 인자로 주어야 합니다.
인자로 주지 않으면 기본으로 host와 port가 각각 localhost, 6379로 설정 되는데, 운영 서버에서는 ElastiCache를 이용해야 하므로 application.yml의 값을 읽어서 인자로 주어야 합니다.
또한, 몇몇 게시글에서 인자로 RedisStandaloneConfiguration을 넘겨주기도 하던데, 생성자 내부적으로 host와 port를 인자로 받아 RedisStandaloneConfiguration 생성해주기 때문에 추가적인 설정이 필요없다면 바로 host와 port를 넘겨주어도 됩니다.

RedisTemplate은 Key-Value의 타입을 지정해줄 줄 수 있는데 주로 String을 사용합니다.
이 때, RedisTemplate<String, String>을 사용해도 좋지만 이를 래핑한 StringRedisTemplate 클래스를 이용해도 됩니다.
이렇게 하면 추가적인 Serializer를 설정해 줄 필요가 없어 간편합니다.

📈 Redis로 조회수 누적하기

Repository Layer

@Repository
@RequiredArgsConstructor
@Slf4j
public class RecreationViewCountRepositoryImpl implements RecreationViewCountRepository {

    private final StringRedisTemplate redisTemplate;

    private final String PREFIX = "recreationViewCount";

    @Override
    public void incrementViewCount(String key) {
        redisTemplate.opsForValue().increment(PREFIX + ":" + key);
    }

    @Override
    public void createViewCount(String key) {
        redisTemplate.opsForValue().set(PREFIX + ":" + key, "0", 30, TimeUnit.MINUTES);
    }
}

Repository 클래스에서는 RedisTemplate을 이용해 Redis에 명령을 내리고 결과를 반환하는 역할을 담당합니다.

incrementViewCount 메소드는 Value의 INCR 명령을 내려 값을 1 증가시킵니다.
createViewCount 메소드는 새 Key를 만들어 TTL을 30분으로 설정해 저장합니다.
이 때, increment는 해당하는 Key가 없을 때 새로운 Key를 만들고 그 값을 0으로 초기화한 다음 1을 증가시킵니다.
하지만 TTL을 설정해 줄 수 없어 새로운 누적 조회수를 만드는 메소드 createViewCount를 작성하였습니다.

Service Layer

@Service
@RequiredArgsConstructor
public class RecreationViewCountServiceImpl implements RecreationViewCountService {

    private final RecreationViewCountRepository recreationViewCountRepository;

    @Override
    public void incrementViewCount(Long id) {
        String recreationId = id.toString();

        if (recreationViewCountRepository.getViewCount(recreationId) == null) {
            recreationViewCountRepository.createViewCount(recreationId);
        }

        recreationViewCountRepository.incrementViewCount(recreationId);
    }
}

서비스 레이어는 비즈니스 로직을 수행하며, 이전 계층과 Repository로 부터 받은 값의 타입을 변환합니다.

서비스의 incrementViewCount 메소드는 레크레이션 아이디 Key가 존재하지 않으면 새로 생성하고 그 값을 1 증가시킵니다.
물론 그 값이 있다면 바로 1을 증가시킵니다.

⏲️ Spring Scheduler를 이용해 조회수 반영하기

이제, 게시글을 읽을 때마다 Redis로 조회수가 누적되어 저장됩니다.
이번에는 Redis에 저장된 누적 조회수를 일정 시간마다 DB에 반영해야 합니다.
다행히도 Spring에는 일정 시간마다 로직을 수행해주는 Scheduler를 설정할 수 있습니다.

  1. Redis에서 모든 Key를 불러온다.
  2. 해당 키를 이용해 DB에 해당 레크레이션이 존재하는지 확인한다.
    (조회수가 반영이 되었지만 레크레이션이 삭제될 수도 있음)
  3. 존재하는 레크레이션만 조회수를 DB에 반영한다.
  4. Scheduler는 30분 마다 실행되며, Redis에 저장된 누적 조회수도 TTL이 30분이기 때문에 DB에 한 번만 반영되는 것이 보장된다.

Repository Layer

    @Override
    public String getViewCount(String key) {
        return redisTemplate.opsForValue().get(PREFIX + ":" + key);
    }

    @Override
    public List<String> getAllRecreationIds() {
        ScanOptions scanOptions =
                ScanOptions.scanOptions().match(PREFIX + ":" + "*").count(100).build();
        Cursor<String> cursor = redisTemplate.scan(scanOptions);

        List<String> keys = new ArrayList<>();
        while (cursor.hasNext()) {
            keys.add(cursor.next());
        }

        return keys.stream().map(key -> key.split(":")[1]).toList();
    }

getAllRecreationIds는 Redis의 SCAN을 이용해 모든 키를 불러옵니다.

KEYS를 사용하지 않는 이유
Redis는 싱글 스레드이기 때문에 KEYS 명령이 종료될 때까지 그 이외의 명령은 수행하지 못합니다.
SCANcount 마다 분산 호출하여 Blocking 되는 시간을 줄일 수 있습니다.

Scheduler

    @Scheduled(cron = "0 */30 * * * *")
    public void updateFlowViewCount() {
        log.info("플로우 조회수 업데이트 시작");

        List<Long> flowIdList = flowViewCountService.getAllFlowIds();
        List<Long> targetFlowIdList = flowService.getUpdateTargetFlowIds(flowIdList);
        log.info(
                "업데이트 대상 플로우: {}",
                targetFlowIdList.stream().map(Object::toString).collect(Collectors.joining(", ")));

        targetFlowIdList.forEach(
                id -> {
                    Long viewCount = flowViewCountService.getViewCount(id);
                    flowService.updateFlowViewCount(id, viewCount);
                });

        log.info("플로우 조회수 업데이트 완료");
    }

매 정각과 30분마다 조회수가 업데이트 되도록 스케쥴러를 구성합니다.

Spring Batch?

조회수 같은 경우 게시물이 많아지면 그만큼 조회수를 업데이트 해야 하는 게시물도 많아지기 때문에 Spring Batch 도입을 고려하였습니다.
실제로, 레크레이션 조회수는 배치를 이용하여 구현하였습니다.
하지만 Spring Boot 3가 되면서 애플리케이션 하나에 Batch를 하나만 구동할 수 있어 사이드 프로젝트와 같은 애자일 환경에서는 구현하기 어려워 Scheduler만 이용해 구현하였습니다.
물론 아직 저의 미숙함으로 인해 Batch에 대한 이해도가 부족하고, 이해도가 부족한 마당에 Spring Batch 5에 대한 레퍼런스도 많이 없어 우선 Batch 사용은 더 공부를 한 후 이용해 볼 계획입니다.

📊 성능 측정하기

게시글이 조회될 때마다 DB에 반영하는 것과 Redis를 이용한 것의 성능을 JMeter를 이용해 비교해보았습니다.

실제 배포 서버에 해본 것이 아닌 로컬 환경에서 테스트 하였지만 Redis 이용시 약 20% 정도의 성능 향상을 이루어 낸 것을 확인할 수 있었습니다!

profile
한 번 더 고민해보는 개발자

0개의 댓글