Spring @EventListener & 비동기

Kevin·2024년 6월 22일
1

Spring

목록 보기
21/27
post-thumbnail

🤔 서론

카카오 테크 캠퍼스에서 개발했던 프로젝트인 “모르는 개 산책”에서부터 아래와 같은 불편함이 생겨났다.

각 Service간 비즈니스 로직에 따른 강결합으로 인해서 유지보수간 많은 리소스가 생기는데..??


위를 예시를 통해 더 자세히 이해 해보자.

PackageServiceImageService, 두 Service가 있다.

이 때 두 Service는 간단한 로직을 가지고 있다.

PackageService에서는 PackageJpaRepository에서 Package Entity를 저장한다.

그리고 저장시 반환된 Package Entity를 ImageService의 save() 메서드에 인자로 넘겨준다.

코드는 아래와 같다.


PackageService.class

    @Transactional
    public void savePackage(PackageDTO.RequestDTO requestDTO) {

        Package packageEntity = packageJpaRepository.save(Package.of(requestDTO));

        imageService.save(Image.of(packageEntity));
        
    }

강결합이라 함은 서로 다른 모듈간 강한 상호 의존 정도를 가지고 있음을 의미한다.

만약 기획이 변경되는 등으로 PackageService, ImageService의 기능을 수정하거나 DB 구조가 변경될 경우에 이 두 Service는 강하게 결합되어있음으로 유지보수에 비효율적이다.

위 코드는 굉장히 간단한 구조이기에 강결합 되어있다 하더라도 크게 변경 사항은 없지만, 비즈니스 로직 자체가 복잡한 코드의 경우에는 이러한 강결합 문제는 치명적이다.

강결합 문제는 Event Handling 방식을 사용하면 해결 할 수 있는데, 본론에서 더 자세히 이야기 해보고자 한다.


🫡 본론

Event Handling 방식을 통해 개발 했을 때 서론의 예시와 같이 PackageService, ImageService에 각각 save(), save() 로직이 있다고 가정 해보자.

일반적인 강결합 상태에서는 아래의 과정을 거치지만

PackageService.save() 실행 → ImageService.save() 실행

Event Handling 방식은 아래와 같은 과정을 거치게 된다.

PackageService.save() 실행 → 이벤트 발행 → ImageService.save() 실행

Event Handling 방식은 위와 달리 이벤트 발행 단계를 거치는 것을 확인 할 수 있다.

그리고 이를 통해서 PackageServiceImageService의 변경 사항과 관련 없이 수정이 필요 없게 된다.

이렇게 강결합 문제를 해결 할 수 있다.


Spring에서 Event Handling

그렇다면 위 Event Handling 방식을 Spring에서는 어떻게 구현할 수 있을까??

Spring 4 version 이전에는 이벤트 객체는 ApplicationEvent와 이벤트 핸들러는 ApplicationListener를 구현해야 했다.

그러나 4 version 이후로는 어노테이션 설정만으로 해결할 수 있다.

이번 글에서는 어노테이션 설정을 통해 EventHandling 하는 방법을 이야기 해보고자 한다.

글 제목과 같이 @EventListener를 사용하는데 아래의 과정을 통해 Event Handling을 할 수 있다.

  1. 이벤트를 전달할 객체를 생성한다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PackageEventDTO {

    private Package packageEntity;

    private List<ImageDTO.RequestDTO> images;

    public static PackageEventDTO of(Package packageEntity, List<ImageDTO.RequestDTO> images) {
        return PackageEventDTO.builder()
                .packageEntity(packageEntity)
                .images(images)
                .build();

    }

}

해당 객체가 이벤트 실행의 기준점이 된다.

이벤트 전달 객체라 하여 너무 어렵게 생각할 필요는 없는데 단순하게 이벤트를 전달 할 때 사용하는 DTO등을 의미한다.


  1. 이벤트 리스너를 정의할 핸들러를 생성한다.
@Component
@RequiredArgsConstructor
@Slf4j
public class PackageEventHandler {

    private final ImageService imageService;

