개발

@TransactionalEventListener 파헤치기

모달조아 2023. 3. 2. 18:07

@TransactionalEventListener 란?

개요

이미지 엔티티를 DB 에 저장/삭제하는 것과 S3 에 업로드/삭제하는 것의 생명주기를 같게 하고 싶었습니다. 쉽게 말하자면, DB 저장/삭제가 성공한다면 S3 업로드/삭제도 함께 성공하고, 실패한다면 함께 실패해야하는 것입니다. 이런 경우 보통 트랜잭션을 이용하는 것을 쉽게 떠올릴 수 있지만 S3 는 트랜잭션과는 연관이 없으므로 어떻게 해결해야할까 찾아보다가 @TransactionalEventListener 를 공부하고 사용해보게 되었습니다. 

참고로 @TransactionalEventListener 와 비슷한 @EventListener 의 경우 event 를 pulish 하는 코드 시점에 event 가 발행됩니다. event 를 발행하는 트랜잭션과 실행되는 이벤트 중 어떤 것은 성공하고 어떤 것은 실패할 수 있으므로 생명주기가 같음을 보장할 수 없습니다. 그렇기에 저의 상황에서는 @EventListener 는 사용하기에 적절치 않습니다.

 

옵션

@TrasactionEventListener 는 event 의 발생을 트랜잭션의 상태를 기준으로 삼습니다. 그 상태를 지정해주는 여러 옵션이 있는데 아래와 같습니다.

  • AFTER_COMMIT (default) - 트랜잭션이 성공적으로 commit 되었을 때 이벤트 실행
  • AFTER_ROLLBACK – 트랜잭션이 rollback 되었을 때 이벤트 실행
  • AFTER_COMPLETION – 트랜잭션이 마무리 되었을 때(commit or rollback) 이벤트 실행
  • BEFORE_COMMIT - 트랜잭션의 커밋 전에 이벤트 실행

 

이미지를 DB 에 저장 실패한다면 S3 에 이미지가 업로드되면 안됩니다. 그리고 또, S3 에 업로드가 실패한다면 DB 에도 저장되면 안되겠죠. 이를 @TrasactionEventListener 을 이용하여 구현해봅시다.

 

이미지 DB / S3 생명주기 맞추기 

@Transactional
public void createAndUploadImage(MultipartFile multipartFile, String subPath, Long referenceId, ImageType imageType) {
	if (multipartFile.isEmpty()) {
		throw new BusinessException(ErrorCode.BAD_REQUEST);
	}
	String savedUrl = imageUploadService.upload(multipartFile, subPath);
	applicationEventPublisher.publishEvent(new UploadRollbackEvent(subPath, getImageFilename(savedUrl)));
	imageRepository.save(new Image(referenceId, imageType, savedUrl));
}

 

이벤트를 발행합니다. 이벤트는 트랜잭션이 롤백되었을 경우 S3 에 업로드된 파일을 삭제하는 이벤트입니다. 그렇기에 이벤트를 발행하는 로직이 DB 저장 로직보다는 먼저 와야합니다. 그렇지 않으면, DB 로직에서 에러가 발생했을 시에 발행된 이벤트가 없어 S3 에서 이미지를 삭제할 수 없습니다.

이벤트 코드는 아래와 같습니다.

@Getter
@RequiredArgsConstructor
public class UploadRollbackEvent {

	private final String subPath;
	private final String savedFilename;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void rollbackUploadImage(UploadRollbackEvent event) {
	imageUploadService.delete(event.getSubPath(), event.getSavedFilename());
}

 

트랜잭션이 롤백되면 발행한 UploadRollbackEvent 에 해당하는 로직이 실행됩니다.

전체 과정을 요약해보겠습니다,

S3 에 업로드, DB 에 저장, 혹은 이 메서드를 호출한 상위 클래스의 어딘가 과정에서 예외가 발생하면 트랜잭션이 롤백될 것입니다. 하지만, 트랜잭션이 롤백되어도 S3 에 올라간 이미지는 롤백되지 않습니다. 그러니, 트랜잭션이 롤백될 시 작동하는 S3 이미지 삭제 이벤트를 발행해놓고, TransactionalEventListener 를 통해 트랜잭션이 롤백되었을 시 이벤트 로직을 실행합니다. 이미 S3 에 올라간 이미지를 삭제하여 생명주기를 맞춰주는 것입니다.

 

삭제의 경우는 반대로 해주면 됩니다.

삭제의 경우는 완벽히 삭제가 되었을 시에 S3 에서 이미지를 삭제해야합니다. 그러므로 트랜잭션이 커밋 완료 후에 S3 이미지를 삭제하는 이벤트를 발행해줍니다.

@Transactional
public void deleteImages(Long referenceId, ImageType imageType, String subPath) {
	List<Image> images = imageRepository.findAllByReferenceIdAndImageType(referenceId, imageType);
	imageRepository.deleteAllByReferenceIdAndImageType(referenceId, imageType);

	List<String> imageUrls = images.stream()
			.map(Image::getImageUrl)
			.toList();

	imageUrls
			.forEach(
					imageUrl -> applicationEventPublisher.publishEvent(new DeleteEvent(subPath, getImageFilename(imageUrl)))
			);
}
@Getter
@RequiredArgsConstructor
public class DeleteEvent {

