Skip to content

Commit 1d8492b

Browse files
리팩토링: 비동기 편지 배송 완료 처리를 전용 트랜잭션 서비스로 분리 (WR9-142)
- 스케줄러에서 LetterProcessingService로 배송 완료 처리 로직 이동 - 개별 편지 처리를 위해 @transactional(REQUIRES_NEW) 적용 - 비동기 처리의 관심사 분리 및 오류 격리 개선
1 parent e26c94f commit 1d8492b

File tree

3 files changed

+185
-25
lines changed

3 files changed

+185
-25
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.crops.warmletter.domain.letter.service;
2+
3+
import io.crops.warmletter.domain.letter.entity.Letter;
4+
import io.crops.warmletter.domain.letter.enums.Status;
5+
import io.crops.warmletter.domain.letter.repository.LetterRepository;
6+
import io.crops.warmletter.domain.timeline.dto.request.NotificationRequest;
7+
import io.crops.warmletter.domain.timeline.enums.AlarmType;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.context.ApplicationEventPublisher;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Propagation;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
@Slf4j
16+
@Service
17+
@RequiredArgsConstructor
18+
public class LetterProcessingService {
19+
20+
private final LetterRepository letterRepository;
21+
private final ApplicationEventPublisher notificationPublisher;
22+
23+
/**
24+
* 편지 배송 완료 처리 및 알림 전송
25+
* 개별 트랜잭션으로 처리하여 다른 편지 처리에 영향을 주지 않음
26+
*/
27+
@Transactional(propagation = Propagation.REQUIRES_NEW)
28+
public void processDeliveryCompletion(Letter letter, String senderZipCode) {
29+
// 편지 상태 업데이트
30+
letter.updateStatus(Status.DELIVERED);
31+
letterRepository.save(letter);
32+
33+
// 알림 전송
34+
if (letter.getReceiverId() != null && senderZipCode != null) {
35+
notificationPublisher.publishEvent(NotificationRequest.builder()
36+
.senderZipCode(senderZipCode)
37+
.receiverId(letter.getReceiverId())
38+
.alarmType(AlarmType.LETTER)
39+
.data(letter.getId().toString())
40+
.build());
41+
}
42+
}
43+
}

src/main/java/io/crops/warmletter/global/schedule/DeliverySchedule.java

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,14 @@
33
import io.crops.warmletter.domain.letter.entity.Letter;
44
import io.crops.warmletter.domain.letter.enums.Status;
55
import io.crops.warmletter.domain.letter.repository.LetterRepository;
6-
import io.crops.warmletter.domain.timeline.dto.request.NotificationRequest;
6+
import io.crops.warmletter.domain.letter.service.LetterProcessingService;
77
import io.crops.warmletter.domain.timeline.dto.response.LetterAlarmResponse;
8-
import io.crops.warmletter.domain.timeline.enums.AlarmType;
98
import lombok.RequiredArgsConstructor;
109
import lombok.extern.slf4j.Slf4j;
1110
import org.springframework.beans.factory.annotation.Qualifier;
12-
import org.springframework.context.ApplicationEventPublisher;
1311
import org.springframework.context.annotation.Configuration;
1412
import org.springframework.core.task.AsyncTaskExecutor;
1513
import org.springframework.scheduling.annotation.Scheduled;
16-
import org.springframework.transaction.annotation.Propagation;
17-
import org.springframework.transaction.annotation.Transactional;
1814

