Get으로 쿼리에 이메일을 담아 보내면, Spring에서는 난수를 생성해 Thymeleaf
기반의 html 파일에 넣고, 쿼리의 이메일 주소로 메일 송신 요청을 SMTP Server
에 보낸다.
그러면 네이버의 SMTP Server
는 클라이언트의 이메일에 메일은 보내게 된다.
그리고 동시에 Redis
서버에 key
는 이메일주소고 value
는 난수인 데이터를 저장한다.
이후 POST로 JSON내부에 난수코드를 넣어, 위와 같이 또 쿼리에 이메일을 담아 보내면, 이메일 주소를 Redis
에 조회해서 해당 데이터와 일치하는지 검증한다.
만약 일치한다면, 이메일과 검증 시간을 조합하여 만든 난수를 memberID
로 보내준다.
구글은 과정이 훨씬 복잡해서 네이버를 사용했다.
네이버 메일에 들어가서 환경설정을 클릭해준다.
만약 구버전을 사용하고 있다면 좌하단 에 있는 환경설정을 클릭하면 된다.
이후 POP3/STMP 설정에 들어가서, 사용에 체크하자.
그리고 아래의 정보들을 기억해놓자. 나중에 스프링과 STMP 서버를 연결할 때 필요한 정보들이다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
implementation group: 'org.springframework', name: 'spring-web', version: '5.3.23'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.thymeleaf:thymeleaf:3.0.15.RELEASE'
implementation 'org.springframework.data:spring-data-redis:2.7.5'
implementation 'io.lettuce:lettuce-core:6.2.1.RELEASE'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
다른 의존성들은 다른 Spring Boot
프로젝트에서 충분히 사용했으니 중요한 것 위주로 보자.
Spring Boot Starter Mail
: Spring으로 465포트를 통해 메일을 보내주는 역할을 해준다.Spring Data Redis
: Redis와 Spring을 통신할 수 있게 해준다.Lettuce
: 확장가능한, thread-safe한 Redis 클라이언트다. Redis 통신 프레임워크로 이해하면 쉽다.aplication.yml
spring:
mail:
host: smtp.naver.com
port: 465
username: ${EMAIL}
password: ${EMAIL_PW}
properties:
mail.smtp.auth: true
mail.smtp.ssl.enable: true
mail.smtp.ssl.trust: smtp.naver.com
redis:
host: localhost
port: 6379
먼저 메일 부분부터 보면, 위의 네이버 메일 설정을 했을때 사용한 정보들을 그대로 사용했다. host는 네이버의 smtp서버를 사용했고, port도 위의 설정과 같이 465로 설정했다.
username과 password는 환경변수를 썼다. 평문으로 넣어도 상관 없다.
메일의 smtp 인증, ssl인증서 부분을 모두 true로 설정해놓고, ssl 신뢰대상엔 smtp 네이버 서버를 넣으면 된다.
Redis
관련 부분에서는 내부 Docker
로 실행할 것이기 때문에 localhost와 포트설정만 하면 된다. Docker
에서 Redis
를 연결하는 방법은 이전 글을 참조하면 된다.
resources/templates/mail.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div style="margin:120px">
<div style="margin-bottom: 10px">
<h1>인증 코드 메일입니다.</h1>
<br/>
<h3 style="text-align: center;"> 아래 코드를 사이트에 입력해주십시오</h3>
</div>
<div style="text-align: center;">
<h2 style="color: crimson;" th:text="${code}"></h2>
</div>
<br/>
</div>
</body>
</html>
간단한 html코드며 Thymeleaf
로 코드만 주입하는 정도의 로직이다.
앞서 말했듯이 먼저 Redis
와 접근하기 위해 Spring Data Redis
라이브러리를 사용하는데, Spring Data Redis
에서도 접근방식은 2가지가 있다.
하나는 Spring Data JPA
처럼 Redis Repository
인터페이스를 생성하여 연결하는 방법과, Redis Temaplate
를 이용하는 방법이다. 이번 로직에서는 딱히 엔티티가 있지도 않고, 이메일 주소와 인증코드만 저장하면 되기 때문에 후자를 이용하겠다.
config/RedisConfig
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;
@Configuration
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory((redisConnectionFactory()));
return redisTemplate;
}
}
Redis
와 관련된 기본적인 설정파일을 만들고 필요한 Bean
들을 등록해주기 위한 파일이다.
RedisConnectionFactory
는 application.yml
에 있는 port와 host를 불러와서 Redis와 연결해주는 역할을 하고,
RedisTemplate
는 RedisConnection
을 통해 넘어온 byte
값을 객체 직렬화(Serialization
) 해준다.
util/RedisUtil
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import java.time.Duration;
@RequiredArgsConstructor
@Service
public class RedisUtil {
private final StringRedisTemplate template;
public String getData(String key) {
ValueOperations<String, String> valueOperations = template.opsForValue();
return valueOperations.get(key);
}
public boolean existData(String key) {
return Boolean.TRUE.equals(template.hasKey(key));
}
public void setDataExpire(String key, String value, long duration) {
ValueOperations<String, String> valueOperations = template.opsForValue();
Duration expireDuration = Duration.ofSeconds(duration);
valueOperations.set(key, value, expireDuration);
}
public void deleteData(String key) {
template.delete(key);
}
}
기본적인 crud 로직이다.
일단 우리는 대부분 String
으로만 값을 주고받을 것이기 때문에 StringRedisTemplate
이라는 의존성을 주입해주고 이것을 기반으로 로직을 실행한다.
getData
는 key
로 value
를 가져오는 메소드고, existData
해당 key
에 해당하는 value
가 존재하는지 확인하는 메소드다.
setData
는 새로은 key - value
쌍을 저장하는 메소드지만, 그동안 배웠던 SQL문과 달리 만료시간을 정할 수 있다.
마지막으로 deleteData
는 key
에 해당하는 데이터를 지우는 메소드다.
@ExtendWith(SpringExtension.class)
@SpringBootTest
class EmailtestApplicationTests {
@Autowired
private RedisUtil redisUtil;
@Test
public void redisTest () throws Exception {
//given
String email = "test@test.com";
String code = "aaa111";
//when
redisUtil.setDataExpire(email, code, 60 * 60L);
//then
Assertions.assertTrue(redisUtil.existData("test@test.com"));
Assertions.assertFalse(redisUtil.existData("test1@test.com"));
Assertions.assertEquals(redisUtil.getData(email), "aaa111");
}
성공적으로 진행된다.
서비스 파일이 좀 길기 때문에 나눠서 보자.
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
private final RedisUtil redisUtil;
@Value("${spring.mail.username}")
private String configEmail;
...
}
서비스에서 주로 사용할 의존성은 총 2개다.
앞서 설정한 RedisUtil
과 메일을 보내기 위한 JavaMailSender
인터페이스.
그리고 송신할 이메일 주소는 앞에서 같이 application.yml
에서 추출해낸다.
private String createdCode() {
int leftLimit = 48; // number '0'
int rightLimit = 122; // alphabet 'z'
int targetStringLength = 6;
Random random = new Random();
return random.ints(leftLimit, rightLimit + 1)
.filter(i -> (i <=57 || i >=65) && (i <= 90 || i>= 97))
.limit(targetStringLength)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
}
난수를 만드는 메소드다. 0~9와 a~z까지의 숫자와 문자를 섞어서 6자리 난수를 만든다.
private String setContext(String code) {
Context context = new Context();
TemplateEngine templateEngine = new TemplateEngine();
ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
context.setVariable("code", code);
templateResolver.setPrefix("templates/");
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode(TemplateMode.HTML);
templateResolver.setCacheable(false);
templateEngine.setTemplateResolver(templateResolver);
return templateEngine.process("mail", context);
}
context
, 즉 여기서는 Thymeleaf
기반의 html 파일에 값을 넣고 연결하는 메소드다.
ClassLoaderTemplateResolver
와 TemplateEngine
을 통해 resources/templates
에 위치한 mail.html
을 Spring과 연결해 주고,
Context
를 통해 매개변수를 Thymeleaf
안에 할당해준다.
private MimeMessage createEmailForm(String email) throws MessagingException {
String authCode = createdCode();
MimeMessage message = mailSender.createMimeMessage();
message.addRecipients(MimeMessage.RecipientType.TO, email);
message.setSubject("안녕하세요 인증번호입니다.");
message.setFrom(configEmail);
message.setText(setContext(authCode), "utf-8", "html");
redisUtil.setDataExpire(email, authCode, 60 * 30L);
return message;
}
앞서 말한 메소드를 통해 코드를 만들어주고, MimeMessage
객체 안에 코드, 송신 이메일, Context
를 담아주고, 난수와 수신 이메일은 Redis
안에 저장한다
public void sendEmail(String toEmail) throws MessagingException {
if (redisUtil.existData(toEmail)) {
redisUtil.deleteData(toEmail);
}
MimeMessage emailForm = createEmailForm(toEmail);
mailSender.send(emailForm);
}
만든 메일을 보내주는 메소드이며, JavaMailSender
를 이용한다. 만약 Redis
에 해당 이메일로 된 값이 있다면 db에서 이를 삭제하고 진행한다.
public Boolean verifyEmailCode(String email, String code) {
String codeFoundByEmail = redisUtil.getData(email);
if (codeFoundByEmail == null) {
return false;
}
return codeFoundByEmail.equals(code);
}
보낸 이메일과 코드가 일치하는지 검증하는 메소드다.
Redis
에서 키와 값을 꺼내 봐서 일치하면 true, 그렇지 않으면 false를 반환한다.
public String makeMemberId(String email) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(email.getBytes());
md.update(LocalDateTime.now().toString().getBytes());
StringBuilder builder = new StringBuilder();
for (byte b: md.digest()) {
builder.append(String.format("%02x", b));
}
return builder.toString();
}
모든 인증에 성공했을때 memgerId
를 생성해서 보내주는 로직이다.
해쉬암호화를 사용하며 로직은 SHA-256
이다.
service/EmailService
import com.kyc.emailtest.util.RedisUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Random;
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
private final RedisUtil redisUtil;
@Value("${spring.mail.username}")
private String configEmail;
private String createdCode() {
int leftLimit = 48; // number '0'
int rightLimit = 122; // alphabet 'z'
int targetStringLength = 6;
Random random = new Random();
return random.ints(leftLimit, rightLimit + 1)
.filter(i -> (i <=57 || i >=65) && (i <= 90 || i>= 97))
.limit(targetStringLength)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
}
private String setContext(String code) {
Context context = new Context();
TemplateEngine templateEngine = new TemplateEngine();
ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
context.setVariable("code", code);
templateResolver.setPrefix("templates/");
templateResolver.setSuffix(".html");
templateResolver.setTemplateMode(TemplateMode.HTML);
templateResolver.setCacheable(false);
templateEngine.setTemplateResolver(templateResolver);
return templateEngine.process("mail", context);
}
// 메일 반환
private MimeMessage createEmailForm(String email) throws MessagingException {
String authCode = createdCode();
MimeMessage message = mailSender.createMimeMessage();
message.addRecipients(MimeMessage.RecipientType.TO, email);
message.setSubject("안녕하세요 인증번호입니다.");
message.setFrom(configEmail);
message.setText(setContext(authCode), "utf-8", "html");
redisUtil.setDataExpire(email, authCode, 60 * 30L);
return message;
}
// 메일 보내기
public void sendEmail(String toEmail) throws MessagingException {
if (redisUtil.existData(toEmail)) {
redisUtil.deleteData(toEmail);
}
MimeMessage emailForm = createEmailForm(toEmail);
mailSender.send(emailForm);
}
// 코드 검증
public Boolean verifyEmailCode(String email, String code) {
String codeFoundByEmail = redisUtil.getData(email);
System.out.println(codeFoundByEmail);
if (codeFoundByEmail == null) {
return false;
}
return codeFoundByEmail.equals(code);
}
public String makeMemberId(String email) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(email.getBytes());
md.update(LocalDateTime.now().toString().getBytes());
return Arrays.toString(md.digest());
}
}
전체로직은 이렇게 된다.
controller/EmailController
import com.kyc.emailtest.dto.EmailRequestDto;
import com.kyc.emailtest.service.EmailService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.mail.MessagingException;
import java.security.NoSuchAlgorithmException;
@RestController
@RequiredArgsConstructor
@RequestMapping("/email")
public class EmailController {
private final EmailService emailService;
@GetMapping("/{email_addr}/authcode")
public ResponseEntity<String> sendEmailPath(@PathVariable String email_addr) throws MessagingException {
emailService.sendEmail(email_addr);
return ResponseEntity.ok("이메일을 확인하세요");
}
@PostMapping("/{email_addr}/authcode")
public ResponseEntity<String> sendEmailAndCode(@PathVariable String email_addr, @RequestBody EmailRequestDto dto) throws NoSuchAlgorithmException {
if (emailService.verifyEmailCode(email_addr, dto.getCode())) {
return ResponseEntity.ok(emailService.makeMemberId(email_addr));
}
return ResponseEntity.notFound().build();
}
}
EmailService
의 로직을 연결해서 만든 메소드들이다.
getEmailPath
는 이메일의 주소를 받아 EmailService
의 sendEmail
메소드로 연결해주는 메소드고
getAndVeifyEmailAndCode
는 이메일과 코드를 받아, EmailService
의 verifyEmailCode
에 연결해 참 거짓 여부에 따라 memberId
나 혹은 404 Not Found
를 보낸다.
Postman을 통해 진행해보자.
일단 이메일을 쿼리에 넣고 GET 요청을 넣자 정상적인 통신이 되었다.
그럼 이메일을 확인해보자.
이메일이 정상적으로 도착했다. 이제 이를 복사해서 POST 요청을 해보자.
난수가 도착했다.
만약 유효하지 않은 코드를 보내면?
404 NOT FOUND
를 반환한다.
모든 로직이 정상적으로 작동된다.