← 목록으로
📅 2026.05.26
스프링 내장 이벤트를 활용한 로깅 아키텍처 (Pub/Sub 기반 의존성 분리)
TechStudyArchitectureSpring BootPub/SubEvent-DrivenLogging

모놀리식(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. 아키텍처 흐름 비교

아키텍처 트랜잭션 시뮬레이터

이벤트 기반 구조의 정합성 방어 능력을 시각적으로 확인해 보세요.

1. 아키텍처 방식
2. 트랜잭션 시나리오
주문 서비스
이벤트 버스(ApplicationEventPublisher)
비활성
로깅 서비스(ELK / File 저장)
대기 중
데이터베이스(주문/결제 트랜잭션)
대기 중
simulation-log.sh
01$ systemctl status event-simulator
02> 대기 중... 설정을 선택하고 '흐름 실행' 버튼을 누르세요.

AS-IS기존 직접 호출 방식 (Direct DI)

주문 서비스
직접 호출 (동기)
로깅 서비스장애 발생 시 주문 마비

TO-BE이벤트 기반 아키텍처 (Pub/Sub)

Publisher주문 서비스
이벤트 던짐
Event BusApplicationEventPublisher
비동기 배달
Subscriber로깅 서비스장애 발생 시 격리

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) 클래스를 만들어 붙이기만 하면 시스템이 무한하게 확장됩니다.