> ## Documentation Index
> Fetch the complete documentation index at: https://blog.nvim.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Application Event는 같은 앱 안에서 무엇을 느슨하게 만들까요?

> ApplicationEventPublisher와 EventListener로 component를 분리하고, TransactionalEventListener의 실행 시점과 내부 event의 전달 한계를 함께 살펴봐요.

> 주문 완료 email은 전송됐는데, 정작 주문 저장은 rollback됐어요.

이상하죠? 주문이 저장된 뒤 email을 보내도록 코드를 작성했는데, 마지막 DB 작업에서 예외가 나면 이런 일이 생길 수 있어요.

```text theme={null}
주문 저장 시도 → 주문 event 발행 → email 전송 → 예외 발생 → 주문 rollback
```

개발자는 “event를 발행했으니 나중에 처리되겠지”라고 생각하기 쉬워요. 근데요, Spring의 application event는 기본적으로 message queue가 아니에요. 같은 application 안에서 listener를 찾아 **지금 호출 흐름 안에서 event를 건네는 기능**에 더 가까워요.

[앞 글](/spring-boot/source-backed-observability-api)에서는 Auth API의 한 요청이 async 작업과 다른 service로 넘어갈 때 metric, trace, log를 연결했어요. 이번에는 범위를 다시 한 application 안으로 좁혀 볼게요.

* `ApplicationEventPublisher`는 무엇을 분리할까요?
* `@EventListener`는 언제, 어느 thread에서 실행될까요?
* Transaction이 commit된 뒤에만 처리하려면 어떻게 해야 할까요?
* 내부 event는 Kafka나 RabbitMQ message와 무엇이 다를까요?
* 어떤 실패는 event로 풀지 않는 편이 나을까요?

오늘의 핵심은 간단해요.

> Application event는 **component 사이의 직접 의존성**을 줄일 수 있지만, 그 자체로 **비동기 실행이나 안전한 전달**을 보장하지는 않아요.

<Note title="이 글의 기준">
  이 글은 Spring Boot 4.x와 Spring Framework 7.x 흐름을 기준으로 작성했어요. `ApplicationEventPublisher`, `@EventListener`, transaction phase라는 핵심 모델은 기존 Spring Boot 3.x에서도 같은 방향으로 읽을 수 있어요.
</Note>

***

## 주문 하나가 끝난 뒤 할 일이 계속 늘어나요

처음에는 주문 service가 주문만 저장했어요.

```java title="src/main/java/com/example/order/OrderService.java" lines theme={null}
package com.example.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public long place(PlaceOrderCommand command) {
        Order order = Order.create(command.memberId(), command.items());
        return this.orderRepository.save(order).getId();
    }
}
```

그런데 요구사항은 금방 늘어나죠.

1. 주문 완료 email을 보내요.
2. 재고 할당을 시작해요.
3. 회원의 첫 주문 coupon을 확인해요.
4. 주문 생성 metric을 기록해요.

이 일을 모두 `OrderService`가 직접 호출하면 어떻게 될까요?

```java theme={null}
@Transactional
public long place(PlaceOrderCommand command) {
    Order order = this.orderRepository.save(
            Order.create(command.memberId(), command.items()));

    this.emailSender.sendOrderConfirmation(order);
    this.inventoryAllocator.allocate(order);
    this.firstOrderCouponIssuer.issueIfEligible(order);
    this.orderMetrics.recordPlaced(order);

    return order.getId();
}
```

코드가 틀렸다는 뜻은 아니에요. 주문 완료 뒤 무엇이 반드시 일어나야 하는지 한눈에 보인다는 장점도 있어요.

하지만 후속 작업이 계속 늘어나면 `OrderService`는 자신이 직접 수행하지 않는 일의 dependency까지 모두 알아야 해요. Email 변경 때문에 주문 service 생성자가 바뀌고, coupon 기능을 끄기 위해 주문 핵심 흐름을 수정하는 일이 생기죠.

| 직접 호출에서 보이는 신호              | 설계 질문                               |
| --------------------------- | ----------------------------------- |
| 생성자 dependency가 계속 늘어나요     | 주문 use case가 이 작업들을 모두 조정해야 하나요?    |
| 후속 작업 하나가 실패하면 주문도 실패해요     | 정말 하나의 성공·실패 단위인가요?                 |
| 기능을 추가할 때마다 주문 service를 고쳐요 | “주문이 생성됐다”는 사실만 알려도 되나요?            |
| 실행 순서가 코드에 드러나요             | 이 순서가 business 규칙인가요, 우연한 구현 순서인가요? |

