Spring Event 도입기 (1) - 핵심 로직을 변경으로부터 보호하라

핵심 로직 변경에 따른 문제 발생과 해결

문제 - 가독성 저하

현재 사내에서 운영하는 서비스에는 사용자의 요청을 받으면 엔티티를 생성해 저장하고 메시지 큐에 발행한다라는 핵심 로직이 존재한다. 이 코드는 처음에는 아주 짧았고, 가독성도 나쁘지 않았다.

그러나 기획이 추가됨에 따라 해당 코드에는 여러 부가 로직들이 추가되었다. 엔티티의 생성 후 또다른 엔티티를 생성해야 하기도 하고, 메시지 큐에 발행하기 전 엔티티에 특정 변환 작업이 추가되기도 했다. 기획이 변경됨에 따라 이러한 변경 사항은 추가되기도, 다시 제거되기도 하며 핵심 로직을 포함한 클래스에 다양한 변경을 일으켰다.

그러다보니 코드는 점점 길어지고 가독성도 나빠졌다. 수정이 잦으니 여러 사람의 작업에 의해 conflict가 발생할 때도 많아졌고, 그럴 때마다 긴 코드의 로직을 점검하며 오랜 시간을 들여 충돌을 해결했다.

해결 - 모듈 분리

생산성의 저하를 느낄 때쯤 부가 로직을 관심사에 따라 별도 모듈로 분리하는 1차 리팩토링을 진행했다. 이를 통해 핵심 로직을 담은 클래스의 몸집도 줄이고, 각 관심사별 변경이 서로 영향을 미치지 않게 함으로써 어느정도 결합을 분리했다.

문제 - 커밋되지 않은 데이터 조회 시도

그러나 얼마 뒤, 또다른 문제가 발생했다.

프로덕트를 발전시키는 과정에서 메시지를 큐에 발행한 후 수행되어야 하는 부가 로직이 추가되었는데, 이 부가 로직에 의해 핵심 로직 트랜잭션의 커밋 시점이 꽤 뒤로 미루어졌다.

큐에 발행된 메시지는 Consumer가 꺼내 처리한 뒤 (핵심 로직에서 생성된) 엔티티를 수정하게 되는데, 메시지의 처리가 비동기로 동작함에 따라 엔티티 생성이 커밋되기 전에 엔티티의 수정을 위한 조회가 일어나 엔티티를 찾을 수 없게 되는 문제가 발생했다. 별도 트랜잭션에서 아직 커밋되지 않은 데이터를 읽으려고 했기 때문에 발생한 문제였다.

이 문제의 근본적 원인은 동기여야 하는 동작의 일부가 비동기로 묶여버린 문제였다. 엔티티가 생성되고 커밋되는 일과 메시지를 큐에 발행하는 일은 명확한 순서를 가져야 한다. 그런데 하나의 메서드(트랜잭션 범위) 내에서 엔티티의 생성과 메시지의 발행을 모두 처리하고 있었고, 이에 따라 엔티티의 커밋과 메시지의 처리 간 순서를 보장할 수 없었다.

해결 - 트랜잭션 분리

이 문제를 근본적으로 해결하기 위해서는 엔티티를 생성하는 트랜잭션이 커밋된 후 메시지가 발행되어야 했다. 이 시점 제어의 해결책을 찾던 중 Spring Event와 @TransactionalEventListener에 대해 알게 되었다.

그리고 Spring Event에 대해 더 자세히 알아가며 일석이조로 본래 코드가 기획 변경에 의해 잦은 수정이 일어나던 이유를 알게 되었다. 그것은 새 로직이 추가될 때마다 이를 수행하는 모듈을 만들고, 핵심 로직 클래스가 이를 주입받고 호출하기 때문이었다.

결국 기능 자체를 모듈로 분리한다고 해도 모듈을 주입받고 메서드를 호출하는 과정이 핵심 로직에 변경을 가져왔고, 외부 모듈의 메서드 시그니처(메서드명, 파라미터)가 변경되었을 때 핵심 로직이 변경되는 것은 여전했다.

따라서, 핵심 로직이 부가 로직의 추가와 수정에 일절 영향을 받지 않도록 하기 위해 핵심 로직은 자신의 역할이 완수되었다는 이벤트만 발행하고 부가 로직들이 이 이벤트를 감지하여 자신의 역할을 수행할 수 있도록 하는 방향으로 2차 리팩토링을 진행하였다.

