Skip to content

Commit 0deef97

Browse files
committed
feat:outbox pattern added, test added
1 parent 4dee414 commit 0deef97

File tree

9 files changed

+680
-0
lines changed

9 files changed

+680
-0
lines changed

auth/logs/auth.log

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,3 +742,23 @@ Caused by: java.lang.IllegalStateException: The following classes could not be e
742742
2026-04-01T22:56:13.099+09:00 INFO 91028 --- [main] c.w.a.c.UserCredentialControllerImpl : logout request
743743
2026-04-01T22:56:13.105+09:00 INFO 91028 --- [main] c.w.a.c.UserCredentialControllerImpl : sign in request
744744
2026-04-01T22:56:13.109+09:00 INFO 91028 --- [main] c.w.a.c.UserCredentialControllerImpl : refresh request
745+
2026-04-01T23:01:49.834+09:00 INFO 93026 --- [main] .UserCredentialControllerIntegrationTest : Starting UserCredentialControllerIntegrationTest using Java 25.0.2 with PID 93026 (started by administrator in /Users/administrator/Documents/projects/art-market-place/auth)
746+
2026-04-01T23:01:49.835+09:00 INFO 93026 --- [main] .UserCredentialControllerIntegrationTest : No active profile set, falling back to 1 default profile: "default"
747+
2026-04-01T23:01:50.261+09:00 INFO 93026 --- [main] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
748+
2026-04-01T23:01:50.262+09:00 INFO 93026 --- [main] o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
749+
2026-04-01T23:01:50.263+09:00 INFO 93026 --- [main] o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 1 ms
750+
2026-04-01T23:01:50.275+09:00 INFO 93026 --- [main] .UserCredentialControllerIntegrationTest : Started UserCredentialControllerIntegrationTest in 0.606 seconds (process running for 1.549)
751+
2026-04-01T23:02:11.610+09:00 INFO 93026 --- [main] c.w.a.c.UserCredentialControllerImpl : login request
752+
2026-04-01T23:02:11.655+09:00 INFO 93026 --- [main] c.w.a.c.UserCredentialControllerImpl : logout request
753+
2026-04-01T23:02:11.661+09:00 INFO 93026 --- [main] c.w.a.c.UserCredentialControllerImpl : sign in request
754+
2026-04-01T23:02:11.667+09:00 INFO 93026 --- [main] c.w.a.c.UserCredentialControllerImpl : refresh request
755+
2026-04-01T23:24:51.337+09:00 INFO 1106 --- [main] .UserCredentialControllerIntegrationTest : Starting UserCredentialControllerIntegrationTest using Java 25.0.2 with PID 1106 (started by administrator in /Users/administrator/Documents/projects/art-market-place/auth)
756+
2026-04-01T23:24:51.338+09:00 INFO 1106 --- [main] .UserCredentialControllerIntegrationTest : No active profile set, falling back to 1 default profile: "default"
757+
2026-04-01T23:24:51.761+09:00 INFO 1106 --- [main] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
758+
2026-04-01T23:24:51.762+09:00 INFO 1106 --- [main] o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
759+
2026-04-01T23:24:51.763+09:00 INFO 1106 --- [main] o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 1 ms
760+
2026-04-01T23:24:51.774+09:00 INFO 1106 --- [main] .UserCredentialControllerIntegrationTest : Started UserCredentialControllerIntegrationTest in 0.59 seconds (process running for 1.494)
761+
2026-04-01T23:25:13.116+09:00 INFO 1106 --- [main] c.w.a.c.UserCredentialControllerImpl : login request
762+
2026-04-01T23:25:13.160+09:00 INFO 1106 --- [main] c.w.a.c.UserCredentialControllerImpl : logout request
763+
2026-04-01T23:25:13.166+09:00 INFO 1106 --- [main] c.w.a.c.UserCredentialControllerImpl : sign in request
764+
2026-04-01T23:25:13.171+09:00 INFO 1106 --- [main] c.w.a.c.UserCredentialControllerImpl : refresh request
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.woobeee.auth.entity;
2+
3+
import com.woobeee.auth.entity.enums.EventStatus;
4+
import com.woobeee.auth.entity.enums.EventType;
5+
import jakarta.persistence.Column;
6+
import jakarta.persistence.Entity;
7+
import jakarta.persistence.Id;
8+
import lombok.*;
9+
import org.hibernate.annotations.JdbcTypeCode;
10+
import org.hibernate.annotations.UuidGenerator;
11+
import org.hibernate.type.SqlTypes;
12+
13+
import java.time.LocalDateTime;
14+
import java.util.UUID;
15+
16+
@Entity
17+
@Getter
18+
@Setter
19+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
20+
@AllArgsConstructor
21+
@Builder
22+
public class Outbox {
23+
@Id
24+
@UuidGenerator
25+
private UUID id;
26+
27+
@Column(name="type", nullable=false)
28+
private EventType type;
29+
30+
@Column(name="status", nullable=false)
31+
private EventStatus status;
32+
33+
@Column(name="topic", nullable=false)
34+
private String topic;
35+
36+
@Column(name="key", nullable=false)
37+
private String key;
38+
39+
@JdbcTypeCode(SqlTypes.JSON)
40+
@Column(name="payload", nullable=false, columnDefinition = "json")
41+
private String payload;
42+
43+
@Column(name="attempts", nullable=false)
44+
private int attempts;
45+
46+
@Column(name="last_error")
47+
private String lastError;
48+
49+
@Column(name="created_at", nullable=false)
50+
private LocalDateTime createdAt;
51+
52+
@Column(name="locked_at")
53+
private LocalDateTime lockedAt;
54+
55+
@Column(name="next_attempt_at", nullable=false)
56+
private LocalDateTime nextAttemptAt;
57+
58+
@Column(name="sent_at")
59+
private LocalDateTime sentAt;
60+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.woobeee.auth.listener;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.woobeee.auth.dto.provider.MessageEvent;
6+
import com.woobeee.auth.entity.Outbox;
7+
import com.woobeee.auth.entity.enums.EventStatus;
8+
import com.woobeee.auth.entity.enums.EventType;
9+
import com.woobeee.auth.repository.OutBoxCustomRepository;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.beans.factory.annotation.Value;
13+
import org.springframework.stereotype.Component;
14+
import org.springframework.transaction.event.TransactionPhase;
15+
import org.springframework.transaction.event.TransactionalEventListener;
16+
17+
import java.time.LocalDateTime;
18+
19+
@Component
20+
@Slf4j
21+
@RequiredArgsConstructor
22+
public class MessageEventListener {
23+
@Value("${spring.config.activate.on-profile}")
24+
private String profile;
25+
26+
private final OutBoxCustomRepository outBoxMessageCustomRepository;
27+
28+
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
29+
public void handleEvent(MessageEvent event) {
30+
log.info("send with redis: {}", event.message());
31+
32+
String topic = profile + event.topic();
33+
String key = event.key();
34+
ObjectMapper objectMapper = new ObjectMapper();
35+
36+
try {
37+
String payload = objectMapper.writeValueAsString(event.message());
38+
39+
Outbox outboxMessage = Outbox.builder()
40+
.id(event.eventId())
41+
.type(EventType.TRIGGER)
42+
.status(EventStatus.NEW)
43+
.topic(topic)
44+
.key(key)
45+
.payload(payload)
46+
.attempts(0)
47+
.lastError(null)
48+
.createdAt(LocalDateTime.now())
49+
.nextAttemptAt(LocalDateTime.now())
50+
.sentAt(null).build();
51+
52+
outBoxMessageCustomRepository.insertNew(
53+
event.eventId(), EventType.TRIGGER, EventStatus.NEW, topic, key, payload, LocalDateTime.now());
54+
55+
log.info("Outbox stored. eventId={}, topic={}, key={}",
56+
outboxMessage.getId(), topic, key);
57+
} catch (JsonProcessingException e) {
58+
throw new RuntimeException(e);
59+
}
60+
}
61+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.woobeee.auth.producer;
2+
3+
4+
import com.woobeee.auth.repository.OutBoxCustomRepository;
5+
import com.woobeee.auth.repository.impl.OutBoxMessageCustomRepositoryImpl;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.kafka.core.KafkaTemplate;
9+
import org.springframework.scheduling.annotation.Scheduled;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.time.LocalDateTime;
13+
import java.util.List;
14+
15+
@Slf4j
16+
@Component
17+
@RequiredArgsConstructor
18+
public class OutboxProducerScheduler {
19+
private static final int BATCH_SIZE = 100;
20+
21+
private final OutBoxCustomRepository outboxRepository;
22+
private final KafkaTemplate<String, String> kafkaTemplate;
23+
24+
@Scheduled(fixedDelayString = "1000") // 1초마다
25+
public void publish() {
26+
// FAIL, NEW를 조회와 동시에 SENDING으로 상태변경을 원자성으로 묶기
27+
// FOR UPDATE SKIP LOCKED을 통해서 락 걸린 자원들은 스킵해서 중복되는 자원은 없게 지원
28+
List<OutBoxMessageCustomRepositoryImpl.OutboxRow> batch =
29+
outboxRepository.claimBatchForSend(LocalDateTime.now(), BATCH_SIZE);
30+
31+
if (batch.isEmpty()) return;
32+
33+
for (var row : batch) {
34+
try {
35+
kafkaTemplate.send(row.topic(), row.key(), row.payload()).get();
36+
outboxRepository.markSent(row.id(), LocalDateTime.now());
37+
38+
log.info("Outbox sent. id={}, key={}, topic={}", row.id(), row.key(), row.topic());
39+
} catch (Exception ex) {
40+
int attempts = row.attempts();
41+
long delaySeconds = Math.min(300, (long) Math.pow(2, Math.min(attempts, 6)) * 5);
42+
LocalDateTime nextAttemptAt = LocalDateTime.now().plusSeconds(delaySeconds);
43+
44+
outboxRepository.markFailed(row.id(), ex.getMessage(), nextAttemptAt);
45+
46+
log.error("Outbox send failed. id={}, attempts={}, nextAttemptAt={}, err={}",
47+
row.id(), attempts, nextAttemptAt, ex.getMessage(), ex);
48+
}
49+
}
50+
}
51+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.woobeee.auth.producer;
2+
3+
import com.woobeee.auth.repository.OutBoxCustomRepository;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.scheduling.annotation.Scheduled;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.time.Duration;
10+
import java.time.LocalDateTime;
11+
12+
@Slf4j
13+
@Component
14+
@RequiredArgsConstructor
15+
public class OutboxSendingRecoveryScheduler {
16+
private static final Duration STUCK_THRESHOLD = Duration.ofMinutes(10);
17+
18+
private final OutBoxCustomRepository recoveryRepository;
19+
20+
@Scheduled(fixedDelayString = "6000")
21+
public void recoverStuckSending() {
22+
LocalDateTime now = LocalDateTime.now();
23+
24+
int recovered = recoveryRepository.recoverStuckSending(now, STUCK_THRESHOLD);
25+
26+
if (recovered > 0) {
27+
log.warn("Recovered {} stuck SENDING outbox messages", recovered);
28+
}
29+
}
30+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.woobeee.auth.repository;
2+
3+
import com.woobeee.auth.entity.enums.EventStatus;
4+
import com.woobeee.auth.entity.enums.EventType;
5+
import com.woobeee.auth.repository.impl.OutBoxMessageCustomRepositoryImpl;
6+
7+
import java.time.Duration;
8+
import java.time.LocalDateTime;
9+
import java.util.List;
10+
import java.util.UUID;
11+
12+
public interface OutBoxCustomRepository {
13+
int insertNew(
14+
UUID id,
15+
EventType eventType,
16+
EventStatus eventStatus,
17+
String topic,
18+
String key,
19+
String payload,
20+
LocalDateTime now
21+
);
22+
int recoverStuckSending(LocalDateTime now, Duration threshold);
23+
List<OutBoxMessageCustomRepositoryImpl.OutboxRow> claimBatchForSend(LocalDateTime now, int limit);
24+
long markSent(UUID id, LocalDateTime sentAt);
25+
long markFailed(UUID id, String lastError, LocalDateTime nextAttemptAt);
26+
}

0 commit comments

Comments
 (0)