ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택

2025. 8. 28. 23:00·Spring

오늘은 Spring ApplicationEvent를 활용해 비즈니스 로직을 분리하면서 겪었던 고민과 선택 과정을 공유해보려고 합니다.

"무조건 이벤트로 분리하는 게 좋은 걸까?"라는 질문에서 시작해서, 트랜잭션 경계와 비즈니스 요구사항에 따른 판단을 어떻게 내렸는지 담아보았습니다.

정답이 있는 것은 아니지만, 고민했던 과정들을 기록해보려고 합니다.


1. 문제 정의 

커머스 시스템을 개발하면서 자연스럽게 다음과 같은 고민에 직면했습니다.

"주문-결제가 성공하면 데이터 플랫폼에도 전송해야 하고, 좋아요를 누르면 집계도 업데이트해야 하는데... 이걸 어떻게 처리하지?"

처음에는 모든 로직을 하나의 메서드에서 순차적으로 처리했습니다:

// 처음 구현 - 모든 로직이 한 곳에
public PaymentInfo processPayment(PaymentCommand command) {
    // 1. 결제 처리
    Payment payment = paymentService.payment(command);
    
    // 2. 주문 상태 변경
    orderService.complete(payment.getOrderId());
    
    // 3. 데이터 플랫폼 전송 (외부 I/O)
    dataPlatformService.send(payment);  // 이게 실패하면?
    
    // 4. 알림 발송
    notificationService.send(payment);  // 이것도 실패하면?
    
    return PaymentInfo.of(payment);
}

2. 겪을 수 있는 문제들 

위 방식으로 개발하다 보니 몇 가지 문제점들이 보였습니다:

주요 문제점들

  • 외부 시스템 의존성: 데이터 플랫폼이 응답하지 않으면 결제 자체가 실패
  • 트랜잭션 범위 애매함: 어디까지가 하나의 트랜잭션이어야 할까?
  • 성능 문제: 모든 후속 처리를 기다려야 하니 응답이 느려짐
  • 단일 책임 원칙 위반: 하나의 메서드가 너무 많은 일을 담당

"핵심 비즈니스 로직과 부가적인 처리를 어떻게 구분해야 할까?"


3. ApplicationEvent 도입 전략

이런 고민 끝에 Spring의 ApplicationEvent를 활용해 보기로 했습니다. 하지만 무조건 이벤트로 분리하는 것이 아니라, 비즈니스 요구사항에 따라 경계를 나누는 것에 집중했습니다.

핵심 설계 원칙

"강한 정합성이 필요한 곳은 묶고, 느슨해도 되는 곳은 분리하자"

// 결제 성공 시 주문도 반드시 성공해야 함 (강한 정합성)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(PaymentEvent.PaymentSuccess event) {
    orderService.complete(event.orderId()); 
}

// 데이터 플랫폼 전송 실패가 결제에 영향을 주면 안 됨 (느슨한 정합성)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentSuccess(PaymentEvent.PaymentSuccess event) {
    dataPlatformService.send(request);
}

 


4. 핵심 설계 원칙

Event vs Command 구분

처음에는 이 둘의 차이가 명확하지 않았습니다. 여러 자료를 찾아보고 고민한 결과를 표로 정리해보았습니다.

구분 Event Command
의미 이미 일어난 과거의 사실 앞으로 실행할 작업 명령
예시 PaymentSuccess, ProductLiked ProcessPayment, SendNotification
특징 여러 리스너가 각자 처리 하나의 핸들러가 처리

 

TransactionPhase 선택 기준

 언제 BEFORE_COMMIT을 쓰고 언제 AFTER_COMMIT을 써야 하는지 이 부분에서 가장 많이 고민했습니다.

BEFORE_COMMIT을 선택한 경우

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(PaymentEvent.PaymentFailedRecovery event) {
    orderService.cancel(event.orderId());    // 주문 취소
    inventoryService.recovery(event.orderId()); // 재고 복구
    couponService.recovery(event.orderId());  // 쿠폰 복구
}

선택 이유: 결제가 실패했다면 주문, 재고, 쿠폰 모두 원래 상태로 돌아가야 합니다. 이 중 하나라도 실패하면 전체가 롤백되어야 한다고 판단했습니다.

 

AFTER_COMMIT을 선택한 경우

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductLike(ProductLikeEvent event) {
    productService.increaseLikeCount(event.productId());
}

선택 이유: 좋아요 집계가 실패해도 사용자의 좋아요 행위 자체는 성공으로 처리되어야 한다고 생각했습니다. 집계는 나중에라도 맞출 수 있으니까요.