Application event는 이 질문 중 “주문 service가 후속 component를 직접 알아야 하나요?”를 풀 때 쓸 수 있어요.

***

## Publisher는 사건을 알리고, listener는 관심 있는 사건을 받아요

먼저 주문이 생성됐다는 event를 만들어요. Java `record`를 쓰면 한 번 만들어진 사실을 간결한 immutable 값으로 표현할 수 있어요.

```java title="src/main/java/com/example/order/OrderPlaced.java" lines theme={null}
package com.example.order;

import java.time.Instant;

public record OrderPlaced(
        long orderId,
        long memberId,
        Instant occurredAt
) {
}
```

Spring event라고 해서 반드시 `ApplicationEvent`를 상속할 필요는 없어요. `publishEvent(Object)`에 일반 객체를 넘기면 Spring이 내부에서 payload event로 감싸 matching listener에 전달해요.

이제 주문 service는 구체적인 email sender나 coupon issuer 대신 `ApplicationEventPublisher`만 알아요.

```java title="src/main/java/com/example/order/OrderService.java" lines highlight={3-6,14-15,19-20,23-24,31-35} theme={null}
package com.example.order;

import java.time.Clock;
import java.time.Instant;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;
    private final Clock clock;

    public OrderService(
            OrderRepository orderRepository,
            ApplicationEventPublisher eventPublisher,
            Clock clock
    ) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
        this.clock = clock;
    }

    @Transactional
    public long place(PlaceOrderCommand command) {
        Order order = this.orderRepository.save(
                Order.create(command.memberId(), command.items()));

        this.eventPublisher.publishEvent(new OrderPlaced(
                order.getId(),
                order.getMemberId(),
                Instant.now(this.clock)));

        return order.getId();
    }
}
```

Publisher는 “누가 받을지”를 정하지 않아요. 주문 생성이라는 사실만 application context에 알려요.

Email component는 그 event에 관심 있다고 표시해요.

```java title="src/main/java/com/example/notification/OrderNotificationListener.java" lines theme={null}
package com.example.notification;

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import com.example.order.OrderPlaced;

@Component
public class OrderNotificationListener {

    private final OrderEmailSender orderEmailSender;

    public OrderNotificationListener(OrderEmailSender orderEmailSender) {
        this.orderEmailSender = orderEmailSender;
    }

    @EventListener
    public void on(OrderPlaced event) {
        this.orderEmailSender.sendConfirmation(event.orderId(), event.memberId());
    }
}
```

`OrderService`는 `OrderNotificationListener`를 import하지 않아요. Listener도 주문을 생성하는 method를 몰라요. 둘은 `OrderPlaced`라는 event 계약으로 연결돼요.

```mermaid theme={null}
flowchart LR
    O[OrderService<br /><small>주문 저장</small>]
    P[ApplicationEventPublisher<br /><small>OrderPlaced 전달</small>]
    M[Event multicaster<br /><small>matching listener 선택</small>]
    E[Email listener]
    C[Coupon listener]
    T[Metric listener]

    O --> P --> M
    M --> E
    M --> C
    M --> T
```

직접 dependency 여러 개가 publisher 하나로 줄었어요. 새 listener를 추가해도 주문 service는 바뀌지 않죠. 이것이 application event가 만들어주는 첫 번째 느슨함이에요.

<Tip title="Event 이름은 명령보다 이미 일어난 사실로 지어 보세요">
  `SendOrderEmail`은 특정 listener에게 일을 시키는 명령처럼 들려요. `OrderPlaced`는 주문이 생성됐다는 과거의 사실이라 email, coupon, metric처럼 여러 관심사가 각자 반응하기 쉬워요.
</Tip>

***

## 근데 `publishEvent`는 queue에 넣는 동작이 아니에요

여기서 가장 많이 생기는 오해가 있어요.

```java theme={null}
this.eventPublisher.publishEvent(event);
```

이 한 줄을 보면 “event를 어딘가에 넣고 바로 다음 줄로 가겠구나”라고 생각하기 쉬워요. 기본 동작은 달라요.

Spring application event multicaster는 기본적으로 listener를 **publisher를 호출한 thread에서 동기 실행**해요. Listener가 끝나야 `publishEvent(...)`도 돌아와요.

