- 이번 프로젝트에서 로그인 시 사용하는 아이디를 이메일 주소로 했다
없는 이메일이나 다른 사람의 이메일로 회원가입 하는 경우를 방지하도록 이메일 인증 기능을 넣었다
그리고 서비스 차원에서 회원가입 후 환영 메일 전송 기능도 넣었다-
같은 메일 전송 기능이지만 구현은 조금 다르게 되었다
그 이유는 이메일이 전송되는 부분이 병목이 되었기 때문이다 (생각보다 시간이 조금 걸리더라는)
인증코드 전송에서는 회원이 본인의 메일을 확인하러 가는 시간이 있기 때문에 크게 문제가 되지 않을 거라 생각했다 -
문제는 회원가입 환영 메일이었다
회원가입은 빠르게 다 되었는데도 필수 기능이라고 생각하지 않았던
서비스 차원으로 넣었던 환영 메일로 인해 전체적인 응답속도가 늦어지는 것이다
또한, 전체 과정을 트랜잭션으로 묶다보니 회원가입은 정상적으로 되더라도 환영 메일 전송 과정에서 문제가 생기면 전체 과정이 롤백되어 버리는 상황이 생긴다
-
- 따라서 전체적인 응답속도가 늦어지는걸 대비해 환영 메일 전송을 비동기 처리를 하였고
메일 전송과 롤백이 상관없을 수 있도록 트랜잭션이 끝나는 시점에 메일 전송이 되도록 구현했다
- 또한, 이렇게 함으로써 회원 도메인이 메일을 보내주는 객체에 직접 의존하지 않게 되어 결합도를 낮출 수 있었다
덕분에 추후에 환영 메일이 아니라 다른 처리를 해주어야 한다고 해도 유연하게 대응할 수 있을 것이라 판단된다
구현하기
-
dependency
implementation 'org.springframework.boot:spring-boot-starter-mail'
- spring-boot-starter-mail dependency를 사용하니까 yml에 Gmail SMTP Server 설정에 필요한 값들 추가
-
password에는 로그인 시 사용하는 비밀번호가 아닌 앱 비밀번호 적기
-
이메일 인증코드 전송 기능
public interface Sender {
void sendCode(String toAddress);
void sendWelcome(String toAddress);
}
@Component
@RequiredArgsConstructor
public class EmailSender implements Sender {
...
private final JavaMailSender javaMailSender;
private final RedisRepository redisRepository;
@Override
public void sendCode(String toAddress) {
try {
String verificationCode = generateVerificationCode();
MimeMessage message = getMessage(toAddress, CODE_SUBJECT, CODE_CONTENT + verificationCode);
javaMailSender.send(message);
redisRepository.save(toAddress, verificationCode, SAVE_SECONDS);
} catch (MessagingException exception) {
throw new MailSendException("메일 전송에 실패하였습니다.");
}
}
private MimeMessage getMessage(String toAddress, String subject, String content) throws MessagingException {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper messageHelper = new MimeMessageHelper(message, false, "UTF-8");
messageHelper.setTo(toAddress);
messageHelper.setFrom(fromAddress);
messageHelper.setSubject(subject);
messageHelper.setText(content);
return message;
}
private String generateVerificationCode() {
return String.valueOf(random.nextInt(100000, 1000000));
}
- 나중에 카카오톡이나 문자 등 보낼 방법이 다양해질 수 있을 것 같아 저수준 component에 의존하지 않도록 신경써보았다
- 이메일 인증코드는 Random을 사용해서 랜덤 6자리의 숫자를 만들었다
- 3분동안만 유효하도록 redis에 저장 시간을 세팅해서 넣었다
회원가입 환영 메일
환영 메일도 위의 인증코드 전송 메일과 동일하게 구현했었는데
전체적인 응답시간 지연 방지와 트랜잭션과 분리하기 위해 변경했다
비동기 처리
@Async
@Override
public void sendWelcome(String toAddress) {
try {
MimeMessage message = getMessage(toAddress, WELCOME_SUBJECT, WELCOME_CONTENT);
javaMailSender.send(message);
} catch (MessagingException exception) {
throw new MailSendException("메일 전송에 실패하였습니다.");
}
}
- method 위에
@Async
annotaion 붙이면 됨- class에 annotaion 붙이면 해당 class의 전체 method에 적용됨
@EnableAsync와 SimpleAsyncTaskExecutor
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(3);
threadPoolTaskExecutor.setMaxPoolSize(20);
threadPoolTaskExecutor.setQueueCapacity(20);
threadPoolTaskExecutor.initialize();
return threadPoolTaskExecutor;
}
}
- ThreadPoolExecutor를 생성하는 @Bean을 생성해 @Async annotaion을 메서드에 태깅할 때 bean 이름을 설정해주는 방법도 있지만
- 모든 코드에서 적용되는 default Executor를 변경하고 싶어(SimpleAsyncTaskExecutor를 사용하지 않기 위해) AsyncConfigurer를 상속해서 오버라이딩해서 사용했다
- Java에서는 ExecutorService를 통해서 비동기 처리가능
- ExecutorService를 사용하면 -> 원하는 크기만큼의 Thread Pool을 생성하고 Pool에서 Thread을 꺼내 사용 후 반납하는 방식으로 처리
- 요청마다 Thread 생성할 수도 있지만, 그렇게 되면 Thread 관리가 되지 않아 위험
- Spring에서
@EnableAsync
옵션만 주어도 비동기 처리를 사용할 수 있음- 기본 설정
- SimpleAsyncTaskExecutor를 사용하도록 설정되어 있음
- SimpleAsyncTaskExecutor는 요청이 올 때마다 매번 Thread를 생성하는 방식이기 때문에 -> 설정 오버라이딩해서 사용하는게 좋음
- CorePoolSize : 1
- MaxPoolSize, QueueCapacity : Integer.MAX_VALUE 로 정의
- warning 레벨의 log 출력
- SimpleAsyncTaskExecutor를 사용하도록 설정되어 있음
- 기본 설정
이벤트 처리
- 회원가입이 성공한다라는 Event를 만들고, Event Listener에서 Sender의 메서드를 호출해 회원가입이 일어나는 트랜잭션과 분리해서 메일 발송 로직을 수행하도록 했다
@Transactional
public MemberRegisterResponse registerMember(MemberRegisterRequest memberRegisterRequest) {
String email = memberRegisterRequest.email();
checkEmail(email);
checkNickname(memberRegisterRequest.nickname());
Member member = memberRepository.save(toMember(memberRegisterRequest));
applicationEventPublisher.publishEvent(new MemberCreateEvent(email));
return toMemberRegisterResponse(member.getId());
}
- 회원가입 트랜잭션 안에서 MemberCreateEvent 이벤트를 발행했다
- 발행된 이벤트는 ApplicationContext에서 @EventListener annotaion이 붙은 Listener handler를 찾아서 인자로 들어가고 Listener의 로직이 수행된다
@Component
@RequiredArgsConstructor
public class MemberEventListener {
private final Sender sender;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handleMemberCreateEvent(MemberCreateEvent memberCreateEvent) {
sender.sendWelcome(memberCreateEvent.getEmail());
}
}
@TransactionalEventListener
트랜잭션 과정에서 언제 Event Listener를 수행할지 정할 수 있음
AFTER_COMMIT
: default, 트랜잭션이 성공적으로 완료된 경우 이벤트 발생AFTER_ROLLBACK
: 트랜잭션이 실패하여 롤백 된 경우에 이벤트 발생AFTER_COMPLETION
: 트랜잭션의 성공 여부와 관계없이 종료되었을 경우 이벤트 발생BEFORE_COMMIT
: 트랜잭션 커밋 직전에 이벤트 발생
- fallbackExecution : Whether the event should be handled if no transaction is running
fallbackExecution = true
: 트랜잭션 없는 곳에서 이벤트 발행하면 예외 던짐
로그로 확인해보기
- 회원가입 로직 수행과 메일 전송을 다른 thread가 맡아 진행되는 것을 볼 수 있고
회원가입의 모든 로직 수행 후 메일 전송 로직이 수행되는 것을 볼 수 있다
결과
- 디자인이나 구성은 로고 등을 추가해 예쁘게 만들어볼 생각이다
나중에 참고하기 위해 정리해두기) Gmail SMTP 서버를 사용해서 이메일 발송하기 위한 준비
SMTP(Simple Mail Transfer Protocol)
Operating System이 전자 메일을 송신하고 수신할 수 있는 프로토콜
- SMTP 송신자(클라이언트)와 목적지 SMTP 리시버(서버) 간에 직접 연결
- 메일을 작성해서 보내면 SMTP 서버(보내는 메일서버)로 일단 전송됨
- SMTP 서버에서 SENDMAIL 프로그램을 구동하여 해당 메일 주소로 메일을 보냄
Gmail SMTP
- 구글의 보안 정책 변경에 따라 2022년 5월 30일부터 아이디/비밀번호를 이용한 SMTP 서버 인증 정책 폐지
-> 보안 수준이 낮은 앱의 액세스가 활성된 계정으로는 SMTP 서버를 이용할 수 없게 되었음
- 그래서 구글 서비스의 2단계 인증을 활성화 한 후
앱 비밀번호
를 발급받아 .yml 파일 내 비밀번호로 대체함-
POP 활성화 하고 IMAP 사용 (서버에 수신된 메일을 받는데에 사용 = IMAP에서 메일을 받겠다)
Reference
SMTP
What Is Gmail SMTP and How to Use Gmail With My Domain?
Creating Asynchronous Methods
Effective Advice on Spring Async: Part 1
Annotation Interface TransactionalEventListener