Redis Keyspace Notification 적용기

구동현·2024년 5월 4일

저희 팀 I's Protocol에서는 동네 경매서비스, 땅땅땅을 운영중입니다.
땅땅땅은 유저가 경매의 기한을 정해두고, 기한이 만료되었을 경우, 자동으로 경매의 상태가 경매중에서 경매 완료로 바뀌어야 했습니다.

기술적 의사결정 과정

자동으로 경매의 상태를 바꾸는 기능을 구현하기 위해 3가지 후보가 있었습니다.

  1. 자바 스케줄러
  2. DynamoDB
  3. Redis KeyspaceNotification

자바 스케줄러

자바 스케줄러는 일정 주기마다 경매글들을 조회해서 경매의 만료기한이 지났을 경우, 경매의 상태를 경매중에서 경매완료로 바꾸게 할 수 있습니다.
하지만 크게는 2가지 단점이 있습니다.

  • 경매의 실시간성을 반영할 수 없다.
  • 주기적인 요청으로 리소스를 잡아먹는다.

그렇기 때문에 저희는 스케줄러를 사용하지 않았습니다.

DynamoDB

스케줄러 대신 저희는 TTL이 있는 DB를 사용해서 경매의 아이디를 집어넣고, 만료가 되었을 경우 CDC (changed data capture)을 통해 감지하고자 했습니다.

Redis나 DynamoDB가 이런 기능을 지원하고 있었고,
저희 팀은 캐싱이나 분산락 때문에 이미 레디스를 사용하고 있기 때문에 추가적인 인프라 구축이 필요없는 레디스를 사용하고자 했습니다.

게다가, 아무래도 Redis는 인메모리 DB이기 때문에 실시간성이 중요한 경매 서비스에서 더욱 강점이 되었습니다.


기술구현과정

Redis keyspace Notification만 구현된 깃허브 보러가기

땅땅땅 깃허브 보러가기

세팅 - Config, Listener

  • redis를 사용하기 위한 기본적인 세팅
  • config를 등록하고
  • 의존성을 추가한다음,
  • RedisKeyExpiredListener를 구현합니다.

application.properties

spring.data.redis.host = localhost
spring.data.redis.port = 6379

로컬에서는 docker hub에서 pull받은 local Redis를 사용중이고, 배포 버전에서는 elasticache를 사용중입니다.

build.gradle

dependencies {
		...
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
}

RedisConfig - redisTemplate을 bean으로 등록

@EnableCaching
@Configuration
public class RedisConfig {

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

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

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); // String, String 이유 설명
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
        RedisConnectionFactory redisConnectionFactory
    ) {
        RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
        redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory);
        return redisMessageListenerContainer;
    }

}

RedisKeyExpiredListener

  • 위에서 등록한 RedisMessageListenerContainer Bean을 통해 expired 만료시 event로 받을 수 있습니다.
