ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • @TransactionalEventListener 적용해보기 😎
    🌱 spring 2024. 3. 17. 15:31

    개인 프로젝트 진행 중 게시물을 생성하는 기능을 처리하는 도중 이미지 업로드하는 과정에서 고민이 생겼다.

     

    📍 이미지를 올리는 이미지 서비스와 게시물을 게시하는 다른 서비스들 간의 호출을 통해 게시물을 올리는 기능을 구현하고자 한다.

     

    이때, 이미지를 S3에 업로드하는 것이 성공하고나서 게시물 서비스에서 게시물을 만드는 도중 실패(예외)했을 경우 S3에 업로드된 이미지에 대한 문제가 발생할 것이라고 생각.

     

    🤔 생각해본 해결 방법 1 - 이미지 업로드와 게시물 작성을 별도 처리

    이미지 관련 요청을 먼저 처리한 후, 게시글과의 연관관계를 null로 설정 후, 게시글이 저장되면 그때 매핑

     

    ⚠️ 위의 방식을 사용했을 때, 발생할 수 있는 문제 - 사용자가 이미지만 등록하고 게시글은 저장하지 않는다면??

    • 리소스를 낭비하는 문제가 발생할 수 있다.
    • 따라서, 스케줄링을 통해 삭제를 주기적으로 해주는 추가적인 관리 작업이 필요하다.

     

    👟 생각해본 처리과정

     

         1️⃣ 클라이언트에서 이미지 서비스로 바로 S3에 업로드 요청

         2️⃣ 업로드된 이미지들의 url을 리턴

         3️⃣ 게시물을 생성할 때 해당 업로드된 이미지들와의 연관관계를 맺어준다.

         4️⃣ 이미지 업로드는 되었지만, 게시물에 대한 생성로직이 제대로 이루어지지 않았다면 스케줄링을 통해 삭제해준다.

     

    👍 장점

    • 이미지 업로드와 게시물 작성을 별도의 트랜잭션으로 처리해 응답 시간을 최적화할 수 있을 것 같다.

     

    😈 단점

    • 사용자가 이미지만 업로드하고 게시물을 작성하지 않는 경우 불필요한 이미지가 남을 수 있다. 이는 시스템의 리소스를 낭비
    • 불필요한 이미지를 주기적으로 삭제해야 하는 추가적인 관리 작업이 필요하다 (스케줄링)

     

    🤔 생각해본 해결 방법 2 - 이미지 업로드와 게시물 작성을 한 번에 처리

    이미지를 업로드 하고 게시글을 만드는 작업을 하나의 트랜잭션에서 처리한다.

     

    👟 생각해본 동작 과정

     

        1️⃣ 클라이언트에서 게시물(피드, 스토리..) 서비스로 게시물 생성 요청

        2️⃣ 게시물 서비스에서 이미지 서비스로 이미지에 대한 업로드 요청 

        3️⃣ 업로드된 이미지들의 url을 리턴

        4️⃣ 업로드된 이미지들의 url의 정보를 가지고 있는 Event 생성

        5️⃣ 게시물 서비스에서 게시물 생성 로직 수행

        6️⃣ 이미지 업로드는 되었지만, 게시물에 대한 생성로직이 제대로 이루어지지 않은 경우

    • @TransactionEventListener를 통해서 해당 트랜잭션에서 rollback 처리가 발생했을 시 이미지 삭제 Event를 발행하고 이미지 서버에서 해당 이벤트를 구독하여 업로드된 이미지를 삭제한다.

     

    👍 장점

    • 이미지 업로드와 게시물 작성을 하나의 트랜잭션으로 처리하여 데이터 일관성을 유지할 수 있습니다. 이는 시스템 안정성을 높일 수 있을 것이라 생각
    • 게시물 작성이 실패했을 시 이미지도 삭제된다.

     

    😈 단점

    • 이미지 업로드와 게시물 작성을 하나의 트랜잭션으로 처리하기 때문에 응답 시간이 더 길어질 수 있다
    • 특히 이미지 크기가 크거나 여러 개의 이미지를 업로드하는 데 시간이 오래 걸리는 경우에는 더 큰 영향을 받을 수 있다.

     

    😎 선택한 방법

    이 프로젝트에서는 두 번째 방법을 이용해 해당 요구사항을 처리해보고자 한다.

     

    ⭐ @TransactionalEventListener


    💡 파일 업로드와 DB 파일 정보 데이터의 생명 주기를 맞춰서 처리해보자

     

    🎯 상황

    AWS S3에 이미지를 업로드하고 업로드에 성공시 이미지 관한 데이터를 DB에 저장해야 한다.

    • DB Table에 저장할 정보 : 이미지 파일 경로, 이미지 파일 명, Post Id, User Id 등

     

    ⚠️ DB 에 저장하는 과정에서 문제가 발생했을 때(트랜잭션이 롤백되어야 하는 상황)

    ⇒ S3 오브젝트는 서비스에서 관리할 수 없는 오브젝트가 된다.

     

    💡  S3 오브젝트를 서비스 레벨에서 관리할 수 있도록 하고 싶다.

     

    ❓@TransactionalEventListener

    트랜잭션의 상태를 감지하여 이벤트를 실행하는 어노테이션

    • DB / S3 파일의 생명주기를 관리하기 위해 사용
    • 트랜잭션의 롤백을 감지하고 업로드 되어버린 S3 오브젝트의 추가적인 처리를 진행

     

    📖 트랜잭션 상태 감지 4단계


    옵션 설명
    BEFORE_COMMIT 트랜잭션이 성공적으로 commit 되었을 때 이벤트 실행
    AFTER_COMMIT 트랜잭션이 rollback 되었을 때 이벤트 실행
    AFTER_ROLLBACK 트랜잭션이 마무리 되었을 때 (commit 또는 rollback) 이벤트 실행
    AFTER_COMPLETION 트랜잭션의 commit 전에 이벤트 실행

     

    📌 @TransactionEventListener트랜잭션 상태 감지 옵션 사용

     

        1️⃣ 게시물을 DB에 저장하는데 실패했을 때, S3에 업로드된 이미지를 삭제 해야한다.

        2️⃣ S3로 이미지 업로드 과정이 실패한다면 DB에 저장하면 안된다.

     

    🆚 @EventListener

     

    @EventListener를 사용하면 PublishEvent() 메서드가 호출되는 시점에 바로 이벤트를 처리한다.

     

    하지만, S3에 이미지를 업로드에 성공 한 후, 이미지 정보를 DB에 저장하는 트랜잭션이 실패했을 경우 S3에 업로드한 이미지를 삭제하는 이벤트를 처리하고 싶기 때문에 @TransactionEventListener를 사용해야 한다.

     

    @TransactionEventListener은 TransactionPhase 값(트랜잭션 작업 처리 상태)에 따라 이벤트를 언제 처리할 것인지 지정할 수 있다.

     

    🙂 TransactionPhase.ROLLBACK을 통해 트랜잭션이 Rollback 된 이후에 S3 이미지 삭제 이벤트를 통해 요구사항을 만족하고자 한다.

     

    🧑🏻‍💻 구현

     

    🏃 동작 하는 과정

    1. 사용자가 이미지 업로드 요청을 게시물(피드, 스토리)서비스에 보낸다.
    2. Post 서비스에서는 DB 저장와 S3 업로드를 하나의 생명주기(Transaction)로 처리한다.
    3. FeignClient를 이용해 S3 업로드 서비스와 통신하여 S3 업로드 서버에 이미지에 대한 정보를 보낸다.
      • S3 업로드 서버에서는 S3에 이미지를 업로드한다.
      • 업로드 후 저장된 S3 오브젝트에 대한 정보를 리턴해준다.
    4. 업로드 성공시
      • 트랜잭션이 롤백되는 상황을 가정하여 S3에 업로드된 파일을 삭제하는 이벤트을 만들어야 한다.
      • DB에 이미지 관련 정보를 저장한다. (이미지 url, 이미지 name, 저장 날짜 등)
        • 💣 저장에 실패하는 경우(예외 발생)
          • 트랜잭션이 rollback되어 @TransactionEventListener로 설정한 메소드가 실행
          • 이 메소드에서 Rollback되는 이미지의 정보를 통해 Kafka로 메시지를 발행한다.
          • 이미지 서비스에서 rollback 이미지에 대한 정보를 consume하여 삭제 로직을 처리(비동기)
    5. 업로드 실패시
      • DB 저장은 업로드 로직이 실행되고 난 후 실행되기 때문에 아무런 처리를 하지 않고 해당 생명주기를 끝내면 된다.

     

    💡 카프카를 이용해서 이미지 삭제를 처리한 이유

    • 이미지 삭제에 대한 로직은 동기적으로 처리할 필요가 없다고 생각
    • 실시간이 아니더라도 삭제만 되면 되기 때문에 카프카를 통해 비동기적으로 처리하는 것이 맞다고 생각

    💥 이미지 생성은 카프카를 사용하지 않은 이유?

    • 게시물을 생성하는데 이미지에 대한 정보가 필요하기 때문에 이는 동기적으로 처리했다.

     

     

     

    🤖 @TransactionalEventListener 적용 이벤트

    @Component
    @RequiredArgsConstructor
    public class UploadImageRollbackEventListener {
    
        ...
        
        @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
        public void rollbackUploadImage(UploadImageRollbackEvent event) {
            ... 
            ImageRollbackEvent imageRollbackEvent = new ImageRollbackEvent(imageName);
            Events.raise(imageRollbackEvent);
        }
    }

     

     

    ✉️ Kafka

    @EventListener(ImageRollbackEvent.class)
    public void handle(ImageRollbackEvent event) {
       imageRollbackEventKafkaTemplate.send(imageRollbackTopic, event);
    }
    

     

     

    🚀 DB와 S3의 생명주기를 맞추기(하나의 트랜잭션 처리)

    @UseCase
    @Transactional
    @RequiredArgsConstructor
    public class PostFeedService implements PostFeedUseCase {
    
        ...
    
        @Override
        public Feed postFeed(PostFeedCommand command) {
    
            // 1. user가 존재하는지 확인
            if(!findUserPort.isExistsUser(command.getUserId())) {
                // todo : custom exception
                throw new RuntimeException();
            }
    
            // 2. image들을 먼저 S3에 생성한다.
            List<UploadedS3Image> s3Images = uploadImage(command.getImages());
    
            // 3. 업로드된 이미지 파일을 토대로 Event 생성(rollback 을 위한 event)
            eventPublisher.publishEvent(UploadImageRollbackEvent.rollbackFeedImagesEvent(s3Images));
    
            // 4. s3에 업로드한 image 정보를 토대로 feedImageEntity 생성
            List<FeedImageEntity> feedImageEntityList = createFeedImageEntity(s3Images, command.getHashtags());
    
            // 피드 생성
            FeedEntity feedEntity = postFeedPort.postFeed(command.getUserId(), feedImageEntityList, command.getCaption());
            return feedMapper.mapToDomain(feedEntity);
        }
    }

     

     

    💣 게시물 저장에 실패한 코드 예시

    @Override
    public Feed postFeed(PostFeedCommand command) {
    
            // 1. user가 존재하는지 확인
            if(!findUserPort.isExistsUser(command.getUserId())) {
                // todo : custom exception
                throw new RuntimeException();
            }
    
            // 2. image들을 먼저 S3에 생성한다.
            List<UploadedS3Image> s3Images = uploadImage(command.getImages());
    
            // 3. 업로드된 이미지 파일을 토대로 Event 생성(rollback 을 위한 event)
            eventPublisher.publishEvent(UploadImageRollbackEvent.rollbackFeedImagesEvent(s3Images));
    
            // 4. s3에 업로드한 image 정보를 토대로 feedImageEntity 생성
            List<FeedImageEntity> feedImageEntityList = createFeedImageEntity(s3Images, command.getHashtags());
    
            try {
                sleep(15000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
    
            throw new RuntimeException();
            // 피드 생성
    //        FeedEntity feedEntity = postFeedPort.postFeed(command.getUserId(), feedImageEntityList, command.getCaption());
    //        return feedMapper.mapToDomain(feedEntity);
    }

     

     

    ❗ 결과

    원하는대로 이미지가 S3에 업로드 되었다가 다시 모두 삭제되었다.

     

    ⏭️ TODO

    게시물을 생성하는데 있어서 이미지에 대한 정보를 먼저 처리한 후 게시물을 생성하고 이미지 정보들과 매핑을 하는 식으로 코드를 수정해보고 싶다.

     

    📕 참고자료

    AWS에서 마이크로 서비스 구현

    [Database] 8. 트랜잭션, 동시성 제어, 회복

    @TransactionalEventListener 파헤치기

    스프링 이벤트를 활용해 로직간 강결합을 해결하는 방법

    '🌱 spring' 카테고리의 다른 글

    Transactional Outbox Pattern  (0) 2024.04.23
    🍀 MongoDB - QueryDsl with Spring  (0) 2023.05.30
    🐱 Tomcat  (0) 2023.03.12
    ThreadLocal  (0) 2023.01.18
    🌊 Connection Pool  (0) 2023.01.13

    댓글

Designed by Tistory.