    @Async
    @EventListener
    public void process(PackageEventDTO packageEventDTO) {

        log.info("EventListener를 통해 Event가 비동기로 Driven 되었습니다.");

        imageService.saveImages(packageEventDTO);

    }

}

Event를 Handling 할 메서드 위에 @EventListener을 작성하면 된다.

이 때 파라미터가 같은 이벤트가 수행될 경우 @EventListener 어노테이션을 설정한 메서드에 한해서 호출된다.

이는 3단계에서 더 자세히 설명 해보겠다.


  1. 기존 서비스에 이벤트 발행자를 정의
@Service
@Log4j
@RequiredArgsConstructor
public class PackageService {

    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void savePackage(PackageDTO.RequestDTO requestDTO) {

        Package packageEntity = packageJpaRepository.save(Package.of(requestDTO));

        eventPublisher.publishEvent(PackageEventDTO.of(packageEntity, requestDTO.getImages()));

    }

위와 같이 ApplicationEventPublisher를 선언 해주고, 해당 객체의 publishEvent() 메서드에 2단계에서 정의한 메서드 중 이벤트를 Handling 할 메서드의 파라미터와 같은 인자를 넘겨주면 된다.

즉 나는 PackageEventDTO 객체를 파라미터로 가지고 있는 이벤트 메서드에게 Event Handling을 하기 위해서 PackageEventDTO 타입을 publishEvent() 메서드의 인자로 넘겨준 것이다.

이 때 나는 아래와 같은 궁금증이 생겼다.

[궁금한점]
파라미터 객체로 구분하는 방식이면 한 클래스(=이벤트 핸들러)에 너무 많은 메서드가 생기는건 아닌가?

[답]
클래스에 지정하는게 아니라 메서드에 지정하는 것이기 때문에 여러 역할을 가진 클래스로 분리하면 된다. 그리고 동일한 파라미터가 쓰일 일은 DTO를 중복해서 쓰는게 아니면 크게는 없기에 괜찮다.


스프링의 이벤트 리스너는 기본적으로 멀티 캐스팅 관계인데, 멀티 캐스팅이란 다수의 수신자가 존재할 수 있는 통신 형태이다.

이로 인해서 동일한 타입의 여러 리스너가 등록되어있다면 해당 타입의 모든 리스너가 이벤트를 받게 되므로 주의 해야 한다.

이는 아래와 같은 경우에 발생한다…

@Component
@RequiredArgsConstructor
@Slf4j
public class PackageEventHandler {

    @Async
    @EventListener
    public void process(PackageEventDTO packageEventDTO) {

			// ...
			
    }
    
}
    

@Component
@RequiredArgsConstructor
@Slf4j
public class ImageEventHandler {

    @Async
    @EventListener
    public void process(PackageEventDTO packageEventDTO) {

			// ...
			
    }
    
}

@Component
@RequiredArgsConstructor
@Slf4j
public class MemberEventHandler {

    @Async
    @EventListener
    public void process(PackageEventDTO packageEventDTO) {

			// ...
			
    }
    
}

위의 경우에 @EventListener를 통해 등록된 메서드들이 다 PackageEventDTO 객체를 파라미터로 두고 있다.

만약 eventPublisher.publishEvent()로 PackageEventDTO 객체를 Event Handling 하게 된다면 위 메서드들에게 모두 Handling 되게 된다.

물론 이 점을 이용할 수도 있겠다.


메서드에 @Async를 붙인 이유

Spring의 이벤트 리스너는 기본적으로 동기 방식으로 동작 하게 된다.

이는 트랜잭션이 하나의 범위로 묶일 수 있다는 것을 의미하기도 한다.

만약 이벤트를 발행하는 곳에서 트랜잭션이 시작된 상태라면 이벤트를 구독하는 곳에서도 동일한 트랜잭션을 공유하게 된다.

만약 이러한 동기 방식에서 DB INSERT 작업등으로 인해서 Event Listener가 시간이 오래 걸리는 작업이 생기는 경우 Block이 발생하기에 비동기 처리를 해주는 것이 좋다.

PackageService.save() 실행 → 이벤트 발행 → ImageService.save() 실행


즉 위와 같은 흐름에서 ImageService.save()가 굉장히 시간이 오래 걸리는 작업이라면 이로 인해서 Block이 발생할 수 있으니 이를 Handling 하는 메서드를 비동기 처리 해주는 것이다.

이러한 비동기적으로 처리하는 방법은 2가지가 있다.

@Async 어노테이션을 사용하는 방법과 ApplicationEventMulticaster를 사용하는 방법이 있다.

전자는 일부 메시지만 비동기 처리할 경우에는 효과적이나, 모든 메시지를 비동기 처리할 때는 일일이 어노테이션을 적용할 때 번거롭다.


@Async 어노테이션을 사용하는 방법

먼저 @Async 어노테이션에 대해서 간략하게 알아보자.

@Async란 Spring에서 지원하는 비동기 어노테이션으로, 로직의 비동기 처리를 지원한다.

Springboot Application에서 @EnableAsync 어노테이션을 적용하고, 비동기 처리 할 로직에 @Async 어노테이션을 적용하면 된다.

@EnableAsync
@SpringBootApplication
public class MySpringApplication {
	...
}
public class AsyncService {
		
