본문 바로가기
프로젝트/협업 프로젝트(2023.12.18-2024.01.25)

[Key Word 개발기] 스프링 이벤트 적용하기

by dal_been 2024. 1. 21.
728x90

갑자기 배포를 해야해서 이 기능은 적용한지 좀 오래됬는데 지금에서야 쓰는 나...

 


스프링 이벤트..??

 

협업프로젝트를 진행하면서 S3를 이용해서 프로필 이미지를 저장하고, 삭제, 수정하는 기능을 도입했다. 

이미지 삭제의 경우 수정할때 사용하는데 과연 삭제와 수정이 같은 트랜젝션안에 있어야하는 가에 대해 고민하게되었다.

 

내 생각의 경우 다른 트랜젝션이라고 생각했다.

그 이유는 s3에서 이미지 삭제 실패했다고 해서 핵심 로직에 큰 영향을 주지 않기 때문이다. 이미지 수정에만 성공하면 s3쪽에 이미지가 저장되고 db의 내용도 잘 변경된다. 그렇다면 이미지 삭제에 에러가 났다고 롤백하는게 좋은 방향일까?? 라고 질문을 던졌을때 좋은 방향은 아닌것같았다.

 

그래서 도입하게된 스프링 이벤트!

스프링 이벤트를 도입하게되면 관심사 분리가 가능하다. 즉 이미지 삭제와 수정의 결합도를 끊고 관심사를 분리하는 것이다.

또한 트랜젝션 분리가 가능하다. 이미지 삭제가 실패해도 수정은 성공하게 만들 수 있다. (당연히 이미지 수정이 실패하면 이미지 삭제도 롤백된다)

뿐만 아니라 비동기적으로 분리할 수 있다. 이벤트를 발생할때마다 새로운 쓰레드를 생성한다.

 


조금더 자세하게 코드로 보면서 설명해보자

 

1. 관심사 분리하기

  public void deleteFile(final String fileName){
    publisher.publishEvent(new S3ImageEvent(fileName));
  }

 

을 통해 이미지 삭제 로직을 분리하는 메서드를 만들었따.

 

@Component
@RequiredArgsConstructor
public class ImageEventListener {

  //기본 이미지가 있다는 가정하에
  private static final String DEFAULT_IMAGE_NAME = "default-image.png";

  private final AmazonS3 amazonS3;

  @Value("${aws.s3.folder}")
  private String folderName;

  @Value("${aws.s3.bucket}")
  private String bucketName;

  @Async
  @TransactionalEventListener(fallbackExecution = true)
  public void deleteImageFileInS3(final S3ImageEvent event) {
    final String imageName = event.getImageName();
    if (imageName.equals(DEFAULT_IMAGE_NAME)) {
      return;
    }
    final String key = folderName+imageName;
    amazonS3.deleteObject(new DeleteObjectRequest(bucketName,  key.trim()));
  }
}

 

여기서 @TransactionalEventListener, @Async)는 뒤에서 차차 설명한다.

 

2. 트랜젝션 분리

이벤트를 활용하여 로직은 분리했지만 원래 @EventListener가

( @TransactionalEventListener, @Async 이 세 어노테이션 제외하고)

붙어있어도 동기적으로 실행되게 된다.

다시 말해 하나의 스레드에서 실행되기때문에 이벤트 처리가 끝나야 이벤트를 발행한 곳의 남은 로직을 처리하고 트랜젝션을 커밋할지 롤백할지 결정한다.

 

그래서 여기서 @EventListener대시 @TransactionalEventListener를 이용하여 이벤트 발행 시점을 결정한다.

// 이벤트 발행 주체가 커밋되면 실행 (default)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

// 이벤트 발행 주체가 롤백되면 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)

// 이벤트 발행 주체가 끝나면 실행 (롤백, 커밋)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)

// 이벤트 발행 주체가 커밋되기 전에 실행
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)

 

phase를 따로 명시하지 않으면 default로 커밋후에 실행하게 된다.

그래서 나의 경우 default로 설정해놓았기때문에 이미지 수정 커밋 후에 이벤트를 발행해서 s3에서 이미지 삭제가 이루어진다.

따라서 이미 이미지 수정은 커밋되었기때문에 s3에서 이미지 삭제가 실패해도 영향을 주지 않는다.

 

다만 여기서 주의할 점은 같은 트랜젝션으로 묶여있는 상황이다. 단순히 트랜젝션이 사라진 것이 아니라 커밋된 트랜젝션에 참여한 상황이기 때문에 조회는 가능하지만 쓰기는 불가능하다.
나의 경우 s3와 이미지 삭제를 요청하는 것이기 때문에 커밋이전의 내용을 다시 쓸 상황은 없지만 혹시 커밋이전의 내용을 변경해야한다면 트랜젝션 전파 속성을 @Trnsactional(propagation = REQUIRED_NEW) 로 변경하여 트랜젝션을 분리할 수 있다

 

 

3. 비동기 적용

스프링 이벤트는 기본적으로 동기방식으로 동작한다. 

그래서 이미지 수정, 이미지 삭제가 하나의 스레드에서 실행하게 된다. 

 

만약 이벤트가 여러가지 발행하게 된다면 이벤트 1이 실패했을때 이벤트 2는 무시될 수 있다.

또한 만약 커넥션 풀을 1로 지정하고 로직을 실행하면 커넥션을 얻기 위해 계속 기다리는 상황이 올 수 있다. 

위처럼 트랜젝션분리를 통해 기존 커넥션과 다른 커넥션과 연결된다. 그런데 만약 이벤트가 n개라면 n개의 커넥션으로 연결된다. 즉 이 쓰레드는 다수의 커넥션과 연결되어있는 상태라서 성능에 문제가 발생할 수 있다.

 

따라서 트랜젝션 분리와 함께 더 확실하게 이벤트가 서로 영향을 주지 않으려면 비동기를 사용해야한다.

이때 사용하는것이 @Async이다. (@EnableAsync config를 만들던가 application에 붙여아한다)

 

붙이게되면 이벤트가 발생할때마다 새로운 스레드를 생성하게 된다. 다만 스레드를 생성하는 작업도 비용과 메모리를 차지하기 때문에 스레드 풀을 설정하여 보다 효율적으로 사용하게 할 수 있다.

 

@Configuration
public class AsyncConfig {

    @Bean
    public TaskExecutor asyncExecutor() {
        ThreadPoolTaskExecutor asyncExecutor = new ThreadPoolTaskExecutor();
        asyncExecutor.setThreadNamePrefix("async-pool");
        asyncExecutor.setCorePoolSize(10);
        asyncExecutor.initialize();
        return asyncExecutor;
    }

 


https://jeong-pro.tistory.com/238

https://tecoble.techcourse.co.kr/post/2022-11-14-spring-event/