```mermaid theme={null}
sequenceDiagram
    participant C as Controller
    participant X as Transaction proxy
    participant S as OrderService target
    participant P as Event publisher
    participant L as Email listener
    participant DB as Database

    C->>X: place(command)
    X->>DB: transaction 시작
    X->>S: 실제 method 호출
    S->>DB: order insert
    S->>P: publishEvent(OrderPlaced)
    P->>L: listener 호출
    L-->>P: email 전송 완료
    P-->>S: publishEvent 반환
    S-->>X: orderId 반환
    X->>DB: transaction commit
    X-->>C: orderId 반환
```

이 흐름에서는 email 전송 시간이 주문 API 응답 시간에 포함돼요. Listener가 예외를 던지면 기본적으로 publisher까지 예외가 전파되고, 아직 transaction 안이라면 주문 transaction도 rollback될 수 있어요.

따라서 기본 `@EventListener`는 이렇게 읽는 편이 정확해요.

| 흔한 기대                        | 실제 기본 동작                                |
| ---------------------------- | --------------------------------------- |
| 별도 thread에서 실행돼요             | Publisher를 호출한 thread에서 실행돼요            |
| Publisher는 바로 반환해요           | Matching listener 실행이 끝날 때까지 기다려요       |
| Listener 실패는 publisher와 무관해요 | 예외가 publisher까지 전파될 수 있어요               |
| Event가 저장됐다가 재시도돼요           | 기본 application event에는 저장과 재시도가 없어요     |
| 다른 server instance도 받아요      | 같은 application context 안의 listener가 받아요 |

이 기본값이 나쁜 것은 아니에요. 같은 process 안에서 빠르게 관심사를 분리하고, listener 실패를 원래 use case 실패로 취급하고 싶을 때는 오히려 이해하기 쉬워요.

문제는 **동기 호출인데 비동기 queue처럼 믿는 순간** 생겨요.

***

## 일반 `@EventListener`는 transaction commit보다 먼저 움직일 수 있어요

Opening에서 본 이상한 장면으로 돌아가 볼게요.

```java theme={null}
@Transactional
public long place(PlaceOrderCommand command) {
    Order order = this.orderRepository.save(
            Order.create(command.memberId(), command.items()));

    this.eventPublisher.publishEvent(new OrderPlaced(
            order.getId(),
            order.getMemberId(),
            Instant.now(this.clock)));

    this.pointRepository.saveWelcomePoints(order.getMemberId());
    return order.getId();
}
```

`OrderPlaced`를 받은 listener가 email을 보낸 뒤, 마지막 point 저장에서 예외가 났다고 해 볼게요.

```mermaid theme={null}
sequenceDiagram
    participant S as OrderService
    participant DB as Database transaction
    participant L as EventListener
    participant Mail as Mail server

    S->>DB: order insert
    S->>L: OrderPlaced 발행
    L->>Mail: 주문 완료 email 전송
    Mail-->>L: 전송 성공
    S->>DB: welcome point insert
    DB-->>S: 예외
    S->>DB: rollback
```

DB에서는 주문이 사라졌지만 외부로 나간 email은 되돌릴 수 없어요. Event 이름이 `OrderPlaced`여도 발행 시점에는 transaction이 아직 commit되지 않았던 거예요.

이 장면에서 필요한 질문은 “event인가요?”보다 “**어느 transaction phase에서 처리해야 하나요?**”예요.

***

## Commit 뒤에 처리하려면 `@TransactionalEventListener`를 써요

주문이 실제 commit된 뒤에만 email을 보내고 싶다면 listener를 transaction에 연결할 수 있어요.

```java title="src/main/java/com/example/notification/OrderNotificationListener.java" lines highlight={5,18} theme={null}
package com.example.notification;

import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import com.example.order.OrderPlaced;

@Component
public class OrderNotificationListener {

    private final OrderEmailSender orderEmailSender;

    public OrderNotificationListener(OrderEmailSender orderEmailSender) {
        this.orderEmailSender = orderEmailSender;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced event) {
        this.orderEmailSender.sendConfirmation(event.orderId(), event.memberId());
    }
}
```

`AFTER_COMMIT`은 기본 phase라 생략할 수도 있지만, 처음 읽는 코드에서는 실행 시점을 명시하면 의도가 더 잘 보여요.

