Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

이벤트에 Transactional Outbox Pattern 적용 #757

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ dependencies {

implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

implementation 'io.hypersistence:hypersistence-utils-hibernate-62:3.6.1'
implementation 'com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations'

}

test {
Expand Down
27 changes: 27 additions & 0 deletions backend/src/main/java/hanglog/event/CompletedEventChecker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package hanglog.event;

import java.util.Arrays;
import lombok.AllArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
@AllArgsConstructor
public class CompletedEventChecker {

private final OutboxRepository outboxRepository;

@AfterReturning("@annotation(org.springframework.transaction.event.TransactionalEventListener)")
public void check(final JoinPoint joinPoint) {
System.out.println("CompletedEventChecker.check");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chk

final Event event = Arrays.stream(joinPoint.getArgs())
.filter(Event.class::isInstance)
.map(Event.class::cast)
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Event 타입이 아님."));
outboxRepository.deleteById(event.getOutboxId());
}
}
21 changes: 21 additions & 0 deletions backend/src/main/java/hanglog/event/Event.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package hanglog.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class Event {

@JsonProperty
private Long outboxId;

@JsonProperty
private final EventType eventType;

public Event(final Long outboxId, final EventType eventType) {
this.outboxId = outboxId;
this.eventType = eventType;
}
}
39 changes: 39 additions & 0 deletions backend/src/main/java/hanglog/event/EventQueue.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package hanglog.event;

import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class EventQueue {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오홍 신기합니다!!!!


private final Queue<Outbox> queue = new LinkedList<>();
private final OutboxRepository outboxRepository;
private final List<OutboxToEventMapper> mappers;

private final ApplicationEventPublisher publisher;

@Scheduled(fixedRate = 2000)
public void offerSavedEvent() {
queue.addAll(outboxRepository.findAll());
}
Comment on lines +22 to +25
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. findAll의 호출 기준을 2초로 둔 이유가 궁금합니다!

  2. 지금 저희는 다중 서버인데 그럼 각 서버마다 큐를 운영하는건가요?
    그러면 큐에서 빼면서 호출하는 로직도 각각 서버마다 진행되는건가요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래 댓글에 한 번에 써둠!!!


@Scheduled(fixedRate = 2000)
public void pollEvent() throws JsonProcessingException {
if (queue.isEmpty()) {
return;
}

final Outbox outbox = queue.poll();
final OutboxToEventMapper outboxToEventMapper = mappers.stream().filter(mapper -> mapper.is(outbox.getEventType()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("이벤트 타입에 해당하는 매퍼가 없어요오"));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이대로 놔둘건가요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레전드

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exception code 하나 만드시죠.!

publisher.publishEvent(outboxToEventMapper.toEvent(outbox));
}
}
13 changes: 13 additions & 0 deletions backend/src/main/java/hanglog/event/EventType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package hanglog.event;

public enum EventType {

TRIP_DELETE(1),
MEMBER_DELETE(2);

private final int number;

EventType(final int number) {
this.number = number;
}
}
47 changes: 47 additions & 0 deletions backend/src/main/java/hanglog/event/Outbox.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package hanglog.event;

import static jakarta.persistence.EnumType.STRING;

import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.databind.ObjectMapper;
import hanglog.global.BaseEntity;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE outbox SET status = 'DELETED' WHERE id = ?")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아웃박스 테이블도 soft delete를 하는 이유가 있을까요? 본래 아웃박스의 의도와는 조금 달라질 수도 있다는 생각이 들어서요.!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

status가 필요해서 재활용했는데, baseEntity의 status대신 아웃박스의 Status를 별도로 만드는 게 좋을까요?!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 그럼 그대로 가도 될 듯 합니다

@Where(clause = "status = 'USABLE'")
public class Outbox extends BaseEntity {

public static final ObjectMapper objectMapper = new ObjectMapper();

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
@Enumerated(value = STRING)
private EventType eventType;

@Type(JsonType.class)
@Column(name = "payload", columnDefinition = "json")
private String eventPayload;

public Outbox(final EventType eventType, final String eventPayload) {
this.id = null;
this.eventType = eventType;
this.eventPayload = eventPayload;
}
}
5 changes: 5 additions & 0 deletions backend/src/main/java/hanglog/event/OutboxEventHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package hanglog.event;


public @interface OutboxEventHandler {
}
7 changes: 7 additions & 0 deletions backend/src/main/java/hanglog/event/OutboxRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hanglog.event;

import org.springframework.data.jpa.repository.JpaRepository;

public interface OutboxRepository extends JpaRepository<Outbox, Long> {

}
9 changes: 9 additions & 0 deletions backend/src/main/java/hanglog/event/OutboxToEventMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package hanglog.event;

import com.fasterxml.jackson.core.JsonProcessingException;

public interface OutboxToEventMapper<T extends Event> {

boolean is(EventType type);
T toEvent(Outbox outbox) throws JsonProcessingException;
Comment on lines +7 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 final 못붙이나요

}
20 changes: 20 additions & 0 deletions backend/src/main/java/hanglog/event/PayloadToEventMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package hanglog.event;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.Serializable;
import org.springframework.stereotype.Component;

@Component
public class PayloadToEventMapper implements Serializable {

private static final ObjectMapper objectMapper = new ObjectMapper();

public static Event toObject(final String payload, final Class<? extends Event> event) throws JsonProcessingException {
return objectMapper.readValue(payload, event);
}

public static String toJson(final Event event) throws JsonProcessingException {
return objectMapper.writeValueAsString(event);
}
}
26 changes: 26 additions & 0 deletions backend/src/main/java/hanglog/event/TripDeleteEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package hanglog.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import lombok.Getter;

@Getter
public class TripDeleteEvent extends Event implements Serializable {

@JsonProperty
private Long tripId;

public TripDeleteEvent(final Long outboxId, final EventType eventType, final Long tripId) {
super(outboxId, eventType);
this.tripId = tripId;
}

public TripDeleteEvent(final Long tripId) {
super(null, EventType.TRIP_DELETE);
this.tripId = tripId;
}

public TripDeleteEvent() {
super(EventType.TRIP_DELETE);
}
}
20 changes: 20 additions & 0 deletions backend/src/main/java/hanglog/event/TripDeleteEventMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package hanglog.event;

import com.fasterxml.jackson.core.JsonProcessingException;
import org.springframework.stereotype.Component;

@Component
public class TripDeleteEventMapper implements OutboxToEventMapper {

@Override
public boolean is(final EventType type) {
return type.equals(EventType.TRIP_DELETE);
}

@Override
public TripDeleteEvent toEvent(final Outbox outbox) throws JsonProcessingException {
final String eventPayload = outbox.getEventPayload();
final TripDeleteEvent tripDeleteEvent = (TripDeleteEvent) PayloadToEventMapper.toObject(eventPayload, TripDeleteEvent.class);
return new TripDeleteEvent(outbox.getId(), outbox.getEventType(), tripDeleteEvent.getTripId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW;

import hanglog.event.TripDeleteEvent;
import hanglog.expense.domain.repository.ExpenseRepository;
import hanglog.login.domain.repository.RefreshTokenRepository;
import hanglog.member.domain.MemberDeleteEvent;
import hanglog.trip.domain.TripDeleteEvent;
import hanglog.trip.domain.repository.CustomDayLogRepository;
import hanglog.trip.domain.repository.CustomItemRepository;
import hanglog.trip.domain.repository.DayLogRepository;
Expand Down
11 changes: 0 additions & 11 deletions backend/src/main/java/hanglog/trip/domain/TripDeleteEvent.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package hanglog.trip.presentation;

import com.fasterxml.jackson.core.JsonProcessingException;
import hanglog.auth.Auth;
import hanglog.auth.MemberOnly;
import hanglog.auth.domain.Accessor;
Expand Down Expand Up @@ -75,7 +76,10 @@ public ResponseEntity<Void> updateTrip(

@DeleteMapping("/{tripId}")
@MemberOnly
public ResponseEntity<Void> deleteTrip(@Auth final Accessor accessor, @PathVariable final Long tripId) {
public ResponseEntity<Void> deleteTrip(
@Auth final Accessor accessor,
@PathVariable final Long tripId
) throws JsonProcessingException {
tripService.validateTripByMember(accessor.getMemberId(), tripId);
tripService.delete(tripId);
return ResponseEntity.noContent().build();
Expand Down
14 changes: 11 additions & 3 deletions backend/src/main/java/hanglog/trip/service/TripService.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import static hanglog.global.exception.ExceptionCode.NOT_FOUND_MEMBER_ID;
import static hanglog.global.exception.ExceptionCode.NOT_FOUND_TRIP_ID;

import com.fasterxml.jackson.core.JsonProcessingException;
import hanglog.event.EventType;
import hanglog.event.Outbox;
import hanglog.event.OutboxRepository;
import hanglog.event.PayloadToEventMapper;
import hanglog.event.TripDeleteEvent;
import hanglog.city.domain.City;
import hanglog.city.domain.repository.CityRepository;
import hanglog.global.exception.AuthException;
Expand All @@ -17,7 +23,6 @@
import hanglog.trip.domain.PublishEvent;
import hanglog.trip.domain.SharedTrip;
import hanglog.trip.domain.Trip;
import hanglog.trip.domain.TripDeleteEvent;
import hanglog.trip.domain.repository.CustomDayLogRepository;
import hanglog.trip.domain.repository.CustomTripCityRepository;
import hanglog.trip.domain.repository.SharedTripRepository;
Expand Down Expand Up @@ -56,6 +61,7 @@ public class TripService {
private final CustomDayLogRepository customDayLogRepository;
private final CustomTripCityRepository customTripCityRepository;
private final ApplicationEventPublisher publisher;
private final OutboxRepository outboxRepository;

public void validateTripByMember(final Long memberId, final Long tripId) {
if (!tripRepository.existsByMemberIdAndId(memberId, tripId)) {
Expand Down Expand Up @@ -186,15 +192,17 @@ private Predicate<DayLog> getDayLogOutOfPeriod(final int currentPeriod, final in
return dayLog -> dayLog.getOrdinal() >= requestPeriod + 1 && dayLog.getOrdinal() <= currentPeriod;
}

public void delete(final Long tripId) {
public void delete(final Long tripId) throws JsonProcessingException {
if (!tripRepository.existsById(tripId)) {
throw new BadRequestException(NOT_FOUND_TRIP_ID);
}

publisher.publishEvent(new PublishDeleteEvent(tripId));
sharedTripRepository.deleteByTripId(tripId);
tripRepository.deleteById(tripId);
publisher.publishEvent(new TripDeleteEvent(tripId));

final String payload = PayloadToEventMapper.toJson(new TripDeleteEvent(tripId));
outboxRepository.save(new Outbox(EventType.TRIP_DELETE, payload));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 되면 결국 삭제 로직에서 DB 접근이 추가적으로 필요한 상황인데 트랜잭션 보장을 위해서 해당 방법이 가장 합리적일까요?

이 외에 다른 방식을 고민해 본게 있을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 외부 db에 저장한다는 것은 필수 조건으로 깔고 갔습니다! 아래 댓글 참고오오오

}

private String generateInitialTitle(final List<City> cites) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import hanglog.event.TripDeleteEvent;
import hanglog.expense.domain.repository.ExpenseRepository;
import hanglog.listener.DeleteEventListener;
import hanglog.login.domain.repository.RefreshTokenRepository;
import hanglog.member.domain.MemberDeleteEvent;
import hanglog.trip.domain.TripDeleteEvent;
import hanglog.trip.domain.repository.CustomDayLogRepository;
import hanglog.trip.domain.repository.CustomItemRepository;
import hanglog.trip.domain.repository.DayLogRepository;
Expand Down