ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] ApplicationEventPublisher를 활용해 Event 기반 동작 구현하기
    Today I Learned 2023. 6. 20. 12:36

    이벤트(Event)란?

    프로그래밍에서 이벤트란, 애플리케이션 내에서 발생시킬 수 있는 어떠한 사건을 의미한다. 애플리케이션에는 어떠한 이벤트를 발생시키는 주체와, 정해진 이벤트의 발생을 탐지해 동작을 처리하는 주체가 존재할 수 있다.
     
    애플리케이션을 개발하는 과정에서 발생하는 문제들 중, 어떤 문제들을 이벤트 개념을 적용해 해결할 수 있을까? 특정 동작을 수행하는 과정에서 그 동작과는 개별적으로 추가적으로 수행해야 하는 외적인 동작이 존재할 경우, 그 외적인 동작을 처리하기 위한 이벤트를 발생시키고, 발생한 이벤트를 처리하는 로직에서 필요한 동작을 수행하게 할 수 있다.
     
    경기를 생성했을 때, 경기 생성에 대한 알림을 같이 생성하는 경우를 생각해보자. 가장 간단하게 생각해볼 수 있는 구현 방식은 Game을 생성하는 CreateGameService에서 Game을 생성해 GameRepository에 저장하고, 이어서 Notice를 생성해 NoticeRepository에 저장하는 것이다.
     

    // services/CreateGameService.java
    
    @Service
    @Transactional
    public class CreateGameService {
        private final GameRepository gameRepository;
        private final NoticeRepository noticeRepository;
    
        public CreateGameService(GameRepository gameRepository,
                                 NoticeRepository noticeRepository) {
            this.gameRepository = gameRepository;
            this.noticeRepository = noticeRepository;
        }
    
        public String createGame(GameCreationRequestDto gameCreationRequestDto) {
            String name = gameCreationRequestDto.getName();
            Game game = new Game(name);
            Game savedGame = gameRepository.save(game);
    
            Notice notice = new Notice(savedGame.name() + " 이름의 경기가 생성되었습니다.");
            noticeRepository.save(notice);
    
            return "생성된 Game Id: " + savedGame.id();
        }
    }

     
    이 Service는 다음과 같은 문제점들이 있다.
     

    1. Game을 생성하는 Service에 Notice가 생성되는 책임이 같이 부여되어 있다. 로버트 C. 마틴이 제안한 객체지향 설계를 위한 5대 원칙 중 하나인 단일 책임 원칙(Single Responsibility Principle)과는 거리가 있다.
    2. 하나의 Transaction에서 Game과 Notice Entity를 모두 생성해 영속시킨다. Game Entity를 생성해 영속시키는 과정에 문제가 없었어도, Notice Entity를 생성해 영속시키는 과정에서 문제가 발생해 Transaction이 롤백되면 결과적으로 Game Entity의 생성도 실패한다. 핵심 로직이 아닌 부수적인 로직이 핵심 로직의 동작에 영향을 끼치게 된다.

     

    문제점을 개선하기 위한 과정에서, 발견한 문제들을 바탕으로 고려해야 하는 점들은 다음과 같다.
     

    1. Game을 생성하는 Service와 Notice를 생성하는 Service는 서로 다른 Service에서 실행되어야 한다.
    2. Game 생성이 성공적으로 끝난 뒤, Notice가 생성되어야 한다.

     

    그러면 이제부터 Event 기반의 동작을 구현하는 방식으로 문제들을 해결해보도록 하자.
     

    Spring에서 Event 기반 동작 구현하기

    본 글에서는 Spring Boot를 통해 구축한 간단한 Spring 웹 애플리케이션에서 REST API 요청을 받아 Game과 Notice Entity를 생성하는 동작을 구현했다.
     
    뒤에 나올 예시들을 따라 구현하기 위해서는 Spring에 다음의 의존성들을 추가해야 한다.

    - Spring Web
    - Spring Boot DevTools
    - Spring Data JPA

     
    다음의 의존성은 반드시 필요하지는 않지만, 데이터베이스에 Entity가 생성되었는지에 대한 결과를 손쉽게 확인하기 위해 추가할 수 있다.

    - H2 Database

     
     
    전체 소스코드는 다음의 링크에서 확인할 수 있다.

     

    https://github.com/hsjkdss228/tech-studies/tree/main/20230619-spring-event

     

    ApplicationEventPublisher

    Spring에서 Event는 ApplicationEventPublisher 객체를 통해 발행할 수 있다. ApplicationEventPublisher는 Event를 위해 구성된 객체를 수신할 수 있는 모든 대상에게 Event를 발행할 수 있다. ApplicationEventPublisher는 ApplicationContext에서 관리하는 Bean으로, 다음과 같이 필요로 하는 모듈에 주입될 수 있다.
     

    // services/CreateGameService.java
    
    @Service
    @Transactional
    public class CreateGameService {
        private final ApplicationEventPublisher eventPublisher;
    
        public CreateGameService(ApplicationEventPublisher eventPublisher) {
            this.eventPublisher = eventPublisher;
        }
    
        public String createGame(GameCreationRequestDto gameCreationRequestDto) {
            // ...
        }
    }

     
    Event의 발행은 ApplicationEventPublisher의 메서드인 publishEvent()에 Event 발행을 위한 객체 인스턴스를 전달하는 방식으로 이루어진다.
     

    public String createGame(GameCreationRequestDto gameCreationRequestDto) {
        String name = gameCreationRequestDto.getName();
        Game game = new Game(name);
        Game savedGame = gameRepository.save(game);
    
        eventPublisher.publishEvent(game.toGameCreationEvent());
    
        return "생성된 Game Id: " + savedGame.id();
    }

     
    Event 발행을 위한 객체는 객체는 Game 객체에 정의되어 있는 toGameCreationEvent() 메서드에서 Game의 정보를 이용해 생성하도록 했다.
     

    // models/Game.java
    
    @Entity
    public class Game {
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
    
        // constructors, other methods
        // ...
    
        public GameCreationEventDto toGameCreationEvent() {
            return new GameCreationEvent(id);
        }
    }

     

    Event 객체와 EventListener

    ApplicationEventPublisher의 publish() 메서드를 통해 이벤트를 발행시키기 위해서는 이벤트를 위해 별도로 정의한 객체를 전달해야 한다. 본 예시에서는 앞서 생성된 Post의 정보를 이용해 Notice를 생성할 수 있도록 생성된 Game의 id를 Event에 데이터로 포함시켰다.
     

    // events/GameCreationEvent.java
    
    public class GameCreationEvent {
        private final Long gameId;
    
        public GameCreationEvent(Long gameId) {
            this.gameId = gameId;
        }
    
        public String getGameId() {
            return gameId;
        }
    }

     
    발행된 Event는 EventListener 객체가 수신한다. Spring에서 EventListener는 Bean으로 등록되어야 하며, 특정 Event가 발생한 경우 EventListener 객체 내에서 @EventListener 어노테이션을 부여받고 발행된 Event 객체를 인자로 받는 메서드가 동작하는 콜백 방식으로 호출이 이루어진다.
     

    @Component
    public class EventListener {
        @EventListener
        public void onApplicationEvent(Event event) {
            // ...
        }
    }
    cf. Spring 4.2 버전 이전 버전에서는 발행되는 Event 객체가 ApplicationEvent로부터 확장되고 (extends), EventListener 객체는 ApplicationListener<발행될 Event 객체 타입>를 구현해야 했었으나(implements), Spring 4.2 버전 이후부터는 ApplicationEventPublisher interface에 ApplicationEvent를 상속받은 객체뿐만이 아닌 Object 타입의 모든 객체를 인자로 전달받는 publishEvent() 메서드가 추가되었기 때문에 앞서 설명한 방식으로도 Event 객체와 EventListener 객체를 정의할 수 있게 되었다.

     
     
    본 예시에서는 생성된 Game의 정보를 담은 Notice를 생성할 것이므로, EventLister에서 CreateNoticeService를 주입받은 뒤 onApplicationEvent() 메서드에서 Service의 메서드를 호출해 수행하도록 했다.
     
    이때 Notice의 생성은 CreateGameService의 Transaction이 모두 성공적으로 마쳐진 뒤 수행되어야 할 것이므로, @EventListener를 다음과 같이 @TransactionalEventListener로 수정하고, phase 속성으로는 기존의 Game을 생성하는 Transaction이 모두 처리된 뒤에 Event가 처리하도록 TransactionPhase.AFTER_COMMIT을 부여했다.
     

    // listeners/CreateNoticeEventListener.java
    
    @Component
    public class CreateNoticeEventListener {
        private final CreateGameCreationNoticeService createGameCreationNoticeService;
    
        public CreateNoticeEventListener(
            CreateGameCreationNoticeService createGameCreationNoticeService
        ) {
            this.createGameCreationNoticeService = createGameCreationNoticeService;
        }    
    
        @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
        public void onApplicationEvent(GameCreationEvent gameCreationEvent) {
            createGameCreationNoticeService.createGameCreationNotice(gameCreationEvent);
        }
    }

     
    Notice를 생성하는 동작을 수행하는 CreateGameCreationNoticeService와 Notice는 다음과 같이 정의했다.
     

    // services/CreateGameCreationNoticeService.java
    
    @Service
    @Transactional
    public class CreateGameCreationNoticeService {
        private final GameRepository gameRepository;
        private final NoticeRepository noticeRepository;
    
        public CreateGameCreationNoticeService(GameRepository gameRepository,
                                               NoticeRepository noticeRepository) {
            this.gameRepository = gameRepository;
            this.noticeRepository = noticeRepository;
        }
    
        public void createGameCreationNotice(GameCreationEvent gameCreationEvent) {
            Game game = gameRepository
                .findById(gameCreationEvent.getGameId())
                .orElseThrow(GameNotFound::new);
            
            String content = game.name() + " 게임이 생성되었습니다.";
            Notice notice = new Notice(content);
            noticeRepository.save(notice);
        }
    }
    @Entity
    public class Notice {
        @Id
        @GeneratedValue
        private Long id;
    
        private String content;
    
        // constructors
        // ...
    }

     

    비동기 처리를 위한 Config

    지금까지 작성한 소스코드들은 모두 동기적으로 작동하게 된다. EventListener의 로직이 원래 서비스와는 독립적인 비동기 방식으로 수행되도록 하기 위해서는 다음의 Config를 추가해줄 수 있다.
     

    // config/AsynchronousEventConfig.java
    
    @Configuration
    public class AsynchronousEventConfig {
        @Bean
        public ApplicationEventMulticaster simpleApplicationEventMulticaster() {
          SimpleApplicationEventMulticaster eventMulticaster
              = new SimpleApplicationEventMulticaster();
    
          eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor());
    
          return eventMulticaster;
        }
    }

     

    Event 처리와 서로 다른 Transaction이 엮였을 때 발생할 수 있는 문제

    부수적인 기능의 처리를 Event 방식으로 처리하도록 로직을 분리시킨 상황에서, 주 기능과 부수적인 기능이 모두 각자의 Transaction이 존재하는 경우 유의해야 할 점이 있다. 지금까지 작성한 로직대로 Game을 생성했을 때 Notice까지 생성되었으면 좋겠지만, 아쉽게도 그렇지 않다.
     

    @RestController
    @RequestMapping("games")
    public class GameController {
        private final CreateGameService createGameService;
    
        public GameController(CreateGameService createGameService) {
            this.createGameService = createGameService;
        }
    
        @PostMapping
        @ResponseStatus(HttpStatus.CREATED)
        public String gameCreation(
            @RequestBody GameCreationRequestDto gameCreationRequestDto
        ) {
            return createGameService.createGame(gameCreationRequestDto);
        }
    
        @ExceptionHandler(GameNotFound.class)
        @ResponseStatus(HttpStatus.NOT_FOUND)
        public String gameNotFound(RuntimeException exception) {
            return exception.getMessage();
        }
    }

     
    POST 요청을 보내 Game Entity를 생성한 결과는 다음과 같다.
     

     
    Game Entity는 생성되어 영속되었지만, Notice Entity는 생성되지 않았다. 그 이유는 @TransactionalEventListener에 대한 설명에서 확인할 수 있다.
     

    TransactionalEventListener.java

    WARNING: if the TransactionPhase is set to AFTER_COMMIT (the default), AFTER_ROLLBACK, or AFTER_COMPLETION, the transaction will have been committed or rolled back already, but the transactional resources might still be active and accessible. As a consequence, any data access code triggered at this point will still "participate" in the original transaction, but changes will not be committed to the transactional resource.

     
     
    해석하자면, EventListener의 로직을 수행할 때 Event를 발행한 시점의 Transaction은 커밋되거나 롤백이 마쳐진 상태이지만, Transaction 자체는 여전히 활성화되어 있는 채로 EventListener의 로직이 수행되게 된다. 그렇다는 의미는 EventListener의 로직에서 생성되는 Transaction에 대해 별도의 속성을 부여하지 않는 이상, 해당 Transaction은 별개의 Transaction으로 실행되는 것이 아니라 기존의 Transaction에 합쳐지게 된다. 그런데 합쳐지는 Transaction은 이미 커밋이 끝난 상태이므로, EventListener 로직에서 수행되는 Entity의 생성이나 조작은 모두 무시되게 되는 것이다.
     
    해당 문제를 해결하는 방법으로는 EventListener에서 수행하는 로직의 @Transactional 어노테이션에 propagation 속성으로 Propagation.REQUIRES_NEW 값을 부여하는 방법이 있다.
     

    TransactionSynchronization.java

    Use PROPAGATION_REQUIRES_NEW for any transactional operation that is called from here.

     
     

    @Service
    // @Transactional에 다음의 속성을 부여한다.
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public class CreateGameCreationNoticeService {
        // ...
    }

     
    이제는 POST 요청을 보내 Game Entity를 생성했을 때, 다음과 같이 Notice Entity도 생성하는 것을 확인할 수 있다.
     

     

    추가로 알아봐야 할 것

    - 하나의 동작에서 2개 이상의 순서 조정이 필요한 Event를 비동기로 발행해야 할 때, Event 처리 순서를 어떻게 조정할 수 있을 것인지 알아보기
     

    References

    Spring Events
    [Spring] Event
    스프링 이벤트 기능을 사용할 때의 고려할 점
     
     
     
     

    댓글

Designed by Tistory.