@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {

    /**
     * Creates new {@link MessageListener} for {@code __keyEvent@*__:expired} messages.
     *
     * @param listenerContainer must not be {@literal null}.
     */
    public RedisKeyExpiredListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    /**
     * @param message redis key
     * @param pattern __keyEvent@*__:expired
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        System.out.println("########## message : " + message);
    }
}

트러블 슈팅

1차

@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {

	private final AuctionService auctionService;

    /**
     * Creates new {@link MessageListener} for {@code __keyEvent@*__:expired} messages.
     *
     * @param listenerContainer must not be {@literal null}.
     */
    public RedisKeyExpiredListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    /**
     * @param message redis key
     * @param pattern __keyEvent@*__:expired
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        auctionService.updateStatusToHold(message.toString());
    }
}
  • AuctionService의 의존성을 추가하여, OnMessage 가 실행될때마다, getNotification메소드를 실행시켰습니다.
  • 하지만, RedisKeyExpiredListener가 AuctionService에 의존해도 되는걸까? 의문이 생깁니다.
  • AuctionService와 RedisKeyExpiredListener가 강한 결합도를 가지기엔 keyExpiredListener을 유지 보수할때 문제가 되고, 조금 더 범용적으로 사용할때도 문제가 된다고 판단이 됩니다.
  • 이런 의문을 가지고 auctionService를 필드로 가지는 방식이 아닌, ApplicationEventPublisher를 통해 의존성을 줄이고자 했습니다.

2차

@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {

    private final ApplicationEventPublisher publisher;

    /**
     * Creates new {@link MessageListener} for {@code __keyEvent@*__:expired} messages.
     *
     * @param listenerContainer must not be {@literal null}.
     */
    public RedisKeyExpiredListener(
        RedisMessageListenerContainer listenerContainer,
        ApplicationEventPublisher publisher
    ) {
        super(listenerContainer);
        this.publisher = publisher;
    }

    /**
     * @param message redis key
     * @param pattern __keyEvent@*__:expired
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        System.out.println("########## 시간이 만료된 키 : " + message);
        publisher.publishEvent(new AuctionExpiredEvent(message.toString()));
    }
}

레디스에 저장된 옥션의 키가 만료될 경우에는, AuctionExpiredEvent에 auction키가 매개변수로 생성되고, 이벤트를 발행합니다.

@Component
@RequiredArgsConstructor
public class AuctionEventHandler {

    private final AuctionService auctionService;

    @EventListener
    public void AuctionKeyExpiredEvent(AuctionKeyExpiredEvent auctionKeyExpiredEvent){
        auctionService.updateStatusToHold(auctionKeyExpiredEvent.getAuctionId());
    }

}

이벤트에서 이를 listen하고 auctionService의 메소드를 실행합니다.

 	@Transactional
    public void updateStatusToHold(String message) {
        if (message.startsWith("auctionId:")) {
        
            // 흐름 순서
            // message = "auctionId: 1", string
            // message.split(" ") = {"auctionId:", "1"}, Array<String>
            // message.split(" ")[1] = "1", string
            // Long.parseLong(message.split(" ")[1]) = 1L, Long
            
            Long auctionId = Long.parseLong(message.split(":")[1]);
            log.info("경매 기한 만료, " + message);
            Auction auction = findAuctionOrElseThrow(auctionId);
            auction.updateStatusToHold();
        } else {
            throw new RuntimeException("redis 에러");
        }
    }
  • AuctionService의 update메소드에서 메세지의 유효성 검사를 한 후, 유효할 경우 auction의 상태를 바꿉니다.
  • 또 의문이 드는데, 저희는 지금 redis를 keyspaceNotification만이 아닌 캐싱서비스와 분산락등 많은 기능을 위해 사용중입니다.
  • 만약 캐싱의 시간이 만료되는 경우에도 auctionService layer까지 가서 유효성을 검증하는 것이 맞을까요?

3차

@Component
public class RedisKeyExpiredListener extends KeyExpirationEventMessageListener {

    private final ApplicationEventPublisher applicationEventPublisher;

    public RedisKeyExpiredListener(
        RedisMessageListenerContainer listenerContainer,
        ApplicationEventPublisher applicationEventPublisher
    ) {
        super(listenerContainer);
        this.applicationEventPublisher = applicationEventPublisher;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String messageToStr = message.toString();

        if (messageToStr.startsWith("auctionId:")) {
            Long auctionId = Long.parseLong(messageToStr.split(":")[1]);
            applicationEventPublisher.publishEvent(new AuctionKeyExpiredEvent(auctionId));
        }

    }
}
  • 유효성 검증을 listener단계에서 진행합니다.

레퍼런스

(spring boot) Redis Key Event Notification 처리 방법

[Spring Boot + Redis] 스프링 부트 Redis 사용해보기

[Spring] 스프링 이벤트를 사용하여 도메인 의존성 분리하기

profile
개발합시다

0개의 댓글