본문 바로가기

스프링

Spring Events로 S3 업로드 문제 해결하기

웹툰 등록 시나리오

우선 웹툰을 등록할 때 시나리오는 다음과 같다.

문제

코드로 나타내면 다음과 같다.

@Service
@RequiredArgsConstructor
public class ComicService {

	private final ComicRepository comicRepository;
	private final ThumbnailRepository thumbnailRepository;

	private final FileStorage fileStorage;

	@Transactional
	public void createComic(ComicCreateRequest comicCreateRequest, String loginId) {
		User user = getUser(loginId);
		Author author = getAuthor(user);

		// 1. Comic 엔티티 영속화
		Comic comic = comicCreateRequest.toComic(author); 
		comicRepository.save(comic);

		comicCreateRequest.thumbnailCreateRequests
        	.forEach(thumbnailCreateRequest -> {
					// 2. S3에 이미지 업로드 및 3. 업로드된 이미지 URL 반환
                    String thumbnailImageUrl = fileStorage.upload(
                    	thumbnailCreateRequest.getThumbnailImage(),
                    	ImageFileType.COMIC_THUMBNAIL
                    );
                    Thumbnail thumbnail = thumbnailCreateRequest.toThumbnail(
                    	thumbnailImageUrl,
                    	comic
                    );
					// 4. Thumbnail 엔티티 영속화
                    thumbnailRepository.save(thumbnail);
                    }
                );
	}
}

웹툰을 등록할 때 Thumbnail 엔티티를 영속화 하기 위해 S3 서버에 웹툰 썸네일을 업로드하고 받은 URL을 Thumbnail 엔티티에 포함시켜야 한다.

 

이 때, S3 서비스에는 이미지가 성공적으로 업로드 됐지만 그 이후에 에러가 발생한다면 다른 영속화된 엔티티는 롤백이 되겠지만 S3에 올라간 이미지는 그대로 있다.

@Transactional
public void createComic(ComicCreateRequest comicCreateRequest, String loginId) {
	User user = getUser(loginId);
	Author author = getAuthor(user);

	Comic comic = comicCreateRequest.toComic(author);
	comicRepository.save(comic);

	comicCreateRequest.thumbnailCreateRequests
    		.forEach(thumbnailCreateRequest -> {
            	String thumbnailImageUrl = fileStorage.upload(
                thumbnailCreateRequest.getThumbnailImage(), 
                ImageFileType.COMIC_THUMBNAIL
                );
                Thumbnail thumbnail = thumbnailCreateRequest.toThumbnail(thumbnailImageUrl, comic);
                thumbnailRepository.save(thumbnail);
                });

	throw new RuntimeException("Thumbnail 저장 중 예외가 발생했습니다.");
}

위와 같이 S3에 이미지가 업로드되고 RuntimeException을 발생시켜보자.

 

그리고 포스트맨으로 위와같이 요청을 보내면

의도한대로 예외가 발생되며 트랜잭션이 롤백될 것이다.

따라서 DB에는 아무 값도 저장되지 않았지만

S3 서버는 롤백되지 않고 이미지가 업로드 되었다.

 

해결

이 문제를 해결하기 위해 @TransactionalEventListener를 사용했다.

FileDeleteEvent

@Getter
@AllArgsConstructor
public class FileDeleteEvent {
	private final String key;
}

파일 저장소(S3)에서 지정된 파일을 삭제하기 위한 데이터를 담고있는 클래스

FileDeleteEventListener

@Component
@RequiredArgsConstructor
public class FileEventListener {

	private final FileStorage fileStorage;

	@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
	public void deleteFile(FileDeleteEvent fileDeleteEvent) {
		System.out.println("delete s3 file: " + fileDeleteEvent.getKey());
		fileStorage.delete(fileDeleteEvent.getKey(), ImageFileType.COMIC_THUMBNAIL);
	}
}

이벤트가 호출되었을 때 실행된다.

  • @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)을 사용하여 이벤트가 호출된 메소드의 트랜잭션이 롤백될 때 이벤트가 발생한다.

 

이벤트 호출

@Service
@RequiredArgsConstructor
public class ComicService {

	private final ComicRepository comicRepository;
	private final ThumbnailRepository thumbnailRepository;

	private final FileStorage fileStorage;

	private final ApplicationEventPublisher applicationEventPublisher;

