본문 바로가기
🌱 Spring

Spring Event를 사용하여 도메인간 의존성 낮추기

by iirin 2023. 9. 13.
이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

 

상황

Secondhand에는 실시간 채팅 어플리케이션이 있습니다. 웹소켓 통신을 기반으로 하며 STOMP와 Redis pub/sub 으로 구현하였는데요. 이 채팅에 대한 요구사항 중에 다른 도메인의 서비스를 참조하는 경우가 많아져 Facade가 비대해지고 각 도메인의 서비스 로직이 복잡하게 구성이 되며 의존성이 높아지는 우려가 생겼습니다.

@Component
@RequiredArgsConstructor
public class ChatroomFacade {
    private final ChatroomService chatRoomService;
    private final ItemService itemService;
    private final MemberService memberService;
    private final ChatroomCacheService chatroomCacheService;

		// 기타 등등 코드들
}

이미지 출처 : WIKI Facade

파사드 패턴으로 저수준 모듈인 서비스를 참조하고 있었는데, 이러다간 모든 도메인의 서비스를 다 참조하게 생겼습니다 👀

그 외에도 몇가지 문제가 대두되는데요. 첫번째로는 하나의 트랜잭션으로 이 전체 로직이 다같이 묶이다보니 뭐 하나가 취소되더라도 롤백을 시켜야 하게됩니다. 예를들어 채팅알람을 보내는데 실패한다면 채팅 발송이 실패해야할까요? 다른 방식으로 이를 처리할 수도 있습니다. 두번째는 이 어플리케이션의 동작 성능에 다른 어플리케이션이 영향을 받습니다. 중간에 어떤 작업이 지연된다면 뒤에 명시된 작업들도 모두 지연될 수 있습니다.

 

구현 모듈 구조

현재의 패키지 구조입니다. 크게 api와 chat으로 나뉘어 있는데, 채팅에 대한 도메인과 로직이 복잡해지며 따로 분리한 결과입니다.

api
⎿ chatroom // 채팅방. 아이템과 구매자(member) 정보를 관리합니다.
⎿ item // 중고 상품.
⎿ member // 회원.
⎿ oauth // 회원 인증인가.
⎿ region // 동네 지역.
⎿ wishlist // 관심 상품.

chat // 실시간 채팅에 대한 도메인들을 모아두었습니다.
⎿ bubble // 채팅 메시지 도메인.
⎿ metainfo // 채팅방 metainfo 도메인. 채팅방 인원, 안읽은 채팅수, 마지막 메시지를 관리합니다.
⎿ notification // 현재 접속중인 member와 실시간 채팅 알람.

global // 전역에서 사용하는 model, exception, config 등을 정의하고 있습니다.

이렇게 패키지 구조가 되어있을 때 도메인간 의존도가 복잡해질만한 로직과 수도코드는 다음과 같습니다.

 

  • 사용자가 채팅 메시지를 발송한다.
    • 채팅 알람을 상대방이 받을 수 있다.
    • 채팅 메시지가 저장되어야 한다.
    • 채팅방 MetaInfo를 ‘안 읽은 메시지’ 개수와 ‘채팅방에 마지막으로 발송된 메시지’가 갱신되어야 한다.
// messagePublishFacade에서

@Transactional
    public void publish(String topic, ChatBubble message) {
        message.ready();
        redisTemplate.convertAndSend(topic, message); // 채팅방 topic으로 pub
				
        chatbubbleService.save(message) // db에 저장
				chatNotificationService.push(message) // 회원에게 알람 발송
				chatMetainfoService.update(message) // metainfo 수정
    }

이 외에도 코드가 복잡해질 수 있는 비즈니스 로직이 매우 많았습니다.

이렇듯 하나의 트리거가 발생했을 때 연쇄적으로 일어나야 하는 서비스 로직이 여럿 있습니다. 심지어 이러한 코드의 문제는 하나의 트랜젝션에서 실행되므로 하나의 익셉션이 발생하면 모두 취소가 된다거나 하나의 로직이 모두 실행될 때까지 다른 로직들의 대기시간이 길어진다는 단점이 있습니다.

