Skip to content

Commit 28d8e24

Browse files
authored
[DDING-000] 이메일 전송 신뢰성 확보를 위한 정책 도입 (#345)
1 parent 13f1c46 commit 28d8e24

28 files changed

+957
-140
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies {
3333
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.12'
3434
implementation 'org.springframework.boot:spring-boot-configuration-processor'
3535
implementation 'org.springframework.boot:spring-boot-starter-cache'
36+
implementation 'org.springframework.retry:spring-retry'
3637

3738
compileOnly 'org.projectlombok:lombok'
3839
annotationProcessor 'org.projectlombok:lombok'
Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,23 @@
11
package ddingdong.ddingdongBE.common.config;
22

33
import java.util.concurrent.Executor;
4-
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
5-
import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler;
4+
import org.springframework.context.annotation.Bean;
65
import org.springframework.context.annotation.Configuration;
7-
import org.springframework.scheduling.annotation.AsyncConfigurer;
86
import org.springframework.scheduling.annotation.EnableAsync;
97
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
108

119
@Configuration
1210
@EnableAsync
13-
public class AsyncConfig implements AsyncConfigurer {
11+
public class AsyncConfig {
1412

15-
@Override
16-
public Executor getAsyncExecutor() {
13+
@Bean
14+
public Executor emailAsyncExecutor() {
1715
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
18-
executor.setCorePoolSize(5);
16+
executor.setCorePoolSize(10);
1917
executor.setMaxPoolSize(10);
2018
executor.setQueueCapacity(500);
2119
executor.setThreadNamePrefix("EmailAsync-");
2220
executor.initialize();
2321
return executor;
2422
}
25-
26-
@Override
27-
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
28-
return new SimpleAsyncUncaughtExceptionHandler();
29-
}
30-
31-
3223
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package ddingdong.ddingdongBE.common.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.retry.annotation.EnableRetry;
5+
6+
@Configuration
7+
@EnableRetry
8+
public class RetryConfig {
9+
10+
}

src/main/java/ddingdong/ddingdongBE/domain/form/controller/dto/request/SendApplicationResultEmailRequest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ddingdong.ddingdongBE.domain.form.controller.dto.request;
22

33
import ddingdong.ddingdongBE.domain.form.service.dto.command.SendApplicationResultEmailCommand;
4+
import ddingdong.ddingdongBE.domain.formapplication.entity.FormApplicationStatus;
45
import io.swagger.v3.oas.annotations.media.Schema;
56
import jakarta.validation.constraints.NotNull;
67

@@ -15,7 +16,7 @@ public record SendApplicationResultEmailRequest(
1516
allowableValues = {"FIRST_PASS", "FIRST_FAIL", "FINAL_PASS", "FINAL_FAIL"}
1617
)
1718
@NotNull(message = "전송 대상은 필수입니다.")
18-
String target,
19+
FormApplicationStatus target,
1920

2021
@Schema(description = "내용", example = "내용")
2122
@NotNull(message = "메일 내용은 필수입니다.")

src/main/java/ddingdong/ddingdongBE/domain/form/service/FacadeCentralFormServiceImpl.java

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,14 @@
2929
import ddingdong.ddingdongBE.domain.form.service.dto.query.MultipleFieldStatisticsQuery.OptionStatisticQuery;
3030
import ddingdong.ddingdongBE.domain.form.service.dto.query.SingleFieldStatisticsQuery;
3131
import ddingdong.ddingdongBE.domain.form.service.dto.query.SingleFieldStatisticsQuery.SingleStatisticsQuery;
32+
import ddingdong.ddingdongBE.domain.formapplication.entity.EmailContent;
3233
import ddingdong.ddingdongBE.domain.formapplication.entity.FormApplication;
33-
import ddingdong.ddingdongBE.domain.formapplication.entity.FormApplicationStatus;
3434
import ddingdong.ddingdongBE.domain.formapplication.service.FormAnswerService;
35+
import ddingdong.ddingdongBE.domain.formapplication.service.FormApplicationEmailService;
3536
import ddingdong.ddingdongBE.domain.formapplication.service.FormApplicationService;
3637
import ddingdong.ddingdongBE.domain.user.entity.User;
37-
import ddingdong.ddingdongBE.email.SesEmailService;
38-
import ddingdong.ddingdongBE.email.dto.EmailContent;
3938
import java.time.LocalDate;
4039
import java.util.List;
41-
import java.util.concurrent.CompletableFuture;
42-
import java.util.concurrent.TimeUnit;
4340
import lombok.RequiredArgsConstructor;
4441
import lombok.extern.slf4j.Slf4j;
4542
import org.springframework.cache.annotation.CacheEvict;
@@ -58,10 +55,10 @@ public class FacadeCentralFormServiceImpl implements FacadeCentralFormService {
5855
private final ClubService clubService;
5956
private final FormStatisticService formStatisticService;
6057
private final FormApplicationService formApplicationService;
61-
private final SesEmailService sesEmailService;
6258
private final FormAnswerService formAnswerService;
6359
private final FileMetaDataService fileMetaDataService;
6460
private final ClubMemberService clubMemberService;
61+
private final FormApplicationEmailService formApplicationEmailService;
6562

6663
@Transactional
6764
@Override
@@ -181,22 +178,16 @@ public SingleFieldStatisticsQuery getTextFieldStatistics(Long fieldId) {
181178
return new SingleFieldStatisticsQuery(type, textStatisticsQueries);
182179
}
183180

181+
@Transactional
184182
@Override
185183
public void sendApplicationResultEmail(SendApplicationResultEmailCommand command) {
186184
Club club = clubService.getByUserId(command.userId());
187185
List<FormApplication> formApplications = formApplicationService.getAllByFormIdAndFormApplicationStatus(
188186
command.formId(),
189-
FormApplicationStatus.findStatus(command.target())
187+
command.target()
190188
);
191189
EmailContent emailContent = EmailContent.of(command.title(), command.message(), club);
192-
CompletableFuture<Void> future = sesEmailService.sendBulkResultEmails(formApplications, emailContent);
193-
194-
try {
195-
future.get(5, TimeUnit.MINUTES); // 최대 5분 대기
196-
} catch (Exception e) {
197-
log.error("Failed to send bulk emails", e);
198-
throw new RuntimeException("이메일 전송 중 오류가 발생했습니다.", e);
199-
}
190+
formApplicationEmailService.sendBulkResult(formApplications, emailContent);
200191
}
201192

202193
@Transactional

src/main/java/ddingdong/ddingdongBE/domain/form/service/dto/command/SendApplicationResultEmailCommand.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package ddingdong.ddingdongBE.domain.form.service.dto.command;
22

3+
import ddingdong.ddingdongBE.domain.formapplication.entity.FormApplicationStatus;
4+
35
public record SendApplicationResultEmailCommand(
46
Long userId,
57
Long formId,
68
String title,
7-
String target,
9+
FormApplicationStatus target,
810
String message
911
) {
1012

src/main/java/ddingdong/ddingdongBE/email/dto/EmailContent.java renamed to src/main/java/ddingdong/ddingdongBE/domain/formapplication/entity/EmailContent.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package ddingdong.ddingdongBE.email.dto;
1+
package ddingdong.ddingdongBE.domain.formapplication.entity;
22

33
import ddingdong.ddingdongBE.domain.club.entity.Club;
44

@@ -21,3 +21,4 @@ public static EmailContent of(String subject, String content, Club club) {
2121
return new EmailContent(subject, htmlContent, plainContent);
2222
}
2323
}
24+
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package ddingdong.ddingdongBE.domain.formapplication.entity;
2+
3+
import static ddingdong.ddingdongBE.domain.formapplication.entity.EmailSendStatus.PENDING;
4+
import static ddingdong.ddingdongBE.domain.formapplication.entity.EmailSendStatus.PERMANENT_FAILURE;
5+
import static ddingdong.ddingdongBE.domain.formapplication.entity.EmailSendStatus.SENDING;
6+
import static ddingdong.ddingdongBE.domain.formapplication.entity.EmailSendStatus.TEMPORARY_FAILURE;
7+
8+
import ddingdong.ddingdongBE.common.BaseEntity;
9+
import jakarta.persistence.Column;
10+
import jakarta.persistence.Entity;
11+
import jakarta.persistence.EnumType;
12+
import jakarta.persistence.Enumerated;
13+
import jakarta.persistence.FetchType;
14+
import jakarta.persistence.GeneratedValue;
15+
import jakarta.persistence.GenerationType;
16+
import jakarta.persistence.Id;
17+
import jakarta.persistence.JoinColumn;
18+
import jakarta.persistence.ManyToOne;
19+
import java.time.LocalDateTime;
20+
import jakarta.annotation.Nullable;
21+
import lombok.AccessLevel;
22+
import lombok.Builder;
23+
import lombok.Getter;
24+
import lombok.NoArgsConstructor;
25+
26+
@Entity
27+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
28+
@Getter
29+
public class EmailSendHistory extends BaseEntity {
30+
31+
@Id
32+
@GeneratedValue(strategy = GenerationType.IDENTITY)
33+
private Long id;
34+
35+
@ManyToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "form_application_id")
37+
private FormApplication formApplication;
38+
39+
@Enumerated(EnumType.STRING)
40+
private EmailSendStatus status;
41+
42+
private int retryCount;
43+
44+
@Nullable
45+
@Column(name = "message_tracking_id")
46+
private String messageTrackingId;
47+
48+
@Nullable
49+
private LocalDateTime sentAt;
50+
51+
@Builder
52+
public EmailSendHistory(FormApplication formApplication, EmailSendStatus status,
53+
int retryCount, LocalDateTime sentAt) {
54+
this.formApplication = formApplication;
55+
this.status = status;
56+
this.retryCount = retryCount;
57+
this.sentAt = sentAt;
58+
}
59+
60+
public EmailSendHistory(FormApplication formApplication, EmailSendStatus status) {
61+
this(formApplication, status, 0, null);
62+
}
63+
64+
public static EmailSendHistory createPending(FormApplication formApplication) {
65+
return new EmailSendHistory(formApplication, PENDING);
66+
}
67+
68+
public void trySend() {
69+
this.sentAt = LocalDateTime.now();
70+
if (this.status == SENDING) {
71+
retryCount++;
72+
return;
73+
}
74+
this.status = SENDING;
75+
}
76+
77+
public void markRetryFail() {
78+
this.status = TEMPORARY_FAILURE;
79+
}
80+
81+
public void markNonRetryFail() {
82+
this.status = PERMANENT_FAILURE;
83+
}
84+
85+
public void updateMessageTrackingId(String messageId) {
86+
this.messageTrackingId = messageId;
87+
}
88+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package ddingdong.ddingdongBE.domain.formapplication.entity;
2+
3+
public enum EmailSendStatus {
4+
5+
PENDING,
6+
SENDING,
7+
TEMPORARY_FAILURE,
8+
PERMANENT_FAILURE,
9+
DELIVERY_SUCCESS,
10+
BOUNCE_REJECT,
11+
COMPLAINT_REJECT,
12+
;
13+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package ddingdong.ddingdongBE.domain.formapplication.entity;
2+
3+
public interface FormApplicationEmailSender {
4+
5+
void sendResult(FormApplication formApplication, EmailContent emailContent, Long emailSendHistoryId);
6+
}

0 commit comments

Comments
 (0)