Yeji's Tech Notes
article thumbnail
반응형

개요

최근 프로젝트를 진행하면서 @EventListener를 이용해 프로세스를 분리하여 처리하는 방법을 적용해 기능의 의존성을 분리시키고 속도를 단축시켰습니다. 기능 개발이 마무리되면 @EventListener에 대해 정리해보는 글을 쓰면 좋을 것같아 이번 글을 작성했습니다.

@EventListener를 사용하기 위해선 많은 사전지식이 필요했는데요, IoC컨테이너 ApplicationContext등 기능에 관한 내용에 대해서도 추가 정리해보았습니다.

 

** 이번글에서 사용하는 예제는 실제 기능 로직은 제하고 Spring Event에 중점을 둬서 작성했습니다. **

스프링 이벤트를 사용하는 이유

우선 SpringEvent를 왜 사용하는지 알 필요가 있습니다. SpringEvent를 사용하는 주된 이유는 서비스 간의 강한 의존성을 줄이기 위함입니다. 위의 그림에서 계정을 생성하는 기능이 있고, 해당 기능은 내부적으로  사용자가 계정 생성 후 서비스에서 요청을 보내면 해당 서비스를 이미 가입된 이메일이 있는지 확인, 이메일 확인을 위해 인증코드 전송, 인증코드를 확인 요청하기 위한 핸드폰 알림메세지 전송 총 3가지 프로세스를 거치면 계정이 최종적으로 생성됩니다.

지금 예시에서는 3가지 프로세스밖에 없지만 실무에서는 이보다 복잡한 프로세스와 기능들이 있습니다. 이렇게 프로세스가 복잡해질수록 서브에 강한 의존성이 생기는데, 스프링 이벤트를 통해 의존성을 줄일 수 있습니다.

 

 

IoC ( Inversion of Control ) 컨테이너

IoC(제어반전) : 객체 생성, 생명주기의 관리까지 모든 객체에 대한 제어권이 바뀌는 것을 의미.
컨테이너 :  컨테이너는 보통 객체의 생명주기 관리, 생성된 인스턴스들에게 추가적인 기능을 제공하도록 하는 것

스프링 프레임워크도 객체를 생성, 관리, 의존성 주입을 해주는 컨테이너가 있는데 이걸 IoC컨테이너 ( 스프링 컨테이너 )라고 부릅니다.

IoC컨테이너(스프링 컨테이너)내에서도 기능이 많지만 여기서 EventListener에 대한 기능만 집중해서 설명하겠습니다.

 

우선 스프링 컨테이너의 최상이 인터페이스로 빈(Bean)이라고 불리는 객체를 관리하는 BeanFactory가 있습니다. 이 빈 BeanFactory에 여러가지 부가 컨테이너 기능들을 추가한 ApplicationContext가 있습니다. 실제로 BeanFactory를 직접 다루는 것보단 ApplicationContext를 통해 Bean을 관리합니다.

 

ApplicationEventPublisher

ApplicationContext가 상속하는 인터페이스 중 저희가 자주 사용하는 BeanFactory외에 MessageSource, EnvironmentCapable, ResourceLoader 그리고 ApplicationEventPublisher가 있습니다. 

 

여기서 나오는 ApplicationEventPublisher가 우리가 앞서 언급한 이벤트 기반 프로그래밍에 필요한 기능들을 제공해주고 있습니다.

💡 이벤트 기반 프로그래밍(Event-Driven Programming) 이란?
이벤트 기반 프로그래밍은 이벤트를 송수신하는 어플리케이션을 구축하는 기능입니다. 프로그램이 이벤트를 보내면, 프로그램은 해당 이벤트와 상황에 등록된 콜백 함수를 실행해서 관련된 데이터를 전달해 응답합니다. 이 패턴으로, 이벤트는 어떤 함수를 구독하지 않아도 에러없이 이벤트를 보낼 수 있습니다.