5. 이벤트가 그렇게 좋다면 모든 곳에 적용하면 되는 거 아닌가?

ApplicationEvent를 처음 접했을 때 정말 매력적이었습니다. 결합도는 낮아지고, 확장성도 좋아지고, 관심사 분리도 깔끔하게 되고... "그렇다면 모든 로직을 이벤트로 만들면 되는 거 아닌가?" 라는 생각이 들었어요.

하지만 실제로 적용해보니 이벤트 발행에는 분명한 단점들이 존재했습니다.

- "이걸 굳이 이벤트로...?"

// 단순한 회원정보 수정을 이벤트로 만들면...
public void updateUserProfile(UserCommand command) {
    eventPublisher.publish(new UserProfileUpdateRequested(command));
}

@EventListener
public void handle(UserProfileUpdateRequested event) {
    userService.update(event.command()); // 이게 맞나...?
}

현실: 단순 CRUD 로직까지 이벤트로 만들게 되면 코드만 복잡해지고 딱히 얻는 게 없다고 판단하였습니다.

 

- 디버깅 지옥의 시작

// 문제가 생겼을 때...
controller -> facade -> service -> eventPublisher 
                                      ↓
                               eventListener (어디에 있지?)
                                      ↓  
                               또 다른 service (여기서 에러?)
                                      ↓
                               또 다른 eventPublisher (무한루프?)

현실: 에러가 발생했을 때 어디서 문제가 생긴 건지 추적하기가 너무 어려웠습니다. 특히 비동기 이벤트의 경우 로그를 뒤져도 연결고리를 찾기가 힘들더라고요.

 

그렇다면 언제 이벤트를 쓰는 게 좋을까?

이번 이벤트발행 코드를 구현하면서  "이벤트가 정말 필요한 때"를 구분하는 나름의 기준을 세웠습니다:

이벤트 발행이 적합한 경우

  1. 여러 도메인이 관심을 가지는 경우
    // 주문 생성 → 재고, 포인트, 알림, 집계 등 여러 도메인이 반응해야 함
    eventPublisher.publish(new OrderCreated(orderId));
  2. 비즈니스 규칙상 분리되어야 하는 경우
    // 좋아요 → 집계 실패가 좋아요 자체에 영향을 주면 안 됨
    eventPublisher.publish(new ProductLiked(productId, userId));
  3. 외부 시스템과의 연동이 있는 경우
    // 결제 성공 후 데이터 플랫폼, 이메일, SMS 등 외부 연동
    eventPublisher.publish(new PaymentSuccess(orderId));

이벤트 발행이 과한 경우

  1. 단순 CRUD 작업
    // 굳이? 그냥 직접 호출하자
    userService.updateProfile(command);
  2. 강한 일관성이 필요한 핵심 로직
    - 주문 생성과 재고 차감은 함께 실패해야 함
    - 이벤트로 분리하면 데이터 정합성 위험
  3. 단일 도메인 내부의 간단한 로직
    // 하나의 서비스 안에서 끝나는 로직
    productService.calculatePrice(product);

 

결과적으로 내린 결론

상황적용 여부 이유
주문 → 결제 실패 복구 적용 여러 도메인(주문, 재고, 포인트, 쿠폰) 복구 필요
좋아요 → 집계 업데이트 적용 집계 실패가 좋아요에 영향 주면 안 됨
결제 성공 → 데이터 전송 적용 외부 시스템 장애가 결제에 영향 주면 안 됨
단순 회원정보 수정 미적용 복잡도만 증가, 얻는 이익 없음
상품 조회 미적용 단순 읽기 작업, 이벤트 불필요

 

6. 계층별 책임 분리 설계 

사용자 활동 추적 시스템

모든 API 호출을 추적하고 싶었지만, 비즈니스 로직에 추적 코드가 섞이는 것은 원하지 않았습니다.

// 인터셉터에서는 이벤트 발행만
@Component  
public class UserActivityInterceptor implements HandlerInterceptor {
    @Override
    public void afterCompletion(HttpServletRequest request, 
                              HttpServletResponse response, 
                              Object handler, Exception ex) {
        UserActivityEvent event = new UserActivityEvent(/*...*/);
        publisher.publish(event); // 발행만!
    }
}

// 실제 저장은 별도 리스너에서
@Async
@EventListener
public void onUserActivityEvent(UserActivityEvent event) {
    userActivityService.save(/*...*/); // 저장만!
}

