문홍의 공부장

[Java/Spring] Event publish & Listener 본문

개발/Spring

[Java/Spring] Event publish & Listener

moonong 2024. 3. 9. 11:03
반응형

서비스 로직 간의 강결합이 주는 문제점

애플리케이션을 개발하면서, 한 번의 요청에 의해 2가지 이상의 기능을 수행하는 경우가 빈번하게 발생한다. 혹은, 간단하게 시작된 도메인 로직이 서비스가 확장됨에 따라 여러가지 추가 로직이 늘어나는 경우 역시 발생한다.

이러한 상황에서, 다수의 기능을 하나의 메서드에서 코드로 구현하면 기능 간 강결합이 생기게 된다. 강한 결합이 생기면 생길수록 로직을 분리해서 관리하기 어렵고, 특정 기능에 문제가 발생하였을 때 이를 처리하는 로직 역시 섞이게 된다.

클라이언트에서 회원 가입 요청이 발생하였을 때, 아래와 같이 프로세스가 진행된다고 가정해보자. (로직에서 사용한 메서드는 별도 구현이 되어있음을 전제한다.)

 

  1. 회원 등록
  2. 가입 축하 메일 발송
@Service
@RequiredArgsConstructor
public class MemberService { 
    private final MemberRepository memberRepository;
    private final MailService mailService;

    @Transactional
    public MemberModel save(MemberRegisterModel model){
        // 1. 회원 등록
        Member member = saveMember(model);

        // 2. 가입 축하 메일 발송 (발송 이력 DB 저장)
        mailService.sendMail(member.id, member.email);

        // 3. 어떠한 사유로 인해 exception 발생
        if ("admin".equals(model.getName())) {
            throw new RuntimeException("can not use this name.");
        }

        return new MemberModel(member); 
    }
}

 

이 코드에는 몇 가지 개선점이 존재한다.

 

1. 기능 간의 강한 의존성

회원 등록과 메일 발송은 각각 MemberService, MailService 로 분리되어 있는 것 처럼 보이지만, 사실은 MemberService 의 메서드 내에서 MailService 를 직접 호출하는 방식이다. 만약, 서비스가 성장함에 따라 MailService 를 별도 모듈/시스템으로 분리해야 할 때, MemberService 의 코드도 함께 수정해야 하는 상황이 발생한다.

 

2. 트랜잭션 관리

위의 코드에서는 saveMember()sendMail() 이 단일 트랜잭션으로 묶여 동작한다. 만일 회원 등록은 성공하였으나 메일 전송 시 오류가 발생한다면, 트랜잭션 매니저에 의해 save() 메서드 전체가 롤백된다. 주요 기능 단위로 트랜잭션을 분리할 필요가 있다.

 

3. 동기 방식의 성능 이슈

도메인 및 프로세스에 따라 다르겠지만, 회원 가입과 이메일 발송은 굳이 동기 방식으로 동작할 필요는 없는 로직이다. 특히, 메일 발송 서비스는 외부 기능을 이용하는 경우가 대다수이며, 애플리케이션 내부에서 동작하는 기능과 비교하였을 때 소요 시간 역시 오래 걸린다.
회원 가입 성공에 200ms가 소요되었는데, 이메일 발송 로직이 3000ms 가 걸리는 기능이라고 가정할 때, 유저는 응답을 받기 위해 3200ms(+a) 를 기다려야 하는 상황이 발생한다. 이를 비동기 방식으로 처리한다면 성능적인 이점을 가져갈 수 있다.

 

4. DB 결과와 무관하게 발송되는 이메일

이메일 발송이 완료되고, save() 메서드의 마지막까지 예외가 없을 때 commit 이 일어난다. 위의 코드에서 만일 member 엔티티를 model 로 변환하는 과정에서 에러가 발생하였다면, 가입 축하 이메일을 받았지만 실제 회원은 생성되지 않은 상황이 발생하게 된다. 때문에 이메일 발송은 commit 이 일어난 뒤에 실행 되어야 한다.

이벤트 방식을 통해 강결합 제거하기

이러한 문제를 해결하고, 기능 간의 느슨한 결합을 위해 이벤트 방식 을 고려할 수 있다. 이벤트 발행을 통해 독립적인 도메인의 비즈니스 로직을 호출하여 서비스 로직 간 강결합을 끊어낼 수 있다.

스프링에서 제공하는 @EventListner를 통해 이벤트를 구현해보자.

동작원리