이 listener는 event가 발행되자마자 body를 실행하지 않아요. 현재 transaction에 작업을 등록해 두었다가 commit이 성공한 뒤 호출돼요. Transaction이 rollback되면 `AFTER_COMMIT` listener는 실행되지 않아요.

```mermaid theme={null}
flowchart TD
    P[OrderPlaced 발행<br /><small>transaction 진행 중</small>]
    R{Transaction 결과}
    C[AFTER_COMMIT listener 실행]
    B[AFTER_ROLLBACK listener 실행]
    A[AFTER_COMPLETION listener 실행]

    P --> R
    R -->|commit| C
    R -->|rollback| B
    C --> A
    B --> A
```

Spring이 제공하는 phase는 네 가지예요.

| Phase              | 실행 시점                | 어울리는 질문                                      |
| ------------------ | -------------------- | -------------------------------------------- |
| `BEFORE_COMMIT`    | Commit 직전            | Commit 전에 마지막 검증이나 같은 transaction 작업이 필요한가요? |
| `AFTER_COMMIT`     | Commit 성공 뒤          | 저장된 사실을 바탕으로 후속 알림을 시작해도 되나요?                |
| `AFTER_ROLLBACK`   | Rollback 뒤           | 실패 보상이나 진단 신호가 필요한가요?                        |
| `AFTER_COMPLETION` | Commit 또는 rollback 뒤 | 결과와 관계없이 정리할 일이 있나요?                         |

<Warning title="`BEFORE_COMMIT`을 마지막 안전장치처럼 남용하지 마세요">
  반드시 지켜야 하는 business invariant는 가능한 한 domain과 service의 원래 transaction 흐름에서 검증하는 편이 좋아요. 중요한 검증이 listener 여러 곳에 흩어지면 주문이 왜 실패했는지 따라가기 어려워져요.
</Warning>

***

## 활성 transaction이 없으면 기본적으로 실행되지 않아요

`@TransactionalEventListener`에는 중요한 조건이 하나 더 있어요. Event가 발행될 때 활성 transaction이 없으면 기본적으로 listener가 실행되지 않아요.

```java theme={null}
public void placeWithoutTransaction(PlaceOrderCommand command) {
    Order order = this.orderRepository.save(
            Order.create(command.memberId(), command.items()));

    this.eventPublisher.publishEvent(new OrderPlaced(
            order.getId(),
            order.getMemberId(),
            Instant.now(this.clock)));
}
```

이 method에 실제 transaction 경계가 없다면 `AFTER_COMMIT`에 연결할 transaction도 없어요. 그래서 event는 발행됐지만 listener가 조용히 실행되지 않는 것처럼 보일 수 있어요.

`fallbackExecution = true`를 켜면 transaction이 없을 때도 listener를 실행할 수 있어요.

```java theme={null}
@TransactionalEventListener(
        phase = TransactionPhase.AFTER_COMMIT,
        fallbackExecution = true
)
public void on(OrderPlaced event) {
    // transaction이 없어도 실행돼요.
}
```

하지만 이 옵션은 의미를 바꿔요.

* Transaction이 있으면 commit 뒤에 실행해요.
* Transaction이 없으면 commit을 기다리지 않고 실행해요.

즉, “commit된 주문만 처리한다”는 강한 계약이 필요하다면 fallback으로 증상을 덮지 말고 publisher의 transaction 경계가 왜 사라졌는지 먼저 확인해야 해요.

<Tip title="Transactional listener가 안 움직이면 세 가지를 먼저 보세요">
  Publisher가 실제 Spring transaction 안에서 호출됐는지, self-invocation 때문에 `@Transactional` 프록시를 우회하지 않았는지, transaction이 commit이 아니라 rollback된 것은 아닌지 확인하세요.
</Tip>

***

## `AFTER_COMMIT`에서 DB를 다시 쓰는 일은 별도 경계가 필요해요

`AFTER_COMMIT`이라는 이름을 보면 listener 안에서 repository를 호출해도 새 transaction이 자동으로 생길 것 같아요. 사실은 아니에요.

Commit은 이미 끝났어요. Transaction resource가 잠시 접근 가능한 상태로 보일 수는 있지만, 그 시점의 새 변경이 별도 commit으로 이어진다고 기대하면 안 돼요.

예를 들어 주문 이력을 반드시 DB에 남겨야 한다면 두 선택을 구분해야 해요.

