BidderOwn을 진행하면서 기존에 Spring batch를 이용하여 상품들을 일괄적으로 경매종료처리하던 기능을 redis로 가볍고 개별처리하는 방식으로 리팩토링하였다.
특정 시간대에 경매 등록되어 있던 상품들을 경매 종료처리하는 로직을 스프링 배치를 사용하여서 구현하였다.
간단하게 스케줄러를 사용하여서 처리가능하지만 이후 확장하기 편하고 학습차원에서 스프링 배치를 사용하기로 결정하였다.
@Bean
public Job bidEndJob() throws Exception {
return jobBuilderFactory.get("bidEndJob")
.incrementer(new RunIdIncrementer())
.start(bidEndStep())
.next(bidEndNotificationStep())
.listener(new BidEndJobListener())
.build();
}
문제는 서비스적으로 봤을 때 특정 시간대에 생성된 상품을 일괄처리를 해야한다.
예를 들어 배치를 10분마다 돌아간다고 가정할 때, 13시 00분에 생성된 상품과 13시 10분에 등록된 상품이 함께 처리되는 방식이었다.
redis를 사용하면 상품의 경매 종료 처리를 개별적으로 처리할 수 있을 것 같아서 리팩토링을 진행하였다.
방법은 아래와 같다.
- 상품 등록 시 EntityListener의 @PostPersist를 통해 생성 이벤트를 감지한다.
- Redis에 item id로 이루어진 key와 만료시간을 설정하여 넣는다.
- Redis의 만료 이벤트를 리스닝하여서 이후 처리를 한다.(경매 종료 상태 변경, 알림)
ItemEntityListener
@NoArgsConstructor
public class ItemEntityListener {
@PostPersist
public void postPersist(Item item) {
ItemRedisService itemRedisService = BeanUtils.getBean(ItemRedisService.class);
itemRedisService.createWithExpire(item, genExpireDay(item));
}
}
먼저 ItemEntityListener
클래스를 생성해준다. Item 엔티티가 생성된 후에만 처리하면 되기 때문에 이후에 처리할 메서드에 @PostPersist 어노테이션을 붙여주면 된다.
EntityListener 어노테이션
@PostLoad - 엔티티를 새로 불러오거나 refresh 한 이후@PrePersist - 해당 엔티티를 저장하기 이전
@PostPersist - 해당 엔티티를 저장한 이후@PreUpdate - 해당 엔티티를 업데이트 하기 이전
@PostUpdate - 해당 엔티티를 업데이트 한 이후@PreRemove - 해당 엔티티를 삭제하기 이전
@PostRemove - 해당 엔티티를 삭제한 이후
일단 로직은 밑에서 알아보자
Item 엔티티
@Entity
@EntityListeners(value = ItemEntityListener.class)
public class Item extends BaseEntity {
...생략
Item 엔티티에 @EntityListeners
어노테이션을 통해서 위에서 생성한 리스너 클래스를 등록만 해주면 된다.
@PostPersist
public void postPersist(Item item) {
ItemRedisService itemRedisService = BeanUtils.getBean(ItemRedisService.class);
itemRedisService.createWithExpire(item, genExpireDay(item));
}
다음으로 Item 데이터를 Redis에 넣는 과정이다.
기본적으로 리스너는 스프링 빈에 등록되지 않기 때문에 applicationContext
에서 ItemRedisService 빈을 가져와서 처리하였다. (bidderown.server.base.util.BeanUtils.java)
ItemRedisService의 createWithExpire()
메서드의 핵심 코드는 아래와 같다.
hashCountOperations.putAll(biddingItemInfoKey + itemId, itemCountResponseMap);
redisTemplate.expire(biddingItemInfoKey + itemId, day, TimeUnit.DAYS);
RedisTemplate를 이용하여서 상품을 redis에 넣어주고 expire 메서드를 통해서 기간을 설정해주었다.
Redis에 들어갈 데이터 형태
현재 게시글에는 포함되지 않은 기능이지만 쿼리 개선을 위해서 redis에 입찰수, 댓글수, 좋아요수를 미리 계산해 놓는다."bidding-item:10" : { "bidCount": 0, "commentCount": 0, "heartCount": 0 }
RedisConfig
@Configuration
@EnableRedisRepositories( // 추가해야됨
enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP
)
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
...생략
종료 이벤트를 리스닝하기 위해서는 @EnableRedisRepositores
어노테이션에 enableKeyspaceEvents
속성을 추가해주어야 한다.
기본적으로 EnableKeyspaceEvents.OFF
으로 설정되어 있기 때문에 서버가 실행될 때 초기화해주는 ON_STARTUP
속성으로 설정해주어야 한다.
다른 방식으로는 redis.conf 에 notify-keyspace-events "Ex" 추가함으로써 이벤트를 받을 수 있다. 프로젝트에선 팀원 모두가 환경이 일치하지 않아서 이 방식을 선택하였다.
ExpirationListener
import org.springframework.data.redis.connection.MessageListener;
...
@Component
public class ExpirationListener implements MessageListener {
@Value("${custom.redis.item.bidding.info-key}")
private String itemQueueKey;
private final ApplicationEventPublisher publisher;
@Override
public void onMessage(Message message, byte[] bytes) {
String key = new String(message.getBody());
if (key.contains(itemQueueKey)) {
publisher.publishEvent(BidEndEvent.of(resolveKeyToItemId(key)));
}
}
}
Redis에서 발행되는 메시지를 리스닝하는 MessageListener를 구현해야한다.
onMessage() 메서드는 이벤트가 발생된 후에 처리할 코드를 작성할 수 있다.
간단하게 만료 처리가 되면 ApplicationEventPublisher
를 통해서 경매가 종료된 후에 처리하는 서비스로 넘기게 된다.
코드보다는 구현 방식을 적은 글이기 때문에 자세한 코드는 상단에 git repository에서 확인 가능하다.
SpringBatch로 구현하였던 것은 기술적으로나 서비스적으로나 적절하지 않았던 것 같다. 기존에 1시간 단위로 만료를 처리했던 아이템을 redis를 사용하여서 각 상품을 개별적으로 처리할 수 있었기 때문에 서비스적으로 완성도가 올라갈 수 있었다. 또한 이벤트 기반으로 처리하게 되니 서비스 간에 의존성이 많이 줄어들 수 있었다.
Lettuce에서 Redisson으로 바꾸면서 key 만료 이벤트 리스닝 코드가 변경되었다.
private void saveItemWithExpire(Long itemId, int day) {
RBucket<Object> bucket = redissonClient.getBucket(biddingItemExpireKey + itemId);
bucket.set("", day, TimeUnit.DAYS);
bucket.addListener((ExpiredObjectListener) name -> {
publisher.publishEvent(BidEndEvent.of(itemId));
log.info("item expired item id: {}", itemId);
});
}
정말 유익한 글이었습니다.