	private final String subPath;
	private final String savedFilename;

}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void deleteImage(DeleteEvent event) {
	imageUploadService.delete(event.getSubPath(), event.getSavedFilename());
}

 

사용 예시 2

다른 사용 예를 살펴보도록 합시다.

사용자가 본인의 계정을 업체 계정으로 바꾸기 위해 신청을 했다고 가정합니다. 이때, 관리자가 이 업체 신청 내역을 보고 승인을 해주면, `업체 신청` 엔티티의 상태가 승인됨으로 바뀌고, `업체` 엔티티에 새로운 데이터가 추가되는 로직이라고 가정합시다.

@Transactional
public void changeEnrollmentStatus(Long enrollmentId, EnrollmentStatus status) {
	applicationEventPublisher.publishEvent(new EnrollmentStatusEvent(enrollmentId, status));
	enrollmentService.changeEnrollmentStatus(enrollmentId, status);
}

바꾸고자 하는 업체 신청 엔티티의 상태를 바꿔주고 이벤트를 발행하는 코드입니다.

@Getter
@RequiredArgsConstructor
public class EnrollmentStatusEvent {

	private final Long enrollmentId;
	private final EnrollmentStatus requestStatus;

	public boolean isApproved() {
		return this.requestStatus == EnrollmentStatus.APPROVED;
	}
}

이벤트는 업체 신청 엔티티의 상태를 바꿔주기 위해 필요한 정보들을 가지고 있습니다.

@Component
@RequiredArgsConstructor
public class EnrollmentEventListener {

	private final MarketService marketService;

	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void changeEnrollmentStatus(EnrollmentStatusEvent event) {
		if (event.isApproved()) {
			marketService.enrollMarket(event.getEnrollmentId());
		}
	}
}

핵심은 TransactionalEventListener 입니다. 이벤트가 발행된 트랜잭션이 커밋되고 난 이후에 marketService 를 통해 업체 를 DB 에 넣는 것을 볼 수 있습니다.

// MarketService

@Transactional
public Long enrollMarket(Long enrollmentId) {

	MarketEnrollment enrollment = getMarketEnrollment(enrollmentId);
	Member member = enrollment.getMember();

	if (member.isMarket()) {
		throw new BusinessException(FORBIDDEN);
	}
	member.changeAuthority(MARKET);

	Market market = Market.builder()
			.phoneNumber(enrollment.getPhoneNumber())
			.marketAddress(enrollment.getMarketAddress())
			.openTime(enrollment.getOpenTime())
			.endTime(enrollment.getEndTime())
			.description(enrollment.getDescription())
			.build();
	market.setMarketEnrollment(enrollment);
	market.setMember(member);
	Market savedMarket = marketRepository.save(market);

	return savedMarket.getId();
}

marketService 에서는 앞서 예시에서 말했듯 회원 권한을 업체로 바꿔주고, 업체를 테이블에 추가해줍니다.

정리해보자면 사용하는 예시는 정말 간단합니다.

발생시키고 싶은 이벤트를 미리 정의해두고, 사용코드에서 이벤트를 발행합니다.

그리고 TransactionalEventListener 를 통해 사용코드의 트랜잭션 어느 시점에 이벤트를 동작하게 할 것인지, 그리고 어떻게 동작하게 할 것인지를 구현해주면 됩니다.

이 예시는 굳이 TransactionalEventListener 를 이용하지 않아도 충분히 구현할 수 있지만, 아래에서 나올 주의점을 위해서 추가해보았습니다. 또 굳이 장점을 찾자면, TransactionalEventListener 를 이용함으로 Service 에 대한 결합을 낮추는 예시가 되겠습니다.


@TransactionalEventListener 사용 시 주의할 점

하지만 위 코드는 제대로 동작하지 않습니다.

이벤트 발생 시 실행되는 로직인 회원의 권한을 업체로 바꿔주고, 업체를 DB 에 저장하는 로직이 실제 DB 에 반영되지 않습니다.

트랜잭션과 관련된 큰 문제가 있기 때문이죠. 한번 살펴보도록 합시다.

@Transactional
public void changeEnrollmentStatus(Long enrollmentId, EnrollmentStatus status) {
	applicationEventPublisher.publishEvent(new EnrollmentStatusEvent(enrollmentId, status));
	enrollmentService.changeEnrollmentStatus(enrollmentId, status);
}

// enrollmentService.changeEnrollmentStatus 코드
@Transactional
public void changeEnrollmentStatus(Long enrollmentId, EnrollmentStatus status) {
	MarketEnrollment enrollment = marketEnrollmentRepository.findByIdFetchWithMember(enrollmentId)
			.orElseThrow(() -> {
				throw new BusinessException(ENTITY_NOT_FOUND);
			});

	if (enrollment.isSameStatus(status)) {
		throw new BusinessException(DUPLICATED);
	}

	enrollment.updateEnrollmentStatus(status);
}
@Component
@RequiredArgsConstructor
public class EnrollmentEventListener {