그리고 만약 로직이 더 추가된다면? 점점 파사드와 서비스가 비대해지며 코드가 더 복잡해질 수 있습니다.

💡 다른 예시
만약 회원가입을 한 회원에게 다음과 같은 서비스 로직이 동시에 적용되어야 한다면 어떻게 해야할까요?
- 회원에게 가입 환영 이메일을 발송합니다.
- 회원이 입력한 추천인에게 포인트를 줍니다.
- 회원에게 가입 기념 쿠폰을 발행합니다.
- …

이런 경우에 어떻게 도메인간의 의존성을 낮추고 코드를 간결하게 할 수 있을까 학습해보다 Spring Event 를 도입해보기로 했습니다. Spring Event를 통해 비동기로 연동되는 로직을 처리하거나 같은 트랜젝션 안에서 처리하게 구현할 수도 있습니다. 이렇게 처리하게 되니 기능 확장과 연동에 큰 자유를 얻게 되어서 좋았습니다.

 

Spring Event

여기서 event는 사전적인 의미 그대로 ‘과거 일어난 어떤 일’이라고 이해하면 쉬운데요. 예를 들어 위의 상황의 경우에서는 “회원이 채팅 메시지를 발송한다”가 하나의 이벤트가 될 수 있습니다.

Spring Event를 통해 이벤트가 발생하면 그 이벤트가 트리거가 되어 반응하여 원하는 동작을 수행하는 기능을 구현할 수 있습니다.

SpringEvent는 ApplicationContext가 제공하는 기능 중 하나입니다. 