본 글에서는 이 과정에서 적극 도입하게 된 Spring Event의 기본적 사용 사례에 대해 소개한다.

 

 

Spring Event

이벤트를 기반으로 데이터를 주고받는 방법으로, 발행된 이벤트의 전달은 Spring Context가 수행한다.

우리가 해야 할 일은 적절한 곳에서 이벤트를 던지는 것, 적절한 이벤트 리스너를 선언해 발행된 이벤트를 받아 처리하는 것이다.

이벤트 발행

@FunctionalInterface
public interface ApplicationEventPublisher {

    default void publishEvent(ApplicationEvent event) {
        publishEvent((Object) event);
    }

    // since Spring 4.2
    void publishEvent(Object event);

}

이벤트의 발행을 담당하는 인터페이스는 ApplicationEventPublisher다. Spring 4.2 이전 버전에서는 모든 커스텀 이벤트가 ApplicationEvent를 상속해야 하지만, 이후부터는 위의 2번째 메서드가 추가되어 그럴 필요가 없어졌다.

위의 계층 구조를 보면 알 수 있듯이 이를 상속받는 ApplicationContext를 타입으로 하여 주입받아도 상관 없다. 하지만 나는 이벤트 발행이 목적이라면 ApplicationEventPublisher 를 타입으로 주입받는 편이 더 명시적이어서 좋은 것 같다.

이벤트 구독

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Reflective
public @interface EventListener {

    @AliasFor("value")
    Class<?>[] classes() default {};

    String condition() default "";

    String id() default "";

}

이벤트를 구독하기 위해서는 발행된 이벤트를 구독할 리스너 클래스를 만들고 빈으로 등록한 뒤, 이벤트가 발행되었을 때 수행할 메서드의 위에 @EventListener 어노테이션을 달아주면 된다.

중요한 점은, 기본 설정의 이벤트 리스너는 동기로 동작한다는 것이다. 이벤트라고 하면 비동기로 동작할 것이라 오인하기 쉽지만, 아무런 추가 설정 없이는 발행자와 동일 스레드, 동일 트랜잭션 내에서 동기로 동작한다.

리스너를 비동기로 동작시키고 싶다면, 아래와 같이 @Async 옵션과 함께 사용하면 된다.

@Async
@EventListener
public void listenEvent() { ... }

물론 저렇게만 달아준다고 바로 비동기가 동작하는 것은 아니고, 루트 클래스 또는 설정 클래스에 @EnableAsync를 명시해야 동작하며 스레드를 효율적으로 관리하기 위해 커스텀 Executor를 생성해주어야 한다. 하지만 이 글은 Spring Async에 대한 글이 아니기 때문에 자세한 내용은 생략한다.

트랜잭션 시점에 따른 이벤트 구독

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EventListener
public @interface TransactionalEventListener {

    TransactionPhase phase() default TransactionPhase.AFTER_COMMIT;

    boolean fallbackExecution() default false;

    // 나머지는 @EventListener와 동일

}

이벤트 리스너는 이벤트의 발행 시점을 기준으로 동작한다면, @TransactionalEventListener는 이벤트를 발행하는 트랜잭션 스코프의 커밋 또는 완료 전후 시점을 기준으로 동작한다. 이를 위해 phase라는 옵션이 존재하는데, 해당 이벤트 리스너가 어떤 시점에 실행될지를 지정할 수 있다.

또한, TransactionalEventListener는 기본적으로 트랜잭션 내에서 발행된 이벤트만 리스닝한다. 만약 트랜잭션 내에서 발행된 것이 아닌 이벤트도 모두 구독하고자 한다면, fallbackExecutioin 옵션을 true로 설정해주어야 한다.

그리고, @TransactionalEventListener를 AFTER 시점으로 활성화할 때 주의할 점이 있다. 이 경우 트랜잭션은 이미 커밋/롤백된 후의 리스너 로직이 호출되지만 트랜잭션 리소스는 아직 활성화되어 있기 때문에 리스너 내 코드 또한 동일한 리소스를 사용하려고 시도한다. 그러나 이 트랜잭션은 이미 커밋/롤백이 완료된 트랜잭션이기 때문에 리스너 내의 변경 사항은 트랜잭션에 반영되지 않게 된다. (이는 javadoc에서도 명시하고 있는 주의점이다.)

