[Spring + SMTP + Redis] 아이디 찾기 - 이메일 인증 구현

olive3·2023년 11월 29일
3

Redis

Rediskey-value 형태로 데이터를 저장하고 관리하는 데이터베이스로 TTL 을 설정할 수 있어 이메일 인증을 구현하는데 적합하다.

Mac Redis 설치

$ brew install redis

$ brew services start redis
$ brew services stop redis
$ brew services restart redis

$ redis-cli

build.gradle 의존성 추가

implementation 'org.springframework.data:spring-data-redis'
implementation 'io.lettuce:lettuce-core:6.2.3.RELEASE'

application.yml 설정

spring:
  data:
    redis:
      host: localhost
      port: 6379

Gmail SMTP

SMTP(Simple Mail Transfer Protocol)는 인터넷을 통해 이메일 메시지를 보내고 받는데 사용되는 통신 프로토콜이고 SMTP 서버SMTP를 사용하여 이메일을 전송하는 애플리케이션이다. Gmail을 사용하여 이메일을 보내도록 하기위해 Gmail SMTP 서버를 사용하자.

Gmail SMTP 설정

  1. Gmail 우측상단 톱니바퀴 -> 모든 설정 보기

  2. 전달 및 POP/IMAP -> IMAP 사용 -> 변경사항 저장

  3. 구글 계정 -> 보안 -> 2단계 인증

  4. 2단계 인증 -> 앱 비밀번호

  5. 이름 입력(ex. GMAIL_SMTP) -> 만들기

  6. 생성된 앱 비밀번호 확인 -> 앱 비밀번호 복사해서 다른 곳에 저장

  7. GMAIL_SMTP 앱 생성된 것을 확인

build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-mail'

application.yml

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: [이메일]
    password: [앱 비밀번호]
    properties:
      mail.smtp.auth: true
      mail.smtp.starttls.enable: true
      mail.smtp.starttls.required: true
      mail.mime.charset: UTF-8

Gmail SMTP는 포트 번호로 465 또는 587 사용가능하다.


구현

findId.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="inner">

    <h2>아이디 찾기</h2>
    <p>계정에 등록된 이메일을 인증하시면 <br/>사용중인 계정의 아이디를 알려드립니다.</p>

    <form action="./showId.html" th:action th:object="${form}" th:method="post" th:onsubmit="return validateAuthCode()">
        <div class="category email-category">
            <label for="email">이메일<span style="color: #dc3545">*</span></label>
            <div class="category-container">
                <input type="text" id="email" th:field="*{email}" th:errorclass="field-error"/>
                <button type="button" id="auth-request">인증요청</button>
            </div>
            <div class="field-error" th:errors="*{email}"></div>
        </div>

        <h4 class="auth-request-message"></h4>

        <div class="category auth-category">
            <label for="auth-code"></label>
            <div class="category-container">
                <input type="text" id="auth-code" th:field="*{authCode}" th:errorclass="field-error"
                       placeholder="인증번호 입력"/>
                <button type="submit" id="auth-confirm">인증</button>
            </div>
            <div class="field-error" th:errors="*{authCode}"></div>
        </div>

        <h4 class="auth-confirm-message"></h4>
    </form>
</div>
</body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="../../static/js/findId.js" th:src="@{/js/findId.js}"></script>
</html>

RedisConfig

RedisTemplate을 이용해 Redis에 접근

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}
  • LettuceConnectionFactory : LettuceRedis와 연결
  • setKeySerializer, setValueSerializer : 직렬화, 역직렬화를 위해 설정

RedisUtil

@Service
@RequiredArgsConstructor
public class RedisUtil {

    private final StringRedisTemplate redisTemplate;

    public String getData(String key) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        return valueOperations.get(key);
    }

    public boolean existData(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    public void setDataExpire(String key, String value, long duration) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        Duration expireDuration = Duration.ofSeconds(duration);
        valueOperations.set(key, value, expireDuration);
    }

    public void deleteData(String key) {
        redisTemplate.delete(key);
    }

}
  • getData() : key 해당하는 value 조회
  • existData() : key 해당하는 value 존재유무 확인
  • setDataExpire() : 데이터에 만료 시간을 설정해서 keyvalueRedis에 저장
  • deleteData() : key에 해당하는 데이터 삭제

