
저희 팀 I's Protocol에서는 동네 경매서비스, 땅땅땅을 운영중입니다.
땅땅땅은 유저가 경매의 기한을 정해두고, 기한이 만료되었을 경우, 자동으로 경매의 상태가 경매중에서 경매 완료로 바뀌어야 했습니다.
자동으로 경매의 상태를 바꾸는 기능을 구현하기 위해 3가지 후보가 있었습니다.
자바 스케줄러는 일정 주기마다 경매글들을 조회해서 경매의 만료기한이 지났을 경우, 경매의 상태를 경매중에서 경매완료로 바꾸게 할 수 있습니다.
하지만 크게는 2가지 단점이 있습니다.
그렇기 때문에 저희는 스케줄러를 사용하지 않았습니다.
스케줄러 대신 저희는 TTL이 있는 DB를 사용해서 경매의 아이디를 집어넣고, 만료가 되었을 경우 CDC (changed data capture)을 통해 감지하고자 했습니다.
Redis나 DynamoDB가 이런 기능을 지원하고 있었고,
저희 팀은 캐싱이나 분산락 때문에 이미 레디스를 사용하고 있기 때문에 추가적인 인프라 구축이 필요없는 레디스를 사용하고자 했습니다.
게다가, 아무래도 Redis는 인메모리 DB이기 때문에 실시간성이 중요한 경매 서비스에서 더욱 강점이 되었습니다.
Redis keyspace Notification만 구현된 깃허브 보러가기
spring.data.redis.host = localhost
spring.data.redis.port = 6379
로컬에서는 docker hub에서 pull받은 local Redis를 사용중이고, 배포 버전에서는 elasticache를 사용중입니다.
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
}
@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;
}
}
@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);
}
}
@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());
}
}
@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 에러");
}
}
@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));
}
}
}
레퍼런스
(spring boot) Redis Key Event Notification 처리 방법