- Event는 ApplicationEvent를 확장해서 사용
- 이벤트 게시자(Event Publisher)는 ApplicationEventPublisher에 이벤트 발행
- 이벤트 리스너(Event Listener)는 ApplicationListener 인터페이스를 구현해 이벤트를 받음 

https://github.com/30-seconds/30-seconds-of-interviews/blob/master/questions/event-driven-programming.md

 

GitHub - 30-seconds/30-seconds-of-interviews: A curated collection of common interview questions to help you prepare for your ne

A curated collection of common interview questions to help you prepare for your next interview. - GitHub - 30-seconds/30-seconds-of-interviews: A curated collection of common interview questions to...

github.com

 

ApplicationEventPublisher를 이용한 이벤트 처리

스프링 4.2전,후로 이벤트처리 방식이 다릅니다. 예제를 통해서 스프링4.2에서는 이벤트 처리를 어떻게 했는지 알아보겠습니다.

 

이벤트 처리로 변경하기 전의 SignIn처리하는 프로세스 입니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;
    private final MessageService messageService;
    
    /**
     * 회원가입
     *
     * @param signInForm
     * @return AccountDTO
     */
    public AccountDTO signIn(SignInForm signInForm) {
        String email = signInForm.getEmail();

        // 등록된 이메일 확인
        accountRepository.findByEmail(email).ifPresent(account -> {throw new RuntimeException("이미 가입된 이메일 주소가 있습니다.");});

        // 이메일 인증
        String authCode = sendSignUpConfirmEmail(email);

        // 알림 메세지 전송
        sendSignUpConfirmMessage(signInForm.getPhoneNumber());

        // 저장
        Account account = Account.from(signInForm, authCode);
        accountRepository.save(account);

        return AccountDTO.from(account);
    }
    
}

 

이벤트 처리시 Event 객체, Event 발행을위한 EventPublisher, Event를 받기위한 EventListener가 있어야합니다.

우선 Event객체를 생성해보겠습니다.

 

Event객체 생성

Spring Framework 4.2 이전에서 Event 생성 클래스

import org.springframework.context.ApplicationEvent;

public class AccountEvent extends ApplicationEvent {

    private final String email;
    private final String phoneNumber;

    public AccountEvent(Object source, String email, String phoneNumber) {
        super(source);
        this.email = email;
        this.phoneNumber = phoneNumber;
    }

    public String getEmail() {
        return email;
    }

    public String getPhoneNumber() {
        return phoneNumber;
    }

}

 

Spring Framework 4.2 이후 Event 생성 클래스

public record CurrentAccountEvent(String email, String phoneNumber) {}

Event 클래스는 이벤트를 처리하는데 필요한 데이터를 가지고 있으며, 기존에는 ApplicationEvent 클래스를 확장하여 사용하였지만, 4.2이후에서는 ApplicationEvent를 확장하지 않아도 됩니다. 또한 Event객체는 한번 생성되면 변하지 않으므로 record로 변경해서 사용할 수 있습니다.

 

EventPublisher

 

Spring Framework 4.2 이전 EventPublisher 발행

@Slf4j
@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;
    private final MessageService messageService;
    private final ApplicationEventPublisher applicationEventPublisher;


    /**
     * 회원가입
     *
     * @param signInForm
     * @return AccountDTO
     */
    public AccountDTO signIn(SignInForm signInForm) {
        log.info("start signIn process : {}", signInForm.getEmail());
        String email = signInForm.getEmail();

        // 등록된 이메일 확인
        accountRepository.findByEmail(email).ifPresent(account -> {throw new RuntimeException("이미 가입된 이메일 주소가 있습니다.");});

        // 저장
        Account account = Account.from(signInForm);
        accountRepository.save(account);

        applicationEventPublisher.publishEvent(new AccountEvent(this, signInForm.getEmail(), signInForm.getPhoneNumber()));

        return AccountDTO.from(account);
    }


}

 

 