두가지 기능을 나누어서 설명하겠다.
1. 인증코드를 담은 메일 전송
2. 인증코드가 맞는지 확인

인증코드 담은 이메일 전송

  1. 사용자가 이메일을 입력하고 인증요청 버튼을 클릭
  2. Ajax를 통해 [사용자가 입력한 이메일]을 전달하면서 메일 전송을 스프링에 요청
  3. 스프링이 인증코드를 생성해 html 기반으로 메일을 작성하고 Gmail SMTP 서버에게 [사용자가 입력한 이메일]로 메일 전송을 요청
  4. Gmail SMTP가 메일을 전송하고 스프링은 Redis[사용자가 입력한 이메일] - 인증코드 를 저장한다.

findId.js

document.getElementById('auth-request').addEventListener('click', function () {
    if(!hasEmailValue()) {
        // 검증 코드
    }
    else if(!checkEmailRegex()) {
       // 검증 코드
    }
    else {
        $.ajax({
            type: 'get',
            url: '/email/auth?address=' + $('#email').val(),
            async: false,
            dataType: 'json',
            success: function (result) {
                let isSuccess = result['success'];
                let message = result['message'];
                if(isSuccess) {
                    // css 설정
                } else {
                    // css 설정
                }
            }
        });
    }
});
  • GET /email/auth?address=[사용자가 입력한 이메일]
  • dataType: 'json' : 서버로부터 json 타입 반환받을 것이다.

EmailController

@RestController
@RequiredArgsConstructor
@RequestMapping("/email")
public class EmailController {

    private final EmailService emailService;

    @GetMapping("/auth")
    public EmailAuthResponse sendAuthCode(@RequestParam String address) {
        return emailService.sendEmail(address);
    }
    
    ...
}
  • sendAuthCode() 호출

EmailService

@Service
@RequiredArgsConstructor
public class EmailService {

    @Value("${spring.mail.username}")
    private String senderEmail;

    private final JavaMailSender mailSender;
    private final RedisUtil redisUtil;

    public EmailAuthResponse sendEmail(String toEmail) {
        if(redisUtil.existData(toEmail)) {
            redisUtil.deleteData(toEmail);
        }

        try {
            MimeMessage emailForm = createEmailForm(toEmail);
            mailSender.send(emailForm);
            return new EmailAuthResponse(true, "인증번호가 메일로 전송되었습니다.");
        } catch(MessagingException | MailSendException e) {
            return new EmailAuthResponse(false, "메일 전송 중 오류가 발생하였습니다. 다시 시도해주세요.");
        }
    }

    private MimeMessage createEmailForm(String email) throws MessagingException {

        String authCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));

        MimeMessage message = mailSender.createMimeMessage();
        message.setFrom(senderEmail);
        message.setRecipients(MimeMessage.RecipientType.TO, email);
        message.setSubject("인증코드입니다.");
        message.setText(setContext(authCode), "utf-8", "html");

        redisUtil.setDataExpire(email, authCode, 10 * 60L); // 10분

        return message;
    }

    private String setContext(String authCode) {
        String body = "";
        body += "<h4>" + "인증 코드를 입력하세요." + "</h4>";
        body += "<h2>" + "[" + authCode + "]" + "</h2>";
        return body;
    }
    
    ...
}
  • sendEmail() : 메일을 전송하는 메서드로 메일 전송 성공 여부와 사용자에게 보여줄 메세지를 담은 EmailAuthResponse를 반환한다.
  • createEmailForm() : 메일 발신자, 메일 수신자, 메일 제목, 메일 내용을 설정하고 Redis메일 수신자인증코드를 저장한다.
  • setContext() : 메일 내용 html로 작성

전송된 이메일

  • 사용자가 입력한 이메일로 인증코드가 전송되었다.

