옌의 로그

[Spring] ApplicationEventPublisher에 대한 고찰 (feat. Fcm push) 본문

스터디/스프링

[Spring] ApplicationEventPublisher에 대한 고찰 (feat. Fcm push)

dev-yen 2025. 8. 8. 01:00

FCM 푸시 모듈 작업을 계기로 마주친 이벤트 기반 설계 이야기

 

1. 사용하게 된 배경

최근 FCM을 활용한 푸시 알림 모듈을 구현하게 되었다.

초기에는 단순히 서비스 로직 안에서 푸시 메시지를 생성하고 전송하는 sendPushMessage() 메서드를 직접 호출하는 방식으로 구현했는데, 이 방식에는 치명적인.. 구조적 문제가 있었다.

푸시 메시지를 생성하고 저장하는 로직이 서비스의 트랜잭션 흐름에 함께 묶여서, 이로 인해 메세지 생성 트랜잭션이 롤백되면 메인 트랜잭션도 함께 롤백되는 상황이 발생하였다.

(push는 로그 데이터 같이, 생성 실패하여 발송이 안되더라도 크게 문제없는 데이터인데 이로 인해 메인로직이 처리가 안되는 건 문제가 있는 상황이다)

 

물론 트랜잭션 전파 속성을 REQUIRES_NEW로 설정하면 해결할 수 있지만, 이번 기회에 좀 더 느슨한 결합 구조를 시도해보고 싶었다.

그 결과, ApplicationEventPublisher를 사용하여 이벤트 기반 구조로 전환하게 되었다.

 

 

2. ApplicationEventPublisher 동작 구조 정리

Spring에서 이벤트 기반 처리는 다음과 같은 흐름으로 동작한다:

 

  1. ApplicationEventPublisher를 통해 이벤트를 발행한다.
  2. 해당 이벤트를 @EventListener로 구독하고 있는 핸들러가 실행된다.
// 이벤트 발행
applicationEventPublisher.publishEvent(new PushEvent(data));

// 이벤트 리스너
@EventListener
public void handlePush(PushEvent event) {
    // 메시지 생성 및 저장
}

여기서 중요한 부분은 @EventListener가 언제 실행되느냐는 점이다.

Spring의 기본 이벤트 처리 방식은 동기적이며, 이벤트가 현재의 트랜잭션 컨텍스트 내에서 처리된다.

 

보다 안전한 후처리를 위해서는 @TransactionalEventListener를 사용하여 트랜잭션 커밋 이후에만 이벤트를 처리하도록 설정할 수 있다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePush(PushEvent event) {
    // 트랜잭션 커밋 후 실행됨
}

 

 

3. 실무에서 마주친 문제들 & 사용 시 주의점

1) 트랜잭션이 없는 경우 이벤트가 동작하지 않음

@TransactionalEventListener를 사용할 경우, 서비스 메서드에 트랜잭션이 없으면 이벤트 리스너가 호출되지 않는 문제가 발생하였다.

 

이는 트랜잭션이 없으면 AFTER_COMMIT 이벤트 자체가 발생하지 않기 때문이다.

해결 방법은 fallbackExecution = true 옵션을 추가하는 것이었다.

@TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true)
public void handlePush(PushEvent event) {
    // 트랜잭션이 없어도 실행됨
}

이렇게 하면 트랜잭션이 없더라도 이벤트 리스너가 정상적으로 동작한다.

 

 

2) 비동기 처리와 트랜잭션 충돌 문제

푸시 메시지를 생성하고 저장하는 로직은 성능상 비동기로 처리하고 싶었다.

이를 위해 @Async를 적용하여 이벤트 리스너를 멀티 스레드로 동작하게 만들었지만, 또 다른 문제가 발생하였다.

 

이벤트 리스너에 @Async@Transactional을 동시에 적용하였더니, 두 어노테이션 중 하나가 무시되는 상황이 발생한 것이다.

 

 

이유는 다음과 같다:

  • @Async@Transactional 모두 Spring의 AOP 기반 프록시에서 동작한다.
  • 하나의 메서드에 두 어노테이션이 함께 적용되면 내부 호출 시 프록시 체인이 꼬일 수 있다.
  • 특히 같은 클래스 내에서 자기 자신을 호출하는 경우 AOP가 적용되지 않는다.

 

 

💡 해결 방법

해결 방법은 트랜잭션과 비동기 처리를 명확히 분리하는 것이었다.

이벤트 리스너는 단순히 작업을 위임만 하고, 실제 DB 저장은 별도의 컴포넌트에서 트랜잭션을 걸고 처리하도록 구성하였다.

@Async
@EventListener
public void handlePushAsync(PushEvent event) {
    pushMessageProcessor.process(event); // 트랜잭션은 이 내부에서 처리
}

@Component
public class PushMessageProcessor {

    @Transactional
    public void process(PushEvent event) {
        // 메시지 생성 및 저장
    }
}

이렇게 구성하니 비동기 처리와 트랜잭션이 충돌 없이 정상적으로 동작하였다.

 

 

정리: 사용 시 주의할 점

  • @TransactionalEventListener는 트랜잭션이 없으면 실행되지 않기 때문에 fallbackExecution = true 옵션을 꼭 설정해야 한다.
  • @Async@Transactional은 함께 사용할 경우 프록시 충돌 문제가 발생할 수 있다. 반드시 로직을 분리해서 구성해야 한다.
  • 트랜잭션 커밋 이후에만 후처리가 필요한 경우, TransactionPhase.AFTER_COMMIT을 명시해야 한다.

 


 

마무리하며

이번 이벤트 기반 구조 전환을 통해, 서비스 로직과 후처리 로직의 결합도를 줄이고, 유연하고 유지보수에 강한 구조를 만들 수 있었다.

 

Spring의 ApplicationEventPublisher는 단순한 이벤트 전달 도구가 아니라, 도메인 로직과 사이드 이펙트를 분리할 수 있게 해주는 강력한 아키텍처 도구라고 생각한다.

 

사실 그동안은 주어진 요구사항에 맞춰 기능 단위로만 API나 모듈을 구현하는 데 집중해왔다.

하지만 이번 푸시 모듈 작업을 계기로 “이 모듈은 어떤 시점에, 어떤 흐름 안에서 동작해야 하는가?”,

“이럴 땐 어떤 아키텍처가 적절할까?” 와 같은 더 근본적인 고민을 해볼 수 있었다.

 

앞으로도 단순히 동작하는 코드를 넘어, 의도에 맞게 잘 설계된 코드를 만들기 위해 계속 고민하고 실험해볼 계획이다.

Comments