PointHistory에 데이터가 삽입될 경우 TierHistory 테이블에 해당 user의 point 변화에 따른 tier 변화를 기록해야 하는 상황이 발생했다. 단순하게 생각하면 아래와 같이 구현할 수 있다.
- PointHistoryService에서 기존의 savePointHistory() 메서드 수정
- 새로운 PointHistory를 저장한 뒤, User의 totalPoint를 업데이트
- 현재 tier를 확인하고 새로운 tier 계산 → 변화가 있을 경우 TierHistory 생성
하지만 PointHistoryService에 Tier와 관련된 로직이 포함되는 것은 좋은 방식이 아니라고 생각했다.
이 경우 @EventListener를 활용하면 PointHistory가 저장됨에 따라 TierHistory를 자동으로 기록할 수 있어서 비즈니스 로직을 더 깔끔하게 분리할 수 있지 않을까? 관련 내용은 이 글을 참고했다.
✅ PointHistory를 처리한 후 TierHistory를 기록해야 함
✅ TierHistory와 PointHistory는 서로에게 독립적이어야 함
위의 상황에 따라 @EventListener를 사용하기로 결정했다. 이제 차근차근 구현해보자.
@Getter
@RequiredArgsConstructor
public class PointHistorySavedEvent {
private final PointHistory pointHistory;
}
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class PointHistoryService {
private final ApplicationEventPublisher eventPublisher;
private final PointHistoryRepository pointHistoryRepository;
public void savePointHistory(List<PointHistory> pointHistories) {
// PointHistory를 DB에 저장할 때 Tier 업데이트 이벤트 발생
pointHistoryRepository.saveAll(pointHistories);
for(PointHistory pointHistory : pointHistories) {
eventPublisher.publishEvent(new PointHistorySavedEvent(pointHistory));
}
}
}
@EventListener가 이를 감지@Slf4j
@Component
@RequiredArgsConstructor
public class TierHistoryListener {
private final TierHistoryRepository tierHistoryRepository;
private final TierRepository tierRepository;
@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handlePointHistorySaved(PointHistorySavedEvent event) {
PointHistory pointHistory = event.getPointHistory();
User user = pointHistory.getUser();
// 유저의 현재 랭킹 포인트
Long prevPoint = user.getRankingPoint();
// 유저의 현재 포인트 업데이트
Long newPoint = prevPoint + pointHistory.getPoint();
user.updateRankingPoint(newPoint);
// 티어 변경 여부 확인 : 티어에 변화가 없는 경우 업데이트 불필요
Tier prevTier = user.getTier();
Tier newTier = calculateTier(newPoint);
if (prevTier.getTierName().equals(newTier.getTierName())) {
return;
}
// 티어에 변화가 있는 경우 업데이트
TierHistory tierHistory = TierHistory.create(user, prevPoint, newPoint, newTier);
tierHistoryRepository.save(tierHistory);
user.updateTier(newTier);
log.info("Tier EventListener 발생");
}
public Tier calculateTier(Long newPoint) {
Tier tier = tierRepository.findTierByPoint(newPoint);
return (tier != null) ? tier : tierRepository.getHighestTier();
}
}
@EventListener를 통해 PointHistorySavedEvent가 발생하면 TierHistory를 기록구현하고 보니 이벤트 리스너가 여러 개일 경우 특정 리스너가 어떠한 원리로 실행되는지 궁금해졌다.
ApplicationEventPublisher를 통해 발행됨@EventListener 또는 @TransactionalEventListener 메서드를 스캔하여, PointHistorySavedEvent를 감지할 수 있는 리스너를 실행 Spring은 이벤트의 타입을 기반으로 적절한 리스너를 실행한다. 즉, 각 이벤트 리스너의 메서드 파라미터 타입을 기준으로 실행할 리스너를 결정하는 것이다.
@EventListener
public void handlePointHistorySaved(PointHistorySavedEvent event) {
// event의 타입이 PointHistorySavedEvent인 경우에만 실행
}
PointHistorySavedEvent 타입을 받는 모든 리스너가 실행됨Spring은 동일한 이벤트를 받는 리스너가 여러 개일 경우, 모든 리스너를 실행한다. 예를 들어, PointHistorySavedEvent를 처리하는 리스너가 2개 있다고 해보자.
@Component
public class TierHistoryListener {
@EventListener
public void handlePointHistorySaved(PointHistorySavedEvent event) {
System.out.println("▶ TierHistoryListener에서 PointHistory 처리");
}
}
@Component
public class NotificationListener {
@EventListener
public void notifyUser(PointHistorySavedEvent event) {
System.out.println("▶ NotificationListener에서 알림 전송");
}
}
이벤트 발행 결과
▶ TierHistoryListener에서 PointHistory 처리
▶ NotificationListener에서 알림 전송
PointHistorySavedEvent가 발행되면 두 개의 리스너가 모두 실행됨@Order를 사용해 실행 우선순위 지정Spring은 기본적으로 리스너의 실행 순서를 보장하지 않지만, @Order를 사용하면 우선순위를 설정할 수 있다.
@Component
public class TierHistoryListener {
@EventListener
@Order(1) // 이 리스너가 가장 먼저 실행됨
public void handlePointHistorySaved(PointHistorySavedEvent event) {
System.out.println("▶ TierHistoryListener에서 PointHistory 처리");
}
}
@Component
public class NotificationListener {
@EventListener
@Order(2) // 이 리스너는 두 번째로 실행됨
public void notifyUser(PointHistorySavedEvent event) {
System.out.println("▶ NotificationListener에서 알림 전송");
}
}
실행 순서
▶ TierHistoryListener에서 PointHistory 처리
▶ NotificationListener에서 알림 전송
@Async)기본적으로 Spring 이벤트 리스너는 동기적(Synchronous)으로 실행된다. 만약 특정 리스너만 비동기(Asynchronous) 실행하고 싶다면 @Async를 사용할 수 있다.
@Component
public class NotificationListener {
@Async
@EventListener
public void notifyUser(PointHistorySavedEvent event) {
System.out.println("▶ 비동기 알림 전송 실행");
}
}
@Async가 적용된 리스너는 별도의 스레드에서 실행됨condition 속성 활용)@Component
public class TierHistoryListener {
@EventListener(condition = "#event.pointHistory.point > 1000")
public void handlePointHistorySaved(PointHistorySavedEvent event) {
System.out.println("▶ 1000포인트 이상일 때만 실행");
}
}
pointHistory.point 값이 1000 초과일 때만 실행됨코드가 깔끔하게 분리되어 만족스럽다. 또한 유기적으로 연결되는 로직이지만 롤백이 발생해도 서로에게 영향을 미치지 않도록 하기 위해 @Transactional(propagation = Propagation.REQUIRES_NEW) 옵션을 사용했다.