안녕하세요.
프로젝트를 진행하는 과정에서 사용자가 회원가입을 위해 입력한 이메일이 실제 사용자의 이메일인지 확인할 수 없다는 문제가 발생했습니다. 따라서 사용자가 입력한 이메일이 정말 본인 이메일인지 검증하는 기능을 개발하게 됐습니다. 기존에는 사용자가 임의의 문자열을 입력해도 가입할 수 있었다면, 이제부터는 이메일로 발송된 인증코드를 입력해야만 가입할 수 있습니다.
이메일 발송 솔루션을 통해 기능을 구현하는 방법이 일반적이지만, 솔루션 구입에 추가적인 비용이 발생하기 때문에 간편하면서 무료로 사용할 수 있는 웹 메일 기반 이메일 인증 방식을 적용했습니다. 본 포스트에서는 Gmail SMTP를 바탕으로 이메일 인증 기능을 구현했습니다.
1. Flow
(1) 이메일 인증 요청 & 인증코드 발송
사용자가 서버로 이메일 인증 요청을 보내면, 서버는 SMTP를 통해 클라이언트가 보낸 이메일로 인증코드를 전송합니다.
(2) 인증코드 임시 저장
이메일로 전송한 인증코드가 일치하는지 비교해야하기 때문에 사용자별로 전송한 인증코드를 저장할 수 있는 공간이 필요합니다. 인증코드는 이메일 인증 과정에서 사용되고 버려지므로 데이터를 영구적으로 관리할 수 있는 RDBMS에 저장할 필요가 없습니다.
본 포스트에서는 인메모리 기반 데이터 저장방식을 사용하는 Redis를 활용했습니다. Redis는 키-값 구조로 데이터를 저장하며, 서버가 꺼지면 데이터도 함께 없어지는 휘발성 특징을 가지고 있습니다. 또한, 저장하는 데이터별로 만료기한을 지정할 수 있어 일시적인 데이터를 저장해서 사용하는 경우에 유용합니다.
저는 서버로부터 발급받은 인증코드를 Key로 설정하고, 만료기한과 이메일 인증여부를 Value로 연결되도록 구조를 설계했습니다. 인증코드는 숫자와 알파벳을 포함한 무작위 6자리 값으로 설정했으며, 만료기한은 인증코드를 발급받고 5분 후로 설정했습니다.
(3) 인증코드 비교
사용자가 이메일로 전달받은 인증코드를 입력해서 요청을 보내면, 서버는 Redis에 저장된 인증코드를 불러온 후 비교하는 과정을 수행합니다. 만약 사용자가 5분이 지난 뒤 인증코드를 입력하면, 인증에 실패하며 인증코드를 재발송해달라는 알림을 받습니다.
(4) 인증 성공 이후
이메일 인증을 완료하면 서버는 사용자가 회원가입 요청을 보낼 수 있도록 Redis에 저장된 인증여부 값을 'Y'로 변경합니다.
2. Gmail SMTP 설정
(1) Google 계정 관리에 접속한 후 보안 탭에서 '2단계 인증'으로 접근합니다.
(2) 2단계 인증을 위한 전화번호를 추가하고, 인증번호를 입력해서 인증을 완료합니다.
(3) 다시 Google 계정 관리로 돌아와서 아래와 같이 검색창에 '앱 비밀번호'를 입력하고 검색합니다.
(4) 앱 이름을 입력하고 생성된 앱 비밀번호를 저장해두도록 합니다.
🚩 Check Point
화면을 닫은 이후에는 앱 비밀번호를 찾을 수 있는 방법이 없기 때문에 반드시 메모해두도록 합니다.
3. 소스코드 작성
(1) Dependency (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-mail'
}
(2) Properties (application.yml)
mail:
smtp:
host: "smtp.gmail.com"
address: "인증코드를 발송하는 이메일 주소"
personal: "인증코드를 발송하는 송신인명"
username: "구글ID"
password: "발급받은 앱 비밀번호"
port: 465
(3) MailProperties
package com.bluewhaletech.Ourry.properties;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
@Getter
@ConfigurationProperties(prefix = "mail")
public class MailProperties {
private final String host;
private final String address;
private final String personal;
private final String userName;
private final String password;
private final int port;
@ConstructorBinding
public MailProperties(String host, String address, String personal, String username, String password, int port) {
this.host = host;
this.address = address;
this.personal = personal;
this.userName = username;
this.password = password;
this.port = port;
}
}
(4) MailConfig
package com.bluewhaletech.Ourry.config;
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
public class MailConfig {
@Value("${mail.smtp.host}")
private String host;
@Value("${mail.smtp.username}")
private String username;
@Value("${mail.smtp.password}")
private String password;
@Value("${mail.smtp.port}")
private int port;
@Bean
public JavaMailSender javaMailService() {
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost(host);
javaMailSender.setUsername(username);
javaMailSender.setPassword(password);
javaMailSender.setPort(port);
javaMailSender.setJavaMailProperties(getMailProperties());
javaMailSender.setDefaultEncoding("UTF-8");
return javaMailSender;
}
private Properties getMailProperties() {
Properties properties = new Properties();
properties.setProperty("mail.smtp.auth", "true");
properties.setProperty("mail.smtp.starttls.enable", "true");
properties.setProperty("mail.smtp.starttls.required", "true");
properties.setProperty("mail.smtp.ssl.enable", "true");
properties.setProperty("mail.smtp.ssl.trust", host);
properties.setProperty("mail.debug", "true");
return properties;
}
}
(5) RedisConfig
package com.bluewhaletech.Ourry.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.util.Map;
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Map<String, String>> redisEmailAuthenticationTemplate() {
RedisTemplate<String, Map<String, String>> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
(6) RedisEmailAuthentication
package com.bluewhaletech.Ourry.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class RedisEmailAuthentication {
private final StringRedisTemplate redisTemplate;
@Autowired
public RedisEmailAuthentication(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public String checkEmailAuthentication(String key) {
HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();
return hashOperations.get(key, "auth");
}
public String getEmailAuthenticationCode(String key) {
HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();
return hashOperations.get(key, "code");
}
public void setEmailAuthenticationExpire(String email, String code, long duration) {
HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();
hashOperations.put(email, "code", code);
hashOperations.put(email, "auth", "N");
redisTemplate.expire(email, Duration.ofMinutes(duration));
}
public void setEmailAuthenticationComplete(String email) {
HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();
hashOperations.put(email, "auth", "Y");
}
public void deleteEmailAuthenticationHistory(String key) {
HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();
hashOperations.delete(key, "code");
hashOperations.delete(key, "auth");
}
}
(7) MailService
package com.bluewhaletech.Ourry.service;
import com.bluewhaletech.Ourry.dto.EmailDTO;
import jakarta.mail.MessagingException;
import java.io.UnsupportedEncodingException;
public interface MailService {
String sendMail(EmailDTO dto) throws MessagingException, UnsupportedEncodingException;
}
(8) MailServiceImpl
package com.bluewhaletech.Ourry.service;
import com.bluewhaletech.Ourry.dto.EmailDTO;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
@Slf4j
@Service
public class MailServiceImpl implements MailService {
@Value("${mail.smtp.address}")
private String address;
@Value("${mail.smtp.personal}")
private String personal;
private final JavaMailSender mailSender;
@Autowired
public MailServiceImpl(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Override
public String sendMail(EmailDTO dto) throws MessagingException, UnsupportedEncodingException {
/* 이메일 발송 */
MimeMessage message = createMessage(dto.getEmail(), dto.getTitle(), dto.getText());
mailSender.send(message);
return "SUCCESS";
}
private MimeMessage createMessage(String recipient, String title, String text) throws MessagingException, UnsupportedEncodingException {
MimeMessage message = mailSender.createMimeMessage();
message.setFrom(new InternetAddress(address, personal));
message.setRecipients(Message.RecipientType.TO, recipient);
message.setSubject(title);
message.setText(text, "UTF-8", "html");
return message;
}
}
(9) MemberServiceImpl
@Slf4j
@Service
public class MemberServiceImpl implements MemberService {
private final MailServiceImpl mailService;
private final RedisEmailAuthentication redisEmailAuthentication;
@Autowired
public MemberServiceImpl(MailServiceImpl mailService, RedisEmailAuthentication redisEmailAuthentication) {
this.mailService = mailService;
this.redisEmailAuthentication = redisEmailAuthentication;
}
@Transactional
public void sendAuthenticationCode(EmailAddressDTO dto) throws MessagingException, UnsupportedEncodingException {
/* 인증코드 생성 및 유효기간 5분으로 설정 */
String code = createRandomCode();
/* Redis 내부에 생성한 인증코드 저장 */
redisEmailAuthentication.setEmailAuthenticationExpire(dto.getEmail(), code, 5L);
String text = "";
text += "안녕하세요. Ourry입니다.";
text += "<br/><br/>";
text += "인증코드 보내드립니다.";
text += "<br/><br/>";
text += "인증코드 : <b>"+code+"</b>";
EmailDTO data = EmailDTO.builder()
.email(dto.getEmail())
.title("이메일 인증코드 발송 메일입니다.")
.text(text)
.build();
/* 입력한 이메일로 인증코드 발송 */
mailService.sendMail(data);
}
@Transactional
public void emailAuthentication(EmailAuthenticationDTO dto) {
/* 회원 이메일로 전송된 인증코드 */
String code = redisEmailAuthentication.getEmailAuthenticationCode(dto.getEmail());
/* Redis 내부에 이메일이 존재하는지 확인 */
if(code == null) {
throw new MemberNotFoundException("등록되지 않은 이메일입니다.");
}
/* 입력한 인증코드와 발송된 인증코드 값 비교 */
if(!code.equals(dto.getCode())) {
throw new EmailAuthenticationCodeMismatchException("이메일 인증코드가 일치하지 않습니다.");
}
/* 이메일 인증 완료 처리 */
redisEmailAuthentication.setEmailAuthenticationComplete(dto.getEmail());
}
}
4. 결과
마무리
이번 포스팅에서는 이메일 인증 기능을 구현하는 과정에 대해 살펴봤습니다. 로직이 많이 복잡하지 않고, 비교적 간단하게 구현할 수 있어서 관심 있으신 분들은 한번쯤 개발해서 프로젝트에 적용해보는것도 괜찮아보이네요! 기능을 개발하는 과정에서 어려움이 있거나 오류가 발생하신 분들은 댓글로 남겨주시면 확인해보고 답글 달아드리겠습니다.
포스팅한 내용이 도움이 되었다면 공감 부탁드립니다 :)
감사합니다.
댓글