스프링 제네릭 이벤트와 타입 소거: ResolvableType으로 해결하기
배경
프로젝트를 진행하며 작업이 고도화 될 수록 클래스 간 의존성이 점점 강해졌다. 그러다보니 클래스가 상호 의존 관계를 갖는 순환 참조 문제가 발생했다. 특정 메서드에서 연관 작업 수행을 위해 다른 서비스의 메서드를 호출하는 과정이 빈번해졌는데, 예를 들어 인기글 순위 갱신의 경우 게시글의 조회, 좋아요, 지원, 삭제 등에 따라 인기글이 서비스를 각 메서드에서 직접 호출하며 인기글 업데이트를 진행하는 등으로 인해 다음과 같은 문제들에 직면하게 되었다.
- 강한 결합도
- 순한 참조
- 테스트의 어려움
- 단일 책임 원칙 위배 가능성
따라서 이 문제를 해결하기 위해 이벤트 기반으로 다양한 로직을 처리하는 것을 고민하게 되었다.
이벤트 기반으로 로직을 설계며 다음과 같이 관련 클래스를 정의했다.
- Event
- EventType
- Payload
Event
이벤트 발행 시 전달하는 파라미터로, EventType과 Payload를 갖고 있다.
@AllArgsConstructor
@Getter
public class Event<T extends Payload> {
private EventType eventType;
private T payload;
public static <T extends Payload> Event<T> create(EventType eventType, T payload) {
return new Event<>(eventType, payload);
}
}
상한을 Payload (interface)로 갖는 Generic 클래스로, 전달 받는 Payload에 따라 타입이 결정된다. 이후 EventListener에서는 전달 받은 Payload 타입으로 구별하여 이벤트를 처리하려고 하였다.
EventType
Event의 Type을 정의하는 Enum 클래스이다.
public enum EventType {
FILE_UPLOAD_FAILED,
STUDY_CREATED,
STUDY_LIKED,
STUDY_LIKE_CANCELED,
STUDY_VIEWED,
STUDY_JOINED,
STUDY_JOIN_CANCELED,
STUDY_DELETED
}
Payload
Event의 타입을 결정 짓는 interface로, Event에 맞는 Payload를 구현하여 사용한다.
public interface Payload {
}
이때 STUDY_CREATED의 경우 다음과 같이 StudyCreatedEventPayload를 구현하여 사용하였다.
@Getter
@AllArgsConstructor
public class StudyCreatedEventPayload implements Payload {
private Long userId;
private StudyCreateRequestDto requestDto;
private Long studyId;
}
사용
이후 Event를 발행하고 사용하는 과정을 다음과 같은 과정을 통해 구현하였다.
@Transactional
public Long create(Long userId, StudyCreateRequestDto requestDto) {
validateStudyCreateRequest(requestDto);
Study study = convertToEntity(requestDto);
Study savedStudy = studyRepository.save(study);
// Study Create 후 STUDY_CREATED 이벤트 발행
eventPublisher.publishEvent(new Event<>(EventType.STUDY_CREATED, new StudyCreatedEventPayload(userId, requestDto, savedStudy.getId())));
return savedStudy.getId();
}
// 이벤트틑 처리하는 Consumer 구현
@Component
@RequiredArgsConstructor
@Slf4j
public class StudyRecruitmentImageConsumer {
private final StudyRecruitmentImageService studyRecruitmentImageService;
@TransactionalEventListener(phase = BEFORE_COMMIT)
// StudyCreatedEventPayload 타입을 갖는 Event 처리
public void handleStudyCreatedEvent(Event<StudyCreatedEventPayload> event) {
StudyCreatedEventPayload payload = event.getPayload();
studyRecruitmentImageService.permanentUpload(payload.getUserId(), payload.getRequestDto(), payload.getStudyId());
}
}
문제
이렇게 Event를 정의하고 발행했을 때, Consumer에서 해당 Event를 잡지 못하는 상황이 발생했다.
주요 상황은 다음과 같았다.
- Event<StudyCreatedEventPayload>타입의 Event를 잡지 못함
- Event 타입의 Event는 정상적으로 잡음
즉 generic으로 동적으로 Event의 타입이 결정되는 상황에서 EventListener가 정상적으로 동적 타입을 잡아내지 못하는 문제였다.
그 이유는 바로 generic class의 type erasure(타입 소거)로 인한 문제였다.
런타임 시점에 generic class는 동적으로 전달 받은 class가 사라져서 알지 못하는 문제를 generic type erasure라고 하는데, 이로 인해 EventListener가 특정 Event를 잡아낼 때 런타임 시점에는 이벤트 객체 자체의 제네릭 타입 파라미터 정보
(예: <StudyCreatedEventPayload>)가 소거되어, 표준 리플렉션만으로는 리스너 매칭 시 이 구체적인 타입을 정확히 알기 어렵다는 문제였다.
따라서 Spring 에서 event를 처리할 때 generic을 통해 동적으로 결정되는 event를 Spring 내부에서 Event를 처리할 때 알려줄 수 있어야했다.
해결
결론적으로, spring에서 제공하는 ResolvableTypeProvider를 구현하면 이 문제를 쉽게 해결할 수 있다.
@AllArgsConstructor
@Getter
public class Event<T extends Payload> implements ResolvableTypeProvider {
private EventType eventType;
private T payload;
public static <T extends Payload> Event<T> create(EventType eventType, T payload) {
return new Event<>(eventType, payload);
}
@Override
public ResolvableType getResolvableType() {
return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(getPayload()));
}
}
ResolvableTypeProvider의 메서드를 구현하였을 때, 문제가 해결되는 과정은 ApplicationEventPublisher.publishEvent() 메서드를 호출했을 때 스프링이 내부적으로 어떻게 Event를 발행하고, 그에 맞는 EventListener를 호출하는지 과정을 살펴보면 알 수 있다.
스프링에서 내부적으로 Event를 발행하고 처리하는 과정은 다음과 같다.
- ApplicationEventPublisher.publishEvent() : 인터페이스를 통해 publishEvent() 메서드 호출
- 스프링은 ApplicationContext가 ApplicationEventPublisher 인터페이스를 상속받고 있음
- 따라서 ApplicationEventPublisher.publishEvent()메서드의 실체 구현은 ApplicationContext 구현체가 이를 구현함
- AbstractApplicationContext가 ApplicationContext를 구현한 구현체
- 구현체의 publishEvent는 내부적으로 이벤트 타입(ResolvableTpye)을 결정하고 multicaster에게 EventListener 매핑을 위임
- AbstractApplicationContext의 publishEvent 메서드가 Event 객체를 받음 -> EventListener 매칭을 위해 Event 객체의 정확한 타입 정보(generic 포함)를 알아내야 함.
- publishEvent 메서드 내부에서 ResolvableType.forInstance() -> getResolvableType()를 호출하여 type 받아옴
- Multicaster에게 EventListener 매핑 위임
protected void publishEvent(Object event, @Nullable ResolvableType typeHint) {
// 생략
if (eventType == null) {
eventType = ResolvableType.forInstance(applicationEvent);
if (typeHint == null) {
typeHint = eventType;
}
}
}
// Multicast right now if possible - or lazily once the multicaster is initialized
if (this.earlyApplicationEvents != null) {
this.earlyApplicationEvents.add(applicationEvent);
}
else if (this.applicationEventMulticaster != null) {
this.applicationEventMulticaster.multicastEvent(applicationEvent, eventType);
}
}
// ResolvableType.forInstance()
public static ResolvableType forInstance(@Nullable Object instance) {
if (instance instanceof ResolvableTypeProvider resolvableTypeProvider) {
ResolvableType type = resolvableTypeProvider.getResolvableType();
if (type != null) {
return type;
}
}
return (instance != null ? forClass(instance.getClass()) : NONE);
}
이후 applicationEventMulticaster는 applicationContext 초기화 시정에 빈으로 등록된 EventListener들을 순회하며 전달 받은 Event와 resovableType을 통해 매핑하여 처리 가능한 EventListener를 호출하여 Event를 처리한다.
위 과정을 통해 Event가 generic이 적용되어 런타임 시점에 타입이 사라져서 적절한 Type으로 Event를 매핑하여 처리하지 못한 문제를 해결할 수 있었다.
문제를 해결하면서 이론적으로만 알고 있던 generic type erasure문제가 실제 어떤 상황에서 문제를 일으키는지 경험해보았고, 평소에 Annotation에 의존하며 사용하던 Event 발행 및 처리 과정을, 디버깅을 통해 스프링 내부적으로 어떻게 이루어지는지 대략적으로 알 수 있게 되었다.