이벤트 리스너 메서드에서 삭제된 매입 이력이 조회되는 문제 해결

2024. 8. 18. 12:36문제해결

1. 문제 배경

주식 포트폴리오 가상 관리 웹 애플리케이션을 구현하던 중 포트폴리오에 있는 어떤 한 종목에 등록되어 있는 매입 이력을 삭제하는 서비스를 구현하고 있었습니다. 이때 포트폴리오, 포트폴리오 종목, 매입 이력 도메인의 관계는 다음과 같습니다. 하나의 포트폴리오에는 0개 이상의 포트폴리오 종목을 가질 수 있고, 하나의 포트폴리오 종목에는 0개 이상의 매입 이력을 가질수 있습니다.

 

위와 같은 관계를 기반으로 매입 이력을 삭제하는 서비스 구현 자체에는 문제가 없었습니다. 그러나 매입 이력 삭제 서비스에 알람 이벤트를 추가하였을 때 문제가 발생하였습니다. 다음 코드는 매입 이력 삭제 서비스에 대한 코드입니다. 매입 이력을 삭제하기 위해서 어떤 한 매입 이력의 식별번호(purchaseHistoryId)를 가지고 삭제를 수행합니다. 그리고 매입 이력에 소속되어 있는 포트폴리오의 식별번호(portfolioId)와 회원 식별번호(memberId)를 publishPushNotificationEvent 메서드에 인수로 전달하여 알림 이벤트를 발생시킵니다. 알림 이벤트가 발생하면 포트폴리오의 목표수익률 또는 최대손실율(종목의 가격이 떨어져서 포트폴리오의 손실이 특정한 값 이하에 도달하는 수치)이 사용자가 설정한 금액에 도달하면 알림을 발생시켜서 사용자에게 전달합니다. 

 

@Transactional
@Authorized(serviceClass = PurchaseHistoryAuthorizedService.class)
@Secured("ROLE_USER")
public PurchaseHistoryDeleteResponse deletePurchaseHistory(Long portfolioHoldingId,
    @ResourceId Long purchaseHistoryId,
    Long portfolioId, Long memberId) {
    log.info("매입 내역 삭제 서비스 요청 : portfolioHoldingId={}, purchaseHistoryId={}", portfolioHoldingId,
        purchaseHistoryId);
    PurchaseHistory deletePurchaseHistory = findPurchaseHistory(purchaseHistoryId);
    repository.deleteById(purchaseHistoryId);

    purchaseHistoryEventPublisher.publishPushNotificationEvent(portfolioId, memberId);
    return PurchaseHistoryDeleteResponse.from(deletePurchaseHistory, portfolioId, memberId);
}

 

다음 코드는 publishPushNotificationEvent 메서드가 발생하여 리스너 메서드에서 이벤트를 받아서 실행하는 부분입니다. 각 이벤트 리스너 메서드인 notifyTargetGainBy, notifyMaxLoss 메서드는 PUshNotificationEvent 객체를 인수로 받아서 notificationService 객체의 메서드에 portfolioId를 전달하여 알림 서비스를 실행합니다. 알림 서비스는 무조건적으로 알림을 발생시키는 것이 아닌 특정한 조건(특정 수치 만족, 알림 활성화 설정 등)을 만족하면 실행되는 구조입니다.

@Component
@RequiredArgsConstructor
@Slf4j
public class PurchaseHistoryEventListener {

	private final NotificationService notificationService;

	// 매입 이력 이벤트가 발생하면 포트폴리오 목표수익률에 달성하면 푸시 알림
	@Async
	@EventListener
	public void notifyTargetGainBy(PushNotificationEvent event) {
		PurchaseHistoryEventSendableParameter parameter = event.getValue();
		PortfolioNotifyMessagesResponse response = (PortfolioNotifyMessagesResponse)notificationService.notifyTargetGain(
			parameter.getPortfolioId());
		log.info("매입 이력 이벤트로 인한 목표 수익률 달성 알림 결과 : {}", response);
	}