Spring Framework 4.2 이후 EventPublisher 발행

@Slf4j
@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;
    private final MessageService messageService;
    private final ApplicationEventPublisher applicationEventPublisher;


    /**
     * 회원가입
     *
     * @param signInForm
     * @return AccountDTO
     */
    public AccountDTO signIn(SignInForm signInForm) {
        log.info("start signIn process : {}", signInForm.getEmail());
        String email = signInForm.getEmail();

        // 등록된 이메일 확인
        accountRepository.findByEmail(email).ifPresent(account -> {throw new RuntimeException("이미 가입된 이메일 주소가 있습니다.");});
        
        // 저장
        Account account = Account.from(signInForm);
        accountRepository.save(account);
		// 이벤트 발행
        applicationEventPublisher.publishEvent(new CurrentAccountEvent(signInForm.getEmail(), signInForm.getPhoneNumber()));

        return AccountDTO.from(account);
    }
    
}

 

이벤트를 발행할 Service에 ApplicationEventPublisher Bean을 주입 후 publishEvent메소드에 Event객체를 넣는 점은 달라지지 않았지만 publishEvent()메소드 구현부를 보면 왜 Event객체가 ApplicationEvent를 상속받지 않아도 되는지 알 수 있습니다.

 

위의 publishEvent(Object event)가 추가 되었기 때문에 이벤트를 생성하는 (Object) source 를 publishEvent 메소드 내부에서 허용하므로 4.2이후부터는 Event객체에 ApplicationEvent를 상속받을 필요가 없어졌습니다.

 

EventListener

 

Spring Framework 4.2 이전 EventListener

@Component
@RequiredArgsConstructor
public class AccountEventListener implements ApplicationListener<AccountEvent> {

    private final MessageService messageService;
    private final AccountRepository accountRepository;

    @Override
    public void onApplicationEvent(AccountEvent event) {
        
        sendSignUpConfirmEmail(event.getEmail());

        sendSignUpConfirmMessage(event.getPhoneNumber());

    }

    private void sendSignUpConfirmEmail(String email) {
        String authCode = RandomStringUtils.randomAlphabetic(12);
        EmailMessage emailMessage = EmailMessage.builder().to(email).subject("회원가입 인증코드 전송").message(authCode).build();
        messageService.sendEmail(emailMessage);

        Account account = accountRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("등록된 이메일이 없습니다."));
        account.updateAuthCode(authCode);
    }

    private void sendSignUpConfirmMessage(String phoneNumber) {
        PushMessage pushMessage = PushMessage.builder().to(phoneNumber).meesage("회원가입 인증코드 전송했습니다. 메일에서 확인바랍니다.").build();
        messageService.pushMessage(pushMessage);

    }
}

 

Spring Framework 4.2 이후 EventListener

@Component
@RequiredArgsConstructor
public class CurrentAccountEventListener {

    private final MessageService messageService;

    /**
     * 이메일 인증코드 전송
     *
     * @param event 인증메일
     * @return authCode 인증코드
     */
    @EventListener
    public void sendSignUpConfirmEmail(CurrentAccountEvent event) {
        String authCode = RandomStringUtils.randomAlphabetic(12);
        EmailMessage emailMessage = EmailMessage.builder().to(event.email()).subject("회원가입 인증코드 전송").message(authCode).build();
        messageService.sendEmail(emailMessage);
    }

    /**
     * 핸드폰 알림 전송
     *
     * @param event event
     */
    @EventListener
    public void sendSignUpConfirmMessage(CurrentAccountEvent event) {
        PushMessage pushMessage = PushMessage.builder().to(event.phoneNumber()).meesage("회원가입 인증코드 전송했습니다. 메일에서 확인바랍니다.").build();
        messageService.pushMessage(pushMessage);
    }

}

 

