Skip to content

Commit 27f7336

Browse files
refactor: 배송 완료 처리 로직 개선 및 비동기 처리 도입 (WR9-142)
- 개별 트랜잭션 적용으로 편지 처리 안정성 향상 - 각 편지마다 독립적인 트랜잭션(REQUIRES_NEW) 설정 - 한 편지 처리 실패가 다른 편지에 영향을 주지 않도록 격리 - CompletableFuture 기반 비동기 처리 구현 - 병렬 처리를 통한 성능 개선 - 작업 결과 추적 및 성공/실패 통계 수집 기능 추가 - 전용 TaskExecutor 설정으로 효율적인 리소스 관리 - CPU 코어 수 기반 동적 스레드 풀 설정 - 과부하 방지를 위한 CallerRunsPolicy 도입 - AsyncConfig 클래스 분리로 설정 모듈화 - 예외 처리 및 로깅 강화 - 상세한 예외 정보 기록으로 문제 진단 용이 - 전체 처리 과정 및 결과에 대한 로깅 개선
1 parent c4f74c7 commit 27f7336

File tree

3 files changed

+93
-21
lines changed

3 files changed

+93
-21
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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.crops.warmletter.global.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
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;
9+
10+
@Configuration
11+
public class AsyncConfig {
12+
13+
@Bean
14+
public AsyncTaskExecutor deliveryTaskExecutor() {
15+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
16+
17+
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;
18+
19+
executor.setCorePoolSize(corePoolSize); // CPU 코어 수 * 2
20+
executor.setMaxPoolSize(corePoolSize * 2); // 부하가 높을 때 확장 가능한 여유
21+
executor.setQueueCapacity(corePoolSize * 4); // 처리 대기열 크기
22+
executor.setKeepAliveSeconds(60); // 유휴 스레드 유지 시간
23+
executor.setThreadNamePrefix("delivery-async-");
24+
25+
// 대기열이 가득 찼을 때 CallerRunsPolicy를 사용하여
26+
// 호출 스레드에서 작업 실행 (시스템 과부하 방지)
27+
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
28+
29+
executor.initialize();
30+
return executor;
31+
}
32+
}

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

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,25 @@
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;
76
import io.crops.warmletter.domain.timeline.dto.request.NotificationRequest;
87
import io.crops.warmletter.domain.timeline.dto.response.LetterAlarmResponse;
98
import io.crops.warmletter.domain.timeline.enums.AlarmType;
10-
import io.crops.warmletter.domain.timeline.facade.NotificationFacade;
11-
import jakarta.transaction.Transactional;
129
import lombok.RequiredArgsConstructor;
1310
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.beans.factory.annotation.Qualifier;
1412
import org.springframework.context.ApplicationEventPublisher;
1513
import org.springframework.context.annotation.Configuration;
14+
import org.springframework.core.task.AsyncTaskExecutor;
1615
import org.springframework.scheduling.annotation.Scheduled;
16+
import org.springframework.transaction.annotation.Propagation;
17+
import org.springframework.transaction.annotation.Transactional;
1718

1819
import java.time.LocalDateTime;
1920
import java.time.format.DateTimeFormatter;
21+
import java.util.ArrayList;
2022
import java.util.List;
2123
import java.util.Map;
24+
import java.util.concurrent.CompletableFuture;
2225
import java.util.stream.Collectors;
2326

2427
@Slf4j
@@ -27,51 +30,85 @@
2730
public class DeliverySchedule {
2831

2932
private final LetterRepository letterRepository;
30-
3133
private final ApplicationEventPublisher notificationPublisher;
34+
@Qualifier("deliveryTaskExecutor")
35+
private final AsyncTaskExecutor taskExecutor;
3236

33-
@Transactional
3437
@Scheduled(cron = "0 */1 * * * *", zone = "Asia/Seoul")
3538
public void processDeliveryCompletion() {
3639
String currentTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
3740
log.info("--------- 배송 완료 처리 시작: {} ---------", currentTime);
3841

3942
LocalDateTime now = LocalDateTime.now();
4043

41-
// 배송 완료 조건을 만족하는 편지 목록 조회 (배송 중이면서 배송 완료 시간이 현재보다 이전인 편지)
44+
// 배송 완료 조건을 만족하는 편지 목록 조회
4245
List<Letter> lettersToComplete = letterRepository.findByStatusAndDeliveryCompletedAtLessThanEqual(
4346
Status.IN_DELIVERY, now);
44-
// lettersToComplete 조건을 만족하는 편지를 보낸 사람의 zipCode 조회
47+
48+
// zipCode 조회
4549
List<LetterAlarmResponse> zipCodeData = letterRepository.findZipCodeByLettersToComplete(now);
4650
Map<Long, String> senderZipCodes = zipCodeData.stream()
4751
.collect(Collectors.toMap(
4852
LetterAlarmResponse::getWriterId,
4953
LetterAlarmResponse::getZipCode,
50-
(existingZipCode, newZipCode) -> existingZipCode // 중복 키 발생 시 기존 값 사용
54+
(existingZipCode, newZipCode) -> existingZipCode
5155
));
5256

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

56-
// 각 편지의 상태를 DELIVERED로 변경
60+
// 결과 추적을 위한 CompletableFuture 목록
61+
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
62+
63+
// 각 편지를 비동기적으로 처리
5764
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());
65+
CompletableFuture<Boolean> future = CompletableFuture.supplyAsync(() -> {
66+
try {
67+
processLetter(letter, senderZipCodes.get(letter.getWriterId()));
68+
log.info("편지 ID: {} 배송 완료 처리됨", letter.getId());
69+
return true;
70+
} catch (Exception e) {
71+
log.error("편지 ID: {} 배송 완료 처리 실패: {}", letter.getId(), e.getMessage(), e);
72+
return false;
73+
}
74+
}, taskExecutor);
75+
76+
futures.add(future);
6777
}
6878

69-
// 변경사항 저장
70-
letterRepository.saveAll(lettersToComplete);
71-
log.info("총 {}개의 편지 배송 완료 처리됨", lettersToComplete.size());
79+
// 모든 비동기 작업 완료 대기 (옵션)
80+
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
81+
82+
// 성공/실패 편지 수 계산
83+
long successCount = futures.stream().filter(f -> {
84+
try {
85+
return f.get();
86+
} catch (Exception e) {
87+
return false;
88+
}
89+
}).count();
90+
91+
log.info("총 {}개 중 {}개의 편지 배송 완료 처리 성공", lettersToComplete.size(), successCount);
7292
} else {
7393
log.info("배송 완료 처리할 편지가 없습니다.");
7494
}
7595
log.info("--------- 배송 완료 처리 완료 ---------");
7696
}
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+
}
77114
}

0 commit comments

Comments
 (0)