Skip to content

Commit 8dd4103

Browse files
authored
refactor: 스케줄러 책임 분리 및 알림 발송 재시도 로직 추가 (#77)
1 parent 61f07b8 commit 8dd4103

7 files changed

Lines changed: 134 additions & 108 deletions

File tree

build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,13 @@ dependencies {
4343
implementation 'software.amazon.awssdk:s3:2.26.12'
4444
runtimeOnly 'org.postgresql:postgresql'
4545

46+
// 헬스 체크
4647
implementation 'org.springframework.boot:spring-boot-starter-actuator'
4748

49+
// 재시도
50+
implementation 'org.springframework.retry:spring-retry:2.0.10'
51+
52+
// 롬복
4853
compileOnly 'org.projectlombok:lombok'
4954
annotationProcessor 'org.projectlombok:lombok'
5055

src/main/java/io/wisoft/prepair/prepair_api/PrepairApiApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.retry.annotation.EnableRetry;
67
import org.springframework.scheduling.annotation.EnableScheduling;
78

9+
@EnableRetry
810
@EnableJpaAuditing
911
@EnableScheduling
1012
@SpringBootApplication
Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,40 @@
11
package io.wisoft.prepair.prepair_api.external.notification.email;
22

3-
import io.wisoft.prepair.prepair_api.common.exception.BusinessException;
4-
import io.wisoft.prepair.prepair_api.common.exception.ErrorCode;
53
import jakarta.mail.MessagingException;
64
import jakarta.mail.internet.MimeMessage;
75
import lombok.RequiredArgsConstructor;
8-
import lombok.extern.slf4j.Slf4j;
96
import org.springframework.mail.MailException;
107
import org.springframework.mail.javamail.JavaMailSender;
118
import org.springframework.mail.javamail.MimeMessageHelper;
9+
import org.springframework.retry.annotation.Backoff;
10+
import org.springframework.retry.annotation.Retryable;
1211
import org.springframework.stereotype.Service;
1312

14-
@Slf4j
1513
@Service
1614
@RequiredArgsConstructor
1715
public class EmailService {
1816

1917
private final EmailTemplateBuilder emailTemplateBuilder;
2018
private final JavaMailSender mailSender;
2119

22-
public void sendInterviewQuestion(String email, String nickname, String questionTag, String question) {
20+
@Retryable(
21+
value = {MessagingException.class, MailException.class},
22+
maxAttempts = 3,
23+
backoff = @Backoff(delay = 1000, multiplier = 2)
24+
)
25+
public void send(String email, String nickname, String questionTag, String question) throws MessagingException {
2326
String html = emailTemplateBuilder.buildInterviewQuestionHtml(nickname, questionTag, question);
24-
send(email, "[PrePair] 오늘의 면접 질문이 도착했어요!", html);
27+
sendMail(email, "[PrePair] 오늘의 면접 질문이 도착했어요!", html);
2528
}
2629

27-
private void send(String to, String subject, String html) {
28-
try {
29-
MimeMessage message = mailSender.createMimeMessage();
30-
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
30+
private void sendMail(String to, String subject, String html) throws MessagingException {
31+
MimeMessage message = mailSender.createMimeMessage();
32+
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
3133

32-
helper.setTo(to);
33-
helper.setSubject(subject);
34-
helper.setText(html, true);
34+
helper.setTo(to);
35+
helper.setSubject(subject);
36+
helper.setText(html, true);
3537

36-
mailSender.send(message);
37-
} catch (MessagingException | MailException e) {
38-
throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED);
39-
}
38+
mailSender.send(message);
4039
}
41-
}
40+
}

src/main/java/io/wisoft/prepair/prepair_api/external/notification/kakao/KakaoService.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
package io.wisoft.prepair.prepair_api.external.notification.kakao;
22

33
import lombok.RequiredArgsConstructor;
4-
import lombok.extern.slf4j.Slf4j;
54
import org.springframework.http.MediaType;
5+
import org.springframework.retry.annotation.Backoff;
6+
import org.springframework.retry.annotation.Retryable;
67
import org.springframework.stereotype.Service;
78
import org.springframework.util.LinkedMultiValueMap;
89
import org.springframework.util.MultiValueMap;
9-
import org.springframework.web.client.HttpClientErrorException;
10+
import org.springframework.web.client.HttpServerErrorException;
11+
import org.springframework.web.client.ResourceAccessException;
1012
import org.springframework.web.client.RestClient;
1113

12-
@Slf4j
1314
@Service
1415
@RequiredArgsConstructor
1516
public class KakaoService {
1617

1718
private final RestClient restClient;
1819
private static final String URL = "https://kapi.kakao.com/v2/api/talk/memo/default/send";
1920

20-
public void sendInterviewQuestion(String kakaoAccessToken, String question, String questionTag) {
21+
@Retryable(
22+
value = {HttpServerErrorException.class, ResourceAccessException.class},
23+
maxAttempts = 3,
24+
backoff = @Backoff(delay = 1000, multiplier = 2)
25+
)
26+
public void send(String kakaoAccessToken, String question, String questionTag) {
2127
String templateJson = """
2228
{
2329
"object_type": "text",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.wisoft.prepair.prepair_api.interview.question.service;
2+
3+
import io.wisoft.prepair.prepair_api.external.member.dto.MemberSchedulerInfo;
4+
import io.wisoft.prepair.prepair_api.external.member.enums.Notification;
5+
import io.wisoft.prepair.prepair_api.external.notification.email.EmailService;
6+
import io.wisoft.prepair.prepair_api.external.notification.kakao.KakaoService;
7+
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.web.client.HttpClientErrorException;
12+
13+
@Slf4j
14+
@Service
15+
@RequiredArgsConstructor
16+
public class QuestionNotificationService {
17+
18+
private final EmailService emailService;
19+
private final KakaoService kakaoService;
20+
21+
public void notifyMember(MemberSchedulerInfo member, InterviewQuestion question) {
22+
Notification notification = member.notification();
23+
24+
if (notification == Notification.EMAIL || notification == Notification.BOTH) {
25+
sendEmail(member, question);
26+
}
27+
28+
if (notification == Notification.KAKAO || notification == Notification.BOTH) {
29+
sendKakao(member, question);
30+
}
31+
}
32+
33+
private void sendEmail(MemberSchedulerInfo member, InterviewQuestion question) {
34+
try {
35+
emailService.send(
36+
member.email(),
37+
member.nickname(),
38+
question.getQuestionTag(),
39+
question.getQuestion()
40+
);
41+
log.info("이메일 발송 완료 | memberId={}", member.id());
42+
} catch (Exception e) {
43+
log.error("이메일 발송 실패 | memberId={}", member.id(), e);
44+
}
45+
}
46+
47+
private void sendKakao(MemberSchedulerInfo member, InterviewQuestion question) {
48+
if (member.kakaoAccessToken() == null || member.kakaoAccessToken().isBlank()) {
49+
log.warn("멤버 스킵 | memberId={} | reason=no_kakao_token", member.id());
50+
return;
51+
}
52+
53+
try {
54+
kakaoService.send(
55+
member.kakaoAccessToken(),
56+
question.getQuestion(),
57+
question.getQuestionTag()
58+
);
59+
log.info("카카오 발송 완료 | memberId={}", member.id());
60+
} catch (HttpClientErrorException.Unauthorized e) {
61+
log.warn("카카오 발송 실패 | memberId={} | reason=token_expired", member.id());
62+
} catch (Exception e) {
63+
log.error("카카오 발송 실패 | memberId={}", member.id(), e);
64+
}
65+
}
66+
}

src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/DailyQuestionGenerationService.java renamed to src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/TodayQuestionService.java

Lines changed: 21 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
package io.wisoft.prepair.prepair_api.interview.question.service;
22

3-
import io.wisoft.prepair.prepair_api.external.member.enums.Notification;
43
import io.wisoft.prepair.prepair_api.external.member.MemberServiceClient;
54
import io.wisoft.prepair.prepair_api.external.member.dto.MemberSchedulerInfo;
65
import io.wisoft.prepair.prepair_api.external.openai.OpenAiClient;
76
import io.wisoft.prepair.prepair_api.external.openai.dto.QuestionWithTags;
8-
import io.wisoft.prepair.prepair_api.external.notification.email.EmailService;
9-
import io.wisoft.prepair.prepair_api.external.notification.kakao.KakaoService;
107
import io.wisoft.prepair.prepair_api.interview.prompt.PromptBuilder;
118
import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion;
129
import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository;
1310
import lombok.RequiredArgsConstructor;
1411
import lombok.extern.slf4j.Slf4j;
1512
import org.springframework.stereotype.Service;
16-
import org.springframework.web.client.HttpClientErrorException;
1713

1814
import java.time.DayOfWeek;
1915
import java.time.LocalDate;
@@ -23,17 +19,16 @@
2319
@Slf4j
2420
@Service
2521
@RequiredArgsConstructor
26-
public class DailyQuestionGenerationService {
22+
public class TodayQuestionService {
2723

24+
private final QuestionPersistenceService questionPersistenceService;
25+
private final QuestionNotificationService questionNotificationService;
2826
private final QuestionRepository questionRepository;
29-
private final QuestionPersistenceService interviewQuestionService;
3027
private final MemberServiceClient memberServiceClient;
3128
private final OpenAiClient openAiClient;
3229
private final PromptBuilder promptBuilder;
33-
private final EmailService emailService;
34-
private final KakaoService kakaoService;
3530

36-
public void generateTodayQuestions() {
31+
public void sendTodayQuestions() {
3732
List<MemberSchedulerInfo> members = memberServiceClient.getMembers();
3833
DayOfWeek today = LocalDate.now(ZoneId.of("Asia/Seoul")).getDayOfWeek();
3934

@@ -45,77 +40,36 @@ public void generateTodayQuestions() {
4540
.toList();
4641

4742
log.info("질문 생성 대상: {} / {} 명", targetMembers.size(), members.size());
48-
targetMembers.forEach(this::processTodayQuestion);
43+
targetMembers.forEach(this::generateAndSend);
4944
}
5045

51-
private void processTodayQuestion(MemberSchedulerInfo member) {
46+
private void generateAndSend(MemberSchedulerInfo member) {
5247
try {
5348
List<InterviewQuestion> previousQuestions = questionRepository.findByMemberId(member.id());
5449
String prompt = promptBuilder.buildDailyQuestionPrompt(member.job(), previousQuestions);
5550
QuestionWithTags result = openAiClient.generateQuestion(prompt);
5651
log.info("질문 생성 완료 | memberId={}", member.id());
5752

58-
InterviewQuestion question = interviewQuestionService.saveTodayQuestion(member.id(), result);
53+
InterviewQuestion question = questionPersistenceService.saveTodayQuestion(member.id(), result);
5954
log.info("질문 저장 완료 | memberId={}", member.id());
6055

61-
notifyMember(member, question);
56+
questionNotificationService.notifyMember(member, question);
6257
} catch (Exception e) {
6358
log.error("질문 처리 실패 | memberId={}", member.id(), e);
6459
}
6560
}
6661

67-
private void notifyMember(MemberSchedulerInfo member, InterviewQuestion question) {
68-
Notification notification = member.notification();
69-
70-
if (notification == Notification.EMAIL || notification == Notification.BOTH) {
71-
sendEmail(member, question);
72-
}
73-
74-
if (notification == Notification.KAKAO || notification == Notification.BOTH) {
75-
sendKakao(member, question);
76-
}
77-
}
78-
79-
private void sendEmail(MemberSchedulerInfo member, InterviewQuestion question) {
80-
try {
81-
emailService.sendInterviewQuestion(
82-
member.email(),
83-
member.nickname(),
84-
question.getQuestionTag(),
85-
question.getQuestion()
86-
);
87-
log.info("이메일 발송 완료 | memberId={}", member.id());
88-
} catch (Exception e) {
89-
log.error("이메일 발송 실패 | memberId={}", member.id(), e);
90-
}
91-
}
92-
93-
private void sendKakao(MemberSchedulerInfo member, InterviewQuestion question) {
94-
if (!isValidKakaoToken(member)) return;
95-
try {
96-
kakaoService.sendInterviewQuestion(
97-
member.kakaoAccessToken(),
98-
question.getQuestion(),
99-
question.getQuestionTag()
100-
);
101-
log.info("카카오 발송 완료 | memberId={}", member.id());
102-
} catch (HttpClientErrorException.Unauthorized e) {
103-
log.warn("카카오 발송 실패 | memberId={} | reason=token_expired", member.id());
104-
} catch (Exception e) {
105-
log.error("카카오 발송 실패 | memberId={}", member.id(), e);
62+
private boolean isValidFrequency(MemberSchedulerInfo member) {
63+
if (member.frequency() == null) {
64+
log.warn("멤버 스킵 | memberId={} | reason=no_frequency", member.id());
65+
return false;
10666
}
67+
return true;
10768
}
10869

109-
private boolean shouldSendToday(MemberSchedulerInfo member, DayOfWeek today) {
110-
return switch (member.frequency()) {
111-
case EVERY -> true;
112-
case WEEKLY -> today == DayOfWeek.MONDAY;
113-
};
114-
}
115-
116-
private boolean isValidKakaoToken(MemberSchedulerInfo member) {
117-
if (member.kakaoAccessToken() == null || member.kakaoAccessToken().isBlank()) {
118-
log.warn("멤버 스킵 | memberId={} | reason=no_kakao_token", member.id());
70+
private boolean isValidJob(MemberSchedulerInfo member) {
71+
if (member.job() == null || member.job().isBlank()) {
72+
log.warn("멤버 스킵 | memberId={} | reason=no_job", member.id());
11973
return false;
12074
}
12175
return true;
@@ -129,20 +83,10 @@ private boolean isValidNotification(MemberSchedulerInfo member) {
12983
return true;
13084
}
13185

132-
private boolean isValidFrequency(MemberSchedulerInfo member) {
133-
if (member.frequency() == null) {
134-
log.warn("멤버 스킵 | memberId={} | reason=no_frequency", member.id());
135-
return false;
136-
}
137-
return true;
138-
}
139-
140-
private boolean isValidJob(MemberSchedulerInfo member) {
141-
if (member.job() == null || member.job().isBlank()) {
142-
log.warn("멤버 스킵 | memberId={} | reason=no_job", member.id());
143-
return false;
144-
}
145-
return true;
86+
private boolean shouldSendToday(MemberSchedulerInfo member, DayOfWeek today) {
87+
return switch (member.frequency()) {
88+
case EVERY -> true;
89+
case WEEKLY -> today == DayOfWeek.MONDAY;
90+
};
14691
}
147-
14892
}

src/main/java/io/wisoft/prepair/prepair_api/scheduler/TodayQuestionScheduler.java

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,35 @@
11
package io.wisoft.prepair.prepair_api.scheduler;
22

3-
import io.wisoft.prepair.prepair_api.interview.question.service.DailyQuestionGenerationService;
3+
import io.wisoft.prepair.prepair_api.interview.question.service.TodayQuestionService;
44
import lombok.RequiredArgsConstructor;
55
import lombok.extern.slf4j.Slf4j;
66
import org.slf4j.MDC;
77
import org.springframework.scheduling.annotation.Scheduled;
88
import org.springframework.stereotype.Component;
99

10-
import java.time.LocalDateTime;
1110
import java.util.UUID;
1211

1312
@Slf4j
1413
@Component
1514
@RequiredArgsConstructor
1615
public class TodayQuestionScheduler {
1716

18-
private final DailyQuestionGenerationService dailyQuestionGenerationService;
17+
private final TodayQuestionService todayQuestionService;
1918

20-
@Scheduled(cron = "0 0 9 * * *")
19+
@Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul")
2120
public void generateTodayQuestions() {
22-
String correlationId = "SCHEDULER-" + UUID.randomUUID().toString();
21+
String correlationId = "SCHEDULER-" + UUID.randomUUID();
2322
MDC.put("correlationId", correlationId);
23+
long startTime = System.currentTimeMillis();
24+
2425
try {
25-
log.info("오늘의 질문 생성 스케줄러 시작 - {}", LocalDateTime.now());
26-
dailyQuestionGenerationService.generateTodayQuestions();
27-
log.info("오늘의 질문 생성 스케줄러 종료");
28-
} finally {
26+
log.info("오늘의 질문 생성 스케줄러 시작");
27+
todayQuestionService.sendTodayQuestions();
28+
log.info("오늘의 질문 생성 스케줄러 종료 | elapsed={}ms", System.currentTimeMillis() - startTime);
29+
} catch(Exception e) {
30+
log.error("오늘의 질문 생성 스케줄러 실패 | elapsed={}ms", System.currentTimeMillis() - startTime, e);
31+
}
32+
finally {
2933
MDC.remove("correlationId");
3034
}
3135
}

0 commit comments

Comments
 (0)