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 { + + List findTop100ByStatusOrderByIdAsc(OutboxStatus status); +} diff --git a/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxRelay.java b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxRelay.java new file mode 100644 index 0000000..3fa6a15 --- /dev/null +++ b/infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxRelay.java @@ -0,0 +1,71 @@ +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.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.ClassUtils; +import tools.jackson.databind.json.JsonMapper; + +/** + * 아웃박스에 쌓인 이벤트를 인프로세스로 다시 발행한다. + *

+ * 트랜잭션 밖에서 발행하고 리스너({@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 { List findTop100ByStatusOrderByIdAsc(OutboxStatus status); + + // 파생 delete는 행을 읽어 한 건씩 지우므로 벌크 DELETE로 지운다. 호출하는 정리 작업에 트랜잭션이 없어 여기서 연다 + @Transactional + @Modifying + @Query(""" + DELETE FROM OutboxEventJpaEntity o + WHERE o.status = :status + AND o.publishedAt < :before + """) + int deleteByStatusAndPublishedAtBefore(@Param("status") OutboxStatus status, @Param("before") LocalDateTime before); } From addd58e66ebd93df328c32b5e0788420174e1381 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 6/6] =?UTF-8?q?docs:=20=EC=95=84=EC=9B=83=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=8A=A4=ED=82=A4=EB=A7=88=C2=B7=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=EB=84=88=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/architecture.md | 16 ++++++++-------- docs/conventions/flyway-migration.md | 15 +++++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index 0bc0425..fa3b17c 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -355,18 +355,18 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom EventServiceImpl → outboxWriter.record(event) // core:common 포트 ↓ 같은 트랜잭션에서 outbox 테이블에 저장 (원 비즈니스 쓰기와 원자적) 릴레이 (infrastructure:outbox) - OutboxRelay(@Scheduled 폴러) → 미발행 레코드 조회 - → applicationEventPublisher.publishEvent(event) // 인프로세스 재발행 - → 전달 성공 시 발행 완료 마킹, 실패 시 다음 폴에서 재시도 -구독 (예: welfare 도메인 — 공지/알림 반영) - @TransactionalEventListener void on(EventApplicationConfirmed e) // spring-tx, Modulith 의존 없음 + OutboxRelay(@Scheduled 폴러) → PENDING 레코드 조회 + → applicationEventPublisher.publishEvent(event) // 트랜잭션 밖에서 인프로세스 재발행, 리스너 동기 실행 + → 리스너가 예외 없이 끝나면 PUBLISHED, 실패하면 attempts+1 후 다음 폴에서 재시도 (한도 초과 시 FAILED) +구독 (예: member 도메인 — 푸시 알림) + @EventListener void on(EventApplied e) // spring-context, Modulith 의존 없음 ``` -- **이벤트 타입은 `core:common.event`에 둔다.** 발행/구독 도메인이 서로를 의존하지 않고 이벤트 타입만 공유한다. +- **이벤트 타입은 `core:common.event`에 두고 `DomainEvent`를 구현한다.** 발행/구독 도메인이 서로를 의존하지 않고 이벤트 타입만 공유한다. `common.event`는 `@NamedInterface("event")`로 열어 두었다(Modulith는 모듈의 하위 패키지를 비공개로 본다). - 이벤트는 **사실**이다. ID·원시값만 담고 애그리거트/값 타입을 싣지 않는다. 커맨드(요청)를 이벤트로 쓰지 않는다. - **발행은 `OutboxWriter` 포트로 기록**한다(원 비즈니스 트랜잭션과 원자적). 인프로세스 재발행은 릴레이만 수행한다. -- **구독은 `@TransactionalEventListener`**(spring-tx)로 한다. `core:domain`에 Modulith 이벤트 의존을 넣지 않는다. -- 소비 리스너는 **멱등**하게 작성한다(at-least-once). +- **구독은 `@EventListener`**로 한다. 릴레이가 트랜잭션 밖에서 발행하므로 `@TransactionalEventListener`는 호출되지 않는다. 릴레이는 리스너가 끝난 뒤에 발행 완료로 표시하므로, 리스너가 예외를 던지면 재시도된다. `core:domain`에 Modulith 이벤트 의존을 넣지 않는다. +- 소비 리스너는 **멱등**하게 작성한다(at-least-once). 리스너가 끝난 뒤 완료 표시 전에 서버가 내려가면 한 번 더 전달된다. 푸시 알림처럼 드문 중복을 감수할 수 있으면 그대로 둔다. - 아웃박스 테이블 스키마는 `flyway-migration.md`로 관리한다. - 최종적 일관성 경로다. "한 요청 안에서 쓰기 직후 결과를 즉시 조회해 응답에 실어야" 하면 이벤트가 아니라 6-1의 동기 UseCase로 처리한다. diff --git a/docs/conventions/flyway-migration.md b/docs/conventions/flyway-migration.md index f1140f0..148dd98 100644 --- a/docs/conventions/flyway-migration.md +++ b/docs/conventions/flyway-migration.md @@ -145,20 +145,23 @@ CREATE INDEX idx_user_departments_user_id ON user_departments (user_id); `infrastructure:outbox` 릴레이가 사용하는 공용 테이블(`architecture.md` 6-2절). 특정 도메인에 종속되지 않는다. ```sql --- V2__create_outbox_table.sql +-- V18__create_outbox_events_table.sql CREATE TABLE outbox_events ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - event_type VARCHAR(200) NOT NULL, + 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 + 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_id ON outbox_events (status, id); +CREATE INDEX idx_outbox_events_status_outbox_event_id ON outbox_events (status, outbox_event_id); ``` -- 발행 도메인은 자기 트랜잭션 안에서 `OutboxWriter`로 기록(원 비즈니스 쓰기와 원자적). +- 발행 도메인은 자기 트랜잭션 안에서 `OutboxWriter`로 기록(원 비즈니스 쓰기와 원자적). 트랜잭션 밖에서 부르면 실패한다. - 릴레이(`@Scheduled` 폴러)는 `status = 'PENDING'`을 조회해 인프로세스 재발행 후 `PUBLISHED`로 마킹한다. at-least-once이므로 소비 리스너는 멱등해야 한다. +- 리스너가 실패하면 `attempts`를 올리고 다음 폴에서 다시 발행한다. 한도(5회)에 닿으면 `FAILED`로 바꿔 더는 재시도하지 않는다. 원인을 고친 뒤 `PENDING`으로 되돌리면 다시 발행된다. +- `PUBLISHED`가 된 지 7일 지난 행은 매일 새벽 정리한다. `FAILED`는 원인을 확인할 수 있게 남긴다. ---