Skip to content

Commit f2b3dd2

Browse files
authored
Merge pull request #251 from prgrms-web-devcourse-final-project/refactor/delivery-schedule-individual-transaction(WR9-142)
Refactor/delivery schedule individual transaction(wr9-142)
2 parents ad395ce + 1d8492b commit f2b3dd2

File tree

5 files changed

+251
-26
lines changed

5 files changed

+251
-26
lines changed

src/main/java/io/crops/warmletter/domain/letter/entity/Letter.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ public class Letter extends BaseTimeEntity {
3232
@Enumerated(EnumType.STRING)
3333
private Category category; // 편지 분류 (enum: 쿠폰, 응원, 그외 등)
3434

35+
@Column(length = 50)
3536
private String title; // 제목
37+
38+
@Column(length = 1000)
3639
private String content; // 내용
3740

3841
@Enumerated(EnumType.STRING)
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+
}
Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,34 @@
11
package io.crops.warmletter.global.config;
22

3+
import org.springframework.context.annotation.Bean;
34
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.core.task.AsyncTaskExecutor;
6+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
7+
8+
import java.util.concurrent.ThreadPoolExecutor;
49
import org.springframework.scheduling.annotation.EnableAsync;
510

611
@Configuration
712
@EnableAsync
813
public class AsyncConfig {
9-
}
14+
15+
@Bean
16+
public AsyncTaskExecutor deliveryTaskExecutor() {
17+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
18+
19+
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;
20+
21+
executor.setCorePoolSize(corePoolSize); // CPU 코어 수 * 2
22+
executor.setMaxPoolSize(corePoolSize * 2); // 부하가 높을 때 확장 가능한 여유
23+
executor.setQueueCapacity(corePoolSize * 4); // 처리 대기열 크기
24+
executor.setKeepAliveSeconds(60); // 유휴 스레드 유지 시간
25+
executor.setThreadNamePrefix("delivery-async-");
26+
27+
// 대기열이 가득 찼을 때 CallerRunsPolicy를 사용하여
28+
// 호출 스레드에서 작업 실행 (시스템 과부하 방지)
29+
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
30+
31+
executor.initialize();
32+
return executor;
33+
}
34+
}

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

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,21 @@
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.member.repository.MemberRepository;
7-
import io.crops.warmletter.domain.timeline.dto.request.NotificationRequest;
6+
import io.crops.warmletter.domain.letter.service.LetterProcessingService;
87
import io.crops.warmletter.domain.timeline.dto.response.LetterAlarmResponse;
9-
import io.crops.warmletter.domain.timeline.enums.AlarmType;
10-
import io.crops.warmletter.domain.timeline.facade.NotificationFacade;
11-
import jakarta.transaction.Transactional;
128
import lombok.RequiredArgsConstructor;
139
import lombok.extern.slf4j.Slf4j;
14-
import org.springframework.context.ApplicationEventPublisher;
10+
import org.springframework.beans.factory.annotation.Qualifier;
1511
import org.springframework.context.annotation.Configuration;
12+
import org.springframework.core.task.AsyncTaskExecutor;
1613
import org.springframework.scheduling.annotation.Scheduled;
1714

1815
import java.time.LocalDateTime;
1916
import java.time.format.DateTimeFormatter;
17+
import java.util.ArrayList;
2018
import java.util.List;
2119
import java.util.Map;
20+
import java.util.concurrent.CompletableFuture;
2221
import java.util.stream.Collectors;
2322

