Spring Event를 사용해보자

Lee hunil·2024년 2월 17일

Spring Event

목록 보기
1/3
post-thumbnail

문제 상황 발생

@Transactional
public void deleteReservation(Long reservationId){
    User user = userUtils.getUserFromSecurityContext();
    Reservation reservation = queryReservation(reservationId);
    
    reservation.validUserIsHost(user.getId());
    reservation.validPastReservation();

    notificationUtils.sendNotification(user, reservation,
            TitleMessage.RESERVATION_DELETE, ContentMessage.RESERVATION_DELETE); // 방 참가자들에게 알림 전송
    notificationReservationUtils.deleteNotificationReservation(reservation); // 예약 알림 삭제

    chatRepository.updateReservationNull(reservationId);
    reservationRepository.delete(reservation); // 약속방 삭제
}

약속방을 삭제할 때, 참가자들에게 "회원님이 참여한 약속방이 삭제되었습니다."라는 알림을 전송하는 기능을 개발하던 중 문제가 발생하였다.

현재 약속방 삭제 로직과 알림 전송 로직이 강하게 결합되어 있어, 만약 한쪽 기능에서 문제가 발생하면 전체 트랜잭션이 롤백되었다. 하지만 주 기능은 약속방 삭제이므로, 알림 전송이 누락되더라도 약속방 삭제는 정상적으로 이루어지는 것이 더 중요하다고 판단했다.

또한, 알림 전송이 오래 걸릴 경우 약속방 삭제가 완료되었음에도 알림 전송이 끝날 때까지 기다려야 하므로 성능 저하의 우려도 있었다.

이를 해결하기 위해 Spring Event를 활용하여 알림 기능을 비동기로 처리하기로 했다.

Spring Event를 사용하는 이유

Spring Event를 사용하기에 앞서 문제상황을 정리하고 Spring Event를 사용하는 이유에 대해서 정리하겠다.

1. Spring Event를 사용하는 가장 주된 이유는 서비스 간의 강한 결합을 줄이기 위함이다.
위와 같은 상황에서 약속방 삭제 프로세스와 알림 전송 프로세스 간의 강한 결합으로 인해 시스템이 복잡해지고 유지 보수가 어려운 경우가 발생하였다. 그래서 이 둘의 프로세스를 Spring Event를 통해 분리하여 유연성과 확장성을 높일 수 있다.

2. 이벤트를 비동기로 실행 시 프로세스 처리 시간을 줄일 수 있다.
만약 약속방 삭제 프로세스 처리 시간이 10초 소요되고 알림 전송 프로세스 처리 시간이 30초 소요된다고 가정했을 때, 사용자는 총 40초를 기다려야 한다. 하지만 비동기로 실행시킬 경우 알림 전송 프로세스 처리 시간을 기다리지 않고 바로 약속방 삭제가 처리된다.

3. 이벤트를 비동기로 실행 시 트랜잭션을 분리할 수 있다.
위와 같은 상황에서는 한 트랜잭션에 묶여 있기 때문에 하나의 프로세스에 문제가 발생하면 모두 롤백 된다. 그래서 이를 분리해서 관리하는 게 더 효율적이다.

출처: https://dgjinsu.tistory.com/41

Event 구성요소

다음으로는 Event 구성요소에 대해서 알아보겠다. 구성요소로는 Event Class, Event Publisher, Event Listener 이렇게 3가지가 있다.

Event Class

  • Event Class는 말 그대로 과거에 벌어진 상태 변화나 사건을 의미한다. 즉 위에 예시로 하면 약속방을 삭제한 것이 이벤트다.
  • 네이밍 규칙으로는 과거형으로 작성하며, 접미사로 Event를 사용해 이벤트로 사용하는 클래스라는 것을 명시적으로 표현한다.
  • Event Class는 이벤트를 처리하는 데 필요한 최소한의 데이터만 포함해야 한다.
  • Event Class 자체의 상위 타입이 존재하지 않지만 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 상위 클래스를 만들고 상속받아서 구현할 수도 있다.

Event Publisher

  • Event 발행자로 Event를 발행하는 역할을 한다.
  • ApplicationEventPublisher의 publishEvent() 메서드를 사용하여 이벤트를 발행할 수 있다.

