스케줄링 기반 로직에서 이벤트 기반 로직으로 리팩토링
티켓팅 연습 서비스를 구현하며 내가 사용해보고 싶은 기술을 연습하고 다양한 비즈니스 로직을 처리하고 있다.
https://kitaees.tistory.com/96
Docker + GitHub Actions로 CI/CD와 배포하기 (Minikube 테스트와 EC2 프리티어 한계)
새로운 사람들과 프로젝트를 시작하며 가장 귀찮고 어렵지만 중요한 서버 배포를 맡았다. 잘못하면 과금이 나가는 AWS와 실질적인 코드를 치는 작업은 거의 없지만 스트레스는 많이 받는 서버
kitaees.tistory.com
https://kitaees.tistory.com/97
실 유저와 가상 유저의 티켓팅 경쟁을 위한 대기열 구현기 (Redis)
티켓팅을 위한 트래픽 발생 (서론)요즘 새롭게 하고 있는 프로젝트인 TiCatch의 메인 아이디어는 '티켓팅 연습'이다. 많은 사람들이 한꺼번에 몰리는 티켓팅을 연습시켜주는 서비스라고 생각하면
kitaees.tistory.com
이번에는 기존 코드의 스케줄링 기반 로직에서 이벤트 기반 로직으로 리팩토링을 한 구현기를 기록해보도록 하겠다.
정해진 티켓팅 시간(TicketingTime)에 티켓팅을 시작하고 종료 시간(TicketingTime+30분)에 티켓팅을 종료시키는 로직이 있다. 티켓팅을 시작할 때는 티켓팅 상태를 WAITING -> IN_PROGRESS로 바꾸고 비동기적으로 좌석 선점이 시작된다. 티켓팅을 종료할 때는 티켓팅 상태를 IN_PROGRESS -> COMPLETED로 바꾼다. 기존 로직에서는 이러한 시간 관련 로직을 스케줄링을 통해 처리했다. 자세히 말하면 스프링에서 제공하는 스케줄링을 1초마다 발생시키며 TicketingTime과 TicketingTime+30분을 현재 시간과 비교해 타겟 대상인지 확인 후 위의 로직들을 수행한다.
@Transactional
@Scheduled(fixedRate = TICKETING_SCHEDULER_PERIOD)
public void activateTicketing() {
List<Ticketing> activateTicketings = ticketingRepository.findAllByTicketingStatusAndTicketingTimeBefore(TicketingStatus.WAITING, LocalDateTime.now());
List<Ticketing> expiredTicketings = ticketingRepository.findAllByTicketingStatusAndTicketingTimeBefore(TicketingStatus.IN_PROGRESS, LocalDateTime.now().minusMinutes(30));
for(Ticketing ticketing : activateTicketings) {
ticketing.changeTicketingStatus(TicketingStatus.IN_PROGRESS);
dynamicScheduler.startTicketingScheduler(ticketing.getTicketingId(),ticketing.getTicketingLevel());
}
for(Ticketing ticketing : expiredTicketings) {
ticketing.changeTicketingStatus(TicketingStatus.COMPLETED);
}
}
위의 코드대로 로직을 수행했을 때 비효율적인 측면이 상당히 많았다. 1) 1초마다 로그가 찍혀 테스트가 불편했으며 2) 비효율적인 DB 탐색으로 성능이 낭비되며 3) 1초마다 수행하는 스케줄러이기에 정확한 TicketingTime과 약간의 간극이 발생한다.
1초마다 스케줄링 로그가 찍히는 서버 화면인데 이 때문에 여간 불편한게 아니었다. 예정되어있는 기능 구현을 마치고 반드시 이 부분을 리팩토링해야겠다고 다짐했다. 나에게 주어진 스프린트를 마무리하고 드디어 해당 부분을 리팩토링하게 되었다. 이러한 불편함을 없애려면 스케줄링 기반 로직에서 이벤트 기반 로직으로 바꿔야한다고 생각했다. 생성된 티켓팅을 Redis에 넣어두고 만료시간(Expire TTL)을 TicketingTime으로 생성해두면 해당 키가 만료될 때 리스너로 읽어와 로직을 수행해줄 수 있을 것 같았다.
Redis Key Expiry Event
우선 Redis에서 만료 이벤트가 발생했을 때 리스너에서 감지할 수 있도록 Redis 및 Spring의 RedisConfig를 수정해주었다.
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "xE"
notify-keyspace-events 명령어를 입력했을 때 위의 결과처럼 나와야한다. 그래야 Redis의 만료 이벤트를 감지할 수 있으며 만약에 아무것도 안나온다면
127.0.0.1:6379> CONFIG SET notify-keyspace-events "xE"
이렇게 세팅해주도록 하자.
그 다음 Spring의 RedisConfig에 만료 이벤트를 감지할 수 있도록 싱글톤 bean으로 추가해주었다.
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
RedisExpirationListener redisExpirationListener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(new MessageListenerAdapter(redisExpirationListener),
new PatternTopic("__keyevent@0__:expired")); // 0번 DB 기준
return container;
}
이렇게 설정해주면 Redis의 TTL로 특정 이벤트가 만료되었을 때 스프링의 리스너에서 읽을 수 있다.
TicketingService
이제는 기존의 TicketingService의 로직을 바꿔주도록하자. 일단 불편한 스케줄러 코드를 지우고 티켓팅이 생성될 때 Redis에 시작 티켓팅 1개, 종료 티켓팅 1개를 넣어주도록 하였다.
private void addExpiryToControlQueue(Long ticketingId, LocalDateTime ticketingTime) {
long nowMillis = Instant.now().toEpochMilli();
long startTime = ticketingTime.toEpochSecond(ZoneOffset.of("+09:00")) * 1000;
long endTime = ticketingTime.plusMinutes(30).toEpochSecond(ZoneOffset.of("+09:00")) * 1000;
long startExpireTime = (startTime - nowMillis) / 1000;
long endExpireTime = (endTime - nowMillis) / 1000;
redisService.addExpiryToControlQueue(ticketingId, startExpireTime, TicketingStatus.IN_PROGRESS);
redisService.addExpiryToControlQueue(ticketingId, endExpireTime, TicketingStatus.COMPLETED);
}
현재 시간과 비교하여 시작 시간, 시작 시간 +30분의 2개의 데이터를 Redis에 넣어줬다.
public void addExpiryToControlQueue(Long ticketId, long ttl, TicketingStatus ticketingStatus) {
redisTemplate.opsForValue().set(ticketingStatus + ":" + ticketId, TIME_TO_LIVE_PREFIX, ttl, TimeUnit.SECONDS);
}
RedisService에서는 이렇게 받은 티켓팅 아이디와 상태 그리고 TTL을 Redis에 저장시킨다.
redis-cli에 접속해 넣어준 키 값을 확인해봤을 때 위의 사진 처럼 2개의 티켓팅이 TTL을 가지고 있는 것을 확인할 수 있다. 첫번 째 사진은 IN_PROGRESS 즉 티켓팅이 TTL 36초 뒤에 시작하는 것이고 두번 째 사진은 1643초 뒤에 COMPLETED 종료 되는 것이다.
RedisExpirationListener
이제 이러한 만료 이벤트가 발생할 때 스프링에서 감지할 수 있도록 Listener를 만들어주도록 하겠다. 이는 Spring Data Redis에서 제공하는 MessageListener 인터페이스를 구현하면 된다. onMessage 메소드를 오버라이딩하면 message의 Redis에서 만료된 키 값을 감지할 수 있게 된다.
package TiCatch.backend.global.service.redis;
import TiCatch.backend.domain.ticketing.entity.Ticketing;
import TiCatch.backend.domain.ticketing.entity.TicketingStatus;
import TiCatch.backend.domain.ticketing.repository.TicketingRepository;
import TiCatch.backend.global.config.DynamicScheduler;
import TiCatch.backend.global.exception.NotExistTicketException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RedisExpirationListener implements MessageListener {
private final DynamicScheduler dynamicScheduler;
private final TicketingRepository ticketingRepository;
@Override
@Transactional
public void onMessage(Message message, byte[] pattern) {
String expiredMessageKey = message.toString();
TicketingStatus ticketingStatus = TicketingStatus.valueOf(expiredMessageKey.split(":")[0]);
Long expiredTicketingId = Long.valueOf(expiredMessageKey.split(":")[1]);
Ticketing ticketing = ticketingRepository.findById(expiredTicketingId).orElseThrow(NotExistTicketException::new);
if(ticketingStatus.equals(TicketingStatus.IN_PROGRESS)) {
ticketing.changeTicketingStatus(TicketingStatus.IN_PROGRESS);
dynamicScheduler.startTicketingScheduler(ticketing.getTicketingId(),ticketing.getTicketingLevel());
} else {
ticketing.changeTicketingStatus(TicketingStatus.COMPLETED);
}
}
}
나는 시작용/종료용을 구분하기 위해 Redis 키 값에 티켓팅 아이디와 상태를 :로 구분 저장했다. TicketingId를 바탕으로 티켓팅 엔티티를 조회하고 앞에서 봤던 스케줄러 메소드에서 사용했던 로직을 수행해줬다.
임시로 좌석 선점 로깅을 찍어놓은 거지만 앞서봤던 스케줄링 로그 없이 Redis에서 만료된 티켓팅(ID=22)이 시작되는 사진이다. 해당 이벤트 기반 로직을 계기고 1) 1초마다 로그가 찍혀 테스트가 불편했으며 2) 비효율적인 DB 탐색으로 성능이 낭비되며 3) 1초마다 수행하는 스케줄러이기에 정확한 TicketingTime과 약간의 간극이 발생한다 의 3가지 불편함을 해결할 수 있었다.