스프링 내장 이벤트를 활용한 로깅 아키텍처 (Pub/Sub 기반 의존성 분리)
모놀리식(Mono) 애플리케이션에서 매 서비스마다 로깅(Logging) 서비스를 주입(DI)받아 호출하는 방식은 관리의 복잡성을 높이고 핵심 비즈니스 로직과의 강한 결합(Tight Coupling)으로 구성된 상태였습니다.
스프링 프레임워크가 기본 제공하는 **ApplicationEventPublisher (인메모리 이벤트 버스)**를 활용하여 시스템을 느슨하게 결합(Loose Coupling)한 아키텍처로 변경한 내용을 소개합니다.
1. 기존 방식(Direct DI)의 문제점
비즈니스 서비스가 로깅 전용 클래스(GeneralLogger)를 직접 DI 받아 호출하는 구조입니다.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final GeneralLogger generalLogger;
// 강한 결합: 로거 객체를 직접 주입받음
public OrderService(GeneralLogger generalLogger) {
this.generalLogger = generalLogger;
}
@Transactional
public void createOrder(OrderRequest request) {
// 1. 핵심 비즈니스 로직 (DB 저장 등)
// ...
// 2. 로깅을 직접 동기 호출 (문제의 원인)
generalLogger.log("ORDER", "SUCCESS", request);
}
}- 강한 결합도: 기획 변경으로
GeneralLogger클래스를 삭제하거나 인터페이스를 변경하면, 이를 호출하던 수십 개의 비즈니스 서비스 코드에서 즉각 컴파일 에러가 발생 위험성이 있습니다. - 응답 지연(Blocking): 메인 스레드가 메인 비지니스 로직을 끝날때까지 기다립니다.
- 트랜잭션 꼬임: 로그기록은 성공했는데 메인 비즈니스 로직(DB 저장)이 롤백될 경우, 데이터 정합성이 깨집니다.
2. 해결책: 이벤트 기반 아키텍처
로깅을 직접 지시하는 대신, 비즈니스 서비스는 단지 **"어떤 일이 일어났다(Event)"**는 사실만 우체통(Event Bus)에 던지고 본인의 역할을 종료합니다.
외부 라이브러리(Guava 등) 없이 스프링 기본 기능만으로 제네릭(Generic)한 이벤트 버스를 구축합니다.
2.1 공통 이벤트 규격 정의
어떤 서비스에서든 돌려 쓸 수 있도록 만능 상자(Envelope) 형태의 DTO를 생성합니다.
import java.time.LocalDateTime;
public class SystemLogEvent<T> {
private final String domain; // 예: "ORDER", "PAYMENT"
private final String action; // 예: "CREATE", "SUCCESS"
private final T payload; // 실제 데이터 객체
private final LocalDateTime time;
public SystemLogEvent(String domain, String action, T payload) {
this.domain = domain;
this.action = action;
this.payload = payload;
this.time = LocalDateTime.now();
}
// Getter 생략 (Lombok @Getter 활용 권장)
}2.2 Publisher (발행자 / 메인 비즈니스)
새로운 서비스를 만들 때마다 로거를 주입받을 필요 없이, 스프링 내장 우체통(ApplicationEventPublisher)만 주입받습니다.
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Transactional
public void createOrder(OrderRequest request) {
// 1. 핵심 비즈니스 로직 (DB 저장 등) 실행
// ...
// 2. 비즈니스 로직 완료 후 공통 이벤트 규격으로 던짐 (로깅 누락 방지)
eventPublisher.publishEvent(new SystemLogEvent<>("ORDER", "SUCCESS", request));
}
}2.3 Subscriber (구독자 / 로깅 처리) (핵심)
스프링 이벤트 버스의 가장 큰 장점인 비동기 처리와 트랜잭션 동기화를 적용합니다.
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class GeneralLoggingSubscriber {
// @Async: 메인 비즈니스 스레드를 막지 않고 백그라운드 스레드에서 처리하여 응답 속도 향상
@Async
// AFTER_COMMIT: Publisher의 DB 트랜잭션이 성공적으로 Commit 되었을 때만 로그 기록
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleLogEvent(SystemLogEvent<?> event) {
try {
// 로깅 처리 (예: ELK 전송, 파일 쓰기 등)
System.out.println(String.format(
"[로깅 완료] 도메인: %s | 액션: %s | 데이터: %s",
event.getDomain(), event.getAction(), event.getPayload().toString()
));
} catch (Exception e) {
// 로깅 중 에러가 발생해도 메인 비즈니스(결제/주문)에는 전혀 영향을 주지 않음 (장애 격리)
}
}
}(참고:
@Async사용을 위해 최상단 Application 클래스나 설정 클래스에@EnableAsync어노테이션 추가가 필요합니다.)
** 의존성이 끊어졌다는 증거:**
이벤트 버스 방식에서는 로깅을 담당하는 Subscriber 클래스를 프로젝트에서 통째로 삭제해도 메인 비즈니스 코드는 아무런 컴파일 에러 없이 100% 정상 작동합니다.
비즈니스 코드는 이벤트를 수신하는 대상의 존재 여부 자체를 모르기 때문입니다.
3. 아키텍처 흐름 비교
[블로그 전용 시각화: EventBusSimulator]
4. Guava EventBus 대신 Spring Event를 선택한 이유
순수 자바 환경에서는 Guava EventBus가 훌륭한 대안이지만, Spring 생태계 내부에서는 다음과 같은 이유로 내장 이벤트를 사용하는 것이 압도적으로 유리합니다.
- 트랜잭션 인지 (
@TransactionalEventListener) Guava는 DB 트랜잭션 개념이 없어 메인 로직이 롤백되어도 이벤트를 처리해버립니다. Spring은 커밋/롤백 상태에 맞춰 이벤트를 제어할 수 있습니다. - 자동 의존성 주입 (Zero Configuration)
Guava는
eventBus.register()를 통해 구독자를 수동으로 묶어줘야 하지만, Spring은@Component와@EventListener만 선언하면 프레임워크가 자동으로 연결해 줍니다. - 유지보수성 Guava 팀은 현재 EventBus에 대한 신규 기능 추가를 중단하고 다른 리액티브 도구 사용을 권장하고 있습니다. ()
5. 결론
이벤트 기반 아키텍처를 도입하면 요구사항이 늘어날 때 진가를 발휘합니다.
추후 '알림톡 전송', '통계 데이터 수집' 등의 요구사항이 추가되더라도 기존 메인 비즈니스 코드(OrderService)는 단 1줄도 수정할 필요가 없습니다.
그저 새로운 구독자(Subscriber) 클래스를 만들어 붙이기만 하면 시스템이 무한하게 확장됩니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.