	// 매입 이력 이벤트가 발생하면 포트폴리오 최대손실율에 도달하면 푸시 알림
	@Async
	@EventListener
	public void notifyMaxLoss(PushNotificationEvent event) {
		PurchaseHistoryEventSendableParameter parameter = event.getValue();
		PortfolioNotifyMessagesResponse response = (PortfolioNotifyMessagesResponse)notificationService.notifyMaxLoss(
			parameter.getPortfolioId());
		log.info("매입 이력 이벤트로 인한 최대 손실율 달성 알림 결과 : response={}", response);
	}
}

 

다음 코드를 보면 findByPortfolioIdWithAll 메서드에 portfolioId를 전달하여 포트폴리오, 포트폴리오 종목, 매입 이력 정보를 한꺼번에 전부 가져오는 것을 볼수 있습니다. 실제 문제가 발생하는 부분은 조회한 Portfolio 객체에 있는 매입 이력들을 이용하여 포트폴리오 평가 금액을 계산하는 부분이지만 조회한 포트폴리오 객체를 디버깅하여 문제를 사전에 확인할 수 있습니다.

@Slf4j
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class NotificationService {
	// ...

	/**
	 * 특정 포트폴리오의 목표 수익률 달성 알림 푸시
	 * @param portfolioId 포트폴리오 등록번호
	 * @return 알림 전송 결과
	 */
	@Transactional
	public NotifyMessageResponse notifyTargetGain(Long portfolioId) {
		Portfolio portfolio = portfolioRepository.findByPortfolioIdWithAll(portfolioId).stream()
			.peek(p -> p.applyCurrentPriceAllHoldingsBy(currentPriceRedisRepository))
			.findFirst()
			.orElseThrow(() -> new FineAntsException(PortfolioErrorCode.NOT_FOUND_PORTFOLIO));
		Consumer<Long> sentFunction = sentManager::addTargetGainSendHistory;
		return PortfolioNotifyMessagesResponse.create(
			notifyMessage(List.of(portfolio), targetGainNotificationPolicy, sentFunction)
		);
	}
    // ...
}

 

위 코드에서 조회한 포트폴리오의 매입 이력을 디버깅한 결과는 다음과 같습니다. 테스트 코드에서 실행시 분명히 id=1인 매입 이력을 서비스 메서드에서 제거하였지만 이벤트 리스너 메서드로 인해서 실행된 알림 서비스에서 포트폴리오 조회시 삭제됬을거라 생각했던 매입 이력(id=1)이 조회된 것을 확인하였습니다. 따라서 삭제된 매입 이력이 같이 조회됨으로써 알림을 보낼것이라고 생각했던 테스트 코드는 실패하게 되었습니다.

 

 

2. 원인

위와 같은 문제가 발생한 원인은 PurchaseHistoryService의 deletePurchaseHistory 메서드와 NotificationService의 notifyTargetGainBy 메서드가 같은 트랜잭션 컨텍스트 내에 있기 때문입니다. 두 메서드가 같은 트랜잭션 컨텍스트 내에 있게 된 원인은 두 메서드가 비동기로 작동하지 않고 동기적으로 작동하는 것이 원인입니다. 동기적으로 작동하기 때문에 매입 이력 삭제 서비스에서 매입이력을 삭제한 다음에 매입 이력 삭제 서비스는 이벤트 알람 서비스가 완료되기 전까지는 데이터베이스에 매입 이력 삭제를 반영하지 않고 알람 서비스가 끝날때까지 대기하게 됩니다. 이벤트 알람 서비스 입장에서는 아직 데이터베이스에 매입 이력 삭제가 반영되지 않기 때문에 삭제 이전의 매입 이력들이 조회됩니다. 이 설명을 그림으로 표현하면 다음과 같습니다.

 