	@Transactional
	public void createComic(ComicCreateRequest comicCreateRequest, String loginId) {
		User user = getUser(loginId);
		Author author = getAuthor(user);

		Comic comic = comicCreateRequest.toComic(author);
		comicRepository.save(comic);

		comicCreateRequest.thumbnailCreateRequests
				.forEach(thumbnailCreateRequest -> {
                String thumbnailImageUrl = fileStorage.upload(
                thumbnailCreateRequest.getThumbnailImage(),
                ImageFileType.COMIC_THUMBNAIL);
                Thumbnail thumbnail = thumbnailCreateRequest.toThumbnail(thumbnailImageUrl, comic);
                thumbnailRepository.save(thumbnail);
                applicationEventPublisher.publishEvent(new FileDeleteEvent(thumbnailImageUrl));
				});

		throw new RuntimeException("Thumbnail 저장 중 예외가 발생했습니다.");
	}
}

우선 ComicService에서 ApplicationEventPublisher를 주입받는다.

 

ComicService.createComit() 메소드가 호출될 때 publishEvent()를 사용하여 이벤트를 호출할 수 있다. 앞서 이벤트를 등록할 때 트랜잭션이 롤백될 경우에만 호출하도록 설정했기 때문에 트랜잭션이 정상적으로 커밋된다면 이벤트가 발생하지 않는다.

 

문제 2

사용자 입장에서 생각해보면, 사용자는 웹툰 등록 API를 호출하고 웹툰 등록에 실패했다면 실패했다는 정보를 빨리 얻기를 바란다. 그리고 S3에 등록된 이미지가 삭제됐는지 안됐는지에 대한 정보는 중요하지 않다. 따라서 삭제하는 작업까지 기다릴 필요가 없다.

 

하지만 현재 로직에서는 S3에 이미지를 삭제하는 로직까지 동기적으로 처리하기 때문에 사용자가 웹툰 등록에 실패했다는 응답을 받기 위해서 S3에 이미지가 삭제되는 작업이 끝날 때까지 기다려야 한다.

 

예를 들어, S3에 파일을 삭제하는 API를 호출할 때마다 3초의 시간이 걸린다고 가정해보자.

만약 웹툰 등록 API에 저장되어야 하는 이미지가 추가될수록 응답을 받기 위해 3초의 시간이 계속 늘어날 것이다.

 

@Component
@RequiredArgsConstructor
public class FileEventListener {

	private final FileStorage fileStorage;

	@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
	public void deleteFile(FileDeleteEvent fileDeleteEvent) {
		try {
			Thread.sleep(3000);  // 3초 시간지연
		} catch (InterruptedException e) {
			throw new RuntimeException();
		}

		System.out.println("delete s3 file: " + fileDeleteEvent.getKey());
		fileStorage.delete(fileDeleteEvent.getKey(), ImageFileType.COMIC_THUMBNAIL);
	}
}

직접 코드를 위와같이 작성해서 파일 삭제 이벤트가 발생할 때마다 3초씩 지연되게 설정하였다.

 

그리고 웹툰 등록 API에서 앞서 했던 것처럼 S3에 파일을 저장한 뒤 RuntimeException을 발생시켜 S3에 올린 파일을 삭제하는 이벤트를 발생시켰다.

2개의 파일을 등록하자 웹툰 등록에 실패했다는 응답을 받기까지 약 7.5초의 시간이 걸렸다.

 

해결

이 문제는 S3의 파일을 삭제하는 이벤트를 비동기로 처리함으로써 해결할 수 있다.

스프링에서는 @Async 어노테이션을 적용하면 손쉽게 비동기로 메소드를 처리할 수 있다.

**@EnableAsync  // 추가**
@EnableJpaAuditing
@SpringBootApplication
public class WebComicsApplication {

	public static void main(String[] args) {
		SpringApplication.run(WebComicsApplication.class, args);
	}
}

Application Class@EnableAsync 어노테이션을 추가하고

@Component
@RequiredArgsConstructor
public class FileEventListener {

	private final FileStorage fileStorage;

	**@Async  // 추가**
	@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
	public void deleteFile(FileDeleteEvent fileDeleteEvent) {
		try {
			Thread.sleep(3000);
		} catch (InterruptedException e) {
			throw new RuntimeException();
		}

		System.out.println("delete s3 file: " + fileDeleteEvent.getKey());
		fileStorage.delete(fileDeleteEvent.getKey(), ImageFileType.COMIC_THUMBNAIL);
	}
}

비동기로 실행될 메소드에 @Async 어노테이션을 추가한다.

 

결론적으로 같은 요청을 보내도 S3에 업로드된 파일을 삭제하는 작업은 비동기로 처리되기 때문에 응답 속도가 훨씬 빨라진 것을 확인할 수 있다.

삭제 로직도 성공적으로 호출되었다.