포텐데이라는 해커톤에 참여하게 됐다.
당연히 백엔드 개발자로 참여했고 앱의 백엔드 서버를 만들게 되었는데 그 중에서 회원 가입 시 진행한는 이메일 인증을 만들어보려고 한다. 회사에서 쓰지 않는 스프링을 써보려고 한다.
기능 자체는 복잡하지 않다. 서버는 6자리의 랜덤 숫자를 생성하고 해당 숫자를 사용자의 메일로 전송한다. 그리고 사용자는 메일로 받은 숫자를 다시 서버로 보내주면, 서버가 가지고 있는 번호와 사용자가 전송한 번호를 비교하여 일치하면 통과시키는 기능이다.
난수를 생성하는 것은 어렵지 않다. 하지만 그 난수가 진짜 랜덤한 수일까?
메일 인증 번호로 사용할 6자리 숫자를 만들어야 하기 때문에 여러 방법이 있겠지만 항상 6자리가 나오도록 간단하게 코드를 작성했다.
SecureRandom secureRandom = new SecureRandom();
int randomInt = secureRandom.nextInt(1000000 - 100000) + 100000
secureRandom.nextInt(max - min) + min
의 형태로 900,000 미만의 숫자에서 랜덤한 정수를 생성한 뒤 100,000을 더하면 항상 6자리의 정수가 된다
난수 생성에 대해 알아보면서 난수 생성이 진짜 랜덤한지에 대해 의심해볼 수 있다는 것도 처음 생각해보는 계기가 됐다. 예전에 tensorflow나 pytorch로 모델 학습을 할 때 항상 같은 결과를 얻기 위해 seed를 설정하는 것이 랜덤값 생성시 설정하는 시드였을 거라는 점이 생각이 났다.
따로 메일서버가 없기 때문에 gmail을 통해 메일 보내도록 했다.
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender emailSender;
public void sendEmail(String toEmail, String subject, String content) {
SimpleMailMessage emailForm = createEmailForm(toEmail, subject, content);
try {
emailSender.send(emailForm);
} catch (RuntimeException e) {
log.debug("MailService.sendEmail exception - toEmail: {}", toEmail);
}
}
private SimpleMailMessage createEmailForm(String toEmail, String subject, String content) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(toEmail);
message.setSubject(subject);
message.setText(content);
return message;
}
}
메일 전송은 이미 있는 라이브러리를 사용했다. toEmail
과 subject
, content
만 만들어주면 손쉽게 메일을 보낼 수 있다.
메일 전송을 위한 설정값은 EmailConfig
클래스에서 설정했다.
package com.potential.hackathon.email;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
@Configuration
@RequiredArgsConstructor
public class EmailConfig {
@Value("${spring.mail.host}")
private String host;
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.username}")
private String username;
@Value("${spring.mail.password}")
private String password;
@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;
@Value("${spring.mail.properties.mail.smtp.starttls.enable}")
private boolean starttlsEnable;
@Value("${spring.mail.properties.mail.smtp.starttls.required}")
private boolean starttlsRequired;
@Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
private int connectionTimeout;
@Value("${spring.mail.properties.mail.smtp.timeout}")
private int timeout;
@Value("${spring.mail.properties.mail.smtp.writetimeout}")
private int writeTimeout;
@Bean
public JavaMailSender javaMailService() {
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost(host);
javaMailSender.setPort(port);
javaMailSender.setUsername(username);
javaMailSender.setPassword(password);
javaMailSender.setDefaultEncoding("UTF-8");
javaMailSender.setJavaMailProperties(getEmailProperties());
return javaMailSender;
}
private Properties getEmailProperties() {
Properties properties = new Properties();
properties.put("mail.smpt.auth", auth);
properties.put("mail.smtp.starttls.enable", starttlsEnable);
properties.put("mail.smtp.starttls.required", starttlsRequired);
properties.put("mail.smtp.connectiontimeout", connectionTimeout);
properties.put("mail.smtp.timeout", timeout);
properties.put("mail.smtp.writetimeout", writeTimeout);
return properties;
}
}
각각 값들은 application.yaml
에 작성한 것을 불러와서 사용한다.
spring:
mail:
host: smtp.gmail.com
port: 587
username: gmail@gmail.com
password: app-password
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
auth-code-expiration-millis: 1800000
이때 password
는 로그인 시 사용하는 비밀번호가 아니라 구글 계정 설정에서 앱 비밀번호 설정을 한 후 해당 값을 사용해야 한다.
위에서 말했듯이 랜덤으로 생성한 숫자를 서버가 저장하고 있어야 사용자의 입력값과 비교해 일치하는지를 판단할 수 있다. DB에 값을 저장하고 지우고 하는 과정이 반복될 것이기 때문에 속도 면에서나, 데이터의 특징적으로나 Redis를 사용하기에 적합한 상황이라고 판단했다.
Redis에 사용자의 email을 key로, 인증번호를 value로 저장하고 일정 시간이 지나면 해당 내용이 휘발하도록 설정하여 따로 삭제 명령을 하지 않더라도 인증번호가 삭제되도록 설정했다.
@Slf4j
@Service
@RequiredArgsConstructor
public class VerificationService {
private final EmailService emailService;
private final RedisService redisService;
private static final String AUTH_CODE_PREFIX = "AUTH_CODE ";
public String createCode() throws NoSuchAlgorithmException {
try {
SecureRandom secureRandom = SecureRandom.getInstanceStrong();
int randomInt = secureRandom.nextInt(900000) + 100000;
return String.valueOf(randomInt);
} catch (NoSuchAlgorithmException e) {
log.debug("RandomNumberGenerator.createCode() exception");
throw new NoSuchAlgorithmException();
}
}
public void sendAndSaveCode(String toEmail) throws NoSuchAlgorithmException {
String subject = "인증번호";
String authCode = this.createCode();
emailService.sendEmail(toEmail, subject, authCode);
redisService.setNumber(AUTH_CODE_PREFIX + toEmail, authCode);
}
public boolean verifyCode(String email, String authCode) {
String redisCode = redisService.getNumber(AUTH_CODE_PREFIX + email);
return authCode.equals(redisCode);
}
}
혹시나 Redis에서 key로 사용한 이메일이 중복될 수도 있을까 싶어서 prefix를 넣어주었다. 따라서 사용자의 입력값과 비교할 때도 prefix를 붙여서 Redis에서 읽어와야 한다.
@RestController
@RequiredArgsConstructor
public class EmailController {
private final VerificationService verificationService;
@PostMapping("/mails")
public ResponseEntity sendVerificationMail(@RequestBody EmailDto body) throws NoSuchAlgorithmException {
verificationService.sendAndSaveCode(body.getEmail());
return new ResponseEntity<>(HttpStatus.OK);
}
@GetMapping("/mails/verification")
public ResponseEntity verification(@RequestParam String email, @RequestParam String authCode) {
boolean result = verificationService.verifyCode(email, authCode);
return new ResponseEntity<>(result, HttpStatus.OK);
}
}
/mails
를 호출하면 사용자의 메일로 인증번호를 전송하고, /mails/verification
을 호출하면 파라미터로 전송한 인증번호와 Redis의 인증번호를 비교해 일치하면 true
일치하지 않으면 false
를 반환하도록 했다.
기능 구현이 가능한지 테스트 하기 위해 간단하게 작업해보았다. 이 기능을 이번 해커톤에서 만들 앱에 넣게 될지는 모르겠지만 넣는다면 코드를 가다듬을 필요는 있다. 리턴 값에 대해 정의한 것도 없고, 예외나 에러가 발생할 경우 전혀 대처가 안되어있기 때문이다. 특히 현재 Redis에 저장한 후 3분이 지나 인증코드가 만료된 경우 만료됐다는 응답을 해주어야 하는데 데이터를 읽어오지 못해 에러가 발생하는 상태이다.