| 요구                                       | 더 자연스러운 위치                                             |
| ---------------------------------------- | ------------------------------------------------------ |
| 주문과 이력이 반드시 함께 commit되어야 해요              | 원래 주문 transaction 안에서 저장해요                             |
| 주문 commit 뒤 독립된 이력을 새 transaction으로 저장해요 | 다른 Spring bean의 `REQUIRES_NEW` method처럼 명시적 새 경계를 검토해요 |
| Process가 죽어도 후속 작업을 잃으면 안 돼요             | Outbox와 broker 같은 durable 전달을 검토해요                     |

새 transaction을 선택한다면 listener와 writer를 다른 bean으로 나눠 프록시 경계를 통과시켜야 해요.

```java title="src/main/java/com/example/order/OrderHistoryWriter.java" lines theme={null}
package com.example.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderHistoryWriter {

    private final OrderHistoryRepository orderHistoryRepository;

    public OrderHistoryWriter(OrderHistoryRepository orderHistoryRepository) {
        this.orderHistoryRepository = orderHistoryRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(OrderPlaced event) {
        this.orderHistoryRepository.save(OrderHistory.from(event));
    }
}
```

이 선택은 원래 주문 transaction과 독립적이에요. 이력 저장이 실패해도 이미 commit된 주문은 되돌아가지 않아요. “별도 transaction을 만들었다”와 “반드시 전달된다”도 같은 말이 아니고요.

***

## 비동기로 돌리려면 `@Async`라는 별도 선택이 필요해요

Email 전송 때문에 주문 응답이 느려지는 문제를 피하려고 listener를 비동기로 실행할 수 있어요.

먼저 async method 실행을 활성화해요.

```java title="src/main/java/com/example/order/AsyncConfig.java" lines theme={null}
package com.example.order;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration(proxyBeanMethods = false)
@EnableAsync
public class AsyncConfig {
}
```

그다음 listener에 `@Async`를 붙여요.

```java title="src/main/java/com/example/notification/OrderNotificationListener.java" lines highlight={3,19} theme={null}
package com.example.notification;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import com.example.order.OrderPlaced;

@Component
public class OrderNotificationListener {

    private final OrderEmailSender orderEmailSender;

    public OrderNotificationListener(OrderEmailSender orderEmailSender) {
        this.orderEmailSender = orderEmailSender;
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced event) {
        this.orderEmailSender.sendConfirmation(event.orderId(), event.memberId());
    }
}
```

이제 commit 뒤 listener 호출이 executor에 넘겨지고 publisher thread는 listener 완료를 기다리지 않아요.

그 대신 실패 의미도 달라져요.

| 동기 listener                      | `@Async` listener                          |
| -------------------------------- | ------------------------------------------ |
| Listener 예외가 호출 흐름으로 전파될 수 있어요   | Publisher는 나중에 난 예외를 직접 받을 수 없어요           |
| 호출 순서를 따라가기 쉬워요                  | 실제 시작·완료 순서는 executor 상태에 따라 달라져요          |
| Publisher thread context를 그대로 봐요 | Trace, MDC, SecurityContext 전파를 따로 확인해야 해요 |
| Process가 살아 있는 동안 바로 호출해요        | 실행 전에 process가 종료되면 작업을 잃을 수 있어요           |

`@Async`는 thread를 바꾸는 도구예요. Message를 저장하거나 재시도하거나 다른 instance로 보내주는 도구가 아니에요.

앞의 관측성 글에서 봤듯이 async 경계에서는 trace context가 이어지는지도 확인해야 해요. 실패를 publisher가 받지 못하므로 error log, metric, alert도 listener 쪽에 필요해져요.

<Warning title="Email처럼 외부 system을 호출한다고 무조건 `@Async`가 답은 아니에요">
  재시작이나 장애 뒤에도 반드시 보내야 하는 email이라면 memory 안의 async event만으로는 부족해요. 전송 상태를 DB에 남기거나 outbox와 broker를 사용해 재처리 가능한 경계를 만들어야 해요.
</Warning>

***

## `@Order`는 business workflow를 만드는 도구가 아니에요

Listener가 여러 개면 실행 순서를 정하고 싶어질 수 있어요.

```java theme={null}
@Order(1)
@EventListener
public void reserveInventory(OrderPlaced event) {
    // ...
}

@Order(2)
@EventListener
public void sendConfirmation(OrderPlaced event) {
    // ...
}
```