EventListener역시 4.2를 기점으로 변경되었습니다. 기존에는 ApplicationListener<AccountEvent> 인터페이스를 구현하여 사용하였지만 4.2이후 @EventListener 어노테이션 통해 발생하는 이벤트를 캐치할 수 있습니다.

 

결과

Event 분기마다 log를 찍어본 결과 정상적으로 동작하는 것을 확인할 수 있었습니다.

 

@TransactionEventListener

위에서는 @EventListener 사용방법과 원리에 대해서 정의했다면 이번에는 좀 더 향상된 기능을 가진 @TransactionEventListener에 대해서 이야기 해보겠습니다. @TransactionEventListener는 동작하는 메서드를 트랜잭션으로 묶어서 처리하는 경우, Transaction의 상태에 따라 발생하는 이벤트를 처리해 주는 이벤트 리스너입니다. 예를들면, @EventListener에 의해 분리된 로직에 DB를 접근하는 로직이 존재할 경우에는 TransactionEventListener를 사용하는 것을 권장합니다.

 

@TransactionalEventListener는 이벤트를 처리할 단계를 명시해줘야하는데, 기본값이 위에 이미지에 표기된 AFTER_COMMIT입니다. 그 외에도 다양한 처리단계들이 존재합니다.

 

TransactionPhase 설명
BEFORE_COMMIT transaction에서 data commit 직전에 event 수행
AFTER_COMMIT commit 완료 후 event 수행
AFTER_ROLLBACK transaction이 rollback 되었을 경우 event 수행
AFTER_COMPLETION transaction이 수행완료 후 event 수행

 

저는 기존에 작성했던 코드 외에 추가로 email을 가진 사용자가 존재하는지 확인하는 로직을 @TransactionalEventListener로 분리 시켜주겠습니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class CurrentAccountEventListener {

    private final MessageService messageService;
    private final AccountRepository accountRepository;

    @TransactionalEventListener
    public void checkIsExistedUser(CurrentAccountEvent event) {
        log.info("check is existed User");
        // 등록된 이메일 확인
        accountRepository.findByEmail(event.email()).ifPresent(account -> {throw new RuntimeException("이미 가입된 이메일 주소가 있습니다.");});
    }
    
}

여기서 주의하셔야될 부분이 @TransactionalEventListener만 표기해서는 안됩니다. publishEvent호출하는 메소드에도 @Transactional를 선언해줘야 합니다

@Slf4j
@Service
@RequiredArgsConstructor
public class AccountService {

    ...
    
	/**
     * 회원가입
     *
     * @param signInForm
     * @return AccountDTO
     */
    @Transactional
    public AccountDTO signIn(SignInForm signInForm) {
        log.info("start signIn process : {}", signInForm.getEmail());

        ...
    }
}

아래와 같이 정상적으로 수행되는 것을 볼수 있습니다 :) 

 

@Order

 

하지만 위의 이미지에서 보이는 문제는 프로세스 시작 전에 해당 email로 가입한 계정이 있는지 사전에 확인해야 되지만 로그를 보면 마지막에 수행되는 것을 볼 수 있습니다. 이러한 순서를 보장받기 위해서는 @Order를 사용해 순서를 명시해 줄 수 있습니다.

 

@Slf4j
@Component
@RequiredArgsConstructor
public class CurrentAccountEventListener {

    ...

    @Order(value = Ordered.HIGHEST_PRECEDENCE)
    @TransactionalEventListener
    public void checkIsExistedUser(CurrentAccountEvent event) {
        log.info("check is existed User");
        // 등록된 이메일 확인
        accountRepository.findByEmail(event.email()).ifPresent(account -> {throw new RuntimeException("이미 가입된 이메일 주소가 있습니다.");});
    }

	...
    
}

 

@Order(value = Ordered.HIGHEST_PRECEDENCE)를 명시해줌으로써 checkIsExistedUser메소드가 가장 먼저 실행되는 것을 볼 수 있습니다. @Order의 기본값은 Ordered.LOWEST_PRECEDENCE로 우선순위가 제일 뒤로 밀려나게 됩니다.

 

