[인용 및 참고 출처]
1. 구글 검색: Baeldung, "Spring Email", Guide to Spring Email, (2025.02.17)
2. 구글 검색: 티스토리, "구글 SMTP 설정", Gmail SMTP로 메일 발송 설정하기, (2025.02.17)
3. 구글 검색: velog, "구글 SMTP 설정", Gmail SMTP 설정으로 이메일 보내기, (2025.02.17)
package com.project.cheerha.domain.notice.dto;
import com.querydsl.core.annotations.QueryProjection;
public record UserKeywordDto(
Long userId,
Long keywordId,
String email
) {
@QueryProjection
public UserKeywordDto {
}
}
package com.project.cheerha.domain.notice.dto;
import com.querydsl.core.annotations.QueryProjection;
public record JobOpeningKeywordDto(
Long jobOpeningId,
Long keywordId,
String url
) {
@QueryProjection
public JobOpeningKeywordDto {
}
}
package com.project.cheerha.domain.notice.repository;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import java.util.List;
public interface NoticeCreationRepositoryQuery {
// (1) 모든 userId, email, keywordId 조회
// (2) UserKeywordDto로 반환
List<UserKeywordDto> findAllUserKeywords();
// (1) 모든 jobOpeningId, url, keywordId 조회
// (2) JobOpeningKeywordDto로 반환
List<JobOpeningKeywordDto> findAllJobOpeningKeywords();
}
package com.project.cheerha.domain.notice.repository;
import com.project.cheerha.domain.jobOpening.entity.QJobOpening;
import com.project.cheerha.domain.keyword.entity.QJobOpeningKeyword;
import com.project.cheerha.domain.keyword.entity.QUserKeyword;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.user.entity.QUser;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class NoticeCreationRepositoryQueryImpl implements NoticeCreationRepositoryQuery {
private final JPAQueryFactory queryFactory;
@Override
public List<UserKeywordDto> findAllUserKeywords() {
QUserKeyword userKeyword = QUserKeyword.userKeyword;
QUser user = QUser.user;
return queryFactory
.select(Projections.constructor(
UserKeywordDto.class,
userKeyword.user.id,
userKeyword.keyword.id,
user.email
)
)
.from(userKeyword)
.join(userKeyword.user, user)
.fetch();
}
public List<JobOpeningKeywordDto> findAllJobOpeningKeywords() {
QJobOpeningKeyword jobOpeningKeyword = QJobOpeningKeyword.jobOpeningKeyword;
QJobOpening jobOpening = QJobOpening.jobOpening;
return queryFactory
.select(Projections.constructor(
JobOpeningKeywordDto.class,
jobOpeningKeyword.jobOpening.id,
jobOpeningKeyword.keyword.id,
jobOpening.jobOpeningUrl
)
)
.from(jobOpeningKeyword)
.join(jobOpeningKeyword.jobOpening, jobOpening)
.fetch();
}
}
package com.project.cheerha.domain.notice.service;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.repository.NoticeCreationRepositoryQuery;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class NoticeCreationService {
private final NoticeCreationRepositoryQuery repositoryQuery;
public List<UserKeywordDto> findAllUserKeywords() {
return repositoryQuery.findAllUserKeywords();
}
public List<JobOpeningKeywordDto> findAllJobOpeningKeywords() {
return repositoryQuery.findAllJobOpeningKeywords();
}
}
package com.project.cheerha.domain.notice;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.service.NoticeCreationService;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class NoticeCreationScheduler {
private final NoticeCreationService noticeCreationService;
@Scheduled(cron = "*/30 * * * * *")
@Transactional
public void sendJobOpeningMatchingNotices() {
List<JobOpeningKeywordDto> jobOpeningKeywordDtoList = noticeCreationService.findAllJobOpeningKeywords();
List<UserKeywordDto> userKeywordDtoList = noticeCreationService.findAllUserKeywords();
log.info("채용 공고 목록 {}: ", userKeywordDtoList);
log.info("사용자 목록 {}: ", jobOpeningKeywordDtoList);
// 사용자의 이메일 및 연결된 채용 공고 URL을 저장할 Map
Map<String, Set<String>> emailUrlMap = new HashMap<>();
// (1) 사용자 키워드 ID 목록 순회
for (UserKeywordDto userDto : userKeywordDtoList) {
// (2) 모든 채용 공고 키워드 ID 목록 순회
for (JobOpeningKeywordDto jobOpeningKeywordDto : jobOpeningKeywordDtoList) {
// (3) 키워드끼리 일치하는지 확인
boolean isUserKeywordMatchingJobKeyword = userDto.keywordId()
.equals(jobOpeningKeywordDto.keywordId());
// (4) 일치하면 이메일과 채용 공고 URL을 Map에 저장
if (isUserKeywordMatchingJobKeyword) {
emailUrlMap
.computeIfAbsent(userDto.email(), emailAsKey -> new HashSet<>())
.add(jobOpeningKeywordDto.url());
}
}
}
// (5) 이메일별 채용 공고 URL 목록 전송 준비
emailUrlMap.forEach((email, urlSet) -> {
log.info("이메일 전송 준비 완료: {} -> {}", email, urlSet);
}
);
}
}
Map<String, Set<String>> emailUrlMap = new HashMap<>();
(1) emailUrlMap
- 사용자의 이메일 및 해당 이메일에 연결된 채용 공고 URL을 저장할 Map
(2) Map의 key
- 사용자의 이메일
- String 타입
(3) Map의 value
- 이메일로 전송될 채용 공고들의 URL 목록
- Set<String> 타입
전날 머릿속에 그린 대로 스케줄러(Scheduler)를 거의 뜯어고치다시피 했다. for문 안에 또 for문이 있는 점은 마음에 안 들지만, 웬만해서는 중첩을 피하고 싶었으나 분리하는 과정에서 오히려 불필요한 로직이 늘어날까 봐 추후 개선할 부분으로 남겨두었다.
수정 후 스케줄러를 실행한 결과, 사용자에게 맞춤 채용 공고를 이메일로 보낼 준비가 끝났다. 이제 스프링 이메일(Spring Email)을 활용하여 이메일 전송 기능을 구현하면 되었다. 이메일 전송 기능은 구현보다 환경 설정이 살짝 더 난도가 높았다. 이메일 전송용 구글 계정을 새로 만들었는데, 이메일 비밀번호가 아니라 앱 비밀번호를 사용해야 했고, 앱 비밀번호를 사용하려면 2단계 인증을 사용해야 했다. 아래와 같은 순서대로 작업했다.
[25.02.18 추가] dev 브랜치에 합치기 전에는 로그를 지워서 몰랐는데, 이름이 반대로 되어서 정정한다.
// [수정 전]
log.info("채용 공고 목록 {}: ", userKeywordDtoList);
log.info("사용자 목록 {}: ", jobOpeningKeywordDtoList);
// [수정 후]
log.info("채용 공고 목록: {}", jobOpeningKeywordDtoList);
log.info("사용자 목록: {}", userKeywordDtoList);
(1) build.gradle에 의존성 추가하기 ▼
implementation 'org.springframework.boot:spring-boot-starter-mail'
(2) application.yml에 추가하기 ▼
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${MAIL_USERNAME} // 골뱅이(@) 앞 부분
password: ${MAIL_PASSWORD} // 앱 비밀번호
properties:
mail:
smtp:
auth: true
starttls:
enable: true
(3) 구글 '보안'에서 2단계 인증 사용하기 ▼ [구글 보안 링크]
처음에 들어가면 '2단계 인증이 사용 중지되었습니다' 문구가 뜰 텐데 누르고 추가 인증을 거쳐 2단계 인증을 사용하면 된다.
(4) 구글 앱 비밀번호로 로그인하기 ▼ [앱 비밀번호로 로그인 링크]
Google 계정 고객센터에 들어가서 2단계 인증 ▷ 2단계 인증으로 로그인 ▷ 앱 비밀번호로 로그인 ▷ '앱 비밀번호를 만들고 관리합니다' 탭 순서로 들어가면 된다. 앱 이름은 비밀번호 생성에 영향을 주지 않으므로 편하게 지으면 된다.
(5) 생성된 앱 비밀번호를 application.yml의 'password: ${MAIL_PASSWORD}'에 넣기 ▼
사용 방법에서 알 수 있다시피 해당 비밀번호는 '확인' 버튼을 누르면 다시 확인할 방법이 없다. 만약 기존 앱 비밀번호가 뭔지 잊었다면 그 비밀번호를 지우고 새로 생성하면 된다. 비밀번호를 입력할 때는 4글자 사이에 있는 공백을 지우고, 즉 띄어쓰기 없이 16글자를 입력해야 한다.
이렇게 비밀번호까지 입력한 후 애플리케이션을 실행하면…….
오류가 뜬다.
그래, 한 번에 통과하리라곤 기대도 안 했다.
한 가지를 더 설정해야 했으니, 발신자 이메일 계정으로 로그인해서 '지메일(Gmail)의 빠른 설정 ▷ 모든 설정 보기' 순서로 들어갔다. ▼
설정으로 들어온 다음에는 '전달 및 POP/IMAP' 탭을 누르고 IAMP 사용으로 설정을 바꾸었다. 변경 사항을 저장하면 오른쪽 사진처럼 'IMAP를 사용할 수 있습니다.' 상태가 나타났다. ▼
이렇게 모든 설정을 끝마친 뒤에도 '535-5.7.8 Username and Password not accepted' 오류가 다시 발생하거든, 다시 인텔리제이(IntelliJ)를 껐다가 재실행하면 되었다. 이 오류가 로직의 문제가 아니라는 점은 두 가지로 유추할 수 있었다.
첫 번째는 GMass를 사용해서 SMTP을 테스트했을 때 이메일 전송에 성공해서 로직 문제가 아니라고 유추했다. 두 번째로는 동일한 코드를 다른 컴퓨터에서 실행했을 때도 동일한 결과를 얻어서 확신할 수 있었다. 로직의 문제가 아니라 시간을 좀 두어야 오류가 해결된다는 사실을.
이메일이 전송된다는 점도 확인했겠다, 속도를 내어 이메일 전송에 필요한 로직을 추가했다.
(1) 데이터를 넣지 않고 'Hello World!'만 이메일로 전송하기 ▼
package com.project.cheerha.domain.notice.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender javaMailSender;
public void sendTestEmail() {
try {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(
message,
true,
"UTF-8"
);
String senderEmail = "발신자의 이메일 주소";
String recipientEmail = "수신자의 이메일 주소";
helper.setFrom(senderEmail);
helper.setTo(recipientEmail);
helper.setSubject("Hello World");
String content = "<h1>Hello World!</h1><p>This is a test email.</p>";
helper.setText(content, true);
javaMailSender.send(message);
log.info("이메일 전송 완료: {}", recipientEmail);
} catch (MessagingException e) {
log.error("이메일 전송 실패: {}", e.getMessage(), e);
}
}
}
package com.project.cheerha.domain.notice;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.service.EmailService;
import com.project.cheerha.domain.notice.service.NoticeCreationService;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class NoticeCreationScheduler {
private final NoticeCreationService noticeCreationService;
private final EmailService emailService;
@Scheduled(cron = "*/30 * * * * *")
@Transactional
public void sendJobOpeningMatchingNotices() {
List<JobOpeningKeywordDto> jobOpeningKeywordDtoList = noticeCreationService.findAllJobOpeningKeywords();
List<UserKeywordDto> userKeywordDtoList = noticeCreationService.findAllUserKeywords();
log.info("채용 공고 목록 {}: ", userKeywordDtoList);
log.info("사용자 목록 {}: ", jobOpeningKeywordDtoList);
Map<String, Set<String>> emailUrlMap = new HashMap<>();
for (UserKeywordDto userDto : userKeywordDtoList) {
for (JobOpeningKeywordDto jobOpeningKeywordDto : jobOpeningKeywordDtoList) {
boolean isUserKeywordMatchingJobKeyword = userDto.keywordId()
.equals(jobOpeningKeywordDto.keywordId());
if (isUserKeywordMatchingJobKeyword) {
emailUrlMap
.computeIfAbsent(
userDto.email(),
emailAsKey -> new HashSet<>()
)
.add(jobOpeningKeywordDto.url());
}
}
}
emailService.sendTestEmail();
}
}
(2) 'Hello World!' 전송 성공 후 채용 공고 이메일 초안 작성하기 ▼
package com.project.cheerha.domain.notice.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender javaMailSender;
private static final String SENDER_EMAIL = "발신자의 이메일 주소";
public void sendMail(String recipientEmail, List<String> jobOpeningUrlList) {
try {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(SENDER_EMAIL);
helper.setTo(recipientEmail);
helper.setSubject("맞춤 채용 공고 알림");
StringBuilder content = new StringBuilder("<h1>새로운 채용 공고 알림!</h1><ul>");
for (String url : jobOpeningUrlList) {
content.append("<li><a href=\"")
.append(url)
.append("\" target=\"_blank\">")
.append(url).append("</a></li>");
}
content.append("</ul>");
helper.setText(content.toString(), true);
javaMailSender.send(message);
log.info("이메일 전송 완료: {}", recipientEmail);
} catch (MessagingException e) {
log.error("이메일 전송 실패: {}", recipientEmail, e);
}
}
}
package com.project.cheerha.domain.notice;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.service.EmailService;
import com.project.cheerha.domain.notice.service.NoticeCreationService;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class NoticeCreationScheduler {
private final NoticeCreationService noticeCreationService;
private final EmailService emailService;
@Scheduled(cron = "*/30 * * * * *")
@Transactional
public void sendJobOpeningMatchingNotices() {
List<JobOpeningKeywordDto> jobOpeningKeywordDtoList = noticeCreationService.findAllJobOpeningKeywords();
List<UserKeywordDto> userKeywordDtoList = noticeCreationService.findAllUserKeywords();
log.info("채용 공고 목록 {}: ", userKeywordDtoList);
log.info("사용자 목록 {}: ", jobOpeningKeywordDtoList);
// 사용자의 이메일 및 연결된 채용 공고 URL을 저장할 Map
Map<String, Set<String>> emailUrlMap = new HashMap<>();
// (1) 사용자 키워드 ID 목록 순회
for (UserKeywordDto userDto : userKeywordDtoList) {
// (2) 모든 채용 공고 키워드 ID 목록 순회
for (JobOpeningKeywordDto jobOpeningKeywordDto : jobOpeningKeywordDtoList) {
// (3) 키워드끼리 일치하는지 확인
boolean isUserKeywordMatchingJobKeyword = userDto.keywordId()
.equals(jobOpeningKeywordDto.keywordId());
// (4) 일치하면 이메일과 채용 공고 URL을 Map에 저장
if (isUserKeywordMatchingJobKeyword) {
emailUrlMap
.computeIfAbsent(
userDto.email(),
emailAsKey -> new HashSet<>()
).add(jobOpeningKeywordDto.url());
}
}
}
// (5) 이메일별로 알림 준비 및 전송
emailUrlMap.forEach((email, urlSet) -> {
log.info("이메일 전송 준비 완료: {} -> {}", email, urlSet);
emailService.sendMail(email, List.copyOf(urlSet));
}
);
}
}
(3) 부족한 미적 감각을 바닥까지 싹싹 긁어모아 채용 공고 꾸미기 ▼
package com.project.cheerha.domain.notice.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
// JavaMailSender 객체로 이메일을 보낼 수 있음
private final JavaMailSender javaMailSender;
// 이메일 발신자 주소 설정
private static final String SENDER_EMAIL = "발신자의 이메일";
/**
* 이메일을 보내는 메서드
*
* @param recipientEmail 수신자 이메일
* @param jobOpeningUrlList 채용 공고 목록
*/
public void sendMail(
String recipientEmail,
List<String> jobOpeningUrlList
) {
try {
// 새로운 이메일 메시지 객체 생성
MimeMessage message = javaMailSender.createMimeMessage();
// 메시지에 다양한 정보를 설정할 수 있도록 돕는 객체
MimeMessageHelper helper = new MimeMessageHelper(
message,
true,
"UTF-8"
);
helper.setFrom(SENDER_EMAIL);
// 발신자 설정
helper.setTo(recipientEmail);
// 수신자 설정
helper.setSubject("📢 새로운 맞춤 채용 공고가 도착했어요!");
// 이메일 제목 설정
// 이메일 본문 내용 구성
StringBuilder content = new StringBuilder();
content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
content.append("<ul>");
// 채용 공고 URL 목록을 리스트 형식으로 출력
for (String url : jobOpeningUrlList) {
content.append("<li>👉 <a href=\"")
.append(url)
.append("\" target=\"_blank\">")
.append("채용 공고 자세히 보기</a></li>");
}
content.append("</ul>");
content.append("<p>행운을 빕니다! 🙌</p>");
// HTML 형식으로 본문 내용 설정
helper.setText(content.toString(), true);
// 이메일 발송
javaMailSender.send(message);
// 로그 기록
log.info("이메일 전송 완료: {}", recipientEmail);
} catch (MessagingException e) {
log.error("이메일 전송 실패: {}", recipientEmail, e);
}
}
}
package com.project.cheerha.domain.notice;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.service.EmailService;
import com.project.cheerha.domain.notice.service.NoticeCreationService;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
public class NoticeCreationScheduler {
private final NoticeCreationService noticeCreationService;
private final EmailService emailService;
@Scheduled(cron = "*/30 * * * * *")
@Transactional
public void sendJobOpeningMatchingNotices() {
List<JobOpeningKeywordDto> jobOpeningKeywordDtoList = noticeCreationService.findAllJobOpeningKeywords();
List<UserKeywordDto> userKeywordDtoList = noticeCreationService.findAllUserKeywords();
// 사용자의 이메일 및 연결된 채용 공고 URL을 저장할 Map
Map<String, Set<String>> emailUrlMap = new HashMap<>();
// (1) 사용자 키워드 ID 목록 순회
for (UserKeywordDto userDto : userKeywordDtoList) {
// (2) 모든 채용 공고 키워드 ID 목록 순회
for (JobOpeningKeywordDto jobOpeningKeywordDto : jobOpeningKeywordDtoList) {
// (3) 키워드끼리 일치하는지 확인
boolean isUserKeywordMatchingJobKeyword = userDto.keywordId()
.equals(jobOpeningKeywordDto.keywordId());
// (4) 일치하면 이메일과 채용 공고 URL을 Map에 저장
if (isUserKeywordMatchingJobKeyword) {
emailUrlMap
.computeIfAbsent(
userDto.email(),
emailAsKey -> new HashSet<>()
).add(jobOpeningKeywordDto.url());
}
}
}
// (5) 이메일별로 알림 전송
emailUrlMap.forEach((email, urlSet) -> {
emailService.sendMail(email, List.copyOf(urlSet));
}
);
}
}
이메일 전송 기능을 구현한 다음, 가장 먼저 한 일은 '중복 조회 방지'였다. 채용 공고 객체(JobOpening Entity)의 속성에 '생성날짜'를 추가하고 스프링 스케줄러(Spring Scheduler)가 동작하기까지 걸리는 시차를 활용하기로 했다.
예를 들어, 스케줄러가 30분마다 작동하고 8시 20분에 채용 공고 A가 생성되고, 8시 40분에 B가 생성되었다고 가정하자.
로직이 올바르다면 A는 8시 30분에 조회되고 채용 공고 B는 9시에 조회된다. 즉, '조회 시간 - 시차'를 활용하여 한 번 조회된 채용 공고는 다시 조회되지 않도록 구현하려고 했다.
이메일 전송에도 성공했겠다, 이번에도 문제없을 줄 알았는데…….
분명 저녁에 채용 공고를 생성했는데 왜 한낮으로 저장되는 거죠?
Day 4에서 계속…….