동기 listener 사이의 우선순위를 표현할 수는 있어요. 하지만 “재고 예약이 성공해야 email을 보낸다”가 실제 business 규칙이라면 숫자 순서에 숨기지 않는 편이 좋아요.

그 요구에는 다음처럼 결과 event를 나누거나, 하나의 orchestrator가 순서를 직접 조정하는 편이 더 선명해요.

```text theme={null}
OrderPlaced
    ↓
InventoryReserved
    ↓
OrderConfirmationRequested
```

Event가 계속 event를 낳는 구조도 무조건 좋은 것은 아니에요. 한 요청의 실제 흐름을 찾기 위해 listener 열 개를 돌아다녀야 한다면 직접 호출보다 더 강한 **보이지 않는 결합**이 생긴 거예요.

다음 질문으로 판단해 보세요.

* 순서가 바뀌면 business 결과가 달라지나요?
* 앞 작업의 결과를 뒤 작업이 입력으로 사용하나요?
* 하나가 실패하면 나머지도 중단해야 하나요?
* 사용자가 한 화면에서 전체 성공 여부를 바로 알아야 하나요?

대부분 “예”라면 event listener 여러 개보다 명시적인 application service가 workflow를 보여주는 편이 좋아요.

***

## Event payload는 작은 과거 사실로 만들어요

Event에는 무엇을 담아야 할까요? 편하다는 이유로 JPA Entity 전체를 넘기면 경계가 흐려져요.

```java theme={null}
public record OrderPlaced(Order order) {
}
```

이 모양은 listener가 lazy loading에 기대거나 Entity를 수정하게 만들 수 있어요. Async listener라면 원래 persistence context가 이미 닫힌 뒤일 수도 있고요.

보통은 listener가 사건을 식별하는 데 필요한 immutable 값만 담는 편이 좋아요.

```java theme={null}
public record OrderPlaced(
        long orderId,
        long memberId,
        Instant occurredAt
) {
}
```

| Payload 선택     | 장점                               | 주의할 점                                             |
| -------------- | -------------------------------- | ------------------------------------------------- |
| ID와 발생 시각      | 작고 안정적이며 transaction 밖에서도 읽기 쉬워요 | Listener가 최신 상태를 다시 조회할 수 있어요                     |
| 필요한 snapshot 값 | 당시 사실을 그대로 처리할 수 있어요             | Event schema가 커지고 개인정보 관리가 필요해요                   |
| JPA Entity     | 작성은 빠를 수 있어요                     | Lazy loading, 변경 가능성, persistence context 결합이 생겨요 |

Event 이름과 payload는 “listener가 지금 무엇을 해라”보다 “무슨 일이 언제 일어났는가”를 표현해야 해요. Password, access token, 주민등록번호 같은 secret과 민감정보는 event에 넣지 않는 것이 기본이에요. Async 처리나 log가 붙는 순간 예상보다 넓은 곳에 남을 수 있기 때문이에요.

***

## Test는 발행과 처리 시점을 따로 증명해요

Application event test에서 “method가 정상 반환됐다”만 확인하면 중요한 경계를 놓쳐요.

Spring Test의 `@RecordApplicationEvents`를 사용하면 test 중 발행된 event를 확인할 수 있어요.

```java title="src/test/java/com/example/order/OrderServiceTest.java" lines theme={null}
package com.example.order;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;

@SpringBootTest
@RecordApplicationEvents
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ApplicationEvents applicationEvents;

    @Test
    void publishesOrderPlaced() {
        long orderId = this.orderService.place(PlaceOrderCommand.sample());

        assertThat(this.applicationEvents.stream(OrderPlaced.class))
                .singleElement()
                .satisfies(event -> assertThat(event.orderId()).isEqualTo(orderId));
    }
}
```

이 test는 `OrderPlaced`가 발행됐다는 계약을 증명해요. Email이 실제로 전송됐는지, commit 뒤에만 listener가 움직였는지까지 증명하지는 않아요.

`@TransactionalEventListener(AFTER_COMMIT)`를 test할 때는 test 자체의 transaction도 주의해야 해요. Spring test가 마지막에 rollback한다면 commit phase가 오지 않아 listener가 실행되지 않는 것이 정상이에요.

