Spring + Redis 이메일 인증 애플리케이션

june·2022년 11월 18일
5
post-thumbnail

🤔기본 로직

Get으로 쿼리에 이메일을 담아 보내면, Spring에서는 난수를 생성해 Thymeleaf기반의 html 파일에 넣고, 쿼리의 이메일 주소로 메일 송신 요청을 SMTP Server에 보낸다.

그러면 네이버의 SMTP Server는 클라이언트의 이메일에 메일은 보내게 된다.

그리고 동시에 Redis서버에 key는 이메일주소고 value는 난수인 데이터를 저장한다.

이후 POST로 JSON내부에 난수코드를 넣어, 위와 같이 또 쿼리에 이메일을 담아 보내면, 이메일 주소를 Redis에 조회해서 해당 데이터와 일치하는지 검증한다.

만약 일치한다면, 이메일과 검증 시간을 조합하여 만든 난수를 memberID로 보내준다.

📧 네이버 메일 연결

구글은 과정이 훨씬 복잡해서 네이버를 사용했다.

네이버 메일에 들어가서 환경설정을 클릭해준다.

만약 구버전을 사용하고 있다면 좌하단 에 있는 환경설정을 클릭하면 된다.

이후 POP3/STMP 설정에 들어가서, 사용에 체크하자.

그리고 아래의 정보들을 기억해놓자. 나중에 스프링과 STMP 서버를 연결할 때 필요한 정보들이다.

🍃 Spring

🐘 의존성

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 통신 프레임워크로 이해하면 쉽다.

📙Resources

Properties

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를 연결하는 방법은 이전 글을 참조하면 된다.

Tempalates

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 연결 설정

앞서 말했듯이 먼저 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들을 등록해주기 위한 파일이다.

RedisConnectionFactoryapplication.yml에 있는 port와 host를 불러와서 Redis와 연결해주는 역할을 하고,

RedisTemplateRedisConnection을 통해 넘어온 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이라는 의존성을 주입해주고 이것을 기반으로 로직을 실행한다.

getDatakeyvalue를 가져오는 메소드고, existData 해당 key에 해당하는 value가 존재하는지 확인하는 메소드다.

setData 는 새로은 key - value 쌍을 저장하는 메소드지만, 그동안 배웠던 SQL문과 달리 만료시간을 정할 수 있다.

마지막으로 deleteDatakey에 해당하는 데이터를 지우는 메소드다.

테스트코드

@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

서비스 파일이 좀 길기 때문에 나눠서 보자.

@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 파일에 값을 넣고 연결하는 메소드다.

ClassLoaderTemplateResolverTemplateEngine을 통해 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

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는 이메일의 주소를 받아 EmailServicesendEmail 메소드로 연결해주는 메소드고

getAndVeifyEmailAndCode는 이메일과 코드를 받아, EmailServiceverifyEmailCode에 연결해 참 거짓 여부에 따라 memberId나 혹은 404 Not Found를 보낸다.

📝 테스트

Postman을 통해 진행해보자.

일단 이메일을 쿼리에 넣고 GET 요청을 넣자 정상적인 통신이 되었다.

그럼 이메일을 확인해보자.

이메일이 정상적으로 도착했다. 이제 이를 복사해서 POST 요청을 해보자.

난수가 도착했다.

만약 유효하지 않은 코드를 보내면?

404 NOT FOUND를 반환한다.

모든 로직이 정상적으로 작동된다.

profile
초보 개발자

0개의 댓글