From 8203372e490e76ea91c947ea642c6057c807736e Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:10:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=95=84=EC=9B=83=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V18__create_outbox_events_table.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 infrastructure/db/src/main/resources/db/migration/V18__create_outbox_events_table.sql diff --git a/infrastructure/db/src/main/resources/db/migration/V18__create_outbox_events_table.sql b/infrastructure/db/src/main/resources/db/migration/V18__create_outbox_events_table.sql new file mode 100644 index 0000000..6212f14 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V18__create_outbox_events_table.sql @@ -0,0 +1,12 @@ +-- 도메인 이벤트를 원 비즈니스 트랜잭션과 함께 기록하는 아웃박스. 릴레이(infrastructure:outbox)가 PENDING을 읽어 다시 발행한다. +-- 리스너가 계속 실패하는 이벤트는 attempts가 한도에 닿으면 FAILED로 바꿔 더는 재시도하지 않는다. +CREATE TABLE outbox_events ( + outbox_event_id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(200) NOT NULL, -- 이벤트 클래스 FQCN. 역직렬화에 쓴다 + payload JSON NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING / PUBLISHED / FAILED + attempts INT NOT NULL DEFAULT 0, -- 발행에 실패한 횟수 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + published_at DATETIME NULL +); +CREATE INDEX idx_outbox_events_status_outbox_event_id ON outbox_events (status, outbox_event_id); From 3e3486072cd203a70d13eec64cae3e228659cad9 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:10:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=95=84=EC=9B=83=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=A1=9D=20=ED=8F=AC=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/common/OutboxWriter.java | 13 +++++++++++++ .../ac/kookmin/stream/common/event/DomainEvent.java | 10 ++++++++++ .../kookmin/stream/common/event/package-info.java | 8 ++++++++ 3 files changed, 31 insertions(+) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/OutboxWriter.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/DomainEvent.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/package-info.java diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/OutboxWriter.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/OutboxWriter.java new file mode 100644 index 0000000..4f5e11c --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/OutboxWriter.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.common; + +import kr.ac.kookmin.stream.common.event.DomainEvent; + +/** + * 도메인 이벤트를 아웃박스에 기록한다. 구현은 {@code infrastructure:outbox}가 맡는다. + *
+ * 원 비즈니스 쓰기와 같은 트랜잭션 안에서 불러야 한다. 트랜잭션이 롤백되면 기록도 함께 사라지고, + * 커밋되면 릴레이가 이벤트를 다시 발행한다. 트랜잭션 밖에서 부르면 실패한다. + */ +public interface OutboxWriter { + void record(DomainEvent event); +} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/DomainEvent.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/DomainEvent.java new file mode 100644 index 0000000..ba600d0 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/DomainEvent.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.common.event; + +/** + * 다른 도메인이 반응할 수 있는 도메인의 사실. 아웃박스에 기록되어 릴레이가 다시 발행한다. + *
+ * 아웃박스에 JSON으로 저장되므로 ID와 원시값만 담는 record로 만든다. 클래스 이름이 역직렬화 키라서,
+ * 이름이나 패키지를 바꿀 때는 아직 발행되지 않은 아웃박스 행이 없는지 확인한다.
+ */
+public interface DomainEvent {
+}
diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/package-info.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/package-info.java
new file mode 100644
index 0000000..f7879bd
--- /dev/null
+++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * 크로스 도메인 이벤트 타입. 발행 도메인과 구독 도메인이 서로를 모른 채 이 타입만 공유한다.
+ * Modulith는 모듈의 하위 패키지를 비공개로 보므로 named interface로 열어 둔다.
+ */
+@NamedInterface("event")
+package kr.ac.kookmin.stream.common.event;
+
+import org.springframework.modulith.NamedInterface;
From 193ca408159aeef65f26b12b2e6316463668c162 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:10:55 +0900
Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=95=84=EC=9B=83=EB=B0=95?=
=?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=A1=9D=C2=B7=EB=A6=B4=EB=A0=88=EC=9D=B4?=
=?UTF-8?q?=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
gradle/libs.versions.toml | 1 +
infrastructure/outbox/build.gradle.kts | 1 +
.../stream/outbox/OutboxEventJpaEntity.java | 78 +++++++++++++++++++
.../outbox/OutboxEventJpaRepository.java | 9 +++
.../ac/kookmin/stream/outbox/OutboxRelay.java | 71 +++++++++++++++++
.../kookmin/stream/outbox/OutboxStatus.java | 7 ++
.../stream/outbox/OutboxWriterImpl.java | 25 ++++++
7 files changed, 192 insertions(+)
create mode 100644 infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaEntity.java
create mode 100644 infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
create mode 100644 infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxRelay.java
create mode 100644 infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxStatus.java
create mode 100644 infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxWriterImpl.java
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6f2b6e1..685b5e6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -25,6 +25,7 @@ springBootStarterAspectj = { module = "org.springframework.boot:spring-boot-star
springBootStarterSecurity = { module = "org.springframework.boot:spring-boot-starter-security" }
springBootStarterDataJpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
springBootStarterFlyway = { module = "org.springframework.boot:spring-boot-starter-flyway" }
+springBootStarterJackson = { module = "org.springframework.boot:spring-boot-starter-jackson" }
springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" }
springBootStarterSecurityTest = { module = "org.springframework.boot:spring-boot-starter-security-test" }
springBootStarterDataJpaTest = { module = "org.springframework.boot:spring-boot-starter-data-jpa-test" }
diff --git a/infrastructure/outbox/build.gradle.kts b/infrastructure/outbox/build.gradle.kts
index 16b7387..f4c9a95 100644
--- a/infrastructure/outbox/build.gradle.kts
+++ b/infrastructure/outbox/build.gradle.kts
@@ -10,6 +10,7 @@ dependencies {
implementation(platform(libs.springBootDependenciesBom))
implementation(libs.springContext)
implementation(libs.springBootStarterDataJpa)
+ implementation(libs.springBootStarterJackson) // 이벤트를 JSON으로 저장·복원 (Jackson 3, JsonMapper)
testImplementation(platform(libs.springBootDependenciesBom))
testImplementation(libs.springBootStarterDataJpaTest)
diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaEntity.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaEntity.java
new file mode 100644
index 0000000..58fb7a9
--- /dev/null
+++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaEntity.java
@@ -0,0 +1,78 @@
+package kr.ac.kookmin.stream.outbox;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+// created_at은 DB 기본값으로만 채우고 읽지 않아서 매핑하지 않는다
+@Entity
+@Table(
+ name = "outbox_events",
+ indexes = {
+ @Index(name = "idx_outbox_events_status_outbox_event_id", columnList = "status, outbox_event_id")
+ }
+)
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class OutboxEventJpaEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "outbox_event_id")
+ private Long id;
+
+ @Column(name = "event_type", nullable = false, length = 200)
+ private String eventType;
+
+ /** 이벤트를 직렬화한 JSON 원문을 문자열 그대로 읽고 쓴다. */
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(nullable = false, columnDefinition = "json")
+ private String payload;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private OutboxStatus status;
+
+ @Column(nullable = false)
+ private int attempts;
+
+ @Column(name = "published_at")
+ private LocalDateTime publishedAt;
+
+ private OutboxEventJpaEntity(String eventType, String payload) {
+ this.eventType = eventType;
+ this.payload = payload;
+ this.status = OutboxStatus.PENDING;
+ }
+
+ public static OutboxEventJpaEntity pending(String eventType, String payload) {
+ return new OutboxEventJpaEntity(eventType, payload);
+ }
+
+ public void markPublished(LocalDateTime publishedAt) {
+ this.status = OutboxStatus.PUBLISHED;
+ this.publishedAt = publishedAt;
+ }
+
+ // 실패 횟수가 한도에 닿으면 FAILED로 바꾸고 true를 돌려준다
+ public boolean recordFailure(int maxAttempts) {
+ this.attempts++;
+ if (attempts < maxAttempts) {
+ return false;
+ }
+ this.status = OutboxStatus.FAILED;
+ return true;
+ }
+}
diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
new file mode 100644
index 0000000..44ee0dd
--- /dev/null
+++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
@@ -0,0 +1,9 @@
+package kr.ac.kookmin.stream.outbox;
+
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface OutboxEventJpaRepository extends JpaRepository
+ * 트랜잭션 밖에서 발행하고 리스너({@code @EventListener})는 이 스레드에서 동기로 실행된다. 리스너가 예외 없이 끝나야
+ * 발행 완료로 표시하므로, 리스너가 실패하면 다음 폴링에서 다시 발행된다(at-least-once). 리스너가 끝난 뒤 완료 표시 전에
+ * 서버가 내려가면 같은 이벤트가 한 번 더 발행될 수 있다.
+ *
+ * 환경마다 인스턴스가 하나라고 보고 행을 선점하지 않는다. 인스턴스를 늘리면 두 릴레이가 같은 행을 발행하지 않도록 선점 방식을 넣어야 한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class OutboxRelay {
+
+ private static final Logger log = LoggerFactory.getLogger(OutboxRelay.class);
+
+ private static final long POLL_INTERVAL_MILLIS = 1000;
+ private static final int MAX_ATTEMPTS = 5;
+
+ private final OutboxEventJpaRepository outboxEventJpaRepository;
+ private final ApplicationEventPublisher applicationEventPublisher;
+ private final JsonMapper jsonMapper;
+
+ @Scheduled(fixedDelay = POLL_INTERVAL_MILLIS)
+ public void relay() {
+ outboxEventJpaRepository.findTop100ByStatusOrderByIdAsc(OutboxStatus.PENDING)
+ .forEach(this::relay);
+ }
+
+ // 한 이벤트가 실패해도 다음 이벤트는 계속 발행한다. 완료·실패 표시는 save가 건별 트랜잭션으로 반영한다
+ private void relay(OutboxEventJpaEntity outboxEvent) {
+ try {
+ applicationEventPublisher.publishEvent(deserialize(outboxEvent));
+ outboxEvent.markPublished(LocalDateTime.now(DateUtil.KST));
+ } catch (RuntimeException e) {
+ logFailure(outboxEvent, outboxEvent.recordFailure(MAX_ATTEMPTS), e);
+ }
+ outboxEventJpaRepository.save(outboxEvent);
+ }
+
+ private Object deserialize(OutboxEventJpaEntity outboxEvent) {
+ try {
+ Class> eventType = ClassUtils.forName(outboxEvent.getEventType(), ClassUtils.getDefaultClassLoader());
+ return jsonMapper.readValue(outboxEvent.getPayload(), eventType);
+ } catch (ClassNotFoundException e) {
+ throw new IllegalStateException("아웃박스 이벤트 타입을 찾을 수 없습니다: " + outboxEvent.getEventType(), e);
+ }
+ }
+
+ private void logFailure(OutboxEventJpaEntity outboxEvent, boolean gaveUp, RuntimeException e) {
+ if (gaveUp) {
+ log.error("아웃박스 이벤트 발행을 포기함(FAILED): outboxEventId={}, eventType={}, attempts={}",
+ outboxEvent.getId(), outboxEvent.getEventType(), outboxEvent.getAttempts(), e);
+ return;
+ }
+ log.warn("아웃박스 이벤트 발행 실패(재시도 예정): outboxEventId={}, eventType={}, attempts={}",
+ outboxEvent.getId(), outboxEvent.getEventType(), outboxEvent.getAttempts(), e);
+ }
+}
diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxStatus.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxStatus.java
new file mode 100644
index 0000000..dc727b5
--- /dev/null
+++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxStatus.java
@@ -0,0 +1,7 @@
+package kr.ac.kookmin.stream.outbox;
+
+public enum OutboxStatus {
+ PENDING, // 아직 발행하지 못함. 릴레이가 다음 폴링에서 다시 발행한다
+ PUBLISHED, // 리스너까지 예외 없이 끝남
+ FAILED // 발행 실패가 한도에 닿아 더는 재시도하지 않음. 원인을 고친 뒤 PENDING으로 되돌리면 다시 발행된다
+}
diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxWriterImpl.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxWriterImpl.java
new file mode 100644
index 0000000..30c5a9a
--- /dev/null
+++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxWriterImpl.java
@@ -0,0 +1,25 @@
+package kr.ac.kookmin.stream.outbox;
+
+import kr.ac.kookmin.stream.common.OutboxWriter;
+import kr.ac.kookmin.stream.common.event.DomainEvent;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import tools.jackson.databind.json.JsonMapper;
+
+@Component
+@RequiredArgsConstructor
+public class OutboxWriterImpl implements OutboxWriter {
+
+ private final OutboxEventJpaRepository outboxEventJpaRepository;
+ private final JsonMapper jsonMapper;
+
+ // 원 비즈니스 쓰기와 함께 커밋·롤백되어야 하므로 진행 중인 트랜잭션이 없으면 실패시킨다
+ @Override
+ @Transactional(propagation = Propagation.MANDATORY)
+ public void record(DomainEvent event) {
+ String payload = jsonMapper.writeValueAsString(event);
+ outboxEventJpaRepository.save(OutboxEventJpaEntity.pending(event.getClass().getName(), payload));
+ }
+}
From 49e099e28625f75a83d45982ad2ff5b864fd13e0 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:10:55 +0900
Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84?=
=?UTF-8?q?=EB=A7=81=20=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/bootstrap/config/SchedulingConfig.java | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 bootstrap/src/main/java/kr/ac/kookmin/stream/bootstrap/config/SchedulingConfig.java
diff --git a/bootstrap/src/main/java/kr/ac/kookmin/stream/bootstrap/config/SchedulingConfig.java b/bootstrap/src/main/java/kr/ac/kookmin/stream/bootstrap/config/SchedulingConfig.java
new file mode 100644
index 0000000..15afcc2
--- /dev/null
+++ b/bootstrap/src/main/java/kr/ac/kookmin/stream/bootstrap/config/SchedulingConfig.java
@@ -0,0 +1,12 @@
+package kr.ac.kookmin.stream.bootstrap.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * {@code @Scheduled} 작업(아웃박스 릴레이 등)을 켠다.
+ */
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {
+}
From e47ff741fc7c751f7928750aee691c54c1b8c4d7 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:10:55 +0900
Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EB=B0=9C=ED=96=89=20=EC=99=84?=
=?UTF-8?q?=EB=A3=8C=20=EC=95=84=EC=9B=83=EB=B0=95=EC=8A=A4=20=EC=A0=95?=
=?UTF-8?q?=EB=A6=AC=20=EC=9E=91=EC=97=85=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../kookmin/stream/outbox/OutboxCleaner.java | 31 +++++++++++++++++++
.../outbox/OutboxEventJpaRepository.java | 15 +++++++++
2 files changed, 46 insertions(+)
create mode 100644 infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxCleaner.java
diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxCleaner.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxCleaner.java
new file mode 100644
index 0000000..c659b0e
--- /dev/null
+++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxCleaner.java
@@ -0,0 +1,31 @@
+package kr.ac.kookmin.stream.outbox;
+
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.common.DateUtil;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 발행을 마친 아웃박스 행을 정리한다. 실패(FAILED) 행은 원인을 확인할 수 있게 남겨 둔다.
+ */
+@Component
+@RequiredArgsConstructor
+public class OutboxCleaner {
+
+ private static final Logger log = LoggerFactory.getLogger(OutboxCleaner.class);
+
+ private static final long RETENTION_DAYS = 7;
+
+ private final OutboxEventJpaRepository outboxEventJpaRepository;
+
+ // 발행이 몰리지 않는 새벽에 돈다
+ @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul")
+ public void deletePublished() {
+ LocalDateTime before = LocalDateTime.now(DateUtil.KST).minusDays(RETENTION_DAYS);
+ int deleted = outboxEventJpaRepository.deleteByStatusAndPublishedAtBefore(OutboxStatus.PUBLISHED, before);
+ log.info("발행 완료 아웃박스 정리: deleted={}, before={}", deleted, before);
+ }
+}
diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
index 44ee0dd..3ffa226 100644
--- a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
+++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
@@ -1,9 +1,24 @@
package kr.ac.kookmin.stream.outbox;
+import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
public interface OutboxEventJpaRepository extends JpaRepository