| 확인하려는 계약                      | 필요한 test 모양                                                    |
| ----------------------------- | -------------------------------------------------------------- |
| 올바른 event type과 payload를 발행해요 | `@RecordApplicationEvents`로 발행 event를 확인해요                     |
| Rollback 때 listener가 실행되지 않아요 | 실제 rollback을 만들고 listener side effect가 없는지 확인해요                |
| Commit 뒤 listener가 실행돼요       | Test transaction을 실제 commit하거나 commit되는 service 호출 경계를 사용해요    |
| Async listener가 실패를 기록해요      | Executor 완료를 기다린 뒤 log 대신 관측 가능한 상태나 metric을 확인해요              |
| Process 장애 뒤에도 복구돼요           | In-memory event test가 아니라 outbox/broker integration test가 필요해요 |

특히 async test에서 고정된 `sleep`만 넣으면 느리고 불안정해져요. 완료 신호를 기다릴 수 있는 test 도구나 관측 가능한 상태를 사용하고, timeout 안에 원하는 결과가 생기는지 확인하는 편이 좋아요.

***

## 내부 event와 message broker event는 같은 말이 아니에요

Application event를 사용하다 보면 자연스럽게 “그럼 Kafka나 RabbitMQ도 같은 event 아닌가요?”라는 질문이 생겨요. 업무에서 “event”라는 단어를 함께 쓰지만 실행 경계는 크게 달라요.

| 기준         | 직접 method 호출  | Spring application event | Kafka·RabbitMQ 같은 broker              |
| ---------- | ------------- | ------------------------ | ------------------------------------- |
| 전달 범위      | 같은 객체 그래프     | 같은 application context   | 다른 process와 service                   |
| 기본 실행      | 동기            | 동기                       | Producer와 consumer가 분리돼요              |
| 저장         | 없음            | 없음                       | Broker 설정에 따라 보관돼요                    |
| 재시도        | 호출자가 설계       | Listener가 직접 설계          | Consumer retry와 DLQ를 설계할 수 있어요        |
| 순서         | 코드 순서         | Listener ordering에 영향받아요 | Partition, queue, consumer 구조에 따라 달라요 |
| Process 종료 | 호출도 함께 끝나요    | 처리 전 event를 잃을 수 있어요     | 저장된 message를 다시 받을 수 있어요              |
| 어울리는 목적    | 명시적인 workflow | 한 app 안의 관심사 분리          | Service 간 비동기 전달과 완충                  |

여기서도 broker를 쓴다고 자동으로 exactly-once business 처리가 완성되지는 않아요. Producer DB transaction과 message 발행 사이의 빈틈, 중복 delivery, ordering, retry, dead-letter queue, idempotency를 따로 설계해야 해요.

내부 application event는 broker를 쓰기 전에 component 경계를 연습하기 좋은 도구예요. 하지만 나중에 broker로 바꿀 계획이라는 이유만으로 전달 보장 차이를 무시하면 안 돼요.

```mermaid theme={null}
flowchart LR
    T[DB transaction]
    A[Application event<br /><small>memory 안의 handoff</small>]
    O[(Outbox table)]
    B[Message broker]
    C[다른 service consumer]

    T -->|commit 뒤 listener| A
    T -->|같이 commit| O
    O -->|relay와 retry| B
    B --> C
```

`AFTER_COMMIT` application event는 rollback 전에 외부 작업이 나가는 문제를 줄여요. 하지만 commit 직후 process가 종료되면 event 처리를 잃을 수 있어요. Outbox는 business 변경과 발행할 message를 같은 DB transaction에 남겨 그 빈틈을 줄이는 별도 패턴이에요.

***

## Reactive transaction에서는 context 전달 방식이 달라요

여기까지 예제는 Spring MVC와 thread-bound transaction manager를 기준으로 했어요. 이 경우 transaction context는 현재 thread와 연결돼 있어요.

R2DBC처럼 reactive transaction manager를 사용하면 transaction context는 thread-local이 아니라 Reactor context에 있어요. 그래서 reactive transactional event에서는 필요한 transaction context를 event source에 담아 전달하는 `TransactionalEventPublisher` 흐름을 확인해야 해요.

처음에는 여기까지만 기억해도 충분해요.

* MVC와 JDBC/JPA의 thread-bound 모델을 reactive 코드에 그대로 옮기지 않아요.
* `@Async`와 Reactor는 둘 다 “비동기”로 불리지만 context 전달 방식이 같지 않아요.
* Transactional listener가 필요하다면 현재 사용하는 transaction manager와 공식 문서를 함께 확인해요.

