Spring Boot 서비스 강한 의존성 탈출 : Spring 내부 이벤트 기반 아키텍처 도입
이 글을 쓰는 이유
프로젝트가 진행되며 서비스가 점점 고도화 되었습니다. 이런 과정에서 여러 서비스 간의 강한 의존성이 만들어지게 되었습니다. 특히 저희 프로젝트는 Study라는 핵심 도메인을 기준으로 스터디 rule, curriculum, file, applyQuestion 등 다양한 하위 도메인과 연결되어 진행되는 작업이 많았습니다. 이외에도 특정 도메인 처리 후부가적인 작업(알림 발송, 인기글 점수 업데이트, 비동기 파일 업데이트)이 필요해졌는데 기존 초기 작업에서는 이를 직정 해당 서비스를 의존 관계 주입 받아 메서드를 직접 호출하는 방식으로 이루어졌습니다.

이렇게 서비스 간의 강한 의존성이 생겼을 때 다양한 문제가 발생합니다.
1. 순환 참조
2. 테스트의 어려움
3. 책임 분산 모호
4. 변경의 어려움
따라서 이러한 의존 관계를 벗어나기 위해 구조를 수정하기 결심했습니다.
왜 Spring 내장 이벤트인가?
저희는 Spring 내장 이벤트 메커니즘을 활용한 이벤트 기반 아키텍처를 선택했습니다. 이외에 @Lazy 어노테이션을 사용하여 의존 관계 주입 시점을 사용 시점으로 미루는 lazy initialization이나 messageQ 서비스도 선택지가 존재하였으나 다음과 같은 이유로 선택하지 않았습니다.
- @Lazy : 순환 참조 문제는 해결할 수 있으나, 강한 의존성을 근본적으로 해결하지는 못했기에 이외의 문제는 여전하셨습니다.
- messageQ : messageQ를 사용하는 것은 좋은 선택지가 될 수 있습니다. 다만, 저희 프로젝트 전체를 살펴보았을 때 이외의 로직에서 messageQ를 필수적으로 사용해야하는 부분이 없다고 판단하였습니다. 따라서 오버 아키텍팅이라고 판단하여 선택하지 않았습니다.
따라서 저희는 최종적으로 새로운 외부 기술 없이 Spring 내장 이벤트 메커니즘을 활용해 서비스 간 강한 의존성이라는 문제를 해결했습니다.
이벤트 시스템 설계
이벤트 기반 아키텍처를 적용하기로 결정한 후 가장 먼저 진행한 작업은 이벤트 정의입니다. 이벤트 정의에서 모든 이벤트가 공통적으로 가질 정보로 이벤트 타입과 실제 데이터 페이로드로 이벤트를 구조화했습니다.

Event 객체는 이벤트 타입(Enum)과 데이터 페이로드를 함께 전달하는 래퍼 클래스입니다. (ResolvableTypeProvider가 궁금하신 분은
스프링 제네릭 이벤트와 타입 소거: ResolvableType으로 해결하기를 읽어주세요)
스프링 제네릭 이벤트와 타입 소거: ResolvableType으로 해결하기
배경프로젝트를 진행하며 작업이 고도화 될 수록 클래스 간 의존성이 점점 강해졌다. 그러다보니 클래스가 상호 의존 관계를 갖는 순환 참조 문제가 발생했다. 특정 메서드에서 연관 작업 수행
shoon95.tistory.com
이때 Event 객체는 제네릭을 사용하여 타입 안정성을 확보하였습니다. 이를 통해 이벤트를 구독할 때는 Payload를 통해 필요한 Event만 특정 리스너에서 받아 처리할 수 있습니다.

이벤트 발행 및 구독
이제 위에서 설계한 이벤트를 활용하여 서비스 간 강한 의존성을 분리해보겠습니다.
먼저 Spring 내장 이벤트 메커니즘을 활용하기 위해 Spring 프레임워크가 제공하는 Spring Application Event를 사용했습니다. 이벤트 발행 ApplicationEventPublisher 인터페이스를 사용하며, 구독은 @EventListener를 사용하여 구현하게 됩니다. (내부적으로 이벤트가 발행되고 구독되는 과정은 스프링 제네릭 이벤트와 타입 소거: ResolvableType으로 해결하기 글 가장 하단을 참고해주시면 됩니다.)
이제 서비스에서 의존성을 제거하고 Event 기반으로 전환해보겠습니다.

