diff --git a/backend/build.gradle b/backend/build.gradle index fbb4f6632..2939be256 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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 { diff --git a/backend/src/main/java/hanglog/event/CompletedEventChecker.java b/backend/src/main/java/hanglog/event/CompletedEventChecker.java new file mode 100644 index 000000000..de6dcb0da --- /dev/null +++ b/backend/src/main/java/hanglog/event/CompletedEventChecker.java @@ -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"); + final Event event = Arrays.stream(joinPoint.getArgs()) + .filter(Event.class::isInstance) + .map(Event.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Event 타입이 아님.")); + outboxRepository.deleteById(event.getOutboxId()); + } +} diff --git a/backend/src/main/java/hanglog/event/Event.java b/backend/src/main/java/hanglog/event/Event.java new file mode 100644 index 000000000..651f6462e --- /dev/null +++ b/backend/src/main/java/hanglog/event/Event.java @@ -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; + } +} diff --git a/backend/src/main/java/hanglog/event/EventQueue.java b/backend/src/main/java/hanglog/event/EventQueue.java new file mode 100644 index 000000000..8d56dd8a2 --- /dev/null +++ b/backend/src/main/java/hanglog/event/EventQueue.java @@ -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 { + + private final Queue queue = new LinkedList<>(); + private final OutboxRepository outboxRepository; + private final List mappers; + + private final ApplicationEventPublisher publisher; + + @Scheduled(fixedRate = 2000) + public void offerSavedEvent() { + queue.addAll(outboxRepository.findAll()); + } + + @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("이벤트 타입에 해당하는 매퍼가 없어요오")); + publisher.publishEvent(outboxToEventMapper.toEvent(outbox)); + } +} diff --git a/backend/src/main/java/hanglog/event/EventType.java b/backend/src/main/java/hanglog/event/EventType.java new file mode 100644 index 000000000..cc5cc01b3 --- /dev/null +++ b/backend/src/main/java/hanglog/event/EventType.java @@ -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; + } +} diff --git a/backend/src/main/java/hanglog/event/Outbox.java b/backend/src/main/java/hanglog/event/Outbox.java new file mode 100644 index 000000000..33872655e --- /dev/null +++ b/backend/src/main/java/hanglog/event/Outbox.java @@ -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 = ?") +@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; + } +} diff --git a/backend/src/main/java/hanglog/event/OutboxEventHandler.java b/backend/src/main/java/hanglog/event/OutboxEventHandler.java new file mode 100644 index 000000000..679369bc9 --- /dev/null +++ b/backend/src/main/java/hanglog/event/OutboxEventHandler.java @@ -0,0 +1,5 @@ +package hanglog.event; + + +public @interface OutboxEventHandler { +} diff --git a/backend/src/main/java/hanglog/event/OutboxRepository.java b/backend/src/main/java/hanglog/event/OutboxRepository.java new file mode 100644 index 000000000..21533a359 --- /dev/null +++ b/backend/src/main/java/hanglog/event/OutboxRepository.java @@ -0,0 +1,7 @@ +package hanglog.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OutboxRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/hanglog/event/OutboxToEventMapper.java b/backend/src/main/java/hanglog/event/OutboxToEventMapper.java new file mode 100644 index 000000000..8793be969 --- /dev/null +++ b/backend/src/main/java/hanglog/event/OutboxToEventMapper.java @@ -0,0 +1,9 @@ +package hanglog.event; + +import com.fasterxml.jackson.core.JsonProcessingException; + +public interface OutboxToEventMapper { + + boolean is(EventType type); + T toEvent(Outbox outbox) throws JsonProcessingException; +} diff --git a/backend/src/main/java/hanglog/event/PayloadToEventMapper.java b/backend/src/main/java/hanglog/event/PayloadToEventMapper.java new file mode 100644 index 000000000..755150a19 --- /dev/null +++ b/backend/src/main/java/hanglog/event/PayloadToEventMapper.java @@ -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 event) throws JsonProcessingException { + return objectMapper.readValue(payload, event); + } + + public static String toJson(final Event event) throws JsonProcessingException { + return objectMapper.writeValueAsString(event); + } +} diff --git a/backend/src/main/java/hanglog/event/TripDeleteEvent.java b/backend/src/main/java/hanglog/event/TripDeleteEvent.java new file mode 100644 index 000000000..e4347d6d9 --- /dev/null +++ b/backend/src/main/java/hanglog/event/TripDeleteEvent.java @@ -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); + } +} diff --git a/backend/src/main/java/hanglog/event/TripDeleteEventMapper.java b/backend/src/main/java/hanglog/event/TripDeleteEventMapper.java new file mode 100644 index 000000000..4505a580a --- /dev/null +++ b/backend/src/main/java/hanglog/event/TripDeleteEventMapper.java @@ -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()); + } +} diff --git a/backend/src/main/java/hanglog/listener/DeleteEventListener.java b/backend/src/main/java/hanglog/listener/DeleteEventListener.java index 74fb311a3..3d1f47688 100644 --- a/backend/src/main/java/hanglog/listener/DeleteEventListener.java +++ b/backend/src/main/java/hanglog/listener/DeleteEventListener.java @@ -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; diff --git a/backend/src/main/java/hanglog/trip/domain/TripDeleteEvent.java b/backend/src/main/java/hanglog/trip/domain/TripDeleteEvent.java deleted file mode 100644 index 8233af111..000000000 --- a/backend/src/main/java/hanglog/trip/domain/TripDeleteEvent.java +++ /dev/null @@ -1,11 +0,0 @@ -package hanglog.trip.domain; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public class TripDeleteEvent { - - private final Long tripId; -} diff --git a/backend/src/main/java/hanglog/trip/presentation/TripController.java b/backend/src/main/java/hanglog/trip/presentation/TripController.java index 90a0d965b..bb782bfa9 100644 --- a/backend/src/main/java/hanglog/trip/presentation/TripController.java +++ b/backend/src/main/java/hanglog/trip/presentation/TripController.java @@ -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; @@ -75,7 +76,10 @@ public ResponseEntity updateTrip( @DeleteMapping("/{tripId}") @MemberOnly - public ResponseEntity deleteTrip(@Auth final Accessor accessor, @PathVariable final Long tripId) { + public ResponseEntity deleteTrip( + @Auth final Accessor accessor, + @PathVariable final Long tripId + ) throws JsonProcessingException { tripService.validateTripByMember(accessor.getMemberId(), tripId); tripService.delete(tripId); return ResponseEntity.noContent().build(); diff --git a/backend/src/main/java/hanglog/trip/service/TripService.java b/backend/src/main/java/hanglog/trip/service/TripService.java index 046945a3e..dfff937a1 100644 --- a/backend/src/main/java/hanglog/trip/service/TripService.java +++ b/backend/src/main/java/hanglog/trip/service/TripService.java @@ -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; @@ -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; @@ -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)) { @@ -186,7 +192,7 @@ private Predicate 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); } @@ -194,7 +200,9 @@ public void delete(final Long tripId) { 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)); } private String generateInitialTitle(final List cites) { diff --git a/backend/src/test/java/hanglog/member/event/DeleteEventListenerTest.java b/backend/src/test/java/hanglog/member/event/DeleteEventListenerTest.java index d26f2a1b7..4e5920ec3 100644 --- a/backend/src/test/java/hanglog/member/event/DeleteEventListenerTest.java +++ b/backend/src/test/java/hanglog/member/event/DeleteEventListenerTest.java @@ -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;