	private final MarketService marketService;

	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void changeEnrollmentStatus(EnrollmentStatusEvent event) {
		if (event.isApproved()) {
			marketService.enrollMarket(event.getEnrollmentId());
		}
	}
}

위의 changeEnrollmentStatus 메서드가 시작될 때 트랜잭션이 시작되고 커밋되면 아래 코드에서 이벤트가 실행되는 것을 볼 수 있습니다.

이벤트 실행 시 실행되는 marketService.enrollMarket 을 살펴보도록 합니다.

쿼리가 실행되는 지 알아보기 위해 회원의 권한을 업체로 바꿔주고, 업체를 DB 에 저장하는 로직 전후로 콘솔에 before, after 를 찍어주기로 합니다.

// marketService.enrollMarket
@Transactional
public Long enrollMarket(Long enrollmentId) {

	MarketEnrollment enrollment = getMarketEnrollment(enrollmentId);
	Member member = enrollment.getMember();

	if (member.isMarket()) {
		throw new BusinessException(FORBIDDEN);
	}
	System.out.println("before");
	member.changeAuthority(MARKET);

	Market market = Market.builder()
			.phoneNumber(enrollment.getPhoneNumber())
			.marketAddress(enrollment.getMarketAddress())
			.openTime(enrollment.getOpenTime())
			.endTime(enrollment.getEndTime())
			.description(enrollment.getDescription())
			.build();
	market.setMarketEnrollment(enrollment);
	market.setMember(member);
	Market savedMarket = marketRepository.save(market);

	System.out.println("after");
	return savedMarket.getId();
}

이벤트가 실행되기 전의 기존 트랜잭션에서 업체 신청 상태를 변경하는 쿼리는 날아가지만 이벤트가 실행되고 난 이후에는 쿼리가 날아가지 않는 것을 볼 수 있습니다.

저는 처음에는 이벤트가 트랜잭션이 커밋되고 난 이후 실행되는 것이니 트랜잭션이 없기에 쿼리가 날아가지 않았구나라고 생각했는데, marketService.enrollMarket 내의

MarketEnrollment enrollment = getMarketEnrollment(enrollmentId);
Member member = enrollment.getMember();

이 코드에서 쿼리가 안날아가고 enrollment 와 member 에 정상적인 값이 들어와있기에 이벤트 실행 전 기존 영속성 컨텍스트를 공유하고 있다는 것을 알 수 있습니다. 영속성 컨텍스트에 이미 enrollment 와 member 가 있으니 쿼리가 안날아간 것이죠.

근데 그럼, 트랜잭션은 이벤트 실행 전 기존 트랜잭션을 그대로 공유하는데 왜 dml 은 날아가지 않았을까요.

공식 문서를 참고해보니 정답이 나왔습니다.

트랜잭션 단계가 AFTER_COMMIT(기본값), AFTER_ROLLBACK 또는 AFTER_COMPLETION으로 설정된 경우 트랜잭션은 이미 커밋되거나 롤백되었지만 트랜잭션 리소스는 여전히 활성화되어 있고 액세스할 수 있습니다. 결과적으로 이 시점에 트리거된 데이터 액세스 코드는 여전히 원래 트랜잭션에 "참여"하지만 변경 사항은 트랜잭션 리소스에 커밋되지 않습니다.

트랜잭션은 공유하지만, 변경된 내용은 반영하지 않는다라고 적혀있습니다. 생각해보면 당연한 것입니다. 이미 트랜잭션이 커밋되었기에 변경을 다시 할 수는 없는 것이죠.

확인을 해보기 위해 saveAndFlush 메서드를 써보기로 했습니다.

예상대로라면 트랜잭션이 이미 커밋되었기에 flush 를 할 시에 예외가 발생할 것입니다.

// marketService.enrollMarket
@Transactional
public Long enrollMarket(Long enrollmentId) {

	MarketEnrollment enrollment = getMarketEnrollment(enrollmentId);
	Member member = enrollment.getMember();

	if (member.isMarket()) {
		throw new BusinessException(FORBIDDEN);
	}
	System.out.println("before");
	member.changeAuthority(MARKET);

	Market market = Market.builder()
			.phoneNumber(enrollment.getPhoneNumber())
			.marketAddress(enrollment.getMarketAddress())
			.openTime(enrollment.getOpenTime())
			.endTime(enrollment.getEndTime())
			.description(enrollment.getDescription())
			.build();
	market.setMarketEnrollment(enrollment);
	market.setMember(member);
	Market savedMarket = marketRepository.saveAndFlush(market);

	System.out.println("after");
	return savedMarket.getId();
}

결과는 예상대로 트랜잭션이 이미 커밋되었기에 flush 시 예외가 발생합니다.

해결할 방법으로 2가지를 생각했습니다.

