← 모든 글
Spring

ApplicationEvent

스프링 Docs - Standard and Custom Events

by rati·

스프링 Docs - Standard and Custom Events

Spring 이벤트 객체를 정의하는 방법과, 제네릭 이벤트를 다룰 때 부딪히는 타입 소거 문제를 다뤄요.

상위 MOC: Spring 이벤트


1. 이벤트를 정의하는 두 방식

Spring 4.2부터 이벤트 객체는 POJO(또는 record) 로도 만들 수 있고, 예전처럼 ApplicationEvent를 상속해서도 만들 수 있어요.

A. record / POJO (권장)

public record OrderCompleted(Long orderId) { }
publisher.publishEvent(new OrderCompleted(orderId));

발행 시 Spring이 내부에서 PayloadApplicationEvent<OrderCompleted>로 감싸요. 리스너는 원본 타입으로 바로 받아요.

@EventListener
public void on(OrderCompleted event) { ... } // 언래핑된 payload가 그대로 들어옴

B. ApplicationEvent 상속

public class OrderCompleted extends ApplicationEvent {
    private final Long orderId;

    public OrderCompleted(Object source, Long orderId) {
        super(source); // 발행자(source) 필수
        this.orderId = orderId;
    }

    public Long getOrderId() { return orderId; }
}
publisher.publishEvent(new OrderCompleted(this, orderId)); // this = 발행한 빈

차이

항목 record / POJO ApplicationEvent 상속
Spring 결합 없음 (프레임워크 비침투적) ApplicationEvent에 결합
source 없음 super(source) 필수
getTimestamp() 없음 기본 제공
도메인 이벤트 재사용 쉬움 도메인이 Spring에 묶임
발행 시 래핑 PayloadApplicationEvent로 감쌈 그대로 발행

TIP · 실무 선택 특별히 source나 timestamp가 필요한 경우가 아니면 record/POJO가 나아요. 도메인 객체가 Spring에 결합되지 않아 도메인 이벤트로 그대로 재사용하기 좋아요. source가 정말 필요할 때만 ApplicationEvent 상속을 써요.

2. java.util.EventObject와 source

ApplicationEvent는 Spring이 새로 만든 게 아니라 JDK 1.1부터 있던 **java.util.EventObject**를 상속받아요.

public class EventObject implements Serializable {
    protected transient Object source;

    public EventObject(Object source) {
        if (source == null)
            throw new IllegalArgumentException("null source");
        this.source = source;
    }

    public Object getSource() { return source; }
}

필드가 source 하나예요. “이 이벤트가 최초로 발생한 객체”만 기억하는 껍데기고, 나머지 정보는 서브클래스가 채워요. AWT/Swing 이벤트(ActionEvent 등)의 뿌리이기도 해요.

source는 흔히 오해하듯 ApplicationContext가 아니에요. 이벤트를 발행한 빈 자신이에요. super(source)가 필수인 이유는 EventObject 생성자가 null source를 IllegalArgumentException으로 막기 때문이에요. Spring은 그 규약을 물려받았어요.

3. 발행 흐름

publisher.publishEvent(event)
    ↓
ApplicationEventMulticaster (멀티캐스터)
    ↓
등록된 리스너들에게 전달 → [[@EventListener]]

발행된 이벤트는 어디에도 저장되지 않고 리스너에게 흘러가요. “방금 뭐가 발행됐지?“를 사후에 물어보려면 별도 장치가 필요한데, 테스트에서는 ApplicationEvents 테스트가 그 역할을 해요.


4. 제네릭 이벤트와 타입 소거

EntityCreatedEvent<Person>과 EntityCreatedEvent<Order>를 리스너가 구분하려 할 때 타입 소거 문제에 부딪혀요.

4-1. 타입 소거(Type Erasure)란

자바 제네릭은 컴파일 시점에만 검사되고, 런타임에는 지워져요.

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();

System.out.println(a.getClass() == b.getClass()); // true — 런타임엔 둘 다 List
컴파일 전                                런타임 (소거 후)
List<String>                  →          List
EntityCreatedEvent<Person> (변수 타입) →  EntityCreatedEvent

<> 안의 정보는 컴파일러가 타입 검사할 때만 쓰고 버려요. (자바 1.5 이전 라이브러리와 바이트코드 레벨에서 호환되어야 했던 하위 호환성 절충안이에요.)

4-2. 왜 이벤트에서 문제가 되나

Spring이 리스너를 매칭할 때 메서드의 파라미터 타입을 리플렉션으로 물어봐요.

@EventListener
public void onPersonCreated(EntityCreatedEvent<Person> event) { ... }

런타임에는 <Person>이 소거돼서 EntityCreatedEvent만 나와요. “Person용인지 Order용인지” 알 방법이 없어요.

4-3. 예외 — 클래스 선언부의 제네릭은 남는다

변수/메서드 시그니처의 타입 인자는 소거되지만, 클래스 선언부에 박힌 제네릭 파라미터는 소거되지 않아요.

public class PersonCreatedEvent extends EntityCreatedEvent<Person> { }
컴파일 전                                   런타임 (.class 파일에 남는 정보)
List<String> myList             →           변수 타입: List           ← 소거됨
class PersonCreatedEvent                     class: PersonCreatedEvent
  extends EntityCreatedEvent<Person> →         superclass: EntityCreatedEvent<Person>  ← 남음

부모가 EntityCreatedEvent<Person>이라는 정보가 클래스 메타데이터로 남아서 getGenericSuperclass()로 런타임에 조회할 수 있어요. 이게 우회법 1의 원리예요.

4-4. 우회법 1 — 서브클래스

public class PersonCreatedEvent extends EntityCreatedEvent<Person> { }

@EventListener
public void onPersonCreated(PersonCreatedEvent event) { ... }

간단하지만 엔티티 타입마다 빈 껍데기 서브클래스를 만들어야 해요 (PersonCreatedEvent, OrderCreatedEvent, ProductCreatedEvent…).

4-5. 우회법 2 — ResolvableTypeProvider

이벤트 객체가 리플렉션 대신 자기 자신의 실제 타입을 알려주는 방식이에요.

public class EntityCreatedEvent<T> extends ApplicationEvent implements ResolvableTypeProvider {

    public EntityCreatedEvent(T entity) {
        super(entity);
    }

    @Override
    public ResolvableType getResolvableType() {
        return ResolvableType.forClassWithGenerics(
                getClass(),
                ResolvableType.forInstance(getSource()));
    }
}

getSource()는 super(entity)로 넘긴 실제 엔티티예요(위 2절 source 참고). 실제 인스턴스에서 타입을 뽑아 “나는 EntityCreatedEvent<[실제 엔티티]>야”라고 알려줘요. 서브클래스 없이 이벤트 클래스 하나로 재사용할 수 있어요.

publisher.publishEvent(new EntityCreatedEvent<>(new Person(...)));

@EventListener
public void onPersonCreated(EntityCreatedEvent<Person> event) { ... }

4-6. 비교

방식 장점 단점
서브클래스 간단, 직관적 엔티티마다 클래스 필요
ResolvableTypeProvider 이벤트 클래스 하나로 재사용 구현 복잡도 증가

관련 노트

끝까지 읽어주셔서 감사합니다.
DISCUSSION

이 글에 대해 이야기해요

질문이나 다른 관점을 남겨 주세요.