		@Async
    public void asyncMethod(){
    	...
    }
    
}

이 때 @Async는 Thread Executor로 SimpleAsyncTaskExecutor를 사용한다.

SimpleAsyncTaskExecutor는 각 작업마다 새로운 스레드를 생성하고 비동기 방식으로 동작하며, 스레드 풀 방식의 Executor가 아니기에, 스레드를 재 사용하지 않는다.

그렇기에 공식 문서에서는 스레드 풀 방식의 TaskExecutor을 사용하길 권장한다.

@Async는 또한 inner method(= 호출하는 클래스의 본인 메서드)는 호출 할 수 없는데, 그 이유는 Spring AOP에 의해서 프록시 방식으로 작동되기 때문이다.

Spring Context에 있는 Async Bean이 호출되면 Spring이 개입하여 해당 Async Bean을 프록시 객체로 Wrapping하며, 호출한 객체는 실제로는 AOP를 통해 만들어진 프록시 객체화된 Async Bean을 참조하게 된다.

위의 설명과 같이 메서드 위에 이벤트 메서드에 @Async 어노테이션을 사용해주면 된다.

    @Async
    @EventListener
    public void process(PackageEventDTO packageEventDTO) {

        log.info("EventListener를 통해 Event가 비동기로 Driven 되었습니다.");

        imageService.saveImages(packageEventDTO);

    }

위 각 이벤트 리스너 위에 @Async 어노테이션을 작성 해주면 된다.


ApplicationEventMulticaster를 사용하는 방법

일부 메세지만 비동기 처리할 경우에는 위와 같이 직접 어노테이션을 작성해주면 되지만 그게 아니라 모든 메세지들을 비동기 처리할 것이라면

ApplicationListener 객체를 관리하고 객체에 이벤트를 전달하기 위한 인터페이스인 ApplicationEventMulticaster의 구현체의 설정을 변경 시켜주면 된다.

ApplicationEventMulticasterApplicationListener 객체를 관리하고 객체에 이벤트를 전달하기 위한 인터페이스이다.

import static org.springframework.context.support.AbstractApplicationContext.APPLICATION_EVENT_MULTICASTER_BEAN_NAME;

@Configuration
public class ApplicationEventConfig {

    @Bean(name = APPLICATION_EVENT_MULTICASTER_BEAN_NAME)
    public ApplicationEventMulticaster applicationEventMulticaster() {
        SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();
        eventMulticaster.setTaskExecutor(asyncExecutor());
        return eventMulticaster;
    }

    private Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(10000);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(10);
        executor.initialize();
        return executor;
    }
}

위 코드와 같이 비동기로 동작하는 ApplicationEventMulticaster 객체를 만들어준 후 Bean으로 등록된 메서드에서 리턴 해주면된다.


레퍼런스

[Spring] 스프링에서 이벤트의 발행과 구독 방법과 주의사항, 이벤트 사용의 장/단점과 사용 예시
늘 감사합니다.. 망개님..

profile
Hello, World! \n

0개의 댓글