Propagation.REQUIRES_NEW

만약 Event 처리 과정에서 " 나는 메일전송까지 완료된 시점에서 핸드폰 알림을 보내고 싶어! " 이럴 경우에는 어떻게 하면 좋을까요?

TransactionalEventListener에서 AFTER_COMMIT으로 처리 단계를 명시해주면 됩니다. 하지만 이 과정 중에서 약간의 문제가 있습니다. 추가로 핸드폰 알림 전송 확인을 위한 Account Entity에 sentMessage와 sentMessage를 체크하는 코드를 추가로 작성해보겠습니다.

 

Account Entity sentMessage 필드 추가

package com.example.eventlistenerexample.modules.account;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Getter;

@Getter
@Entity
public class Account {

   ...

    /** 메세지 전송 확인 용 */
    private boolean sentMessage;
	
    ...

    public void updateSentMessage() {
        this.sentMessage = true;
    }

}

 

sendSignUpConfirmMessage 메소드에 성공적으로 메세지를 전송 메소드 생성

public class CurrentAccountEventListener {
	...

	/**
     * 핸드폰 알림 전송
     *
     * @param event event
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendSignUpConfirmMessage(CurrentAccountEvent event) {
        log.info("send SignUp Confirm Message");
        PushMessage pushMessage = PushMessage.builder().to(event.phoneNumber()).meesage("회원가입 인증코드 전송했습니다. 메일에서 확인바랍니다.").build();
        messageService.pushMessage(pushMessage);

        Account account = accountRepository.findByEmail(event.email()).orElseThrow(() -> new NoSuchMessageException("찾을 수 없는 계정입니다."));
        // 메세지 전송 완료 
        account.updateSentMessage();
        log.info("account update send message status");
    }

}

 

확인용 테스트코드 작성

@SpringBootTest
class AccountServiceTest {

    @Autowired
    private AccountService accountService;

    @Autowired
    private AccountRepository accountRepository;
    
    ...
    
    @Test
    @DisplayName("message 전송 완료 확인")
    void sendMessage() {
        SignInForm signInForm = new SignInForm();
        String mail = "choyeji1591@gmail.com";
        signInForm.setEmail(mail);
        signInForm.setPhoneNumber("010-1234-5678");
        signInForm.setPassword("choyeji");
        signInForm.setRePassword("choyeji");

        accountService.signIn(signInForm);
        Account account = accountRepository.findByEmail(mail).get();

        assertTrue(account.isSentMessage());
    }
}

 

하지만 예상한 것과 달리 테스트코드에서는 sentMessage 필드가 false라는 결괏값이 나왔습니다. 

이렇게 변경되지 않은 이유는 @TransactionalEventListener의 경우, event publisher의 트랜잭션 안에서 동작하며, 커밋이 된 이후 추카 커밋을 허용하지 않기 때문입니다.  그래서 변경,수정,삭제 작업이 필요할 경우에는 해당 메소드 위에 추가적으로@Transactional(propagation=Progation.REQUIRES_NEW)를 추가 설정하는 것입니다.

위에서 언급한 설정을 추가하면 정상적으로 동작하는 것을 알 수 있습니다. REQUIRES_NEW 설정은 해당 메서드가 이전 트랜잭션을 이어받지 않고 새로운 트랜잭션을 시작한다는 설정입니다. 

결국 위의 설정을 통해 event publisher의 transaction과 별도의 새로운 transaction에서 작업을 수행하게 됩니다.

 

예제코드

https://github.com/cyeji/blog-sample/tree/main/eventlistener-example

 

GitHub - cyeji/blog-sample

Contribute to cyeji/blog-sample development by creating an account on GitHub.

github.com

 

반응형
profile

Yeji's Tech Notes

@Jop

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!