이번 프로젝트에서 추가 기능으로 회원가입 시, 이메일 인증번호를 보낼 수 있도록 구현하였다
어떻게 이메일을 보낼지에 대한 것은 구글에 나와있는 자료도 많고, 참고할 만한 코드도 많아서 그리 어렵지 않았다
RequestParam으로 수신 이메일을 받고, 랜덤하게 인증번호를 만든 후, JavaMailSender를 통해 이메일을 보내주었다
Thymeleaf TemplateEngine 등을 통해 아래와 같은 이메일을 보내도록 구성하였다
이메일로 보내진 인증번호를 사용자가 입력하면, 저장해둔 이메일, 인증번호와 입력한 이메일, 인증번호와 맞는지 확인하는 방법에 대해 생각해야했다
가장 먼저 인증번호를 보내고 나서 이메일과 인증번호를 어떻게 저장해둘지를 고민해야했다
Email Entity와 EmailRepository를 만들어 데이터베이스에 직접 저장하는 방법
데이터를 안전하게 저장할 수 있는 방법이지만, 입력, 삭제가 너무 잦을 것 같다고 생각했다
인메모리 데이터 구조 저장소로서 키-값 쌍을 저장하는 NoSQL 데이터베이스
메모리에 데이터를 저장하기 때문에 매우 빠른 속도로 데이터를 읽고 쓸 수 있으며, HashMap보다는 관리, 저장에 용이하기 때문에 일반적으로 가장 적합한 방법인 것 같다
단순한 키-값 저장소로서 주요 데이터 구조 및 기능 외에는 제한적인 기능을 가지고 있다
비동기 작업을 실행하는 ConcurrentHashMap이라는 것도 알게되었다
기능적으로 봤을 때는 HashMap과 큰 차이는 없지만, 내부적으로 세분화된 잠금 (fine-grained locking) 기법을 사용하여 동시에 여러 스레드가 ConcurrentHashMap에 접근할 수 있도록 한다는 특징이 있다
어떤 방법을 보편적으로 사용하는지는 잘 모르겠으나, 소규모 프로젝트이기도하고 많은 양의 이메일을 보낼 일이 없을 것 같았기 때문에 HashMap을 사용하였다
이번 프로젝트에서는 HashMap을 선택하긴 했지만, 만약 이용자가 많을 경우 많은 단점들이 존재할 것 같다
[ HashMap의 단점 ]
1. 메모리에 저장하는 것이므로, 서버의 메모리 한계에 도달할 수 있다는 점
2. 서버가 재시작되거나 오류가 생기면 모든 데이터가 손실될 수 있다는 점
3. 스레드의 불안정으로 인하여 여러 클라이언트가 동시에 HashMap을 수정하려는 경우 데이터 손상이 발생할 수 있다는 점
4. 메모리 내에 데이터를 저장하기 때문에 보안에 취약하다는 점
만약 이용자가 많아지고 관리해야할 이메일 수가 많아지게 되면, 데이터베이스나 Redis에 저장하는 방법으로 구현해야할 것 같다
앞에서 이메일과 인증번호를 어떻게 저장해둘지를 고민해봤다면, 그 다음으로는 인증번호를 보내고 3분뒤에 저절로 삭제되도록 하는 로직에 대해 생각해야했다
예를들어, abc@gmail.com이라는 사람에게 Jy2s0Oo라는 인증번호가 전송되었고, 수신자는 이 인증번호를 3분 안에 Request로 보내서 인증번호가 일치하는지 아닌지를 판단해야한다고 하자
이 경우, 내부 로직을 어떻게 설정할지 경우의 수를 나누어 생각해보았다
1. 3분 이내로 인증번호를 맞게 입력한 경우 → 일치한다는 Response를 보내고, HashMap에서 해당 {이메일:인증번호} 쌍을 삭제한다
2. 3분 이내로 인증번호를 틀리게 입력한 경우 → 불일치한다는 Response를 보낸다
3. 3분 이내로 입력하지 않는 경우 → EventListener를 사용하여 {이메일:인증번호} 쌍이 자동으로 삭제되도록 한다
EventListener, ApplicationEvent를 이용하여 Hashmap에 존재하는 {이메일:인증번호} 쌍이 3분뒤에 자동으로 사라지도록 처리하였다
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/email")
public class EmailController {
private final EmailService emailService;
@PostMapping
public ResponseEntity<EmailDto> sendEmail(@RequestParam String email) throws Exception {
emailService.sendEmail(email);
return ResponseEntity.noContent().build();
}
@PostMapping("/confirm")
public ResponseEntity<Boolean> confirmEmail(@RequestBody EmailDto emailDto) {
return ResponseEntity.ok(emailService.confirmEmail(emailDto));
}
}
@Getter
public class EmailDto {
private String email;
private String code;
}
@Service
@Slf4j
@Transactional
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
private final MemberService memberService;
private final TemplateEngine templateEngine;
public static HashMap<String, String> codeStorage = new HashMap<>();
@Value("${email.from.address}")
private String FROM_ADDRESS;
@Value("${email.from.name}")
private String FROM_NAME;
private String ePw;
private final ApplicationEventPublisher publisher;
public void sendEmail(String email) throws MessagingException, UnsupportedEncodingException {
memberService.verifyExistEmail(email);
ePw = createKey();
MimeMessage message = mailSender.createMimeMessage();
message.setFrom(new InternetAddress(FROM_ADDRESS, FROM_NAME,"UTF-8"));
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(email);
helper.setSubject("InddyBuddy의 회원가입 인증 코드입니다.");
Context context = new Context();
context.setVariable("ePw", ePw);
String html = templateEngine.process("email", context);
helper.setText(html, true);
helper.addInline("image", new ClassPathResource("static/logo.png"));
try {
if (!codeStorage.containsKey(email)) {
log.info("인증번호 전송");
log.info(ePw);
mailSender.send(message);
codeStorage.put(email, ePw);
System.out.println(codeStorage);
publisher.publishEvent(new EmailSendApplicationEvent(this, email, ePw));
} else {
log.info("3분이 지나지 않았으므로 전송 불가");
throw new CustomException(ExceptionCode.CODE_ISSUANCE_UNAVAILABLE);
}
} catch (MailException es) {
es.printStackTrace();
log.info("인증번호 전송 실패");
codeStorage.remove(email);
System.out.println(codeStorage);
throw new CustomException(ExceptionCode.MAIL_SEND_ERROR);
}
}
public static String createKey() {
StringBuffer key = new StringBuffer();
Random rnd = new Random();
for (int i = 0; i < 8; i++) {
int index = rnd.nextInt(3);
switch (index) {
case 0:
key.append((char) (rnd.nextInt(26) + 97));
break;
case 1:
key.append((char) (rnd.nextInt(26) + 65));
break;
case 2:
key.append((rnd.nextInt(10)));
break;
}
}
return key.toString();
}
public boolean confirmEmail(EmailDto emailDto) {
String email = emailDto.getEmail();
String code = emailDto.getCode();
String findCode = codeStorage.get(email);
log.info("이메일과 코드가 일치하는지 확인");
if (code.equals(findCode)) {
log.info("일치!!!");
codeStorage.remove(email);
System.out.println(codeStorage);
return true;
}
log.info("불일치!!!");
System.out.println(codeStorage);
return false;
}
}
@Getter
public class EmailSendApplicationEvent extends ApplicationEvent {
private String email;
private String code;
public EmailSendApplicationEvent(Object source, String email, String code) {
super(source);
this.email = email;
this.code = code;
}
}
@RequiredArgsConstructor
@Component
@Slf4j
public class EmailSendEventListener {
@Async
@EventListener
public void listen(EmailSendApplicationEvent event) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
EmailService.codeStorage.remove(event.getEmail());
timer.cancel();
log.info("3분 지남. 자동 삭제");
System.out.println(EmailService.codeStorage);
}
}, 3 * 60 * 1000);
}
}
application.properties를 사용하다가 서버에 있는 application.yml을 사용하니 다음과 같은 오류가 발생하였다
com.sun.mail.smtp.SMTPSendFailedException: 530 5.7.0 Must issue a STARTTLS command first. w12-20020a170902a70c00b001ac381f1ce9sm9404316plq.185 - gsmtp
(원인은 찾지 못했다 분명 properties 파일이라는 점과 yml 파일이라는 점을 제외하면 다를 것이 없었는데...)
여러가지 구글링과 시도를 해본 끝에 EmailConfig를 추가하였다
@Configuration
public class EmailConfig {
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;
@Value("${spring.mail.properties.mail.starttls.enable}")
private boolean starttls;
@Value("${spring.mail.username}")
private String id;
@Value("${spring.mail.password}")
private String password;
@Bean
public JavaMailSender javaMailService() {
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost("smtp.gmail.com");
javaMailSender.setPort(port);
javaMailSender.setUsername(id);
javaMailSender.setPassword(password);
javaMailSender.setJavaMailProperties(getMailProperties());
javaMailSender.setDefaultEncoding("UTF-8");
return javaMailSender;
}
private Properties getMailProperties() {
Properties pt = new Properties();
pt.put("mail.smtp.auth", auth);
pt.put("mail.smtp.starttls.enable", starttls);
return pt;
}
}
# google
spring.mail.default-encoding=UTF-8
spring.mail.host = smtp.gmail.com
spring.mail.port = 587
spring.mail.username = ${USERNAME}
spring.mail.password = ${PASSWORD}
spring.mail.properties.mail.smtp.auth = true
spring.mail.properties.mail.smtp.starttls.enable = true
email.from.address = ${SENDER_ADDRESS}
email.from.name = ${SENDER_NAME}