본인이 위와 같은 원인을 파악하는데 시간이 걸린 이유는 두 서비스 메서드가 비동기적으로 수행되었다고 생각하였기 때문입니다. 다음 리스너 메서드를 보면 @Async 애노테이션을 적용하였음에도 로그를 보면 main 스레드에서 실행하여 두 서비스 메서드가 동기적으로 수행되는 것을 알수 있습니다.

// 매입 이력 이벤트가 발생하면 포트폴리오 목표수익률에 달성하면 푸시 알림
@Async
@EventListener
public void notifyTargetGainBy(PushNotificationEvent event) {
    PurchaseHistoryEventSendableParameter parameter = event.getValue();
    PortfolioNotifyMessagesResponse response = (PortfolioNotifyMessagesResponse)notificationService.notifyTargetGain(
        parameter.getPortfolioId());
    log.info("매입 이력 이벤트로 인한 목표 수익률 달성 알림 결과 : {}", response);
}

 

다음 로그은 알림 서비스 메서드 실행중 찍힌 로그입니다. 로그 결과를 보면 매입 이력이 2개 조회된 것을 볼수 있습니다. 하지만 더 주목할 것은 해당 스레드가 main 스레드로 동작하는 것을 볼수 있습니다.

 

위 설명을 기반으로 문제가 발생한 원인은 비동기적으로 실행되는 줄 알았던 두 메서드가 실제 수행에서는 동기적으로 수행되어 두 메서드가 같은 트랜잭션 내에서 실행한 것이 원인이었습니다.

 

3. 해결 방법

3.1 @EnableAsync 애노테이션 추가하기

위와 같은 문제를 해결하기 위해서 @EnableAsync 애노테이션을 스프링 설정에 추가하는 것입니다. 이 애노테이션을 통하여 @Async 애노테이션을 적용한 메서드는 비동기적으로 수행되도록 합니다. 따라서 다음과 같이 스프링 설정에 애노테이션을 추가합니다.

@EnableAsync
@Configuration
public class SpringConfig {
}

 

위와 같이 @EnableAsync 애노테이션을 추가하게 되면 @Async 애노테이션이 적용된 메서드는 비동기적으로 수행될 것입니다. 

 

3.2 @EventListener를 @TransactionalEventListener로 변경하기

이벤트 리스너 메서드에서 @EventListener 애노테이션을 @TransactionalEventListener로 변경하는 이유는 이벤트 리스너 메서드가 비동기적으로 수행되면서 커밋이 반영되기 전에 삭제된 매입 이력을 조회할 수 도 있기 때문입니다. 이와 같은 문제를 해결하기 위해서 @TransactionalEventListener 애노테이션으로 변경한다면 이 애노테이션을 적용한 이벤트 리스너 메서드가 커밋후에 실행됩니다. @TransactionalEventLister의 phase 옵션의 기본값은 AFTER_COMMIT으로써 별도의 옵션 설정없이 추가하게 되면 해당 이벤트 리스너 메서드는 비즈니스 서비스 메서드의 커밋후에 실행됩니다. 따라서 3.1과 3.2의 해결방법 둘을 적용한 결과는 다음과 같습니다. 다음 코드 외에 별도의 @EnableAsync 애노테이션을 설정 클래스에 추가하여야 합니다.

@Async
@TransactionalEventListener
public CompletableFuture<PortfolioNotifyMessagesResponse> notifyTargetGainBy(PushNotificationEvent event) {
    PurchaseHistoryEventSendableParameter parameter = event.getValue();
    return CompletableFuture.supplyAsync(() ->
        (PortfolioNotifyMessagesResponse)notificationService.notifyTargetGain(parameter.getPortfolioId()));
}

 

위와 같이 설정하면 커밋 후에 notifyTargetGainBy 메서드가 실행되어 삭제된 매입 이력이 조회되지 않습니다.

 

References

github : https://github.com/fine-ants/FineAnts-was/pull/438/files#