스프링 4.2 이전에는 반드시 이벤트 클래스가 ApplicationEvent 클래스를 상속받아야 했다. 하지만 4.2부터는 해당 클래스를 상속받지 않고도 이벤트를 발행 및 구독할 수 있도록 하였다.

스프링에서 이벤트 발행은 ApplicationEventPublisher 인터페이스가 담당한다. publishEvent() 메서드는 AbstractApplicationContext 추상 클래스에 구현되어, 최종적으로 애플리케이션 컨텍스트(ApplicationContext) 에서 관리된다.

 

스프링에서 이벤트 발행은 ApplicationEventPublisher 인터페이스가 담당하는데, 이를 구현하는 것은 결국 애플리케이션 컨텍스트(ApplicationContext)이다. 애플리케이션 컨텍스트는 빈 탐색과 등록, 리소스 처리 등 많은 책임을 제공하고 있는데, 인터페이스 분리 원칙(ISP)에 맞게 이벤트 발행 책임만 처리하는 것이 바로 ApplicationEventPublisher 인터페이스이다.

public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory, MessageSource, ApplicationEventPublisher, ResourcePatternResolver {
...
}

 

 

ApplicationEventPublisher 인터페이스와 AbstractApplicationContext 추상 클래스는 각각 아래와 같다.
AbstractApplicationContext 는 publishEvent() 메서드 내부에서 ApplicationEventMulticaster 를 거쳐 multicastEvent() 메서드를 실행한다. multicastEvent() 에서 invokeListener()를 통해 이벤트 리스너 실행(구독)을 일으킨다.

 

이벤트 구독은 ApplicationListener 인터페이스를 구현하거나 @EventListener를 사용하면 된다.
스프링 4.2 이전에는 이벤트를 구독하려면 반드시 ApplicationListener 인터페이스를 구현해주어야 했다. 하지만 ApplicationListener 인터페이스를 직접 구현하는 방식은 상당히 번거롭다. 스프링 4.2부터 @EventListener 어노테이션을 제공하여, 편리하게 이벤트를 구독할 수 있다.

 

@FunctionalInterface  
public interface ApplicationEventPublisher {  
    default void publishEvent(ApplicationEvent event) {  
        this.publishEvent((Object)event);  
    }  

    void publishEvent(Object event);  
}
public abstract class AbstractApplicationContext extends DefaultResourceLoader implements ConfigurableApplicationContext {
    @Nullable  
    private ApplicationEventMulticaster applicationEventMulticaster;
    ...

    protected void publishEvent(Object event, @Nullable ResolvableType eventType) {  
        Assert.notNull(event, "Event must not be null");  
        Object applicationEvent;  
        if (event instanceof ApplicationEvent) {  
            applicationEvent = (ApplicationEvent)event;  
        } else {  
            applicationEvent = new PayloadApplicationEvent(this, event);  
            if (eventType == null) {  
                eventType = ((PayloadApplicationEvent)applicationEvent).getResolvableType();  
            }  
        }  

        if (this.earlyApplicationEvents != null) {  
            this.earlyApplicationEvents.add(applicationEvent);  
        } else {  
            this.getApplicationEventMulticaster().multicastEvent((ApplicationEvent)applicationEvent, eventType);  
        }  

        if (this.parent != null) {  
            if (this.parent instanceof AbstractApplicationContext) {  
                ((AbstractApplicationContext)this.parent).publishEvent(event, eventType);  
            } else {  
                this.parent.publishEvent(event);  
            }  
        }  

    }
    ApplicationEventMulticaster getApplicationEventMulticaster() throws IllegalStateException {  
        if (this.applicationEventMulticaster == null) {  
            throw new IllegalStateException("ApplicationEventMulticaster not initialized - call 'refresh' before multicasting events via the context: " + this);  
        } else {  
            return this.applicationEventMulticaster;  
        }
}
public class SimpleApplicationEventMulticaster extends AbstractApplicationEventMulticaster {
    public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {  
        ResolvableType type = eventType != null ? eventType : this.resolveDefaultEventType(event);  
        Executor executor = this.getTaskExecutor();  
        Iterator var5 = this.getApplicationListeners(event, type).iterator();  

        while(var5.hasNext()) {  
            ApplicationListener<?> listener = (ApplicationListener)var5.next();  
            if (executor != null) {  
                executor.execute(() -> {  
                    this.invokeListener(listener, event);  
                });  
            } else {  
                this.invokeListener(listener, event);  
            }  
        }  

    }
}

이벤트 방식 적용하기

초기 코드를 이벤트 방식을 이용하여 개선해보자.

ApplicationEventPublisher.publishEvent() 를 이용해 이벤트를 발행하고, 이벤트를 구독할 수 있도록 SaveMemberEventListener 클래스를 생성하였다. @EventListener 어노테이션을 통해 이벤트를 구독하여 sendMail() 를 수행할 수 있도록 하였다.

@Service
@RequiredArgsConstructor
public class MemberService { 
private final MemberRepository memberRepository;
private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void save(MemberRegisterModel model) {
         // 1. 회원 등록
        Member member = saveMember(model);

        // 2. 가입 축하 메일 발송 이벤트 발행
        eventPublisher.publishEvent(new SavedMemberEvent(model)); 

        // 3. 어떠한 사유로 인해 exception 발생
        if ("admin".equals(model.getName())) {
            throw new RuntimeException("can not use this name.");
        }
    }
}
---
public class SavedMemberEvent {
    private Long id; 
    private String email; 
}
@Component
@RequiredArgsConstructor
public class SaveMemberEventListener {
    private final MailService mailService; 