2423
@Slf4j
@@ -27,48 +26,72 @@
2726
public class DeliverySchedule {
2827

2928
private final LetterRepository letterRepository;
29+
private final LetterProcessingService letterProcessingService;
30+
@Qualifier("deliveryTaskExecutor")
31+
private final AsyncTaskExecutor taskExecutor;
3032

31-
private final ApplicationEventPublisher notificationPublisher;
32-
33-
@Transactional
3433
@Scheduled(cron = "0 */1 * * * *", zone = "Asia/Seoul")
3534
public void processDeliveryCompletion() {
3635
String currentTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
3736
log.info("--------- 배송 완료 처리 시작: {} ---------", currentTime);
3837

3938
LocalDateTime now = LocalDateTime.now();
4039

41-
// 배송 완료 조건을 만족하는 편지 목록 조회 (배송 중이면서 배송 완료 시간이 현재보다 이전인 편지)
40+
// 배송 완료 조건을 만족하는 편지 목록 조회
4241
List<Letter> lettersToComplete = letterRepository.findByStatusAndDeliveryCompletedAtLessThanEqual(
4342
Status.IN_DELIVERY, now);
44-
// lettersToComplete 조건을 만족하는 편지를 보낸 사람의 zipCode 조회
43+
44+
// zipCode 조회
4545
List<LetterAlarmResponse> zipCodeData = letterRepository.findZipCodeByLettersToComplete(now);
4646
Map<Long, String> senderZipCodes = zipCodeData.stream()
4747
.collect(Collectors.toMap(
4848
LetterAlarmResponse::getWriterId,
4949
LetterAlarmResponse::getZipCode,
50-
(existingZipCode, newZipCode) -> existingZipCode // 중복 키 발생 시 기존 값 사용
50+
(existingZipCode, newZipCode) -> existingZipCode
5151
));
5252

5353
if (!lettersToComplete.isEmpty()) {
5454
log.info("배송 완료 처리할 편지 수: {}", lettersToComplete.size());
5555

56-
// 각 편지의 상태를 DELIVERED로 변경
56+
// 결과 추적을 위한 CompletableFuture 목록
57+
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
58+
59+
// 각 편지를 비동기적으로 처리
5760
for (Letter letter : lettersToComplete) {
58-
letter.updateStatus(Status.DELIVERED);
59-
log.info("편지 ID: {} 배송 완료 처리됨", letter.getId());
60-
// 도착 알림 전송
61-
notificationPublisher.publishEvent(NotificationRequest.builder()
62-
.senderZipCode(senderZipCodes.get(letter.getWriterId()))
63-
.receiverId(letter.getReceiverId())
64-
.alarmType(AlarmType.LETTER)
65-
.data(letter.getId().toString())
66-
.build());
61+
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(() -> {
62+
try {
63+
// 별도 서비스를 통해 트랜잭션 관리
64+
letterProcessingService.processDeliveryCompletion(letter, senderZipCodes.get(letter.getWriterId()));
65+
log.info("편지 ID: {} 배송 완료 처리됨", letter.getId());
66+
return true;
67+
} catch (Exception e) {
68+
log.error("편지 ID: {} 배송 완료 처리 실패: {}", letter.getId(), e.getMessage(), e);
69+
return false;
70+
}
71+
}, taskExecutor);
72+
73+
futures.add(future);
6774
}
6875

69-
// 변경사항 저장
70-
letterRepository.saveAll(lettersToComplete);
71-
log.info("총 {}개의 편지 배송 완료 처리됨", lettersToComplete.size());
76+
// 모든 비동기 작업 완료 대기
77+
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
78+
79+
// 성공/실패 편지 수 계산
80+
long successCount = futures.stream().filter(f -> {
81+
try {
82+
return f.get();
83+
} catch (InterruptedException e) {
84+
// 인터럽트 상태 복원
85+
Thread.currentThread().interrupt();
86+
log.error("편지 처리 중 스레드 인터럽트 발생", e);
87+
return false;
88+
} catch (Exception e) {
89+
log.error("편지 처리 중 오류 발생", e);
90+
return false;
91+
}
92+
}).count();
93+
94+
log.info("총 {}개 중 {}개의 편지 배송 완료 처리 성공", lettersToComplete.size(), successCount);
7295
} else {
7396
log.info("배송 완료 처리할 편지가 없습니다.");
7497
}
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)