회원가입 시 이메일 인증을 위한 기능을 만들려고 해요. 사용할 수 있는 방안은 두 가지 있어요.
저는 두 번째 방안을 선택했어요. 그 이유는 다음과 같아요.
제가 생각한 사용자 인증 과정은 다음과 같아요.
2단계 인증 과정은 다음과 같아요.
2단계 인증을 거친다면 16자리 앱 비밀번호를 알려줍니다. 꼭 따로 저장해두셔야 해요.
→ 이 비밀번호가 gmail 비밀번호 대신 사용하게 될 비밀번호에요!!
우선 SMTP을 사용할 계정 설정을 해줘야 해요. 저는 구글을 활용했어요.
구글 로그인 → 구글 계정 관리 → 검색창에 “앱 비밀번호” 검색
(❗️만약 앱 비밀번호가 나오지 않는다면? 2단계 인증을 해야 하니 위의 과정을 거치고 오면 되요!!)
다음과 같이 설정을 합니다.
구글 Gmail → 설정 → 전달 및 POP/IMAP → 아래 이미지처럼 설정 → 변경사항 저장 클릭
저는 gradle을 사용하기에 다음과 같이 적용했어요.
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
단, 저는 인증번호를 redis에 저장할 목적이기에 redis 부분도 추가해주었어요.
이메일 설정에는 두 가지 방식이 있어요.
그 이유는 다음과 같아요.
또한, 보안에 민감한 부분도 포함되어 있기에 yml 파일도 나누어서 관리했어요.
spring:
mail:
host: smtp.gmail.com
port: 587
properties:
mail:
debug: true
smtp.auth: true
smtp.timeout: 50000 # SMTP 서버에 연결을 시도하고 응답을 기다리는 최대 시간이 50,000ms
smtp.starttls.enable: true
data:
redis:
mail:
host: localhost # 임시 방편으로 로컬에서 테스트를 위해 설정
port: 6380
spring:
mail:
username: {인증 메일을 보낼 계정}
password: {앱 비밀번호 (16자리)}
맨 위의 사용자 인증 과정에 대해 6가지로 나누었는데 그 중 1번과 5번은 사용자가 해야할 것이고, 구현해야할 과정은 2, 3, 4, 6번 이에요. 이를 다시 정리하면 다음과 같이 4 가지 과정이 있어요.
이 과정을 하기 전 가장 먼저 해야할 것은 redis를 빈으로 등록하여 설정하는 것이에요. 스프링의 가장 위대한 점 중 하나인 추상화가 있기에 어떠한 구현체를 선택하는지는 개발자의 역할이에요.
그렇다면 저는 왜 reids를 사용했을까요?
저는 다음과 같은 이점이 redis에 있다고 생각했기 때문이에요.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.mail.host}")
private String host;
@Value("${spring.data.redis.mail.port}")
private int port;
public RedisConnectionFactory redisMailConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean(name = "redisTemplate")
public StringRedisTemplate redisTemplate() {
StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
stringRedisTemplate.setConnectionFactory(redisMailConnectionFactory());
return stringRedisTemplate;
}
}
RedisConnectionFactory로는 Lettuce 와 Jedis 방식이 있어요.
제가 Lettuce를 선택한 기준은 향로(이동욱)님의 블로그에 자세히 나와있어요 👍👍👍
❓redisTemplate에 왜 빈 네임을 붙였나요? → @Bean(name = "redisTemplate")
⚠️ @Bean(name = "redisTemplate")
로 name을 커스터마이징 하지 않으면
A component required a bean named 'redisTemplate' that could not be found.
와 같은 에러가 발생하므로 빈 이름을 설정해주었어요.
이 클래스는 난수를 만드는 역할이에요.
@Component
public class CertificationGenerator {
public String createCertificationNumber() throws NoSuchAlgorithmException {
String result;
do {
int num = SecureRandom.getInstanceStrong().nextInt(999999);
result = String.valueOf(num);
} while (result.length() != 6);
return result;
}
}
난수를 만드는 방법 중에 저는 세 가지 방식이 떠올랐어요.
저는 마지막 방식인 SecureRandom 클래스 활용했어요. 이 클래스는 암호학적으로 안전한 난수를 생성하기에 더 안정적이고 무작위성에 대한 품질이 높다고 생각했기 때문이에요.
이 클래스는 redis에 데이터를 저장· 조회 · 삭제 하는 역할이에요.
@Repository
@RequiredArgsConstructor
public class CertificationNumberDao {
private final StringRedisTemplate redisTemplate;
public void saveCertificationNumber(String email, String certificationNumber) {
redisTemplate.opsForValue()
.set(email, certificationNumber,
Duration.ofSeconds(EMAIL_VERIFICATION_LIMIT_IN_SECONDS));
}
public String getCertificationNumber(String email) {
return redisTemplate.opsForValue().get(email);
}
public void removeCertificationNumber(String email) {
redisTemplate.delete(email);
}
public boolean hasKey(String email) {
Boolean keyExists = redisTemplate.hasKey(email);
return keyExists != null && keyExists;
}
}
“EMAIL_VERIFICATION_LIMIT_IN_SECONDS”은 180으로 설정해서 유효기간은 3분이에요.
redisTemplate의 opsForValue
메서드를 통해 String 자료구조 데이터를 활용할 수 있어요.
이 클래스는 Mail과 관련된 요청과 그에 대한 로직 처리 후 응답해주는 역할이에요.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/mails")
public class MailController {
private final MailSendService mailSendService;
private final MailVerifyService mailVerifyService;
@PostMapping("/send-certification")
public ResponseEntity<ApiResponse<EmailCertificationResponse>> sendCertificationNumber(@Validated @RequestBody EmailCertificationRequest request)
throws MessagingException, NoSuchAlgorithmException {
mailSendService.sendEmailForCertification(request.getEmail());
return ResponseEntity.ok(ApiResponse.success());
}
@GetMapping("/verify")
public ResponseEntity<ApiResponse<Void>> verifyCertificationNumber(
@RequestParam(name = "email") String email,
@RequestParam(name = "certificationNumber") String certificationNumber
) {
mailVerifyService.verifyEmail(email, certificationNumber);
return ResponseEntity.ok(ApiResponse.success());
}
}
위의 과정에서 언급한 것처럼 메일을 보내는 기능과 메일에 관련된 난수를 인증하는 기능이 있어요.
@NoArgsConstructor
@AllArgsConstructor
@Getter
@EqualsAndHashCode
public class EmailCertificationRequest {
@NotBlank(message = "이메일 입력은 필수입니다.")
@Email(message = "이메일 형식에 맞게 입력해 주세요.")
private String email;
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class EmailCertificationResponse {
private String email;
private String certificationNumber;
}
이 클래스는 이메일에 난수를 포함한 링크를 보내주는 역할이에요.
@Service
@RequiredArgsConstructor
public class MailSendService {
private final JavaMailSender mailSender;
private final CertificationNumberDao certificationNumberDao;
private final CertificationGenerator generator;
public EmailCertificationResponse sendEmailForCertification(String email) throws NoSuchAlgorithmException, MessagingException {
String certificationNumber = generator.createCertificationNumber();
String content = String.format("%s/api/v1/users/verify?certificationNumber=%s&email=%s 링크를 3분 이내에 클릭해주세요.", DOMAIN_NAME, certificationNumber, email);
certificationNumberDao.saveCertificationNumber(email, certificationNumber);
sendMail(email, content);
return new EmailCertificationResponse(email, certificationNumber);
}
private void sendMail(String email, String content) throws MessagingException {
MimeMessage mimeMessage = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
helper.setTo(email);
helper.setSubject(MAIL_TITLE_CERTIFICATION);
helper.setText(content);
mailSender.send(mimeMessage);
}
}
JavaMailSender를 의존하니 그에 대한 내용을 살펴 보아야 해요.
This class represents a MIME style email message.
MIME은 "Multipurpose Internet Mail Extensions"의 약자로, 이메일 메시지를 보다 풍부하게 표현하고 다양한 데이터 유형을 전송하는 데 사용되는 표준 인터넷 프로토콜이에요. MIME 스타일 이메일 메시지는 이러한 MIME 규격을 사용하여 생성된 이메일 메시지를 나타내는 것이라고 볼 수 있어요.
MimeMessage mimeMessage = mailSender.createMimeMessage();
createMimeMessage 메서드는 보낸 사람의 기본 JavaMail Session에 대한 새 JavaMail MimeMessage를 만들어요. 클라이언트가 준비하고 보낼 수 있는 MimeMessage 인스턴스를 만들려면 호출해야 해요.
Helper class for populating a MimeMessage.
이 클래스는 MimeMessage를 채우는 것을 도와주는 역할이에요.
주석으로 사용방법을 편리하게 설명해 주고 있어요.
이 클래스는 이메일에 대한 인증코드가 일치하는지 확인하는 역할이에요.
저는 다음과 같은 예외사항이 떠올랐기에 메서드들을 다음과 같이 분리했어요.
@Service
@RequiredArgsConstructor
public class MailVerifyService {
private final CertificationNumberDao certificationNumberDao;
public void verifyEmail(String email, String certificationNumber) {
if (!isVerify(email, certificationNumber)) {
throw new InvalidCertificationNumberException();
}
certificationNumberDao.removeCertificationNumber(email);
}
private boolean isVerify(String email, String certificationNumber) {
boolean validatedEmail = isEmailExists(email);
if (!isEmailExists(email)) {
throw new EmailNotFoundException();
}
return (validatedEmail &&
certificationNumberDao.getCertificationNumber(email).equals(certificationNumber));
}
private boolean isEmailExists(String email) {
return certificationNumberDao.hasKey(email);
}
}
먼저 이메일 인증 요청을 하면 다음과 같은 응답을 받아요.
그리고 redis를 통해 값을 확인해도 같아요. redis에 잘 저장된 것도 확인할 수 있어요.
이제 인증코드 검증을 해봐요.
위의 링크를 클릭하면 아래와 같이 요청이 가고 이렇게 인증이 끝나요.
그런데 기능을 모두 구현하였지만 한 가지 고민사항이 생겼어요. 이메일 인증 요청을 보내는 시간이 약 3초~6초 사이의 시간이 걸리고, 이는 무시할 수 없는 시간이에요. 이에 대한 고민과 해결 과정은 다음 편에 올릴게요.