@EventListener 활용하기

pitseleh·2025년 2월 25일
post-thumbnail

PointHistory에 데이터가 삽입될 경우 TierHistory 테이블에 해당 user의 point 변화에 따른 tier 변화를 기록해야 하는 상황이 발생했다. 단순하게 생각하면 아래와 같이 구현할 수 있다.

  1. PointHistoryService에서 기존의 savePointHistory() 메서드 수정
  2. 새로운 PointHistory를 저장한 뒤, User의 totalPoint를 업데이트
  3. 현재 tier를 확인하고 새로운 tier 계산 → 변화가 있을 경우 TierHistory 생성

하지만 PointHistoryService에 Tier와 관련된 로직이 포함되는 것은 좋은 방식이 아니라고 생각했다.

이 경우 @EventListener를 활용하면 PointHistory가 저장됨에 따라 TierHistory를 자동으로 기록할 수 있어서 비즈니스 로직을 더 깔끔하게 분리할 수 있지 않을까? 관련 내용은 이 글을 참고했다.

✅ PointHistory를 처리한 후 TierHistory를 기록해야 함
✅ TierHistory와 PointHistory는 서로에게 독립적이어야 함

위의 상황에 따라 @EventListener를 사용하기로 결정했다. 이제 차근차근 구현해보자.


@Getter
@RequiredArgsConstructor
public class PointHistorySavedEvent {
	private final PointHistory 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));
		}
	}
}
  • PointHistory를 저장한 후 이벤트를 발생시킴
  • ApplicationEventPublisher를 사용해서 이벤트를 트리거하면 @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를 기록

구현하고 보니 이벤트 리스너가 여러 개일 경우 특정 리스너가 어떠한 원리로 실행되는지 궁금해졌다.

🔍 Spring 이벤트 시스템의 동작 방식

  1. 이벤트 발행
    • 특정 이벤트 클래스(PointHistorySavedEvent)가 Spring의 ApplicationEventPublisher를 통해 발행됨
  1. Spring의 이벤트 브로드캐스트 (Event Broadcasting)
    • Spring은 모든 @EventListener 또는 @TransactionalEventListener 메서드를 스캔하여, PointHistorySavedEvent를 감지할 수 있는 리스너를 실행
  2. 이벤트 타입을 기반으로 리스너 실행
    • 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가 발행되면 두 개의 리스너가 모두 실행됨
  • Spring은 비동기가 아닌 경우 기본적으로 순차적으로 실행함

🔍 특정 리스너를 우선 실행하거나 순서를 지정하고 싶다면?

(1) @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에서 알림 전송

(2) 특정 리스너만 비동기 실행 (@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) 옵션을 사용했다.

0개의 댓글