선택 이유

  • 인터셉터는 이벤트 발행만 담당 (단일 책임)
  • 저장 로직은 비동기로 처리 (성능)
  • API 응답 속도에 영향을 주지 않음

7. 실제 구현과 고민들

 이벤트 데이터 설계

// 최소한의 데이터만 전달
public record PaymentSuccess(Long orderId) {}

// vs 모든 데이터 전달  
public record PaymentSuccess(
    Long orderId, 
    Long paymentId,
    BigDecimal amount,
    String paymentType,
    // ... 더 많은 필드들
) {}

선택한 방식: 최소한의 식별자만 전달하고, 필요한 데이터는 리스너에서 조회하도록 했습니다. 이벤트 구조가 단순해지고, 추후 여러 이벤트리스너가 생겨나도 최소한의 식별자만 있다면 더욱 자유롭게 이벤트 구현을 할 수 있지 않을까? 라고 판단하였습니다.

 

 비동기 처리 설정

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "generalTaskExecutor")
    public TaskExecutor generalTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);     // 작게 시작
        executor.setMaxPoolSize(5);      
        executor.setQueueCapacity(50);   
        executor.setThreadNamePrefix("general-async-");
        return executor;
    }
}

고민 포인트: 스레드 풀 크기를 얼마나 설정해야 할지 고민이 많았습니다. 이 부분에 대한 조정은 지속적인 모니터링과 경험으로 최적의 설정을 찾는것이 방법인거 같습니다.


8. 회고 및 결론

잘했던 점

  • 비즈니스 요구사항 기반 판단: 기술적 멋보다는 실제 요구사항에 맞춰 선택
  • 점진적 적용: 모든 곳에 한 번에 적용하지 않고 필요한 곳부터 차근차근
  • 단순함 유지: 복잡한 이벤트 구조보다는 이해하기 쉬운 구조 선택

아쉬웠던 점

  • 에러 처리: 비동기 이벤트 처리 실패 시 대응 방안이 부족
  • 테스트: 이벤트 기반 로직의 테스트 작성이 생각보다 복잡함

성장한 부분

  • 트랜잭션 경계에 대한 이해: 언제 묶고 언제 분리할지에 대한 감각
  • 비즈니스 관점 사고: 기술적 관점뿐만 아니라 비즈니스 요구사항 고려
  • 트레이드오프 판단: 완벽한 설계보다는 적절한 수준의 설계 선택

9. 핵심 학습

"무조건 이벤트로 분리하는 것이 정답이 아니다"

이번 경험을 통해 가장 중요하게 느낀 것은 "왜"에 대한 명확한 답을 갖는 것이었습니다.

핵심 질문들

  • 왜 이 로직을 분리했는가? → 외부 시스템 장애가 핵심 비즈니스에 영향을 주면 안 되니까
  • 왜 BEFORE_COMMIT을 선택했는가? → 데이터 정합성이 절대적으로 중요하니까
  • 왜 비동기로 처리했는가? → 사용자 응답 속도가 중요하고, eventual consistency로 충분하니까

기술적인 패턴을 적용하는 것보다 비즈니스 요구사항을 정확히 이해하고, 그에 맞는 기술적 선택을 하는 것이 더 중요하다는 걸 배웠습니다.

완벽한 이벤트 드리븐 아키텍처를 만든 것은 아니지만, 초기 서비스의 요구사항에는 적절한 수준의 분리를 했다고 생각합니다.

앞으로도 "기술을 위한 기술"이 아닌 "문제 해결을 위한 기술 선택"을 하는 개발자가 되고 싶습니다.

 

'Spring' 카테고리의 다른 글

Redis ZSET으로 실시간 랭킹 시스템 구축하기  (0) 2025.09.11
Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기  (0) 2025.09.05
결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략  (1) 2025.08.22
DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기  (2) 2025.08.15
Spring 트랜잭션 핵심 정리  (2) 2025.08.08
'Spring' 카테고리의 다른 글
  • Redis ZSET으로 실시간 랭킹 시스템 구축하기
  • Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기
  • 결제 시스템에 Circuit Breaker를 도입하며 – 계층별 책임 분리와 안정성 확보 전략
  • DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기
JoshDev
JoshDev
    • 분류 전체보기 (24)
      • Java (3)
      • Spring (9)
      • Test Code (2)
      • WIL (6)
      • Vue.js (2)
      • WEB (0)
      • DB (1)
        • MySQL (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.4
JoshDev
ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택
상단으로

티스토리툴바