때문에 AFTER 시점으로 활용하고자 하며 트랜잭션 내에서 이루어져야 하는 로직을 리스너로 등록헐 때에는 반드시 새 트랜잭션을 열어줘야 한다. 이는 @Transactional 어노테이션의 propagation(전파 속성)을 REQUIRES_NEW로 설정하면 된다.

TransactionalEventListener 또한 기본적으로는 동일 스레드에서 동기로 동작하며, 비동기로 실행하고자 한다면 @Async 옵션과 함께 사용하면 된다. 이 경우 작업이 별도 스레드로 분리되며 어차피 트랜잭션도 분리되므로, 위와 같이 트랜잭션의 전파 속성을 지정해 줄 필요가 없다.

 

 

간단 예제

친구의 이름을 받아 저장하고 인사말을 반환하는 메서드가 있다. 이 메서드는 엔티티를 저장한 뒤 Created 이벤트를 발행한다고 해 보자.

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Friend {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column
    private String name;

}
@Slf4j
@Service
@RequiredArgsConstructor
public class FriendService {

    private final FriendRepository friendRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public String saveAndGetHelloMessage(String name) {
        Friend friend = friendRepository.save(Friend.builder()
                .name(name)
                .build());
        eventPublisher.publishEvent(new CreatedFriendEvent(friend.getId(), System.currentTimeMillis()));
        return "Hello, %s. your id is %s.".formatted(friend.getName(), friend.getId());
    }

}

발행된 이벤트를 받아 처리하는 4개의 리스너가 있다.

@Slf4j
@Component
@RequiredArgsConstructor
public class GreetedEventListener {

    private final FriendRepository friendRepository;

    @EventListener
    public void logGreetedEvent(CreatedFriendEvent event) {
        log.info("[friend ID: {}] 동기 이벤트 리스너 호출", event.friendId());
        findAndLogging("동기 이벤트 리스너", event.friendId());
    }

    @Async("customExecutor")
    @EventListener
    public void logGreetedEventAsynchronously(CreatedFriendEvent event) {
        log.info("[friend ID: {}] 비동기 이벤트 리스너 호출", event.friendId());
        findAndLogging("비동기 이벤트 리스너", event.friendId());
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener
    public void updateEntity(CreatedFriendEvent event) {
        log.info("[friend ID: {}] 동기 트랜잭셔널 이벤트 리스너 호출", event.friendId());
        findAndLogging("동기 트랜잭셔널 이벤트 리스너", event.friendId());
    }

    @Async("customExecutor")
    @TransactionalEventListener
    public void updateEntityAsynchronously(CreatedFriendEvent event) {
        log.info("[friend ID: {}] 비동기 트랜잭셔널 이벤트 리스너 호출", event.friendId());
        findAndLogging("비동기 트랜잭셔널 이벤트 리스너", event.friendId());
    }

    private void findAndLogging(String listenerName, long friendId) {
        Optional<Friend> friendOptional = friendRepository.findById(friendId);
        if (friendOptional.isPresent()) {
            Friend friend = friendOptional.get();
            log.info("[listener: {}] 친구 {} 찾았다!", listenerName, friend.getName());
        } else {
            log.info("[listener: {}] id가 {}인 친구가 없다.", listenerName, friendId);
        }
    }

}

이제 아래와 같은 테스트 코드를 통해 saveAndGetHelloMessage 메서드를 호출해보자.

@SpringBootTest
public class FriendServiceTests {

    @Autowired
    FriendService friendService;

    @Test
    void sayHello_success() {
        // given
        String name = "yihyun";

        // when
        String helloMessage = friendService.saveAndGetHelloMessage(name);

        // then
        assertTrue(helloMessage.contains("Hello, %s".formatted(name)));
    }

}

결과는 아래와 같다.

비동기 리스너들은 모두 별도 스레드에서 실행되었다. 또한 트랜잭셔널 이벤트 리스너들은 커밋 이후 시점에 실행되므로 이벤트 리스너들보다 나중에 실행되었다. 유일하게 비동기 이벤트 리스너만이 friend 엔티티 조회에 실패했는데, 이는 이벤트를 발행한 메서드의 트랜잭션이 종료되기 전에 비동기로 분리된 다른 스레드에서 생성된 트랜잭션이 엔티티 조회를 시도했기 때문이다.

 

 

이벤트 도입 후 느낀 장단점

장점