1915
import java.time.LocalDateTime;
2016
import java.time.format.DateTimeFormatter;
@@ -30,7 +26,7 @@
3026
public class DeliverySchedule {
3127

3228
private final LetterRepository letterRepository;
33-
private final ApplicationEventPublisher notificationPublisher;
29+
private final LetterProcessingService letterProcessingService;
3430
@Qualifier("deliveryTaskExecutor")
3531
private final AsyncTaskExecutor taskExecutor;
3632

@@ -64,7 +60,8 @@ public void processDeliveryCompletion() {
6460
for (Letter letter : lettersToComplete) {
6561
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(() -> {
6662
try {
67-
processLetter(letter, senderZipCodes.get(letter.getWriterId()));
63+
// 별도 서비스를 통해 트랜잭션 관리
64+
letterProcessingService.processDeliveryCompletion(letter, senderZipCodes.get(letter.getWriterId()));
6865
log.info("편지 ID: {} 배송 완료 처리됨", letter.getId());
6966
return true;
7067
} catch (Exception e) {
@@ -76,14 +73,20 @@ public void processDeliveryCompletion() {
7673
futures.add(future);
7774
}
7875

79-
// 모든 비동기 작업 완료 대기 (옵션)
76+
// 모든 비동기 작업 완료 대기
8077
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
8178

8279
// 성공/실패 편지 수 계산
8380
long successCount = futures.stream().filter(f -> {
8481
try {
8582
return f.get();
83+
} catch (InterruptedException e) {
84+
// 인터럽트 상태 복원
85+
Thread.currentThread().interrupt();
86+
log.error("편지 처리 중 스레드 인터럽트 발생", e);
87+
return false;
8688
} catch (Exception e) {
89+
log.error("편지 처리 중 오류 발생", e);
8790
return false;
8891
}
8992
}).count();
@@ -94,21 +97,4 @@ public void processDeliveryCompletion() {
9497
}
9598
log.info("--------- 배송 완료 처리 완료 ---------");
9699
}
97-
98-
@Transactional(propagation = Propagation.REQUIRES_NEW)
99-
public void processLetter(Letter letter, String senderZipCode) {
100-
// 편지 상태 업데이트
101-
letter.updateStatus(Status.DELIVERED);
102-
letterRepository.save(letter);
103-
104-
// 알림 전송
105-
if (letter.getReceiverId() != null && senderZipCode != null) {
106-
notificationPublisher.publishEvent(NotificationRequest.builder()
107-
.senderZipCode(senderZipCode)
108-
.receiverId(letter.getReceiverId())
109-
.alarmType(AlarmType.LETTER)
110-
.data(letter.getId().toString())
111-
.build());
112-
}
113-
}
114100
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package io.crops.warmletter.domain.letter.service;
2+
3+
import io.crops.warmletter.domain.letter.entity.Letter;
4+
import io.crops.warmletter.domain.letter.enums.*;
5+
import io.crops.warmletter.domain.letter.repository.LetterRepository;
6+
import io.crops.warmletter.domain.timeline.dto.request.NotificationRequest;
7+
import io.crops.warmletter.domain.timeline.enums.AlarmType;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.extension.ExtendWith;
11+
import org.mockito.ArgumentCaptor;
12+
import org.mockito.Captor;
13+
import org.mockito.InjectMocks;
14+
import org.mockito.Mock;
15+
import org.mockito.junit.jupiter.MockitoExtension;
16+
import org.springframework.context.ApplicationEventPublisher;
17+
import org.springframework.test.util.ReflectionTestUtils;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.mockito.ArgumentMatchers.any;
21+
import static org.mockito.Mockito.*;
22+
23+
@ExtendWith(MockitoExtension.class)
24+
class LetterProcessingServiceTest {
25+
26+
@Mock
27+
private LetterRepository letterRepository;
28+
29+
@Mock
30+
private ApplicationEventPublisher notificationPublisher;
31+
32+
@InjectMocks
33+
private LetterProcessingService letterProcessingService;
34+
35+
@Captor
36+
private ArgumentCaptor<NotificationRequest> notificationCaptor;
37+
38+
private final String ZIP_CODE = "12345";
39+
private final Long TEST_RECEIVER_ID = 100L;
40+
private final Long TEST_WRITER_ID = 200L;
41+
private final Long TEST_LETTER_ID = 300L;
42+
43+
private Letter createTestLetter() {
44+
// Builder 패턴을 사용하여 Letter 객체 생성
45+
Letter letter = Letter.builder()
46+
.writerId(TEST_WRITER_ID)
47+
.receiverId(TEST_RECEIVER_ID)
48+
.parentLetterId(null)
49+
.letterType(LetterType.DIRECT)
50+
.category(Category.ETC)
51+
.title("테스트 편지")
52+
.content("테스트 내용입니다.")
53+
.status(Status.IN_DELIVERY)
54+
.fontType(FontType.DEFAULT)
55+
.paperType(PaperType.PAPER)
56+
.matchingId(null)
57+
.build();
58+
59+
ReflectionTestUtils.setField(letter, "id", TEST_LETTER_ID);
60+
61+
return letter;
62+
}
63+
64+
@Test
65+
@DisplayName("편지 배송 완료 처리 - 상태 업데이트 및 저장 검증")
66+
void processDeliveryCompletion_ShouldUpdateStatusAndSave() {
67+
// Given
68+
Letter testLetter = createTestLetter();
69+
70+
// When
71+
letterProcessingService.processDeliveryCompletion(testLetter, ZIP_CODE);
72+
73+
// Then
74+
assertThat(testLetter.getStatus()).isEqualTo(Status.DELIVERED);
75+
verify(letterRepository, times(1)).save(testLetter);
76+
}
77+
78+
@Test
79+
@DisplayName("편지 배송 완료 처리 - 알림 전송 검증")
80+
void processDeliveryCompletion_ShouldSendNotification() {
81+
// Given
82+
Letter testLetter = createTestLetter();
83+
84+
// When
85+
letterProcessingService.processDeliveryCompletion(testLetter, ZIP_CODE);
86+
87+
// Then
88+
verify(notificationPublisher, times(1)).publishEvent(notificationCaptor.capture());
89+
90+
NotificationRequest capturedRequest = notificationCaptor.getValue();
91+
assertThat(capturedRequest.getSenderZipCode()).isEqualTo(ZIP_CODE);
92+
assertThat(capturedRequest.getReceiverId()).isEqualTo(TEST_RECEIVER_ID);
93+
assertThat(capturedRequest.getAlarmType()).isEqualTo(AlarmType.LETTER);
94+
assertThat(capturedRequest.getData()).isEqualTo(TEST_LETTER_ID.toString());
95+
}
96+
97+
@Test
98+
@DisplayName("편지 배송 완료 처리 - 수신자 ID가 null인 경우 알림 미전송")
99+
void processDeliveryCompletion_WhenReceiverIdIsNull_ShouldNotSendNotification() {
100+
// Given
101+
Letter testLetter = Letter.builder()
102+
.writerId(TEST_WRITER_ID)
103+
.receiverId(null) // 수신자 ID를 null로 설정
104+
.title("테스트 편지")
105+
.content("테스트 내용입니다.")
106+
.status(Status.IN_DELIVERY)
107+
.build();
108+
ReflectionTestUtils.setField(testLetter, "id", TEST_LETTER_ID);
109+
110+
// When
111+
letterProcessingService.processDeliveryCompletion(testLetter, ZIP_CODE);
112+
113+
// Then
114+
verify(letterRepository, times(1)).save(testLetter);
115+
verify(notificationPublisher, never()).publishEvent(any());
116+
}
117+
118+
@Test
119+
@DisplayName("편지 배송 완료 처리 - 발신자 우편번호가 null인 경우 알림 미전송")
120+
void processDeliveryCompletion_WhenSenderZipCodeIsNull_ShouldNotSendNotification() {
121+
// Given
122+
Letter testLetter = createTestLetter();
123+
124+
// When
125+
letterProcessingService.processDeliveryCompletion(testLetter, null);
126+
127+
// Then
128+
verify(letterRepository, times(1)).save(testLetter);
129+
verify(notificationPublisher, never()).publishEvent(any());
130+
}
131+
}

0 commit comments

Comments
 (0)