  1. 이벤트 실행 시의 실행하는 로직에서 트랜잭션을 새로 열어준다.
  2. 이벤트 실행 시점을 AFTER_COMMIT 이 아닌 BEFORE_COMMIT 으로 하여 트랜잭션을 끝까지 유지한다.

첫번째 방법입니다.

// marketService.enrollMarket
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Long enrollMarket(Long enrollmentId) {

	MarketEnrollment enrollment = getMarketEnrollment(enrollmentId);
	Member member = enrollment.getMember();

	if (member.isMarket()) {
		throw new BusinessException(FORBIDDEN);
	}
	System.out.println("before");
	member.changeAuthority(MARKET);

	Market market = Market.builder()
			.phoneNumber(enrollment.getPhoneNumber())
			.marketAddress(enrollment.getMarketAddress())
			.openTime(enrollment.getOpenTime())
			.endTime(enrollment.getEndTime())
			.description(enrollment.getDescription())
			.build();
	market.setMarketEnrollment(enrollment);
	market.setMember(member);
	Market savedMarket = marketRepository.save(market);

	System.out.println("after");
	return savedMarket.getId();
}

이벤트 실행 시 발생하는 로직에서 새롭게 트랜잭션을 열어줍니다.

트랜잭션이 새로 열렸기에 영속성 컨텍스트에 없기 때문에 memberEnrollment 와 member 를 조회하고 market 을 저장한 후, member 상태 변경 쿼리가 변경 감지로 쿼리가 마지막에 나가는 것을 볼 수 있습니다.

하지만 1안은 문제점이 있습니다. 업체 신청 의 상태 변경, 회원 권한 변경 및 업체 등록은 함께 성공하거나 실패해야하는데 다른 트랜잭션으로 분리되어 있어 데이터 정합성이 문제될 수 있습니다.

또한, 기존의 트랜잭션을 유지하지 못하기에 영속성 컨텍스트가 다시 생겨 조회 쿼리가 다시 발생합니다.

2안의 코드는 아래와 같습니다.

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void changeEnrollmentStatus(EnrollmentStatusEvent event) {
	if (event.isApproved()) {
		marketService.enrollMarket(event.getEnrollmentId());
	}
}
// marketService.enrollMarket
@Transactional
public Long enrollMarket(Long enrollmentId) {

	MarketEnrollment enrollment = getMarketEnrollment(enrollmentId);
	Member member = enrollment.getMember();

	if (member.isMarket()) {
		throw new BusinessException(FORBIDDEN);
	}
	System.out.println("before");
	member.changeAuthority(MARKET);

	Market market = Market.builder()
			.phoneNumber(enrollment.getPhoneNumber())
			.marketAddress(enrollment.getMarketAddress())
			.openTime(enrollment.getOpenTime())
			.endTime(enrollment.getEndTime())
			.description(enrollment.getDescription())
			.build();
	market.setMarketEnrollment(enrollment);
	market.setMember(member);
	Market savedMarket = marketRepository.save(market);

	System.out.println("after");
	return savedMarket.getId();
}

이벤트 발생 시점을 BEFORE_COMMIT 으로 하여 트랜잭션을 끝까지 공유하고 변경도 가능하도록 하였습니다.

이벤트 실행 전 트랜잭션에서 fetch join 을 통해 가져온 쿼리입니다.

업체 등록 쿼리가 나가고 트랜잭션이 끝나는 시점에 변경 감지를 통한 update 쿼리가 나가는 것을 볼 수 있습니다.