1. 문제
Kafka를 사용하는 이번 프로젝트에서 데이터의 일관성을 보장하기 위해 Transactional Outbox Pattern을 적용하였다. Outbox에 새로운 데이터가 커밋되는 것을 트리거로 동작하는 Kafka 이벤트 발행 로직을 구현하기 위해 Spring Event와 @TransactionalEventListener를 사용하였다.
그러나 Spring Event를 발행하는 작업은 정상적으로 동작하였으나, @TransactionalEventListener가 이벤트를 인식하지 못하는 문제가 발생하였다.
1.1. 소스코드
문제가 발생한 코드이다. ApplicationEventPublisher
를 통해 DomainEventEnvelop<CouponIssuanceEvent>
객체를 이벤트로 발행한다.
@RequiredArgsConstructor
@Service
public class ApplicationEventPublishService {
private final ApplicationEventPublisher eventPublisher;
public void publish(DomainEventEnvelop<CouponIssuanceEvent> envelop) {
eventPublisher.publishEvent(envelop);
}
}
DomainEventEnvelop<CouponIssuanceEvent>
객체를 구독하는 리스너이다.
@Slf4j
@RequiredArgsConstructor
@Component
public class CouponExternalEventRecordListener {
private final CouponExternalEventRecorder eventRecorder;
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void recordMessageHandle(DomainEventEnvelop<CouponIssuanceEvent> envelop) {
eventRecorder.record(envelop);
}
}
Spring Event로 발행되는 이벤트 객체의 구조이다.
public interface DoaminEvent { } // type casting을 위함
public class CouponIssuanceEvent implements DomainEvent { ... }
public class DomainEventEnvelop<T extends DomainEvent> {
private final T event;
// metadata
// ... //
}
2. 이유 (타입소거)
문제가 발생한 이유는 발행되는 이벤트가 Generic 타입을 포함하고 있기 때문이었다.
Generic
타입은 컴파일 타임에 데이터 타입을 검사하여 더 안전하고 가독성이 좋은 코드를 작성할 수 있도록 설계되어 있다. 다만 Java
에서 Generic
타입은 런타임 시점에 타입 정보가 제거되는 타입 소거
(Type Erasure) 메커니즘을 사용한다.
즉, 현재 코드에서 발행되는 이벤트는 DomainEventEnvelop<CoponIssuanceEvent>
가 아닌 제네릭 타입에 타입 소거가 적용된 DomainEventEnvelop<DomainEvent>
가 된다.
하지만 Listener는 DomainEventEnvelop<CoponIssuanceEvent>
타입을 기다리고 있기에 이벤트를 인식하지 못하는 문제가 발생한 것이다.
3. 해결 방법
3.1. 타입 소거 이후의 이벤트 타입을 구독
가장 간단한 해결 방법은 EventListener가 구독하는 이벤트 타입을 타입 소거 이후의 이벤트 타입인 DomainEventEnvelop<DomainEvent>
로 변경하는 것이다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void recordMessageHandle(DomainEventEnvelop<DomainEvent> envelop) {
// ...
}
3.2. 문제점
다만 이런 구조는 DomainEvent를 구현하는 모든 인스턴스의 이벤트를 하나의 리스너에서 받기 때문에 확장성이 떨어진다는 단점이 존재한다.
DomainEvent를 구현하는 이벤트가 2개 이상이라면 아래와 같이 처리해야 할 것이다.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void recordMessageHandle(DomainEventEnvelop<DomainEvent> envelop) {
DomainEvent type = envelop.getEvnet();
if (type instanceof CouponIssuanceEvent) {
// ...
} else if (type instanceof XxxxXxxxEvent) {
// ...
}
}
해당 코드는 하나의 메소드가 많은 책임을 가지게 되며, DomainEvent 타입이 추가될 때마다 해당 메소드를 변경해야 하므로 설계 관점에서 좋지 못하다.
4. 각 이벤트 타입에 맞는 클래스를 정의
따라서 아래와 같이 Spring Event를 위한 클래스를 생성하여 문제를 해결한다.
public record CouponIssuance(DomainEventEnvelop<CouponIssuanceEvent> envelop,
LocalDateTime publishedAt) { }
// 이벤트 발행
public void publish(DomainEventEnvelop<CouponIssuanceEvent> envelop) {
CouponIssuance issuance = CouponIssuance.of(envelop, LocalDateTime.now());
eventPublisher.publishEvent(issuance);
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void recordMessageHandle(CouponIssuance couponIssuance) {
// ...
}
4.1. 특징
새로운 클래스를 정의하고 관리해야 한다는 단점이 존재하지만. 각 리스너가 필요한 객체만을 바라볼 수 있는 구조로 설계가 가능하기에 확장성이 뛰어난 설계라고 생각한다. 또한 이벤트를 처리하는 데 있어 필요한 메타 데이터를 추가할 수 있다는 장점이 존재한다.
5. 결론
Generic을 포함한 이벤트를 발행할 때, 타입소거로 인한 문제가 발생한다면, 확장성과 유지보수성을 고려하여 이벤트 타입에 맞는 전용 클래스를 정의하는 방식이 더 적합하다고 생각한다.
해당 방식은 이벤트 처리 로직이 명확해지고, 새로운 이벤트 타입 추가 시 기존 코드에 영향을 주지 않아 설계 품질을 높이는 데 유리하다.
Reference
Type Erasure (The Java™ Tutorials > Learning the Java Language > Generics (Updated))
The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Dev.java for updated tutorials taking advantag
docs.oracle.com
'트러블슈팅' 카테고리의 다른 글
DB의 외부식별자와 내부식별자 분리 (Auto_Increment vs UUID) (0) | 2025.02.10 |
---|---|
조건부 속성 문제 해결기 (DDD + Factory Method Pattern) (0) | 2025.01.19 |
이벤트 기반 비동기 통신 구현 및 Kafka를 사용한 이유 (feat. RabbitMQ) (2) | 2024.12.20 |
Jackson 직렬화 내부 동작 방식으로 인한 Redis 캐시 데이터 파싱 오류 (0) | 2024.12.16 |
테스트 더블을 직접 구현한 테스트에서 발생했던 문제들 (0) | 2024.05.03 |