인증코드 일치

사용자가 메일로 받은 인증코드를 입력하고 인증 버튼을 클릭하면 submit하기 전에 th:onsubmit="return validateAuthCode()" 에서 ajax를 통해 올바른 인증코드인지 확인한다. 참고로 onsubmitform전송을 하기 전에 입력된 데이터의 유효성을 체크하기 위해 사용하는 이벤트이다.

findId.js

function validateAuthCode() {

    let isAuthCodeValidate = false;

    if(!hasAuthCodeValue()) {
        // 검증 코드
      	..
    } else {
        $.ajax({
            type: 'post',
            url: '/email/auth?address=' + $('#email').val(),
            async: false,
            dataType: 'json',
            data: {"authCode": $('#auth-code').val()},
            success: function (result) {
                let isSuccess = result['success'];
                let message = result['message'];
                if (isSuccess) {
                    isAuthCodeValidate = true;
                } else {
                    // css 설정 
                }
            }
        })
    }

    return isAuthCodeValidate;
}
  • POST /email/auth?address=[사용자가 입력한 이메일]
    {"authCode : "[사용자가 입력한 인증코드]"}
  • dataType: 'json' : 서버로부터 json 타입 반환받을 것이다.

EmailController

@RestController
@RequiredArgsConstructor
@RequestMapping("/email")
public class EmailController {

    private final EmailService emailService;

    ...
    
    @PostMapping("/auth")
    public EmailAuthResponse checkAuthCode(@RequestParam String address, @RequestParam String authCode) {
        return emailService.validateAuthCode(address, authCode);
    }
}
  • checkAuthCode() 호출

EmailService

@Service
@RequiredArgsConstructor
public class EmailService {

    @Value("${spring.mail.username}")
    private String senderEmail;

    private final JavaMailSender mailSender;
    private final RedisUtil redisUtil;

	...

    public EmailAuthResponse validateAuthCode(String email, String authCode) {
        String findAuthCode = redisUtil.getData(email);
        if(findAuthCode == null) {
            return new EmailAuthResponse(false, "인증번호가 만료되었습니다. 다시 시도해주세요.");
        }

        if(findAuthCode.equals(authCode)) {
            return new EmailAuthResponse(true, "인증 성공에 성공했습니다.");

        } else {
            return new EmailAuthResponse(false, "인증번호가 일치하지 않습니다.");
        }
    }
}
  • 인증에 성공하면 true가 반환되어 submit 된다.

MemberController

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/find-id")
    public String findId(@ModelAttribute("form") FindIdForm form) {
        return "members/findId";
    }

    @PostMapping("/find-id")
    public String showId(@Valid @ModelAttribute("form") FindIdForm form, BindingResult bindingResult, Model model) {

        if(bindingResult.hasErrors()) {
            return "members/findId";
        }

        String encodedId = memberService.findLoginIdByEmail(form.getEmail());
        model.addAttribute("encodedId", encodedId);
        return "members/showId";
    }
}
  • showId() 호출 : 이메일로 아이디 찾아서 반환한다.

실행 결과

  1. 이메일 입력하고 인증 요청
  2. 이메일로 인증 코드 받음
  3. Redis에 잘 저장된 것을 확인
  4. 인증코드 입력하면 아이디 찾기 완료

참고: https://velog.io/@juno0713/Spring-Redis-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%9D%B8%EC%A6%9D-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4

3개의 댓글

comment-user-thumbnail
2023년 12월 1일

오 도움되는 글 감사하무니다

답글 달기
comment-user-thumbnail
2024년 5월 13일

EmailAuthResponse에 관한 인터페이스 내용이 생략된 것 같은데 그 부분은 어떻게 처리하셨나요?

답글 달기
comment-user-thumbnail
2024년 5월 13일

그리고 또 emailService 부분 sendEmail 메소드랑 validateAuthCode 메소드는 어떻게 처리하셨나요? ㅠㅠㅠ...
MemberService 랑 FindIdForm은 어떻게 처리하셨는지도 궁금합니다...

답글 달기