[Event 기반 설계] 다양한 이벤트 확장 : 이벤트 추상화와 동적 매핑 구현 방법
✏️ 지난 포스트 이야기 & 서론
지난 포스트 : https://dev-wooni.tistory.com/13
이벤트 기반 아키텍처 도입하기 (Transactional Outbox Pattern)
✏️ 서론웹 애플리케이션 서비스에서 회원가입 시 사용자가 입력한 이메일이 유효한지 확인하는 이메일 인증 절차는 필수적입니다. 이는 사용자와 서비스 모두에게 중요한 과정입니다. 먼
dev-wooni.tistory.com
회원가입 후 이벤트 발행을 통해 알림을 전송하는 구조는 기존 로직의 문제점을 성공적으로 해결했습니다. 또한, Transactional Outbox Pattern의 사용을 통해 이벤트 메시지 발행을 보장할 수 있는 구조를 만들었습니다.
하지만 이벤트 추상화와 관련하여, 시스템의 확장성을 높이기 위해 추가적인 엔지니어링이 필요합니다.
현재 구현한 방식은 회원가입 이벤트에 구체적으로 맞춰져 있습니다. 하지만 앞으로 회원가입뿐만 아니라, 다른 도메인 이벤트 발행에 대한 요구사항이 생길 가능성이 큽니다.
예를 들어, Member의 패스워드가 변경되면, 보안 강화를 위해 이메일을 통해 개인정보가 변경됐다는 알림을 전송해야 할 수 있습니다.
이와 같이 다양한 도메인 이벤트가 계속해서 추가되면, 이벤트 발행 서비스와 이벤트 저장소를 매번 새롭게 구현해야 할 것입니다.
따라서, 특정 도메인 이벤트에 종속되지 않는 추상화된 이벤트 구조를 설계하는 것이 중요합니다.
도메인 이벤트 추상화 : DomainEvent 클래스 설계
@Getter
@RequiredArgsConstructor
public abstract class DomainEvent {
private final LocalDateTime createdAt;
}
위의 코드에서는 여러 도메인 이벤트에 적용할 수 있도록 DomainEvent 추상 클래스를 정의합니다.
DomainEvent 추상 클래스는 이벤트 발생 시점(createdAt) 필드만을 가지고 있으며, 이는 이벤트가 발생한 시간을 기록하는 역할을 합니다. 각 도메인 이벤트의 구현체는 이 클래스를 상속받아 추가적인 데이터를 담을 수 있도로 설계되었습니다.
Event 테이블(Outbox)
이전에는 회원가입 이벤트에만 종속된 테이블(MEMBER_SIGN_UP_EVENT)을 설계했습니다. 그러나, 이제는 모든 종류의 이벤트를 저장할 수 있는 이벤트 테이블로 전환하겠습니다.
Event 테이블의 컬럼을 살펴보겠습니다.
CREATE TABLE EVENT (
EVENT_ID INT AUTO_INCREMENT PRIMARY KEY,
EVENT_TYPE VARCHAR(255) NOT NULL,
PAYLOAD TEXT NOT NULL,
CREATED_AT DATETIME NOT NULL,
PRIMARY KEY (`EVENT_ID`),
);
✔️ EVENT_TYPE: DomainEvent 구현체의 클래스명입니다. 해당 이벤트 클래스의 getClass().getName()으로 가져오도록 설계했습니다.
✔️ PAYLOAD: DomainEvent 각 구현체에서 정의한 필드가 직렬화를 통해 JSON 형태로 저장됩니다. DomainEvent 추상 클래스의 구현체가 어떤 필드를 가지든지 관계없이, 직렬화된 JSON 형태로 PAYLOAD 컬럼에 저장이 가능합니다.
이벤트 처리 동작 과정
지난 글에서 Event 테이블(Outbox)에 저장된 이벤트를 처리하기 위한 방법으로 polling을 사용하기로 결정했습니다.
아래는 이벤트를 처리하는 EventConsumer 클래스 코드입니다. 이 클래스는 정해진 주기대로 polling을 진행하여, 이벤트를 소비(처리)하는 역할을 합니다.
✔️ EventConsumer
@Component
@RequiredArgsConstructor
public class EventConsumer {
private final Logger log = LoggerFactory.getLogger(this.getClass());
private final JdbcTemplate jdbcTemplate;
private final EventMapper eventMapper;
private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
private final EventPublish eventPublish;
@Scheduled(fixedDelay = 5000)
public void consume() {
List<EventDto> eventDtos = findAll();
List<Long> ids = new ArrayList<>();
eventDtos.forEach(eventDto -> {
try {
eventPublish.publish(eventDto.getEvent());
ids.add(eventDto.getMetaData().getEventId());
} catch (RuntimeException e) {
log.error("id : {} message : {}",
eventDto.getMetaData().getEventId(), e.getMessage());
}
});
deleteProcessedEvents(ids);
}
public List<EventDto> findAll() {
String query = "SELECT EVENT_ID, EVENT_TYPE, PAYLOAD, CREATED_AT FROM EVENT";
return jdbcTemplate.query(query, eventDtoRowMapper());
}
public void deleteProcessedEvents(List<Long> processedEventIds) {
if(processedEventIds.isEmpty()) return;
String query = "DELETE FROM EVENT WHERE EVENT_ID IN (:ids)";
MapSqlParameterSource parameters = new MapSqlParameterSource();
parameters.addValue("ids", processedEventIds);
namedParameterJdbcTemplate.update(query, parameters);
}
private RowMapper<EventDto> eventDtoRowMapper() {
return (rs, rowNum) -> new EventDto(
rs.getLong("EVENT_ID"),
eventMapper.eventOf(rs.getString("PAYLOAD"),
rs.getString("EVENT_TYPE"))
);
}
}
@Getter
@AllArgsConstructor
public class EventDto {
private final Long id;
private final DomainEvent event;
}
✔️ EventConsumer 동작 흐름
1. 이벤트 조회 (findAll()) :
DB에서 이벤트 데이터를 조회합니다. 조회된 데이터는 EventDto로 매핑되며, 이때 이벤트의 타입과 페이로드를 동적으로 DomainEvent로 변환합니다.
저는 동적 매핑을 담당할 클래스로 EventMapper를 구현했습니다. EventMapper에서 동적 매핑을 어떻게 구현했는지는 아래에서 자세하게 설명하겠습니다.
2. 이벤트 처리 (consume()) :
조회된 각 이벤트를 eventPublish.publish() 메서드를 통해 발행합니다.
3. 이벤트 삭제 (deleteProcessedEvents()) :
처리된 이벤트의 ID 목록을 모아, 해당 이벤트들을 DB에서 삭제합니다.
✔️ 주요 동작
이벤트 추상화를 진행하고, 이벤트를 처리하는 부분에 있어서 주요 동작 흐름은 아래와 같습니다.
1. EventConsumer에서 주기적으로 DB polling
: 스케줄링된 시간에 따라 주기적으로 데이터베이스를 폴링 하여 Event 테이블의 새로운 이벤트 데이터를 조회합니다.
2. EventMapper를 통한 동적 매핑
: 조회된 이벤트 데이터는 이벤트 타입(EVENT_TYPE)과 페이로드(PAYLOAD)를 포함하고 있습니다. 이 데이터를 적절한 도메인 이벤트 객체로 변환하기 위해 EventMapper가 사용됩니다.
EventMapper는 리플렉션을 사용하여 이벤트 클래스 이름에(EVENT_TYPE)에 맞는 이벤트 클래스를 찾아, JSON 데이터(PAYLOAD)를 해당 클래스의 객체로 변환합니다. 이를 통해 이벤트마다 적절한 DomainEvent 객체가 생성됩니다.
3. EventPublish에서 이벤트 발행
: 매핑된 도메인 이벤트 객체는 EventPublish 클래스를 통해 발행됩니다.
이때, Spring 프레임워크의 ApplicationEventPublisher를 사용하여 이벤트가 발행되며, 해당 이벤트는 이를 처리할 수 있는 리스너들(EventListener)에게 전파됩니다.
4. 이벤트 리스너에서 이벤트 처리
: 발행된 이벤트는 리스너 클래스에서 처리됩니다. @EventListener 어노테이션을 사용하여 특정 이벤트를 처리할 수 있는 메서드를 정의합니다. 각 리스너는 이벤트의 타입에 맞는 핸들링 로직을 수행하며, 이를 통해 다양한 비즈니스 로직이 실행됩니다.
Event 동적 매핑 : EventMapper 클래스
이제 EventMapper 클래스에 대해 자세히 살펴보겠습니다. 이번 추상화 과정에서 구현이 가장 까다로웠던 부분입니다.
DB에서 조회한 이벤트 데이터를 적절한 이벤트 객체(DomainEvent)로 동적으로 매핑하는 중요한 역할을 담당하는 클래스입니다.
EVENT_TYPE과 직렬화된 PAYLOAD 데이터를 바탕으로, 해당 이벤트에 맞는 도메인 이벤트 객체로 동적으로 변환합니다. 이는 다양한 이벤트 타입에 유연하게 대응하기 위한 핵심적인 설계입니다.
EventMapper의 구체적인 코드를 살펴보겠습니다.
✔️ 코드 전체
package dev.flab.studytogether.domain.event.infrastructure;
import cohttp://m.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import dev.flab.studytogether.domain.event.DomainEvent;
import dev.flab.studytogether.domain.event.infrastructure.rowmapper.DomainEventRowMapper;
import org.springframework.stereotype.Component;
import org.reflections.Reflections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class EventMapper {
private Map<Class<? extends DomainEvent>, Class<? extends DomainEventRowMapper>> typeMap = new HashMap<>();
public EventMapper() {
this("dev.flab.studytogether");
}
public EventMapper(String basePackage) {
Reflections reflections = new Reflections(basePackage);
Set<Class<? extends DomainEvent>> eventClasses = reflections.getSubTypesOf(DomainEvent.class);
Set<Class<? extends DomainEventRowMapper>> rowClasses = reflections.getSubTypesOf(DomainEventRowMapper.class);
typeMap = rowClasses
.stream()
.collect(Collectors.toMap(rowClass -> {
try {
return rowClass.getDeclaredConstructor().newInstance().eventType();
} catch (Exception e) {
throw new RuntimeException(e);
}}, rowClass -> rowClass));
if(!typeMap.keySet().containsAll(eventClasses))
throw new RuntimeException(new ClassNotFoundException());
}
public DomainEvent eventOf(String json, String eventTypeValue) {
Class<?> clazz;
try {
clazz = Class.forName(eventTypeValue);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
if(!DomainEvent.class.isAssignableFrom(clazz)) throw new ClassCastException();
Class<? extends DomainEvent> eventType = clazz.asSubclass(DomainEvent.class);
Class<? extends DomainEventRowMapper> rowMapperType = rowMapperFrom(eventType);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
try {
return objectMapper.readValue(json, rowMapperType).createDomainEvent();
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private Class<? extends DomainEventRowMapper> rowMapperFrom(Class<? extends DomainEvent> eventType) {
Class<? extends DomainEventRowMapper> type = typeMap.get(eventType);
if(type == null) throw new RuntimeException(new ClassNotFoundException("not found class : " + eventType));
return type;
}
}
StudyTogether/src/main/java/dev/flab/studytogether/domain/event/infrastructure/EventMapper.java at main · wooni97/StudyTogether
개발자를 위한 온라인 모각코. Contribute to wooni97/StudyTogether development by creating an account on GitHub.
github.com
✔️ 주요 필드
typeMap
private Map<Class<? extends DomainEvent>, Class<? extends DomainEventRowMapper>> typeMap = new HashMap<>();
typeMap은 이벤트 클래스(DomainEvent)와 그에 대응하는 매퍼 클래스(DomainEventMapper)를 저장하는 Map입니다.
✔️ DomainEventRowMapper 정의 이유
DomainEventRowMapper는 JSON 데이터를 도메인 이벤트 객체로 변환하는 역할을 담당합니다. 이 설계는 도메인 이벤트 클래스(DomainEvent)에 기본 생성자와 setter를 추가하지 않기 위해 도입되었습니다.
Jackson의 objectMapper를 사용하여 객체를 직렬화/역직렬화 하도록 구현하였는데, 이 과정에서 기본 생성자와 setter를 필요로합니다.
도메인 이벤트 객체(DomainEvent)에 기본 생성자와 setter를 추가하지 않는 이유
각 도메인 이벤트 객체에 기본 생성자와 setter를 추가하면 해결 될 일인데, DomainEventRowMapper를 별도로 구현하는 것은 개발자에게 번거로운 작업일 수 있습니다.
그러나 순수한 도메인 이벤트 객체는 해당 도메인에서 발생한 이벤트 자체만을 정의하는 불변 객체여야 하며, 특정 기술 구현체에 의존하지 않아야 한다고 생각합니다. 이를 유지하기 위해, RowMapper를 통해 JSON 역직렬화를 안전하게 처리하고, 도메인 이벤트 객체의 불변 상태를 유지하면서도 필요한 데이터를 정확하게 이벤트 객체로 변환할 수 있도록 설계하였습니다.
예시)
MemberSignUpEvent와 그에 대응하는 MemberSignUpEventMapper는 아래와 같습니다.
@Getter
@AllArgsConstructor
public class MemberSignUpEvent extends DomainEvent {
private final Long userId;
private final String userEmail;
private final String authKey;
public MemberSignUpEvent(Long userId, String userEmail, String authKey) {
super(LocalDateTime.now());
this.userId = userId;
this.userEmail = userEmail;
this.authKey = authKey;
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class MemberV2SignUpEventRowMapper implements DomainEventRowMapper<MemberV2SignUpEvent> {
private Long userId;
private String userEmail;
private String authKey;
private LocalDateTime createdAt;
기존의 DomainEvent에 기본 생성자와, setter 추가된 형태입니다.
✔️ 생성자
Reflections 라이브러리를 활용해 지정된 패키지(basePackage) 내에서 DomainEvent와 DomainEventRowMapper의 하위 타입들을 탐색합니다. 이를 통해 이벤트 클래스와 매퍼 클래스를 자동으로 탐색할 수 있습니다.
탐색한 DomainEventMapper 클래스들(rowClasses)을 순회하며, 각 매퍼 클래스의 eventType() 메서드를 호출해 해당 매퍼가 처리할 이벤트 타입을 구하고, 이를 typeMap에 저장합니다.
✔️ eventOf 메서드
eventOf 메서드는 이벤트 타입에 맞는 이벤트 객체를 JSON 문자열로부터 생성하는 역할을 합니다.
우선, 이벤트 타입에 맞는 매퍼 클래스(DomainEventRowMapper)를 찾기 위해 rowMapperFrom 메서드를 호출합니다.
ObjectMapper를 사용해 매퍼 클래스로 JSON 문자열을 역직렬화(deserialize) 한 후, 매퍼의 createDomainEvent() 메서드를 호출하여 실제 이벤트 객체를 생성합니다.
✔️ rowMapperFrom 메서드
주어진 이벤트 타입에 맞는 매퍼 클래스를 typeMap에서 가져옵니다.
+) DomainEventRowMapper 인터페이스
결과 및 성과
이벤트 추상화를 적용함으로써, 다음과 같은 결과를 얻었습니다.
✔️ 유지보수성 향상: 특정 도메인 이벤트에 종속되지 않는 추상화된 이벤트 구조를 통해 다양한 도메인 이벤트를 유연하게 처리할 수 있습니다.
✔️ 확장성 증가: 새로운 도메인 이벤트가 추가되더라도 기존 코드를 최소한으로 변경하거나 추가적인 구현을 통해 시스템을 확장할 수 있습니다.
✔️ 불변성 유지: 도메인 이벤트 객체의 불변성을 유지하면서도 필요한 데이터를 정확하게 처리할 수 있게 되었습니다.
RowMapper에 대해 고려할 점
현재 구조에서 발생할 수 있는 문제점이 존재합니다.
바로, 개발자가 새로운 DomainEvent 구현체를 정의할 때 RowMapper에 대한 정의도 반드시 필요하다고 인지하는 시점이 컴파일 타임보다는 런타임 시점이 되는 것입니다.
물론 이 규칙을 전사적으로 공유하여 모두가 지키면 되지만 컴파일 타임 수준에서 RowMapper를 정의해야 한다를 강제할 수 있으면 훨씬 빠른 시점에 문제를 인지하고 이벤트 확장을 할 수 있을 것입니다.
이 부분이 정말로 필요한지는 프로젝트를 진행하면서 추후 고민해 볼 예정입니다.