코드의 개선 사항이 필요해보인다면 언제든 댓글로 지적해주세요 :)
기능 구현 요구 사항
- 사용자는 로또 예상 번호를 입력할 수 있다.
- 이미 당첨 번호가 공개된 로또 회차는 입력 불가하다.
- 매주 토요일 저녁 예상 번호와 당첨 번호를 비교한다.
- 이메일 알림을 허락한 사용자에 한해서 이메일 발송을 진행한다.
- 유저 테이블에 저장된 이메일 정보는 이미 검증이 완료 된 상태라고 가정한다.
- 이메일에는 당첨 여부, 일치하는 개수 등의 정보를 기재한다.
Entity와 Dto, Repository 코드는 더 이상 보여줄 필요가 없을 것이라 생각되어서 사용한 주요 Spring 기능 소개와 적용한 로직의 일부만을 포스팅할 예정이다.
JavaMailSender
스프링 프레임워크에는 이메일을 쉽게 보낼 수 있는 모듈인 Spring Boot Mail이 존재한다. 해당 라이브러리중에서 주로 사용되는 클래스와 인터페이스들은 다음과 같다.
MailMessage | 이메일 메시지의 기본 인터페이스로, 메시지의 기본 속성을 정의 |
SimpleMailMessage | 간단한 텍스트 이메일을 위한 구현체로, 기본적인 이메일 속성만 포함 |
MimeMessage | 복잡한 이메일(HTML, 첨부파일 등)을 위한 구현체로, 다양한 MIME 타입을 지원 |
MimeMessageHelper | MimeMessage 생성을 돕는 유틸리티 클래스로, 복잡한 이메일 작성을 간소화 해줌 |
MailSender | 이메일 전송을 위한 기본 인터페이스로, SimpleMailMessage 전송 메서드를 제공 |
JavaMailSender | MailSender를 확장한 인터페이스로, MimeMessage 지원 등 추가적인 기능 제공 |
우리는 HTML형식으로 이메일을 전송할 예정이어서 JavaMailSender를 사용하여 구현을 진행하였다. 코드 작성에 앞서 해야하는 설정들은 아래 블로그를 참고하면 된다.
Spring Boot | 메일 발송 기능 구현하기 (Gmail)
Spring Boot, Java, Gmail을 이용하여 메일 발송 기능 구현하기
velog.io
@Service
@RequiredArgsConstructor
public class MailLogService {
private final JavaMailSender mailSender;
private final MailLogRepository mailLogRepository;
public void sendEmail(MailLogEntity mailLogEntity) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(mailLogEntity.getRecipientEmail());
helper.setSubject(mailLogEntity.getTitle());
helper.setText(mailLogEntity.getContent(), true);
mailSender.send(message);
mailLogEntity.setSentSuccessfully(true);
} catch (Exception e) {
mailLogEntity.setSentSuccessfully(false);
mailLogEntity.setErrorMessage(e.getMessage());
} finally {
mailLogRepository.save(mailLogEntity);
}
}
public MailLogEntity preparePrizeNotificationEmail(UserLottoEntity userLotto, String finalNumber) {
String subject = "로또 당첨 결과 알림 - " + userLotto.getRound() + "회차";
String content = buildEmailContent(userLotto, finalNumber);
return MailLogEntity.create(userLotto.getAccount().getEmail(), subject, content, "LottoNotification");
}
private String buildEmailContent(UserLottoEntity userLotto, String finalNumber) {
return "<html>" +
"<body>" +
"<h1>로또 당첨 결과</h1>" +
"<p>안녕하세요, " + userLotto.getAccount().getUserName() + "님.</p>" +
"<p>예측하신 번호: " + userLotto.getPredictedNumbers() + "</p>" +
"<p>당첨 번호: " + finalNumber + "</p>" +
"<p>맞춘 개수: " + userLotto.getCorrectCount() + "개</p>" +
"<p>당첨 등수: " + userLotto.getCorrectCount() + "</p>" +
"<p>감사합니다. 다음 회차도 행운을 빕니다!</p>" +
"</body>" +
"</html>";
}
}
UserLottoService에서 사용자가 입력한 예측 번호와 실제 로또 번호를 비교한 후 결과를 저장해 preparePrizeNotificationEmail() 메서드를 호출하면서 메일 발송 로직이 시작된다.
해당 메서드는 발송할 메일의 제목과 본문을 준비하는 메서드이다. 리턴값을 보면 mailLogEntity를 생성해 반환하는 것을 볼 수 있는데 엔티티 생성은 필수가 아니니 각자의 상황에 맞게 작성하면 될것같다.
나는 추후 이메일 발송 실패/성공 여부를 파악하거나, 발송하는 이메일의 종류가 확장될 경우를 고려하여서 엔티티를 생성하여 진행했다.
로직은 최종적으로 sendEmail()을 호출하여서 메일을 전송하게 된다. MimeMessageHelper를 사용해서 메일 구조를 손쉽게 작성하였다.
위에서도 말했듯이 전송 여부를 확인하고자 했기에 그와 관련된 로직도 추가하였다.
@Scheduled
스프링부트에서는 @Scheduled 어노테이션을 통해 코드 실행 주기를 맞출 수 있다. 사용법도 굉장히 간단하다.
어플리케이션 코드 위에 @EnableScheduling 어노테이션을 추가해주고, 그 하위 패키지에서 @Scheduled 어노테이션을 붙여 사용하기만 하면 된다. 해당 어노테이션은 빈에 등록되어야 하는점을 유의해주자.
@EnableScheduling
public class LottoApplication {
public static void main(String[] args) {
SpringApplication.run(LottoApplication.class, args);
}
}
실행 주기는 다음과 같은 방식 중 하나를 택해서 하면된다. 나는 정해진 시간에 실행이 되게끔 하고자했기에 cron 표현식을 사용하였다.
- fixedRate: 작업 수행시간과 상관없이 일정 주기마다 메소드를 호출하는 것
- fixedDelay는 (작업 수행 시간을 포함하여) 작업을 마친 후부터 주기 타이머가 돌아 메소드를 호출
- initialDelay: 스케줄러에서 메소드가 등록되자마자 수행하는 것이 아닌 초기 지연시간을 설정
- cron: Cron 표현식을 사용하여 작업을 예약
현재 UserLottoService 계층에는 사용자의 로또 예측 번호와 실제 로또 번호를 비교하는 로직이 작성되어있다.
그 중 로직의 시작이 되는 메서드가 아래와 같다.
@Scheduled(cron = "0 0 22 * * SAT", zone = "Asia/Seoul")
@Transactional
public void checkWinningsAndNotify() {
LottoDto lottoInfo = lottoService.getLatestLotto();
Integer latestRound = lottoInfo.getRound();
LottoResult lottoResult = parseLottoNumbers(lottoInfo);
List<UserLottoEntity> userLottos = userLottoRepository.findAllByRound(latestRound);
processUserLottos(userLottos, lottoResult);
notifyUsers(userLottos,lottoInfo);
}
parseLottoNumber는 string 형식으로 저장된 로또 번호를 실제 리스트로 변환해주는 역할을 한다.(메인 번호와 보너스 번호 분리도 함)
processUserLottos는 유저가 저장한 예측 번호와 실제 로또 번호를 비교하고, 그 결과를 저장하는 역할을 한다.
notifyUsers는 userLotto 엔티티에 저장된 정보와 최신 회차 로또 정보를 파라미터로 하여 preparePrizeNotificationEmail()를 호출한다. 호출은 알림 여부를 true로 한 사용자에 한해서만 진행된다.
이 과정을 매주 토요일 저녁 로또 정보가 엘라스틱 서치에 저장되고 난 후 자동적으로 진행되게 하기 위해 @Scheduled 어노테이션을 사용하였다.
Full Service Code
@Service
@RequiredArgsConstructor
public class UserLottoService {
private final UserLottoRepository userLottoRepository;
private final MailLogService mailLogService;
private final LottoService lottoService;
private final CurrentUserService currentUserService;
public UserLottoResponseDto saveUserLotto(UserLottoRequestDto userLottoRequestDto) {
AccountEntity account = currentUserService.getCurrentUser();
Integer latestRound = lottoService.getLatestLotto().getRound();
if (userLottoRequestDto.getRound() < latestRound) {
throw new AppException(ExceptionCode.INVALID_LOTTO_ROUND);}
if (!isValidPredictedNumbers(userLottoRequestDto.getPredictedNumbers())) {
throw new AppException(ExceptionCode.INVALID_PREDICTED_NUMBERS);}
UserLottoEntity entity = UserLottoRequestDto.toEntity(userLottoRequestDto, account);
UserLottoEntity savedEntity = userLottoRepository.save(entity);
return UserLottoResponseDto.fromEntity(savedEntity);
}
private boolean isValidPredictedNumbers(String predictedNumbers) {
try {
List<Integer> numbers = Arrays.stream(predictedNumbers.split(","))
.map(String::trim)
.map(Integer::parseInt)
.toList();
return numbers.size() == 6 && numbers.stream().allMatch(n -> n >= 1 && n <= 45);
} catch (Exception e) {
return false;
}
}
public List<UserLottoResponseDto> getUserLottoByAccount() {
AccountEntity account = currentUserService.getCurrentUser();
List<UserLottoEntity> userLottoList = userLottoRepository.findAllByAccount(account);
return userLottoList.stream().map(UserLottoResponseDto::fromEntity).toList();
}
public Optional<UserLottoResponseDto> getUserLottoByRound(Integer round) {
AccountEntity account = currentUserService.getCurrentUser();
return userLottoRepository.findByAccountAndRound(account, round)
.map(UserLottoResponseDto::fromEntity)
.or(() -> {
throw new AppException(ExceptionCode.NON_EXISTENT_LOTTO);
});
}
public void removeUserLotto(Long userLottoOid) {
UserLottoEntity existingEntity = userLottoRepository.findByUserLottoOid(userLottoOid)
.orElseThrow(() -> new AppException(ExceptionCode.NON_EXISTENT_LOTTO));
userLottoRepository.delete(existingEntity);
}
public UserLottoResponseDto updateUserLotto(Long userLottoOid, String predictedNumbers) {
UserLottoEntity existingEntity = userLottoRepository.findByUserLottoOid(userLottoOid)
.orElseThrow(() -> new AppException(ExceptionCode.NON_EXISTENT_LOTTO));
if (!isValidPredictedNumbers(predictedNumbers)) {
throw new AppException(ExceptionCode.INVALID_PREDICTED_NUMBERS);}
UserLottoEntity updatedEntity = userLottoRepository.save(existingEntity);
return UserLottoResponseDto.fromEntity(updatedEntity);
}
@Scheduled(cron = "0 0 22 * * SAT", zone = "Asia/Seoul")
@Transactional
public void checkWinningsAndNotify() {
LottoDto lottoInfo = lottoService.getLatestLotto();
Integer latestRound = lottoInfo.getRound();
LottoResult lottoResult = parseLottoNumbers(lottoInfo);
List<UserLottoEntity> userLottos = userLottoRepository.findAllByRound(latestRound);
processUserLottos(userLottos, lottoResult);
notifyUsers(userLottos,lottoInfo);
}
private LottoResult parseLottoNumbers(LottoDto lottoInfo) {
List<Integer> lottoNumbers = Arrays.stream(lottoInfo.getFinalNumbers().split(","))
.map(String::trim)
.map(Integer::parseInt)
.toList();
Integer bonusNumber = lottoNumbers.get(lottoNumbers.size() - 1);
List<Integer> mainNumbers = lottoNumbers.subList(0, lottoNumbers.size() - 1);
return new LottoResult(mainNumbers, bonusNumber);
}
private void processUserLottos(List<UserLottoEntity> userLottos, LottoResult lottoResult) {
for (UserLottoEntity userLotto : userLottos) {
List<Integer> predictedNumbers = Arrays.stream(userLotto.getPredictedNumbers().split(","))
.map(String::trim)
.map(Integer::parseInt)
.toList();
long matchCount = predictedNumbers.stream().filter(lottoResult.getMainNumbers()::contains).count();
boolean hasBonusNumber = predictedNumbers.contains(lottoResult.getBonusNumber());
String prizeRank = determinePrizeRank(matchCount, hasBonusNumber);
userLotto.setCorrectCount((int) matchCount);
userLotto.setPrizeRank(prizeRank);
userLottoRepository.save(userLotto);
}
}
private void notifyUsers(List<UserLottoEntity> userLottos, LottoDto lottoDto) {
for (UserLottoEntity userLotto : userLottos) {
if (Boolean.TRUE.equals(userLotto.getNotification())) {
MailLogEntity email = mailLogService.preparePrizeNotificationEmail(userLotto, lottoDto.getFinalNumbers());
mailLogService.sendEmail(email);
}
}
}
private String determinePrizeRank(long matchCount, boolean hasBonusNumber) {
if (matchCount == 6) {
return "1등";
} else if (matchCount == 5 && hasBonusNumber) {
return "2등";
} else if (matchCount == 5) {
return "3등";
} else if (matchCount == 4) {
return "4등";
}else {
return "낙첨";
}
}
}
@Service
@RequiredArgsConstructor
public class MailLogService {
private final JavaMailSender mailSender;
private final MailLogRepository mailLogRepository;
public void sendEmail(MailLogEntity mailLogEntity) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(mailLogEntity.getRecipientEmail());
helper.setSubject(mailLogEntity.getTitle());
helper.setText(mailLogEntity.getContent(), true);
mailSender.send(message);
mailLogEntity.setSentSuccessfully(true);
} catch (Exception e) {
mailLogEntity.setSentSuccessfully(false);
mailLogEntity.setErrorMessage(e.getMessage());
} finally {
mailLogRepository.save(mailLogEntity);
}
}
public MailLogEntity preparePrizeNotificationEmail(UserLottoEntity userLotto, String finalNumber) {
String subject = "로또 당첨 결과 알림 - " + userLotto.getRound() + "회차";
String content = buildEmailContent(userLotto, finalNumber);
return MailLogEntity.create(userLotto.getAccount().getEmail(), subject, content, "LottoNotification");
}
private String buildEmailContent(UserLottoEntity userLotto, String finalNumber) {
return "<html>" +
"<body>" +
"<h1>로또 당첨 결과</h1>" +
"<p>안녕하세요, " + userLotto.getAccount().getUserName() + "님.</p>" +
"<p>예측하신 번호: " + userLotto.getPredictedNumbers() + "</p>" +
"<p>당첨 번호: " + finalNumber + "</p>" +
"<p>맞춘 개수: " + userLotto.getCorrectCount() + "개</p>" +
"<p>당첨 등수: " + userLotto.getCorrectCount() + "</p>" +
"<p>감사합니다. 다음 회차도 행운을 빕니다!</p>" +
"</body>" +
"</html>";
}
}
'Dev Tool > Spring boot' 카테고리의 다른 글
[Spring] 리팩토링 - 객체 생성 방식 (0) | 2025.02.03 |
---|---|
[Spring] 장바구니 기능 구현 (0) | 2025.01.25 |
[Spring] 로또 API 단위 테스트 코드 작성 (0) | 2025.01.23 |
[Spring] 테스트 코드 기본 개념 (0) | 2025.01.22 |
[Spring] 로또 API 구현(2) & 예외 처리(@validate) (0) | 2025.01.22 |