Study 하위 도메인을 의존 관계 주입 받던 것들이 대부분 제거 되고 대신 ApplicationEventPublisher를 의존 관계 주입을 받는 것으로 바뀌었습니다. 이후 publishEvent() 메서드를 호출하며 통해 저희가 정의한 Event를 넘겨주었습니다. 이 이벤트는 STUDY_CREATED라는 eventType과 StudyCreatedEvetPayload라는 데이터 페이로드를 가지게 됩니다.
이후 리스너에서는 StudyCreatedEventPayload 타입의 Event를 받아와서 이벤트를 처리합니다.

이때 EventListener를 보면 @TransactionalEventListener를 사용한 것을 확인할 수 있습니다. 그 이유는 Study를 생성할 때 study의 하위 도메인(benefit, rule 등)도 함께 생성하는데, 만약 이 중 하나라도 잘못된 요청으로 트랜잭션이 롤백되면 모두 동일 트랜잭션을 통해 롤백하기 위함입니다.
추가적인 문제
이렇게 이벤트 기반 아키텍처를 사용하던 도중 문제가 발생했습니다. 저희 서비스에서는 알림 기능을 제공합니다. 알림은 스터디 지원, 승인, 파일 업로드 완료 등 여러 도메인의 작업이 완료된 후 진행이 이벤트 발행과 구독을 통해 알림이 발송되게 됩니다.
그런데, 저희가 사용하는 Event의 구조를 살펴보면 각 이벤트 리스너틑 Payload를 구현한 타입의 Event를 전달받아 특정 로직을 처리하게 됩니다. 위 이미지에서 handleStudyCreatedEvent는 Event<StudyCreatedEventPayload>를 받아서 처리하는 것을 볼 수 있습니다.
이와 같은 구조에서는 해당 이벤트가 알림 전송과 관련된 이벤트인지 확인하는 것이 어렵습니다. 예를 들어 StudyCreatedEvent가 발행되었을 때 알림이 발송되어야 한다고 판단하기 어려운 상황입니다. 때문에 알림 로직을 처리하는 이벤트 리스너에서는 모든 이벤트를 구독 후 알림 전송이 필요한 지 판단하는 로직이 필요했습니다.
이는 다음과 같은 비효율을 야기했습니다.
1. 모든 이벤트를 받는다.
2. 모든 이벤트를 알림 전송이 필요한 이벤트인지 판단한다.
이는 모든 이벤트에 대해 특정 로직이 수행되어 성능 하락으로 이어질 수 있으며, 이벤트 리스너가 필요한 Event만 구독하기 위해 generic 타입을 통해 이벤트를 구독하려던 기존 의도에서 벗어나게 되었다.
따라서 저희는 알림 전송이 포함된 이벤트라는 것을 명시적으로 나타내어 이벤트 리스너에서 알림 이벤트만을 구분하여 받을 수 있는 방법이 필요했습니다.
이벤트 유형의 구분을 위한 마커 인터페이스 설계
해결 방법은 간단합니다. 기존 Payload 인테페이스를 상속 받는 NotificationPaload를 만들어 사용했습니다.

이후 알림 전송이 필요하지 않은 이벤트의 경우 Payload를 구현한 데이터페이로드를 사용하며, 알림 전송이 필요한 Event의 경우 NotificationPayload를 구현한 데이터페이로드를 사용하였습니다.
이를 통해 알림 이벤트 리스너에서는 불필요하게 모든 Event를 받아서 처리할 필요가 없이 Event<NotificationPayload>만 받아서 처리하게 되어 효과적으로 리스너 필터링이 구현되었습니다.

이벤트 기반 아키텍처를 사용하며 서비스 간의 강한 의존성을 분리해내는 작업을 진행해보았습니다. 이벤트를 사용하여 로직을 처리하면 굉장히 유용하나, 이를 위해 트랜잭션 관리나 효율적인 이벤트 설계 과정이 필요하다는 비용도 발생하였습니다. 그럼에도 이벤트 기반 아키텍처로 전환했을 때, 향후 기능 확장 측면에서 기존 서비스와 의존성이 적어 굉장히 편리하다는 장점이 있습니다. 또한 여러 로직을 처리할 때도 해당 도메인의 로직(알림 전송, 각 하위 도메인 로직)에만 집중할 수 있다는 장점도 있어 좋은 선택이었다고 생각합니다.