    @EventListener
    public void sendMail(SavedMemberEvent event){
        // 메일 발송 이벤트 구독
        mailService.sendMail(event.id, event.email);
    }
}

@EventListener와 @TransactionEventlistener

@EventListener와 @TransactionEventlistener의 차이는 실행시점 차이이다.

3. 어떠한 사유로 인해 exception 발생을 했을 경우, 2. 회원가입 축하 메일 전송 이벤트가

  • @EventListener 로 지정되었을 경우, 호출 즉시 실행되기 때문에 Rollback이 불가능하다
  • @TransactionEventlistener로 지정되었을 경우, 이벤트를 잠시 들고있다가 save() 메서드의 트랜잭션이 커밋된 후 실행된다. (이벤트 실행시점 지정 가능)

그렇기 때문에, 하나의 트랜잭션 경계 안에서 트랜잭션에 관련된 이벤트 처리를 하고 싶다면@TransactionalEventListener를 사용하여 처리할 수 있다. 트랜잭션 간 이벤트 실행 시점은 기본적으로 commit 후에 일어나나, phase 옵션을 통해 실행 시점을 지정할 수 있다.

  • AFTER_COMMIT - 트랜잭션 결과를 반영(commit)하고 난 시점에 이벤트 실행 (default)
  • AFTER_ROLLBACK – 트랜잭션에 rollback이 일어난 시점에 이벤트 실행
  • AFTER_COMPLETION – 트랜잭션이 끝났을 때(commit이든 rollback이든) 이벤트 실행
  • BEFORE_COMMIT - 트랜잭션이 커밋되기 전에 이벤트 실행

비동기 방식으로 @EventListener 사용하기

@EventListener 는 동기적으로 실행되어, 기본적으로 하나의 쓰레드를 공유한다. 비동기로 실행하고자 한다면 @Async 를 함께 사용하여 별도의 쓰레드에서 동작하도록 하여야 한다.

@Component
@RequiredArgsConstructor
public class SaveMemberEventListener {
    private final MailService mailService; 

    @Async  
    @EventListener
    public void sendMail(SavedMemberEvent event){
        // 메일 발송 이벤트 구독
        mailService.sendMail(event.id, event.email);
    }
}

 

 

주의할 점은 예외처리에 관한 것이다.

 

비동기로 실행될 경우 새로운 쓰레드를 생성하여 로직이 수행되기 때문에, DispatcherServlet 에서 수행된 로직이 아니기에 @ControllerAdvice 를 이용한 전역 에러 핸들링에 잡히지 않게 된다. 이를 해결하기 위해AsyncUncaughtExceptionHandler 를 상속받아 비동기 쓰레드에서 발생하는 예외를 전역적으로 처리할 수 있다.

 

@EnableAsync
@Configuration
public class AsyncConfig extends AsyncConfigurerSupport {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();  
executor.setCorePoolSize(30);  
executor.setMaxPoolSize(100);  
executor.setQueueCapacity(5000);  
executor.setThreadNamePrefix("def-async-");  
executor.initialize();  
return executor;
    }

    @Override 
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { 
        return new AsyncExceptionHandler(); 
    }
}
---
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(AsyncExceptionHandler.class);

    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        logger.warn("비동기 처리중 예외가 발생했습니다\n" +
                "예외 메세지 : " + ex.getMessage() + "\n" +
                "메소드 : " + method.getName() + "\n" +
                "파라미터 : " + Arrays.toString(params));
    }
}

References.

반응형