  1. 매우 낮아진 결합도 - 핵심 로직이 부가 로직의 변경에 전혀 영향을 받지 않는다. 애당초 어떤 부가 로직이 어떤 시점에 실행되는지조차 모르기 때문이다.
  2. 트랜잭션 스코프를 작게 유지 - 기존 코드에서는 핵심 로직과 부가 로직이 하나의 트랜잭션에 묶여 있었다. 트랜잭션의 핵심 원칙 ACID 중 A를 위반한 셈이다. 이벤트 리스너를 도입하면서, 부가 로직들이 각자의 로직을 단위로 트랜잭션 스코프를 유지할 수 있게 되었다.
  3. 비동기 시점 제어 용이 - 기존에도 별도 스레드로 분리되어 비동기로 처리되는 로직이 존재했으나, 위에서 언급한 것과 같이 커밋 시점 이전에 비동기 로직에서 엔티티를 조회하거나 하는 문제가 발생해 커밋 후 비동기 처리라는 시점 제어를 위해 어려움을 겪었던 경험이 있었다. 이벤트 리스너를 통해 트랜잭션이 완료된 후 비동기 로직을 시작할 수 있게 됨으로써 비동기 시점 제어가 용이해졌다.

단점

  1. 테스트 어려움 - 트랜잭션이 커밋된 후 별도 스레드에서 동작하는 로직을 테스트하는 것은 아주 어렵다.
  2. 전체 로직의 흐름을 파악하기 어려움 - 부가 로직을 직접 호출하는 구조일 때에는 핵심 로직을 읽으면 어떤 순서로 어떤 일이 일어나는지 한 번에 파악할 수 있으나, 부가 로직을 별도 리스너로 분리한 이후에는 발행된 이벤트를 어떤 리스너에서 처리하는지까지 모두 살펴야 전체 흐름을 이해할 수 있다. 다행히 인텔리제이 유료 버전을 사용하면 어떤 리스너가 해당 이벤트를 구독하고 있는지와 어떤 모듈이 해당 이벤트를 발행하고 있는지를 알려주기 때문에 개발 과정에서 불편함은 없지만, 여기에는 또 인텔리제이 의존도가 너무 올라간다는 단점이 있다. (그리고 무료 버전에서도 지원되는지 잘 모르겠다)
  3. 리스너의 전역적 예외 처리 어려움 - 리스너에서 전파한 예외는 발행자에게 가지 않는다. 전역적 예외 처리에 대한 명확한 가이드라인도 찾아보기 어려워 이 부분에서 어려움을 겪고 있는 스택오버플로우 글들도 많이 봤다. 결론적으로 나는 이 부분은 해결을 했는데, 이 내용은 길어지기 때문에 2장에서 다뤄보려고 한다.

 

 

결론

어떤 도구든 그러하겠지만, Spring Event가 반드시 모든 상황에서 좋은 기법이 되어줄 거라고 확신할 수는 없다.

그저 우리 서비스는 단점보다 아래와 같은 이점이 더 크게 작용한다고 느껴 도입했을 뿐이다.

  1. 핵심 로직에 여러 부가 로직이 붙는 특성이 있고, 이 부가 로직들이 여러 핵심 로직들에서 공통적으로 사용되는 경우가 많아 Spring Event를 도입하는 것이 결합도를 낮춰 중복 코드와 변경의 영향을 줄여줌
  2. 이벤트를 계층 구조로 관리하여 여러 모듈에서 중복으로 호출되던 로직들을 상위 계층 이벤트를 받는 하나의 리스너로 처리할 수 있도록 함

결론적으로 기존의 복잡하던 핵심 로직 클래스의 복잡도도 개선했고, 이벤트 단위로 로직을 분리하는 과정에서 부가 로직 간의 결합도도 착실하게 정리할 수 있었다.

하지만 Spring Event 도입의 진정한 삽질쇼는 이벤트 도입이 아니라 이벤트 리스너에서 전파한 예외 핸들러 구현이었다. 이 부분은 2장에서 다뤄보고자 한다.

 

 

예제 코드

본 글에서 사용된 예제 코드는 아래 리포지토리에서 확인할 수 있다.

 

GitHub - hyh1016/spring-event-playground: Repository containing sample code using spring event, written in Java 17 & Spring Boot

Repository containing sample code using spring event, written in Java 17 & Spring Boot 3.1 - GitHub - hyh1016/spring-event-playground: Repository containing sample code using spring event, writ...

github.com