Skip to content

Commit fa65c23

Browse files
authored
이메일 발송 로직 notification_history outbox 패턴 전환 (#77)
* refactor: 이메일 전송 로직 재구성 - notification_history를 outbox 패턴 전환: append-only INSERT → 단건 UPDATE 방식 - fail_count, last_attempted_at 컬럼 추가 - 스케줄러 조회를 정확 일치에서 범위 조회로 변경해 서버 다운 시 PENDING 누락 방지 - findAllRetryableCycles에 cutoffDateTime으로 단기 주기 재시도 제외 * refactor: 불필요한 트랜잭션 annotation 제거 * refactor: clearAutomatically 적용 * refactor: 벌크 update 결과 검증 로직 추가 * style: 단기 주기 판단 기준 주석화 * refactor: NotificationHistoryService#updateStatus 로직 최적화
1 parent 659c79a commit fa65c23

14 files changed

Lines changed: 255 additions & 113 deletions

src/main/java/com/recyclestudy/email/EmailRetryService.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.recyclestudy.email;
22

33
import com.recyclestudy.member.domain.Member;
4+
import com.recyclestudy.review.domain.NotificationStatus;
45
import com.recyclestudy.review.domain.ReviewCycle;
56
import com.recyclestudy.review.domain.ReviewURL;
67
import com.recyclestudy.review.repository.ReviewCycleRepository;
78
import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement;
9+
import java.time.Clock;
10+
import java.time.LocalDateTime;
811
import java.util.List;
912
import java.util.Map;
1013
import java.util.stream.Collectors;
@@ -18,14 +21,19 @@
1821
@RequiredArgsConstructor
1922
public class EmailRetryService {
2023

24+
// 단기 주기(1일 미만) 재시도 제외: review_cycle에 주기 컬럼이 없으므로 scheduledAt 경과 시간으로 단기/장기 여부를 역산
25+
private static final long SHORT_CYCLE_THRESHOLD_DAYS = 1;
2126
private static final int MAX_RETRY_COUNT = 3;
2227

2328
private final ReviewCycleRepository reviewCycleRepository;
2429
private final SingleReviewEmailSender singleReviewEmailSender;
30+
private final Clock clock;
2531

2632
@Transactional(readOnly = true)
2733
public void retryFailedEmails() {
28-
final List<ReviewCycle> failedCycles = reviewCycleRepository.findAllRetryableCycles(MAX_RETRY_COUNT);
34+
final LocalDateTime cutoffDateTime = LocalDateTime.now(clock).minusDays(SHORT_CYCLE_THRESHOLD_DAYS);
35+
final List<ReviewCycle> failedCycles = reviewCycleRepository.findAllRetryableCycles(
36+
NotificationStatus.FAILED, MAX_RETRY_COUNT, cutoffDateTime);
2937

3038
if (failedCycles.isEmpty()) {
3139
return;

src/main/java/com/recyclestudy/email/SingleReviewEmailSender.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ public void sendOne(final ReviewSendElement element) {
3030
final boolean success = sendToTargetEmail(targetEmail, message);
3131

3232
if (success) {
33-
notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.SENT);
33+
notificationHistoryService.updateStatus(element.reviewCycleIds(), NotificationStatus.SENT);
3434
return;
3535
}
36-
notificationHistoryService.saveAll(element.reviewCycleIds(), NotificationStatus.FAILED);
36+
notificationHistoryService.updateStatus(element.reviewCycleIds(), NotificationStatus.FAILED);
3737
}
3838

3939
private boolean sendToTargetEmail(final Email targetEmail, final String message) {

src/main/java/com/recyclestudy/review/domain/NotificationHistory.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import jakarta.persistence.JoinColumn;
1111
import jakarta.persistence.ManyToOne;
1212
import jakarta.persistence.Table;
13+
import java.time.LocalDateTime;
1314
import lombok.AccessLevel;
1415
import lombok.AllArgsConstructor;
1516
import lombok.Getter;
@@ -32,12 +33,18 @@ public class NotificationHistory extends BaseEntity {
3233
@Column(name = "status", nullable = false)
3334
private NotificationStatus status;
3435

36+
@Column(name = "fail_count", nullable = false)
37+
private int failCount;
38+
39+
@Column(name = "last_attempted_at")
40+
private LocalDateTime lastAttemptedAt;
41+
3542
public static NotificationHistory withoutId(
3643
final ReviewCycle reviewCycle,
3744
final NotificationStatus status
3845
) {
3946
validateNotNull(reviewCycle, status);
40-
return new NotificationHistory(reviewCycle, status);
47+
return new NotificationHistory(reviewCycle, status, 0, null);
4148
}
4249

4350
private static void validateNotNull(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,37 @@
11
package com.recyclestudy.review.repository;
22

33
import com.recyclestudy.review.domain.NotificationHistory;
4+
import com.recyclestudy.review.domain.NotificationStatus;
5+
import java.time.LocalDateTime;
6+
import java.util.List;
47
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Modifying;
9+
import org.springframework.data.jpa.repository.Query;
10+
import org.springframework.data.repository.query.Param;
511

612
public interface NotificationHistoryRepository extends JpaRepository<NotificationHistory, Long> {
13+
14+
@Modifying(clearAutomatically = true)
15+
@Query("""
16+
UPDATE NotificationHistory nh
17+
SET nh.status = :status, nh.lastAttemptedAt = :now
18+
WHERE nh.reviewCycle.id IN :reviewCycleIds
19+
""")
20+
int updateStatus(
21+
@Param("reviewCycleIds") List<Long> reviewCycleIds,
22+
@Param("status") NotificationStatus status,
23+
@Param("now") LocalDateTime now
24+
);
25+
26+
@Modifying(clearAutomatically = true)
27+
@Query("""
28+
UPDATE NotificationHistory nh
29+
SET nh.status = :status, nh.failCount = nh.failCount + 1, nh.lastAttemptedAt = :now
30+
WHERE nh.reviewCycle.id IN :reviewCycleIds
31+
""")
32+
int updateStatusWithIncrementFailCount(
33+
@Param("reviewCycleIds") List<Long> reviewCycleIds,
34+
@Param("status") NotificationStatus status,
35+
@Param("now") LocalDateTime now
36+
);
737
}
Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.recyclestudy.review.repository;
22

3+
import com.recyclestudy.review.domain.NotificationStatus;
34
import com.recyclestudy.review.domain.ReviewCycle;
45
import java.time.LocalDateTime;
56
import java.util.List;
@@ -13,17 +14,27 @@ public interface ReviewCycleRepository extends JpaRepository<ReviewCycle, Long>
1314
SELECT rc FROM ReviewCycle rc
1415
JOIN FETCH rc.review r
1516
JOIN FETCH r.member
16-
WHERE rc.scheduledAt = :scheduledAt
17+
JOIN NotificationHistory nh ON rc.id = nh.reviewCycle.id
18+
WHERE rc.scheduledAt <= :scheduledAt
19+
AND nh.status = :status
1720
""")
18-
List<ReviewCycle> findAllByScheduledAt(@Param("scheduledAt") LocalDateTime scheduledAt);
21+
List<ReviewCycle> findAllByScheduledAt(
22+
@Param("scheduledAt") LocalDateTime scheduledAt,
23+
@Param("status") NotificationStatus status
24+
);
1925

2026
@Query("""
2127
SELECT rc FROM ReviewCycle rc
28+
JOIN FETCH rc.review r
29+
JOIN FETCH r.member
2230
JOIN NotificationHistory nh ON rc.id = nh.reviewCycle.id
23-
GROUP BY rc
24-
HAVING SUM(CASE WHEN nh.status = 'SENT' THEN 1 ELSE 0 END) = 0
25-
AND SUM(CASE WHEN nh.status = 'FAILED' THEN 1 ELSE 0 END) > 0
26-
AND SUM(CASE WHEN nh.status = 'FAILED' THEN 1 ELSE 0 END) < :maxRetryCount
31+
WHERE nh.status = :status
32+
AND nh.failCount < :maxRetryCount
33+
AND rc.scheduledAt <= :cutoffDateTime
2734
""")
28-
List<ReviewCycle> findAllRetryableCycles(@Param("maxRetryCount") long maxRetryCount);
35+
List<ReviewCycle> findAllRetryableCycles(
36+
@Param("status") NotificationStatus status,
37+
@Param("maxRetryCount") int maxRetryCount,
38+
@Param("cutoffDateTime") LocalDateTime cutoffDateTime
39+
);
2940
}
Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package com.recyclestudy.review.service;
22

3-
import com.recyclestudy.review.domain.NotificationHistory;
43
import com.recyclestudy.review.domain.NotificationStatus;
5-
import com.recyclestudy.review.domain.ReviewCycle;
64
import com.recyclestudy.review.repository.NotificationHistoryRepository;
7-
import com.recyclestudy.review.repository.ReviewCycleRepository;
5+
import java.time.Clock;
6+
import java.time.LocalDateTime;
87
import java.util.List;
98
import lombok.RequiredArgsConstructor;
109
import lombok.extern.slf4j.Slf4j;
@@ -17,17 +16,24 @@
1716
public class NotificationHistoryService {
1817

1918
private final NotificationHistoryRepository notificationHistoryRepository;
20-
private final ReviewCycleRepository reviewCycleRepository;
19+
private final Clock clock;
2120

2221
@Transactional
23-
public void saveAll(final List<Long> reviewCycleIds, final NotificationStatus status) {
24-
final List<ReviewCycle> reviewCycles = reviewCycleRepository.findAllById(reviewCycleIds);
22+
public void updateStatus(final List<Long> reviewCycleIds, final NotificationStatus status) {
23+
if (reviewCycleIds.isEmpty()) {
24+
return;
25+
}
26+
final LocalDateTime now = LocalDateTime.now(clock);
27+
int updated;
28+
if (status == NotificationStatus.FAILED) {
29+
updated = notificationHistoryRepository.updateStatusWithIncrementFailCount(reviewCycleIds, status, now);
30+
} else {
31+
updated = notificationHistoryRepository.updateStatus(reviewCycleIds, status, now);
32+
}
33+
if (updated != reviewCycleIds.size()) {
34+
log.warn("[NOTIFY_HIST_MISMATCH] 기대={}, 실제={}", reviewCycleIds.size(), updated);
35+
}
2536

26-
final List<NotificationHistory> histories = reviewCycles.stream()
27-
.map(cycle -> NotificationHistory.withoutId(cycle, status))
28-
.toList();
29-
30-
notificationHistoryRepository.saveAll(histories);
31-
log.info("[NOTIFY_HIST_UPDATED] 알림 이력 상태 변경: status={}, count={}", status, histories.size());
37+
log.info("[NOTIFY_HIST_UPDATED] 알림 이력 상태 변경: status={}, count={}", status, updated);
3238
}
3339
}

src/main/java/com/recyclestudy/review/service/ReviewCycleService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.recyclestudy.review.service;
22

3+
import com.recyclestudy.review.domain.NotificationStatus;
34
import com.recyclestudy.review.domain.ReviewCycle;
45
import com.recyclestudy.review.repository.ReviewCycleRepository;
56
import com.recyclestudy.review.service.input.ReviewSendInput;
@@ -17,7 +18,8 @@ public class ReviewCycleService {
1718

1819
@Transactional(readOnly = true)
1920
public ReviewSendOutput findTargetReviewCycle(final ReviewSendInput input) {
20-
final List<ReviewCycle> targetCycle = reviewCycleRepository.findAllByScheduledAt(input.scheduledAt());
21+
final List<ReviewCycle> targetCycle = reviewCycleRepository.findAllByScheduledAt(
22+
input.scheduledAt(), NotificationStatus.PENDING);
2123
return ReviewSendOutput.from(targetCycle);
2224
}
2325
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE notification_history
2+
ADD COLUMN fail_count INT NOT NULL DEFAULT 0,
3+
ADD COLUMN last_attempted_at DATETIME NULL;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
-- 기존 append-only 레코드를 review_cycle_id당 1개로 통합
2+
3+
-- 1. PENDING 레코드(기준 레코드)에 fail_count 반영
4+
-- review_cycle_id별 FAILED 개수를 집계해서 PENDING 레코드에 update
5+
UPDATE notification_history nh
6+
JOIN (
7+
SELECT review_cycle_id, COUNT(*) AS cnt
8+
FROM notification_history
9+
WHERE status = 'FAILED'
10+
GROUP BY review_cycle_id
11+
) sub ON nh.review_cycle_id = sub.review_cycle_id
12+
SET nh.fail_count = sub.cnt
13+
WHERE nh.status = 'PENDING';
14+
15+
-- 2. SENT가 있는 경우: PENDING → SENT 로 status 변경
16+
UPDATE notification_history nh
17+
JOIN (
18+
SELECT DISTINCT review_cycle_id
19+
FROM notification_history
20+
WHERE status = 'SENT'
21+
) sub ON nh.review_cycle_id = sub.review_cycle_id
22+
SET nh.status = 'SENT'
23+
WHERE nh.status = 'PENDING';
24+
25+
-- 3. SENT 없고 FAILED 있는 경우: PENDING → FAILED 로 status 변경
26+
UPDATE notification_history nh
27+
JOIN (
28+
SELECT review_cycle_id, COUNT(*) AS cnt
29+
FROM notification_history
30+
WHERE status = 'FAILED'
31+
GROUP BY review_cycle_id
32+
) sub ON nh.review_cycle_id = sub.review_cycle_id
33+
LEFT JOIN (
34+
SELECT DISTINCT review_cycle_id
35+
FROM notification_history
36+
WHERE status = 'SENT'
37+
) sent ON nh.review_cycle_id = sent.review_cycle_id
38+
SET nh.status = 'FAILED'
39+
WHERE nh.status = 'PENDING'
40+
AND sent.review_cycle_id IS NULL;
41+
42+
-- 4. review_cycle_id당 MIN(id) 레코드 1개만 남기고 나머지 삭제
43+
-- PENDING이 항상 먼저 INSERT되므로 MIN(id) = 기준 레코드
44+
DELETE FROM notification_history
45+
WHERE id NOT IN (
46+
SELECT min_id FROM (
47+
SELECT MIN(id) AS min_id
48+
FROM notification_history
49+
GROUP BY review_cycle_id
50+
) AS keeper
51+
);

src/test/java/com/recyclestudy/email/EmailRetryServiceTest.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
import com.recyclestudy.member.domain.Email;
44
import com.recyclestudy.member.domain.Member;
5+
import com.recyclestudy.review.domain.NotificationStatus;
56
import com.recyclestudy.review.domain.Review;
67
import com.recyclestudy.review.domain.ReviewCycle;
78
import com.recyclestudy.review.domain.ReviewURL;
89
import com.recyclestudy.review.repository.ReviewCycleRepository;
10+
import java.time.Clock;
11+
import java.time.Instant;
12+
import java.time.LocalDateTime;
13+
import java.time.ZoneId;
914
import java.util.Collections;
1015
import java.util.List;
1116
import org.junit.jupiter.api.DisplayName;
@@ -17,6 +22,7 @@
1722

1823
import static org.mockito.ArgumentMatchers.any;
1924
import static org.mockito.ArgumentMatchers.argThat;
25+
import static org.mockito.ArgumentMatchers.eq;
2026
import static org.mockito.BDDMockito.given;
2127
import static org.mockito.Mockito.mock;
2228
import static org.mockito.Mockito.never;
@@ -32,14 +38,21 @@ class EmailRetryServiceTest {
3238
@Mock
3339
SingleReviewEmailSender singleReviewEmailSender;
3440

41+
@Mock
42+
Clock clock;
43+
3544
@InjectMocks
3645
EmailRetryService emailRetryService;
3746

3847
@Test
3948
@DisplayName("재시도 대상이 없으면 아무 동작도 하지 않는다")
4049
void retryFailedEmails_noData() {
4150
// given
42-
given(reviewCycleRepository.findAllRetryableCycles(any(Long.class))).willReturn(Collections.emptyList());
51+
given(clock.instant()).willReturn(Instant.parse("2026-01-01T00:00:00Z"));
52+
given(clock.getZone()).willReturn(ZoneId.of("UTC"));
53+
given(reviewCycleRepository.findAllRetryableCycles(
54+
eq(NotificationStatus.FAILED), any(Integer.class), any(LocalDateTime.class)))
55+
.willReturn(Collections.emptyList());
4356

4457
// when
4558
emailRetryService.retryFailedEmails();
@@ -52,6 +65,9 @@ void retryFailedEmails_noData() {
5265
@DisplayName("재시도 대상을 멤버별로 그룹화하여 메일을 발송한다")
5366
void retryFailedEmails_success() {
5467
// given
68+
given(clock.instant()).willReturn(Instant.parse("2026-01-01T00:00:00Z"));
69+
given(clock.getZone()).willReturn(ZoneId.of("UTC"));
70+
5571
final Member member1 = mock(Member.class);
5672
final Email email1 = Email.from("user1@test.com");
5773
given(member1.getEmail()).willReturn(email1);
@@ -68,7 +84,8 @@ void retryFailedEmails_success() {
6884
given(cycle2.getId()).willReturn(2L);
6985
given(cycle2.getReview()).willReturn(review1);
7086

71-
given(reviewCycleRepository.findAllRetryableCycles(any(Long.class)))
87+
given(reviewCycleRepository.findAllRetryableCycles(
88+
eq(NotificationStatus.FAILED), any(Integer.class), any(LocalDateTime.class)))
7289
.willReturn(List.of(cycle1, cycle2));
7390

7491
// when

0 commit comments

Comments
 (0)