public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory, HierarchicalBeanFactory,
		MessageSource, ApplicationEventPublisher, ResourcePatternResolver {
  • 동작 방식은 다음의 블로그 글을 참고했습니다. 참고링크

출처 : 도메인 주도 개발 시작하기

이벤트를 도입하려면 다음의 구성을 구현해야 합니다.

  1. 이벤트 : 이벤트 퍼블리셔를 통해 발행될 이벤트
  2. 이벤트 생성 주체 : 이벤트를 생성하여 Event Publisher에 이벤트를 전달합니다.
  3. 이벤트 퍼블리셔(이벤트 디스패처) : 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파합니다. (어노테이션을 기반으로 동작하며 해당하는 메서드를 invoke시키는 방식입니다.)
  4. 이벤트 핸들러 (이벤트 리스너) : 최종적으로 이벤트를 받아 처리합니다.

 

SpringEvent를 적용시켜보자

위에서 언급한 구성을 구현하여 다음과 같은 이벤트 처리 로직을 구현하려고 합니다.

  1. BubbleService 에서 Events.raise() 메서드를 통해 arrivedEvent 을 발행시킵니다.
  2. 그럼 퍼블리셔(디스패처)를 거쳐 이벤트가 발행되었을 때
  3. 각 다른 도메인의 `arrivedEvent` 핸들러가 동작합니다.

Events는 EventPublisher를 참조하고 있으므로 같은 노드로 표현했습니다.

 

구현사항

  • BaseEvent
    • 먼저 모든 event에 공통으로 적용할 필드를 기반으로 BaseEvent 를 구현하였습니다.
    • Spring Framework 4.2 이전 버전을 사용하는 경우 이벤트를 ApplicationEvent를 확장해서 사용해야하지만, 이후 버전에서는 클래스를 확장하지 않고 구현할 수 있습니다.
    • 제네릭으로 이벤트 유형의 정보를 같이 전달하는 것도 가능합니다.
@Getter
public abstract class BaseEvent ~~extends ApplicationEvent~~ {
    private Instant createdAt;

	    protected BaseEvent() {
        this.createdAt = Instant.now();
    }
}
  • Event 구현
    • 이를 확장하여 구체적인 이벤트를 구현해주었습니다.
    • 해당 이벤트는 사용자가 발송한 채팅이 도착했을 때 발행되는 이벤트입니다.
@Getter
public class ChatBubbleArrivedEvent extends BaseEvent {
    private final ChatBubble chatBubble;

    public ChatBubbleArrivedEvent(ChatBubble chatBubble) {
        super();
        this.chatBubble = chatBubble;
        log.info("ChatBubble Arrived Event Occur = {}", chatBubble.toString());
    }

    public String getChatReceiverId() {
        return chatBubble.getReceiver();
    }
}
  • Event 를 발행을 시킬 클래스 Events 를 만들었습니다.
public class Events {
    private static ApplicationEventPublisher publisher;

    static void setPublisher(ApplicationEventPublisher publisher) {
        Events.publisher = publisher;
    }

    public static void raise(BaseEvent event) {
        if (publisher!=null) {
            publisher.publishEvent(event);
        }
    }
}
  • Service 에서 Event를 발생시킵니다.
@Transactional
    public void publish(String topic, ChatBubble message) {
        log.debug("pub log : " +  message.toString() + "/ topic: " + topic);
        message.ready();
        redisTemplate.convertAndSend(topic, message);

        Events.raise(new ChatBubbleArrivedEvent(message)); // 이벤트를 발생시킵니다.
    }
  • EventListener를 구현합니다.
@EventListener // 채팅방 메타 정보를 업데이트합니다.
public void chatBubbleArrivedEventHandler(**ChatBubbleArrivedEvent** event) throws NotChatroomMemberException {
    ChatBubble chatBubble = event.getChatBubble();
    Chatroom chatroom = getChatroom(chatBubble.getRoomId());
    chatroom.updateLastMessage(chatBubble);
    Chatroom saveChatroom = metaInfoRepository.save(chatroom);
}

이렇게 구현할 경우 다음과 같은 타임라인으로 동작합니다.

 

비동기 이벤트 처리

모든 이벤트가 순서대로 연쇄적으로 발생하지 않아도 되므로 비동기 이벤트 처리를 하는 편이 어플리케이션 효율성에 유리하다고 판단했습니다. 예를 들어 상대방이 채팅 메시지를 보냈을 때 채팅 metainfo 업데이트가 몇 초 후에 동작해도 상관이 없는 것이지요.

비동기 이벤트로 해당 코드를 바꾸기 위해서는 간단히 다음과 같이 어노테이션을 붙여주면 됩니다.

@Async // 비동기 이벤트처리합니다.
@EventListener
public void chatBubbleArrivedEventHandler(ChatBubbleArrivedEvent event) throws NotChatroomMemberException {
    ChatBubble chatBubble = event.getChatBubble();
    Chatroom chatroom = getChatroom(chatBubble.getRoomId());
    chatroom.updateLastMessage(chatBubble);
    Chatroom saveChatroom = metaInfoRepository.save(chatroom);
    Events.raise(ChatNotificationEvent.of(saveChatroom, chatBubble));
}
@EnableAsync // 이것도 붙여주어야 적용됩니다.
@SpringBootApplication
public class BackendApplication {

	public static void main(String[] args) {
		SpringApplication.run(BackendApplication.class, args);
	}

}

 

이벤트 발행 주체의 트랜잭션 단계와 바인딩 `@TransactionalEventListener`

이 어노테이션을 사용해 이벤트 리스너 실행과 트랜젝션 단계를 바인딩하여 실행할 수 있습니다.

phase 정의를 잘 활용하면 채팅 메시지 발송이 실패했을 때의 로직을 구현할 수 있을 것 같습니다.

  • AFTER_COMMIT : (기본값)은 트랜잭션이 성공적으로 완료된 경우 이벤트를 발생시키는 데 사용됩니다.
  • AFTER_ROLLBACK : 트랜잭션이 롤백된 경우
  • AFTER_COMPLETION : 트랜잭션이 완료된 경우
  • BEFORE_COMMIT : 트랜잭션 커밋 직전에 실행
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) // 이런식으로 사용합니다.
public void handleCustom(CustomSpringEvent event) {
    log.info("Handling event inside a transaction BEFORE COMMIT.");
}

 

