Repository navigation
[Feat/#113] 아웃박스 릴레이 추가 #117
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
Open
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8203372
feat: 아웃박스 테이블 추가
tnals0924 3e34860
feat: 아웃박스 기록 포트 추가
tnals0924 193ca40
feat: 아웃박스 기록·릴레이 구현
tnals0924 49e099e
feat: 스케줄링 활성화
tnals0924 e47ff74
feat: 발행 완료 아웃박스 정리 작업 추가
tnals0924 addd58e
docs: 아웃박스 스키마·리스너 컨벤션 반영
tnals0924 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
12 changes: 12 additions & 0 deletions
12
bootstrap/src/main/java/kr/ac/kookmin/stream/bootstrap/config/SchedulingConfig.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
| } |
13 changes: 13 additions & 0 deletions
13
core/common/src/main/java/kr/ac/kookmin/stream/common/OutboxWriter.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package kr.ac.kookmin.stream.common; | ||
|
|
||
| import kr.ac.kookmin.stream.common.event.DomainEvent; | ||
|
|
||
| /** | ||
| * 도메인 이벤트를 아웃박스에 기록한다. 구현은 {@code infrastructure:outbox}가 맡는다. | ||
| * <p> | ||
| * 원 비즈니스 쓰기와 같은 트랜잭션 안에서 불러야 한다. 트랜잭션이 롤백되면 기록도 함께 사라지고, | ||
| * 커밋되면 릴레이가 이벤트를 다시 발행한다. 트랜잭션 밖에서 부르면 실패한다. | ||
| */ | ||
| public interface OutboxWriter { | ||
| void record(DomainEvent event); | ||
| } |
10 changes: 10 additions & 0 deletions
10
core/common/src/main/java/kr/ac/kookmin/stream/common/event/DomainEvent.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package kr.ac.kookmin.stream.common.event; | ||
|
|
||
| /** | ||
| * 다른 도메인이 반응할 수 있는 도메인의 사실. 아웃박스에 기록되어 릴레이가 다시 발행한다. | ||
| * <p> | ||
| * 아웃박스에 JSON으로 저장되므로 ID와 원시값만 담는 record로 만든다. 클래스 이름이 역직렬화 키라서, | ||
| * 이름이나 패키지를 바꿀 때는 아직 발행되지 않은 아웃박스 행이 없는지 확인한다. | ||
| */ | ||
| public interface DomainEvent { | ||
| } |
8 changes: 8 additions & 0 deletions
8
core/common/src/main/java/kr/ac/kookmin/stream/common/event/package-info.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| /** | ||
| * 크로스 도메인 이벤트 타입. 발행 도메인과 구독 도메인이 서로를 모른 채 이 타입만 공유한다. | ||
| * Modulith는 모듈의 하위 패키지를 비공개로 보므로 named interface로 열어 둔다. | ||
| */ | ||
| @NamedInterface("event") | ||
| package kr.ac.kookmin.stream.common.event; | ||
|
|
||
| import org.springframework.modulith.NamedInterface; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
infrastructure/db/src/main/resources/db/migration/V18__create_outbox_events_table.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
31 changes: 31 additions & 0 deletions
31
infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxCleaner.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
78 changes: 78 additions & 0 deletions
78
infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaEntity.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
24 changes: 24 additions & 0 deletions
24
...astructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxEventJpaRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +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<OutboxEventJpaEntity, Long> { | ||
|
|
||
| List<OutboxEventJpaEntity> 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); | ||
| } |
71 changes: 71 additions & 0 deletions
71
infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxRelay.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /** | ||
| * 아웃박스에 쌓인 이벤트를 인프로세스로 다시 발행한다. | ||
| * <p> | ||
| * 트랜잭션 밖에서 발행하고 리스너({@code @EventListener})는 이 스레드에서 동기로 실행된다. 리스너가 예외 없이 끝나야 | ||
| * 발행 완료로 표시하므로, 리스너가 실패하면 다음 폴링에서 다시 발행된다(at-least-once). 리스너가 끝난 뒤 완료 표시 전에 | ||
| * 서버가 내려가면 같은 이벤트가 한 번 더 발행될 수 있다. | ||
| * <p> | ||
| * 환경마다 인스턴스가 하나라고 보고 행을 선점하지 않는다. 인스턴스를 늘리면 두 릴레이가 같은 행을 발행하지 않도록 선점 방식을 넣어야 한다. | ||
| */ | ||
| @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); | ||
| } | ||
| } |
7 changes: 7 additions & 0 deletions
7
infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxStatus.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package kr.ac.kookmin.stream.outbox; | ||
|
|
||
| public enum OutboxStatus { | ||
| PENDING, // 아직 발행하지 못함. 릴레이가 다음 폴링에서 다시 발행한다 | ||
| PUBLISHED, // 리스너까지 예외 없이 끝남 | ||
| FAILED // 발행 실패가 한도에 닿아 더는 재시도하지 않음. 원인을 고친 뒤 PENDING으로 되돌리면 다시 발행된다 | ||
| } |
25 changes: 25 additions & 0 deletions
25
infrastructure/outbox/src/main/java/kr/ac/kookmin/stream/outbox/OutboxWriterImpl.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MANDATORY로 호출하는 쪽이 트랜잭션을 잊었을 때를 막아서 잘못된 알림이 가거나 알림이 누락되는 경우를 막은 점 이해했습니다!