해당 글은 개인적인 경험과 학습을 바탕으로 작성된 내용으로, 일부 내용이 다소 부족하거나 잘못된 부분이 있을 수 있습니다. 잘못된 점의 피드백을 주시면 배우고 성장하겠습니다!
경매 플랫폼 프로젝트 개발을 진행하는 과정에서, 경매 방식에 대한 요구사항이 변경되었습니다. 이를 해결하기 위해 Redis의 Key Event Notification 기능을 활용하게 되었고, 이번 글에서는 이 경험을 정리하고 회고해보려 합니다.
(redis keyspace notification 사용법만 보시려면 트러블 슈팅을 뛰어넘어 가셔도 됩니다!)
트러블 슈팅
변경된 요구 사항
요구사항 변경 전에는 경매가 비공개 입찰 방식으로 진행되었습니다. 입찰자들은 경매 진행 시간 동안 비공개로 입찰을 제출하며, 경매 마감 시점에 가장 높은 입찰가를 제시한 사용자가 낙찰자로 선정되는 구조였습니다. 그러나 요구사항이 변경되면서 시스템은 실시간 공개 입찰 방식으로 전환되었습니다. 이 방식에서는 경매 시작 시간과 최대 마감 시간이 사전에 정해져 있으며, 첫 입찰이 발생하면 경매 마감 시간이 5분 후로 갱신됩니다. 이후 새로운 입찰이 발생할 때마다 마감 시간이 다시 5분 연장되지만, 갱신 시간은 기존에 설정된 최대 마감 시간을 초과하지 않습니다. 또한 입찰가는 실시간으로 공개되며, 새로운 입찰자는 현재 최고 입찰가보다 높은 금액으로만 입찰할 수 있습니다. 마지막 입찰 후 5분 동안 추가 입찰이 없을 경우 자동으로 낙찰자가 선정되도록 시스템이 변경되었습니다.
요약
- 입찰 방식 변경
- 기존 비공개 입찰 → 실시간 공개 입찰 방식으로 전환
- 입찰가는 실시간으로 공개되며, 새로운 입찰자는 현재 최고 입찰가보다 높은 금액으로만 입찰 가능
- 입찰 마감 시간 갱신
- 첫 입찰 발생 시, 경매 마감 시간이 해당 입찰 시점으로부터 5분 후로 갱신
- 이후 새로운 입찰 발생 시마다 5분 연장
- 단, 마감 시간은 최대 설정된 마감 시간을 초과하지 않음
- 자동 낙찰자 선정
- 마지막 입찰 이후 5분 동안 추가 입찰이 없을 경우, 자동으로 낙찰자 선정
문제 상황
기존 비공개 입찰 방식에서는 정해진 마감 시간에 스케줄러를 통해 MySQL의 입찰 기록을 확인하고 낙찰자를 선정하는 로직을 사용했습니다. 하지만 실시간 공개 입찰 방식으로 변경되면서, 입찰 시점에 따라 마감 시간이 동적으로 변경되어 기존 방식으로는 두 가지 주요 문제가 발생했습니다.
1. 지속적인 스케줄링의 비효율성
상품별 마지막 입찰 시점에서 5분이 지나면 자동 낙찰자를 선정하는 기능을 구현하기 위해 스케줄러로 일정시간마다 입찰건을 조회하여 확인하는 로직이 필요했습니다. 그러나 이 방식은 다음과 같은 문제를 야기했습니다:
- 과도한 DB 부하: 경매가 다수 동시에 진행될 경우, 지속적으로 데이터를 조회하는 스케줄링 방식은 MySQL 서버에 부하를 발생시켰습니다.
- 비효율성: 입찰이 없는 상태에서도 데이터베이스를 조회해야 하므로, 불필요한 자원 소모가 발생했습니다.
2. 타이밍 정확성 문제
입찰 시점에 따라 마감 시간이 변동되므로, 정확한 낙찰 시점을 스케줄러만으로 처리하기 어려웠습니다.
- 실시간성 부족: 스케줄러는 주기적인 실행으로 인해 입찰과 낙찰 사이에 시간 차이가 발생할 수 있어, 실시간 요구사항에 부합하지 않았습니다.
요약
- 지속적인 스케줄링으로 인한 비효율성: MySQL 부하 증가 및 자원 낭비
- 정확한 타이밍 처리의 어려움: 실시간 입찰 반영 및 낙찰자 선정 과정에서의 오류 가능성
이러한 문제를 해결하기 위해 새로운 기술적 접근이 필요했습니다.
해결
결론부터 이야기하자면, Redis의 Keyspace Notification 기능을 활용해 입찰 데이터의 실시간 처리와 자동 낙찰 로직을 구현했습니다.
입찰 마감 조건은 마지막 입찰 후 5분이 경과하면 낙찰 처리가 이루어지는 것이었는데, Redis의 TTL(Time to Live) 기능을 활용할 수 있다는 점에서 아이디어를 얻었습니다. "Redis의 key가 만료되었을 때 이를 감지할 방법은 없을까?"라는 고민 끝에 Redis Keyspace Notification이라는 기술을 알게 되었고, 이를 통해 효율적으로 문제를 해결할 수 있었습니다. 입찰 과정에서 Redis에 최근 입찰 정보를 저장하고, 해당 key가 만료되면 이벤트를 발생시켜 낙찰 처리를 수행하도록 구현했습니다.
Redis keyspace notification
Redis Keyspace Notification은 Redis 데이터셋에 영향을 미치는 이벤트를 실시간으로 감지하고, 이를 Pub/Sub 형태로 클라이언트에 전달하는 기능입니다. 이를 통해 특정 key의 생성, 갱신, 삭제, 만료 등의 이벤트를 실시간으로 수신할 수 있습니다.
하지만 기본적으로 이 기능은 비활성화되어 있기 때문에, Redis 설정 파일 또는 명령어를 통해 활성화해야 합니다.
Keyspace Notification 이벤트 유형
Keyspace Notification에는 크게 두 가지 유형이 있습니다:
- Key-space notification
- 특정 key의 이름을 기준으로 모든 이벤트를 수신합니다.
- 채널 접두사:
__keyspace
- 메시지 형식:
__keyspace@<db번호>__:key이름 이벤트명
- 예:
__keyspace@0__:product_key del
- 예:
- Key-event notification
- 특정 이벤트 유형을 기준으로 모든 key의 변화를 수신합니다.
- 채널 접두사:
__keyevent
- 메시지 형식:
__keyevent@<db번호>__:이벤트명 key이름
- 예:
__keyevent@0__:expired product_key
- 예:
기본적으로는 keyspace notification은 비활성화되어 있기 때문에, 관심있는 이벤트의 유형을 활성화하여 사용할 수 있습니다. 저는 만료 이벤트를 수신하기 위하여 key-event notification의 만료이벤트를 활성화 했습니다. 아래에서 활용한 방법을 살펴보겠습니다.
참고: keyspace notification은 cpu 일부 전력을 사용하기 때문에 기본적으로 비활성화 되어있습니다.
전체 로직
아래는 로직을 도식화한 이미지입니다.
위의 이미지 로직에서 redis 사용한 부분을 위주로 코드를 설명해보겠습니다.
Redis key 만료 이벤트 활성화 및 채널 구독 및 처리(1, 7, 8 해당)
Redis Keyspace Notification 이벤트를 사용하려면 해당 기능을 활성화해야 합니다. 활성화는 Redis 설정 파일(redis.conf
)에서 notify-keyspace-events
옵션을 설정하거나, CONFIG SET
명령어를 통해 실행 시 동적으로 적용할 수 있습니다.
참고: Redis 설정 활성화
redis.conf
에서 설정
설정 파일에 다음 옵션을 추가하거나 수정합니다.notify-keyspace-events Ex
CONFIG SET
명령어 사용
Redis CLI에서 동적으로 설정을 적용할 수 있습니다.
CONFIG SET notify-keyspace-events Ex
E
: Key-event notification 활성화x
: Expired 이벤트 활성화
Spring Boot에서는 Redis Key 만료 이벤트를 감지하고 처리할 수 있도록 KeyExpirationEventMessageListener
클래스를 제공합니다. 이 클래스를 구현해 Spring Bean으로 등록하면, Redis의 키 만료 이벤트를 구독하여 특정 키가 만료되었을 때 관련 로직을 실행할 수 있습니다.
Redis의 키 만료 이벤트를 받을 수 있는 이유는 KeyExpirationEventMessageListener가 KeyspaceEventMessageListener를 상속하고 있기 때문입니다. KeyspaceEventMessageListener를 살펴보면, Redis 설정에서 notify-keyspace-events 옵션에 Ex 값을 설정하여 이벤트를 활성화하도록 구성되어 있습니다.
아래는 KeyExpirationEventMessageListener를 상속하는 클래스입니다.
import com.goodsending.bid.repository.ProductBidPriceMaxRepository;
import com.goodsending.global.redis.handler.RedisMessageHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {
private final ApplicationContext applicationContext;
public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer,
ApplicationContext applicationContext) {
super(listenerContainer);
this.applicationContext = applicationContext;
}
/**
* 만료된 키에 대한 처리를 수행합니다.
*
* @param message redis key
* @param pattern __keyevent@*__:expired
*/
@Override
public void onMessage(Message message, byte[] pattern) {
String key = message.toString();
log.info("Expired key: {}", key);
RedisMessageHandler handler = null;
if(key.startsWith(ProductBidPriceMaxRepository.KEY_PREFIX)){
handler = this.applicationContext.getBean(
"bidPriceMaxKeyExpirationHandler", RedisMessageHandler.class);
}
if(handler != null){
handler.handle(key);
}
}
}
구현 설명
KeyExpirationEventMessageListener
를 상속한 리스너 구현- Redis의 키 만료 이벤트를 감지하고 처리할 수 있도록
KeyExpirationEventMessageListener
를 상속합니다. - Spring Boot에서 이 클래스를 빈으로 등록하면, Redis의
__keyevent@<db_number>__:expired
채널을 구독합니다.
- Redis의 키 만료 이벤트를 감지하고 처리할 수 있도록
onMessage
메서드- Redis의 만료 이벤트가 발생할 때 실행되는 콜백 메서드입니다.
- 이벤트 메시지에서 만료된 키를 추출하고, 해당 키에 대한 비즈니스 로직을 실행합니다.
- Redis Key 패턴 매칭 및 핸들러 호출
- 키가 특정 패턴(
ProductBidPriceMaxRepository.KEY_PREFIX
)과 일치하면, 해당 핸들러를 가져와 만료 이벤트 처리 로직을 실행합니다.
- 키가 특정 패턴(
코드의 동작 방식
- Redis에서 설정된 TTL이 만료되면, 키 만료 이벤트가 발생합니다.
- Redis는 만료된 키와 관련된 메시지를
__keyevent@<db_number>__:expired
채널에 발행합니다. RedisKeyExpirationListener
클래스의onMessage
메서드가 호출되어 해당 키를 처리합니다.
Redis 연결 및 이벤트 처리에 필요한 Spring Boot 설정은 아래와 같습니다.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.goodsending.product.dto.response.ProductRankingDto;
import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.password}")
private String password;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(password);
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
return lettuceConnectionFactory;
}
@Bean(name = "redisMessageTaskExecutor")
public Executor redisMessageTaskExecutor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(2);
threadPoolTaskExecutor.setMaxPoolSize(4);
return threadPoolTaskExecutor;
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.setTaskExecutor(redisMessageTaskExecutor());
return container;
}
// 생략
}
- RedisConnectionFactory: Redis와 연결을 설정하는 팩토리입니다.
RedisMessageListenerContainer
와RedisTemplate
모두 이 팩토리를 사용합니다.
- RedisMessageListenerContainer: Redis와 연결되어 이벤트를 리스너로 전달하는 역할을 합니다.
KeyExpirationEventMessageListener
가 Redis 이벤트(예: 키 만료 이벤트)를 수신하려면RedisMessageListenerContainer
가 필요합니다.
- Executor 설정: Redis 이벤트가 수신될 때 사용할 스레드 풀을 설정합니다.
RedisMessageListenerContainer
에서 사용됩니다.
입찰 시, redis에 최근 내역 저장(2~4 해당)
입찰 관리 로직에서는 최근 입찰 데이터를 Redis에 저장하고, TTL을 설정하여 만료 시 자동으로 삭제되도록 설계했습니다.
Redis 데이터를 효율적으로 관리하기 위해 추상 클래스를 만들어 공통 로직을 구현했습니다.
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
@RequiredArgsConstructor
public abstract class RedisRepository<K, V> {
private final String PREFIX;
private final RedisTemplate<String, V> redisTemplate;
public void setValue(K key, V value, Duration expiration) {
redisTemplate.opsForValue().set(PREFIX + key, value, expiration);
}
// 생략
}
입찰 데이터를 Redis에 저장하고 TTL을 설정하는 저장소를 구현했습니다.
import com.goodsending.global.redis.RedisRepository;
import java.time.Duration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
@Repository
public class ProductBidPriceMaxRepository extends RedisRepository<Long, Integer> {
public final static String KEY_PREFIX = "PRODUCT_BID_PRICE_MAX:";
private static final int BID_EXTENSION_MINUTES = 5;
public ProductBidPriceMaxRepository(RedisTemplate<String, Integer> redisTemplate) {
super(KEY_PREFIX, redisTemplate);
}
// 생략
public void setValueWithDuration(Long key, Integer value) {
super.setValue(key, value, Duration.ofMinutes(BID_EXTENSION_MINUTES));
}
}
입찰 API에서는 RedisTemplate
을 활용해 Redis에 데이터를 저장합니다. 저장 시 TTL을 5분으로 설정하여 데이터가 자동으로 만료되도록 구현했습니다.
이렇게 이번 글에서는 Redis의 Keyspace Notification 기능과 Spring Boot를 활용하여 키 만료 이벤트를 처리하는 방법을 살펴보았습니다.
CPU 측정
스케줄러를 통한 낙찰(Redis Key 이벤트 알람 도입 전)
Redis Key 알람 도입 후
측정 결과
측정 결과를 csv로 뽑아 평균을 측정을 하니 아래와 같은 결과가 나왔습니다.
- 기존 CPU 사용량 평균: 15.06%
- 새로운 CPU 사용량 평균: 7.35%
- CPU 사용량 감소율: 51.22%
회고
- 낙찰 로직의 성능은 개선했지만, 입찰 부분에서도 Redis를 더 효율적으로 활용하면 성능을 더욱 개선할 수 있을 것이라는 생각이 들었습니다. 이 부분은 다음 글에서 리팩토링하며 다뤄볼 예정입니다!
- redis pub/sub은 'fire and forget'이어서 redis 서버는 이벤트를 pub한 이후로는 관심이 없기 때문에, 추가 방안이 필요할 것 같습니다.
- 여기에서는 자세히 다루지는 않았지만 입찰 시, 유저가 충전한 캐시와 포인트를 사용하는 로직이 포함되어 있습니다. 해당 부분에서 동시성 이슈가 발생할 가능성을 고려하여 비관적 락을 적용했습니다. 또한, 입찰 금액이 이전 최고 입찰가보다 커야 한다는 유효성 검사 로직은 Redis를 활용해 구현했습니다. 그러나, 이 로직에서도 동시성 문제가 발생할 여지가 있어, 추가적인 개선이 필요하다고 판단됩니다.
KeyExpirationEventMessageListener
를 구현한 클래스의onMessage
메서드에서 최근 입찰 금액을 저장한 키에 대한 이벤트 처리 로직을 분리하기 위해 고민했습니다. 이를 해결하기 위해 하나의 메서드를 가진RedisMessageHandler
라는 인터페이스를 정의하고, 이를 구현한 핸들러들을 빈으로 등록했습니다. 이벤트가 발생한 키의 패턴에 따라 적합한 핸들러 빈을 가져와 다형성을 활용해 처리를 수행했습니다.위 코드에서"bidPriceMaxKeyExpirationHandler"
와 같은 문자열이 오타가 있더라도 컴파일 단계에서 오류를 잡아내지 못한다는 점이 아쉬웠습니다. 이런 문제를 방지하기 위해@TransactionalEventListener
를 활용하고,ApplicationEventPublisher
의publishEvent
메서드를 통해 이벤트를 호출하는 방법이 더 나은 선택일 수 있다고 생각했습니다. 이 접근 방식은 컴파일 단계에서 오타를 발견할 수 있어 코드 안정성을 높이는 데 도움이 될 것입니다.- 또한, 실제로
KeyExpirationEventMessageListener
의 필드에ApplicationEventPublisher publisher;
가 선언되어 있는 것을 보며, Redis 이벤트를 처리하는 구조를 더 간결하게 만들라는 힌트를 제공한 것이 아닐까 하는 생각이 들었습니다. 앞으로 이러한 점을 개선하며 코드를 더욱 효율적으로 작성해보고자 합니다.
- 또한, 실제로
this.applicationContext.getBean("bidPriceMaxKeyExpirationHandler", RedisMessageHandler.class);
참고:
https://redis.io/docs/latest/develop/use/keyspace-notifications/
'database > redis' 카테고리의 다른 글
[Spring][Redis] Redis & 캐싱 (0) | 2024.09.09 |
---|---|
[Redis][Spring][Exception] RedisConnectionException (0) | 2024.04.16 |
Redis 사용하기 (Windows) (1) | 2024.02.11 |