Event Listener

  • 발행한 이벤트를 처리하는 역할을 한다. 위에 예시로 하면 알림을 보내는 프로세스이다.
  • Spring에서 제공하는 @EventListener 어노테이션을 사용해서 구현한다.
  • 이벤트 클래스가 발행되면 바로 수신하여 이벤트를 처리한다.

Event 적용하기

Event Class

  1. DomainEvent interface
public interface DomainEvent {
}
  1. NotificationEvent.class
@Getter
@RequiredArgsConstructor
public abstract class NotificationEvent implements DomainEvent {
    private final User user;
    private final Long reservationId;
    private final String titleMessage;
    private final String contentMessage;
}
  • 추상 클래스로 알림과 관련된 Event를 따로 만들어서 관리하기로 했다.
  • 알림 받는 사람, 어떠한 약속방에서 알림이 발생했는지, 알림 제목, 알림 내용 이렇게 정말 필요한 데이터 4개로만 프로퍼티를 구성했다.
  1. ReservationDeletedEvent.class
@Getter
public class ReservationDeletedEvent extends NotificationEvent {
    public ReservationDeletedEvent(User user,
                                   Long reservationId,
                                   String titleMessage,
                                   String contentMessage) {
        super(user, reservationId, titleMessage, contentMessage);
    }
}
  • 약속방 삭제 후 발생하는 이벤트이기 때문에 과거형인 Deleted접미사 Event를 붙여서 네이밍 규칙을 잘 지켜주었다.
  • 그리고 알림과 관련된 이벤트니깐 NotificationEvent를 상속받아서 Event Class를 생성했다.

Event Publisher

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class ReservationService implements ReservationUtils {
	private final ApplicationEventPublisher publisher; // ApplicationEventPublisher 주입
    
    @Transactional
    public void deleteReservation(Long reservationId){
        User user = userUtils.getUserFromSecurityContext();
        Reservation reservation = queryReservation(reservationId);

        reservation.validUserIsHost(user.getId());
        reservation.validPastReservation();

        publisher.publishEvent(
                new ReservationDeletedEvent(user, reservation.getId(),
                        TitleMessage.RESERVATION_DELETE.getTitle(),
                        makeContent(user, ContentMessage.RESERVATION_DELETE, reservation))); // 방 참가자들에게 알림 전송
		notificationReservationUtils.deleteNotificationReservation(reservation.getId()); // 예약 알림 삭제

        chatRepository.updateReservationNull(reservationId);
        reservationRepository.delete(reservation);
    }
}
  • 위와 같이 ApplicationEventPublisher를 주입받는다.
  • publishEvent()를 통해 발행하고 싶은 Event를 생성해서 발행하면 된다.

Event Listener

@Component
@RequiredArgsConstructor
public class NotificationEventHandler {
    private final NotificationUtils notificationUtils;

    @EventListener
    public void sendNotification(NotificationEvent notificationEvent) {
        notificationUtils
                .sendNotification(
                        notificationEvent.getUser(),
                        notificationEvent.getReservationId(),
                        notificationEvent.getTitleMessage(),
                        notificationEvent.getContentMessage());
    }
}
  • @EventListner라는 어노테이션을 붙여 이벤트 리스너임을 명시한다.
  • 처리하고 싶은 Event를 매개변수로 작성한다.
  • 그리고 수행하고 싶은 프로세스를 작성하면 된다. 여기서는 알림을 보내는 프로세스를 작성했다.

결과

약속방을 삭제한 후, 이벤트를 발행하여 이벤트 리스너가 해당 이벤트를 처리하여 알림이 잘 전송된 것을 확인할 수 있었다. 프로세스를 분리하여 훨씬 유지보수가 수월하게 되었다.
하지만 이 과정은 모두 하나의 쓰레드에서 동작한다. Spring Event로 강한 결합은 해결했지만, 결국에는 프로세스 처리 지연 문제와 트랜잭션 전체 롤백 문제를 아직 해결하지 못했다.
이를 해결하기 위해 다음 포스팅에서 비동기로 실행시켜 보겠다.

profile
백엔드 개발자

0개의 댓글