***

## 실무에서는 이 체크리스트로 선택해 보세요

새 후속 작업이 생겼다고 모두 event로 바꿀 필요는 없어요.

### 직접 호출이 더 잘 보이는 경우

* 반드시 성공해야 원래 use case도 성공이에요.
* 실행 순서가 business 규칙이에요.
* 앞 작업의 반환값이 뒤 작업에 필요해요.
* 실패를 바로 사용자에게 알려야 해요.

### Application event가 잘 맞는 경우

* 같은 application 안에서 이미 일어난 사실을 여러 component가 관찰해요.
* Publisher가 구체적인 listener를 몰라도 돼요.
* Listener 추가와 제거가 핵심 use case를 바꾸지 않아요.
* 동기, transaction phase, async 실패 의미를 팀이 명확히 정할 수 있어요.

### Broker나 outbox를 검토해야 하는 경우

* 다른 service나 process가 받아야 해요.
* 재시작 뒤에도 event를 잃으면 안 돼요.
* Traffic을 완충하거나 consumer가 독립적인 속도로 처리해야 해요.
* Retry, DLQ, idempotency, 장기 보관이 필요해요.

Code review에서는 Annotation보다 아래 문장을 먼저 완성해 보세요.

> 이 event는 ***시점에 발행되고,*** phase에서 처리되며, listener가 실패하면 ***되고, process가 종료되면*** 방식으로 복구한다.

빈칸을 채우기 어렵다면 event 이름은 있어도 운영 계약은 아직 없는 상태예요.

## 참고한 링크

* [Spring Framework 공식 문서: Application Events](https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events)
* [Spring Framework 공식 문서: Transaction-bound Events](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html)
* [Spring Framework API: ApplicationEventPublisher](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/ApplicationEventPublisher.html)
* [Spring Framework API: EventListener](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/event/EventListener.html)
* [Spring Framework API: TransactionalEventListener](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionalEventListener.html)
* [Spring Framework 공식 문서: TestContext Application Events](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/application-events.html)

## 자, 정리해볼까요?

<Info title="오늘 우리가 배운 것">
  * `ApplicationEventPublisher`는 publisher가 구체적인 후속 component를 직접 알지 않게 해 같은 application 안의 의존성을 줄여줘요.
  * Spring application event는 기본적으로 호출 thread에서 동기 실행돼요. Queue, 별도 thread, 저장, 재시도를 자동으로 제공하지 않아요.
  * 일반 `@EventListener`는 transaction commit 전에 실행될 수 있고, listener 예외가 publisher와 원래 transaction에 영향을 줄 수 있어요.
  * `@TransactionalEventListener`의 기본 phase는 `AFTER_COMMIT`이며, 활성 transaction이 없으면 기본적으로 listener가 실행되지 않아요.
  * `AFTER_COMMIT`에서 새 DB 변경이 자동 commit된다고 기대하면 안 돼요. 원래 transaction에 포함할지, 별도 transaction이나 durable 전달을 만들지 정해야 해요.
  * `@Async`는 실행 thread를 바꾸지만 event를 저장하지 않아요. 실패 관측, context propagation, process 종료 시 유실을 따로 설계해야 해요.
  * Business 순서가 중요하다면 `@Order` 숫자에 숨기기보다 명시적인 workflow나 더 구체적인 결과 event로 표현하는 편이 좋아요.
  * 다른 service로 안전하게 전달해야 한다면 Kafka, RabbitMQ, outbox, retry, DLQ, idempotency가 필요한 다음 단계예요.
</Info>

다음 글에서는 application process 밖으로 event를 보내 볼게요. Kafka와 RabbitMQ가 각각 어떤 문제를 풀고, message ordering, retry, dead-letter queue, idempotency를 왜 함께 설계해야 하는지 이어서 살펴볼 거예요.


## Related topics

- [Spring Framework 공식 문서 - Application Events](https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events)
- [Spring Framework 공식 문서 - Transaction-bound Events](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html)
- [Spring Framework API - ApplicationEventPublisher](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/ApplicationEventPublisher.html)
- [Spring Framework API - EventListener](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/event/EventListener.html)
- [Spring Framework API - TransactionalEventListener](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/event/TransactionalEventListener.html)
- [Spring Framework 공식 문서 - TestContext Application Events](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/application-events.html)
