diff --git a/build.gradle b/build.gradle index 0e0afc5..e383495 100644 --- a/build.gradle +++ b/build.gradle @@ -43,8 +43,13 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.26.12' runtimeOnly 'org.postgresql:postgresql' + // 헬스 체크 implementation 'org.springframework.boot:spring-boot-starter-actuator' + // 재시도 + implementation 'org.springframework.retry:spring-retry:2.0.10' + + // 롬복 compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/io/wisoft/prepair/prepair_api/PrepairApiApplication.java b/src/main/java/io/wisoft/prepair/prepair_api/PrepairApiApplication.java index e131e0d..9502b3d 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/PrepairApiApplication.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/PrepairApiApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; +@EnableRetry @EnableJpaAuditing @EnableScheduling @SpringBootApplication diff --git a/src/main/java/io/wisoft/prepair/prepair_api/external/notification/email/EmailService.java b/src/main/java/io/wisoft/prepair/prepair_api/external/notification/email/EmailService.java index 1c078ff..ce43567 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/external/notification/email/EmailService.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/external/notification/email/EmailService.java @@ -1,17 +1,15 @@ package io.wisoft.prepair.prepair_api.external.notification.email; -import io.wisoft.prepair.prepair_api.common.exception.BusinessException; -import io.wisoft.prepair.prepair_api.common.exception.ErrorCode; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.mail.MailException; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; -@Slf4j @Service @RequiredArgsConstructor public class EmailService { @@ -19,23 +17,24 @@ public class EmailService { private final EmailTemplateBuilder emailTemplateBuilder; private final JavaMailSender mailSender; - public void sendInterviewQuestion(String email, String nickname, String questionTag, String question) { + @Retryable( + value = {MessagingException.class, MailException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2) + ) + public void send(String email, String nickname, String questionTag, String question) throws MessagingException { String html = emailTemplateBuilder.buildInterviewQuestionHtml(nickname, questionTag, question); - send(email, "[PrePair] 오늘의 면접 질문이 도착했어요!", html); + sendMail(email, "[PrePair] 오늘의 면접 질문이 도착했어요!", html); } - private void send(String to, String subject, String html) { - try { - MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); + private void sendMail(String to, String subject, String html) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); - helper.setTo(to); - helper.setSubject(subject); - helper.setText(html, true); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(html, true); - mailSender.send(message); - } catch (MessagingException | MailException e) { - throw new BusinessException(ErrorCode.EMAIL_SEND_FAILED); - } + mailSender.send(message); } -} +} \ No newline at end of file diff --git a/src/main/java/io/wisoft/prepair/prepair_api/external/notification/kakao/KakaoService.java b/src/main/java/io/wisoft/prepair/prepair_api/external/notification/kakao/KakaoService.java index f882536..8ff740b 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/external/notification/kakao/KakaoService.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/external/notification/kakao/KakaoService.java @@ -1,15 +1,16 @@ package io.wisoft.prepair.prepair_api.external.notification.kakao; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; -@Slf4j @Service @RequiredArgsConstructor public class KakaoService { @@ -17,7 +18,12 @@ public class KakaoService { private final RestClient restClient; private static final String URL = "https://kapi.kakao.com/v2/api/talk/memo/default/send"; - public void sendInterviewQuestion(String kakaoAccessToken, String question, String questionTag) { + @Retryable( + value = {HttpServerErrorException.class, ResourceAccessException.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2) + ) + public void send(String kakaoAccessToken, String question, String questionTag) { String templateJson = """ { "object_type": "text", diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/QuestionNotificationService.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/QuestionNotificationService.java new file mode 100644 index 0000000..d7e852e --- /dev/null +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/QuestionNotificationService.java @@ -0,0 +1,66 @@ +package io.wisoft.prepair.prepair_api.interview.question.service; + +import io.wisoft.prepair.prepair_api.external.member.dto.MemberSchedulerInfo; +import io.wisoft.prepair.prepair_api.external.member.enums.Notification; +import io.wisoft.prepair.prepair_api.external.notification.email.EmailService; +import io.wisoft.prepair.prepair_api.external.notification.kakao.KakaoService; +import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class QuestionNotificationService { + + private final EmailService emailService; + private final KakaoService kakaoService; + + public void notifyMember(MemberSchedulerInfo member, InterviewQuestion question) { + Notification notification = member.notification(); + + if (notification == Notification.EMAIL || notification == Notification.BOTH) { + sendEmail(member, question); + } + + if (notification == Notification.KAKAO || notification == Notification.BOTH) { + sendKakao(member, question); + } + } + + private void sendEmail(MemberSchedulerInfo member, InterviewQuestion question) { + try { + emailService.send( + member.email(), + member.nickname(), + question.getQuestionTag(), + question.getQuestion() + ); + log.info("이메일 발송 완료 | memberId={}", member.id()); + } catch (Exception e) { + log.error("이메일 발송 실패 | memberId={}", member.id(), e); + } + } + + private void sendKakao(MemberSchedulerInfo member, InterviewQuestion question) { + if (member.kakaoAccessToken() == null || member.kakaoAccessToken().isBlank()) { + log.warn("멤버 스킵 | memberId={} | reason=no_kakao_token", member.id()); + return; + } + + try { + kakaoService.send( + member.kakaoAccessToken(), + question.getQuestion(), + question.getQuestionTag() + ); + log.info("카카오 발송 완료 | memberId={}", member.id()); + } catch (HttpClientErrorException.Unauthorized e) { + log.warn("카카오 발송 실패 | memberId={} | reason=token_expired", member.id()); + } catch (Exception e) { + log.error("카카오 발송 실패 | memberId={}", member.id(), e); + } + } +} diff --git a/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/DailyQuestionGenerationService.java b/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/TodayQuestionService.java similarity index 53% rename from src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/DailyQuestionGenerationService.java rename to src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/TodayQuestionService.java index 1c918ac..5a15e91 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/DailyQuestionGenerationService.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/interview/question/service/TodayQuestionService.java @@ -1,19 +1,15 @@ package io.wisoft.prepair.prepair_api.interview.question.service; -import io.wisoft.prepair.prepair_api.external.member.enums.Notification; import io.wisoft.prepair.prepair_api.external.member.MemberServiceClient; import io.wisoft.prepair.prepair_api.external.member.dto.MemberSchedulerInfo; import io.wisoft.prepair.prepair_api.external.openai.OpenAiClient; import io.wisoft.prepair.prepair_api.external.openai.dto.QuestionWithTags; -import io.wisoft.prepair.prepair_api.external.notification.email.EmailService; -import io.wisoft.prepair.prepair_api.external.notification.kakao.KakaoService; import io.wisoft.prepair.prepair_api.interview.prompt.PromptBuilder; import io.wisoft.prepair.prepair_api.interview.question.entity.InterviewQuestion; import io.wisoft.prepair.prepair_api.interview.question.repository.QuestionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpClientErrorException; import java.time.DayOfWeek; import java.time.LocalDate; @@ -23,17 +19,16 @@ @Slf4j @Service @RequiredArgsConstructor -public class DailyQuestionGenerationService { +public class TodayQuestionService { + private final QuestionPersistenceService questionPersistenceService; + private final QuestionNotificationService questionNotificationService; private final QuestionRepository questionRepository; - private final QuestionPersistenceService interviewQuestionService; private final MemberServiceClient memberServiceClient; private final OpenAiClient openAiClient; private final PromptBuilder promptBuilder; - private final EmailService emailService; - private final KakaoService kakaoService; - public void generateTodayQuestions() { + public void sendTodayQuestions() { List members = memberServiceClient.getMembers(); DayOfWeek today = LocalDate.now(ZoneId.of("Asia/Seoul")).getDayOfWeek(); @@ -45,77 +40,36 @@ public void generateTodayQuestions() { .toList(); log.info("질문 생성 대상: {} / {} 명", targetMembers.size(), members.size()); - targetMembers.forEach(this::processTodayQuestion); + targetMembers.forEach(this::generateAndSend); } - private void processTodayQuestion(MemberSchedulerInfo member) { + private void generateAndSend(MemberSchedulerInfo member) { try { List previousQuestions = questionRepository.findByMemberId(member.id()); String prompt = promptBuilder.buildDailyQuestionPrompt(member.job(), previousQuestions); QuestionWithTags result = openAiClient.generateQuestion(prompt); log.info("질문 생성 완료 | memberId={}", member.id()); - InterviewQuestion question = interviewQuestionService.saveTodayQuestion(member.id(), result); + InterviewQuestion question = questionPersistenceService.saveTodayQuestion(member.id(), result); log.info("질문 저장 완료 | memberId={}", member.id()); - notifyMember(member, question); + questionNotificationService.notifyMember(member, question); } catch (Exception e) { log.error("질문 처리 실패 | memberId={}", member.id(), e); } } - private void notifyMember(MemberSchedulerInfo member, InterviewQuestion question) { - Notification notification = member.notification(); - - if (notification == Notification.EMAIL || notification == Notification.BOTH) { - sendEmail(member, question); - } - - if (notification == Notification.KAKAO || notification == Notification.BOTH) { - sendKakao(member, question); - } - } - - private void sendEmail(MemberSchedulerInfo member, InterviewQuestion question) { - try { - emailService.sendInterviewQuestion( - member.email(), - member.nickname(), - question.getQuestionTag(), - question.getQuestion() - ); - log.info("이메일 발송 완료 | memberId={}", member.id()); - } catch (Exception e) { - log.error("이메일 발송 실패 | memberId={}", member.id(), e); - } - } - - private void sendKakao(MemberSchedulerInfo member, InterviewQuestion question) { - if (!isValidKakaoToken(member)) return; - try { - kakaoService.sendInterviewQuestion( - member.kakaoAccessToken(), - question.getQuestion(), - question.getQuestionTag() - ); - log.info("카카오 발송 완료 | memberId={}", member.id()); - } catch (HttpClientErrorException.Unauthorized e) { - log.warn("카카오 발송 실패 | memberId={} | reason=token_expired", member.id()); - } catch (Exception e) { - log.error("카카오 발송 실패 | memberId={}", member.id(), e); + private boolean isValidFrequency(MemberSchedulerInfo member) { + if (member.frequency() == null) { + log.warn("멤버 스킵 | memberId={} | reason=no_frequency", member.id()); + return false; } + return true; } - private boolean shouldSendToday(MemberSchedulerInfo member, DayOfWeek today) { - return switch (member.frequency()) { - case EVERY -> true; - case WEEKLY -> today == DayOfWeek.MONDAY; - }; - } - - private boolean isValidKakaoToken(MemberSchedulerInfo member) { - if (member.kakaoAccessToken() == null || member.kakaoAccessToken().isBlank()) { - log.warn("멤버 스킵 | memberId={} | reason=no_kakao_token", member.id()); + private boolean isValidJob(MemberSchedulerInfo member) { + if (member.job() == null || member.job().isBlank()) { + log.warn("멤버 스킵 | memberId={} | reason=no_job", member.id()); return false; } return true; @@ -129,20 +83,10 @@ private boolean isValidNotification(MemberSchedulerInfo member) { return true; } - private boolean isValidFrequency(MemberSchedulerInfo member) { - if (member.frequency() == null) { - log.warn("멤버 스킵 | memberId={} | reason=no_frequency", member.id()); - return false; - } - return true; - } - - private boolean isValidJob(MemberSchedulerInfo member) { - if (member.job() == null || member.job().isBlank()) { - log.warn("멤버 스킵 | memberId={} | reason=no_job", member.id()); - return false; - } - return true; + private boolean shouldSendToday(MemberSchedulerInfo member, DayOfWeek today) { + return switch (member.frequency()) { + case EVERY -> true; + case WEEKLY -> today == DayOfWeek.MONDAY; + }; } - } diff --git a/src/main/java/io/wisoft/prepair/prepair_api/scheduler/TodayQuestionScheduler.java b/src/main/java/io/wisoft/prepair/prepair_api/scheduler/TodayQuestionScheduler.java index c28bc45..f38c6fc 100644 --- a/src/main/java/io/wisoft/prepair/prepair_api/scheduler/TodayQuestionScheduler.java +++ b/src/main/java/io/wisoft/prepair/prepair_api/scheduler/TodayQuestionScheduler.java @@ -1,13 +1,12 @@ package io.wisoft.prepair.prepair_api.scheduler; -import io.wisoft.prepair.prepair_api.interview.question.service.DailyQuestionGenerationService; +import io.wisoft.prepair.prepair_api.interview.question.service.TodayQuestionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; import java.util.UUID; @Slf4j @@ -15,17 +14,22 @@ @RequiredArgsConstructor public class TodayQuestionScheduler { - private final DailyQuestionGenerationService dailyQuestionGenerationService; + private final TodayQuestionService todayQuestionService; - @Scheduled(cron = "0 0 9 * * *") + @Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul") public void generateTodayQuestions() { - String correlationId = "SCHEDULER-" + UUID.randomUUID().toString(); + String correlationId = "SCHEDULER-" + UUID.randomUUID(); MDC.put("correlationId", correlationId); + long startTime = System.currentTimeMillis(); + try { - log.info("오늘의 질문 생성 스케줄러 시작 - {}", LocalDateTime.now()); - dailyQuestionGenerationService.generateTodayQuestions(); - log.info("오늘의 질문 생성 스케줄러 종료"); - } finally { + log.info("오늘의 질문 생성 스케줄러 시작"); + todayQuestionService.sendTodayQuestions(); + log.info("오늘의 질문 생성 스케줄러 종료 | elapsed={}ms", System.currentTimeMillis() - startTime); + } catch(Exception e) { + log.error("오늘의 질문 생성 스케줄러 실패 | elapsed={}ms", System.currentTimeMillis() - startTime, e); + } + finally { MDC.remove("correlationId"); } }