조건부 이벤트를 구현할 수 있습니다.

이벤트 내부 정보로 플래그를 구현하고 이를 `condition` 에 작성해주면 조건부로 이벤트를 만들 수 있습니다.

public GenericSpringEvent T {
	private boolean notificationAgree; // 플래그를 구현합니다.
}
@EventListener(condition = "#event.notificationAgree") // 이벤트를 조건부로 만드는 것도 가능합니다.
public void handleSuccessful(GenericSpringEvent<String> event) {
    log.info("Handling generic event (conditional).");
}

 

구현의 한계점과 개선할 점

코드 가독성과 테스트 문제

이와 같이 이벤트를 구현하여 서비스 로직을 연동하였을 때, 도메인 의존성을 낮추려는 목표는 훌륭히 달성하였습니다. 하지만 이벤트 발행이 어떤 영향을 미치는 지 코드를 읽기 어려워졌다는 단점이 있습니다. 이벤트를 발행했을 때 어떤 로직이 발생하는 지 테스트와 디버깅을 하기 힘들어졌다는 점 또한 아쉬운 점입니다.

 

로컬 핸들러의 한계

Spring Event는 어플리케이션 로컬 핸들러로 이루어지므로 하나의 어플리케이션에서 실행한다는 한계점이 있습니다. 특히 다음의 주의사항을 고려해야 합니다.

  • 로컬 핸들러의 경우 이벤트 유실 주의
    • 이벤트를 핸들링하다 예외 발생 등으로 실패하면 해당 이벤트는 유실될 수 있습니다.
  • 이벤트 재처리하며 멱등성 주의
    • 만약 이벤트 재처리를 위한 로직을 구현한다면, 재처리를 통해 데이터가 변경되거나 의도와 다르게 어플리케이션이 동작하지는 않는지 주의해야 합니다.

MSA를 구현한다면 서비스간 이벤트 핸들링을 하는 것이 힘듭니다. 이럴경우 다음의 대안들이 있습니다.

  • 메시지 큐를 사용합니다. -> 이 방법을 가장 많이 사용하는 것 같습니다.
    • 카프카, 레빗MQ 등 메시징 시스템
  • 이벤트 저장소와 이벤트 포워더를 사용합니다.
    • 이벤트 저장소 DB와 포워더를 구현하여 사용합니다.
    • 이벤트 유실 가능성을 줄일 수 있습니다.
  • 이벤트 저장소와 이벤트 제공 API를 사용합니다.
    • 이벤트 저장소 DB와 외부 API를 사용합니다.

이에 대한 자세한 정보는 `도메인 개발 시작하기` 책에서 참고할 수 있으며 현재 프로젝트 규모에서는 다루지 않았습니다만 중요한 이벤트라거나 좀 더 복잡한 구조의 어플리케이션, 혹은 MSA에서는 적극 메시지 큐, 이벤트 저장소 도입을 고려할 것입니다.

 

실무에서의 이벤트 사용

실무 사례에서 카프카, 레빗MQ 등 메시징 시스템을 필두로 이벤트를 기반한 아키텍처를 손쉽게 찾아볼 수 있습니다. 물론 실무에서는 로컬 핸들링만이 아니라 계층적으로 이를 구조화하고 이벤트 저장소를 도입한 이벤트 기반 아키텍처를 구현한 모습입니다.

해당 기능을 구현하고 아키텍처를 공부하면서 도움이 되었던 글과 영상을 남깁니다.

이런 식으로 이벤트의 발행과 구독으로 어플리케이션 구동이 연동될 수 있습니다. 출처 : https://techblog.woowahan.com/7835/

 

Refs.