임대일

스프링(Spring): 스프링 부트로 이메일 인증 구현하기(feat. docker, redis, SMTP, IMAP, POP3) 본문

스프링/지식

스프링(Spring): 스프링 부트로 이메일 인증 구현하기(feat. docker, redis, SMTP, IMAP, POP3)

limdae94 2024. 8. 6. 10:56

1. 배경

오늘날 회원 가입에서 휴대전화번호 혹은 이메일 인증 그리고 서드 파티 애플리케이션 인증을 흔하게 볼 수 있다. [회원, 인증] 도메인 담당한 경험이 있다면 언급한 세 가지 인증 수단 중에서 한 가지를 공부하거나 구현한 경험이 있을 것이라고 생각이 든다. 가장 흔한 인증 방식인 서드 파티 애플리케이션을 활용한 로그인 방식은 책 혹은 강의에서 흔하게 공부할 수 있다. 대표적인 서드 파티 애플리케이션은 카카오톡 인증 서비스가 있다. 스마트폰 앱과 웹 페이지 모두 제공해야 하는 서비스에서 서드 파티 애플리케이션 인증은 무상태(Stateless)를 준수하기 위한 최소한의 환경이기 때문에 탁월한 선택이라고 생각이 든다.(지금까지 경험으로 Stateless를 완벽하게 만족하는 것은 어려운 것으로 보인다.) 2023년 하반기부터 서드 파티 애플리케이션에서 제공되는 정보는 사업자가 없으면 개인에게 제공되는 정보는 매우 제한적으로 변경되었다. 그리고 2024년 상반기부터 휴대전화번호 인증 또한 사업자가 없다면 이용하는 것은 거의 불가능하다.

 

간단하게 휴대전화번호 인증 방법에 대해 소개하자면 기업 협약 프로젝트 경험으로 휴대전화번호 인증 API를 구현한 경험이 있다. 공공 데이터 포털 API 혹은 오픈 API처럼 API를 프로젝젝트에 사용하기 위한 필수적인 사항에 대해 충분히 공부하고 코드를 작성한다면 큰 어려움 없이 성공적으로 휴대전화번호 인증을 구현할 수 있었다. 그리고 마지막 인증 수단인 이메일은 구현한 경험이 아직 없다. 그래서 이메일 인증을 구현하면서 공부한 내용을 정리하고 부딪힌 문제점들에 대해 소개하고자 한다.

 

2. SMTP, IMAP, POP3

SMTP(Simple Mail Transfer Protocol)는 인터넷을 통해 이메일 메시지를 보내고 받는 데 사용되는 통신 프로토콜이다. 다른 네트워크 프로토콜과 마찬가지로 컴퓨터와 서버는 SMTP를 이용하여 기반 하드웨어나 소프트웨어와 관계없이 데이터를 교환할 수 있다. 편지 봉투에 표준화된 주소 양식을 사용해서 우편 서비스가 이루어지는 것처럼, SMTP 덕분에 이메일이 발신자에게서 수신자에게로 이동하는 방식이 표준화되므로 광범위하게 이메일을 전송할 수 있다.

 

SMTP는 메일 검색 프로토콜이 아니라 메일 전송 프로토콜이라는 것에 유의하자. 우편 서비스로 우편함에 우편물이 전달되더라도 수신자는 우편함에서 우편물을 찾아야 한다. 마찬가지로 SMTP도 이메일 공급자의 메일 서버로 이메일을 전송하지만, 수신자가 메일 서버에서 이메일을 검색해 읽는 데는 별도의 프로토콜이 이용된다. 이메일을 검색하고 읽기 위한 프로토콜로는 주로 IMAP(Internet Message Access Protocol), POP3(Post Office Protocol version 3)가 사용된다.

 

IMAP는 이메일을 서버에 저장하고, 클라이언트가 서버에 접근하여 이메일을 읽고 관리할 수 있도록 한다. IMAP를 사용하면 여러 기기에서 동일한 이메일 계정을 동기화할 수 있다. 클라이언트는 서버에 있는 메일의 사본을 다운로드하여 볼 수 있고, 메일을 삭제하거나 이동하면 서버에서도 반영된다.

 

POP3는 이메일을 서버에서 클라이언트로 다운로드하여 로컬에 저장하는 방식으로 작동한다. 다운로드 후에는 서버에서 이메일이 삭제되므로, 하나의 기기에서만 이메일을 확인할 수 있다. 이메일을 오프라인에서 관리할 수 있는 장점이 있지만, 여러 기기에서 동기화가 어렵다.

따라서, 이메일을 전송하는 데는 SMTP를 사용하고, 이메일을 검색하고 읽는 데는 IMAP 또는 POP3를 사용한다.

 

3. SMTP 작동 방식

모든 네트워킹 프로토콜은 미리 정의한 데이터 교환 프로세스를 준수한다. SMTP는 이메일 클라이언트와 메일 서버 간의 데이터 교환 프로세스를 정의하며 준수한다. 사용자는 이메일 클라이언트와 상호 작용한다. 이때 이메일 클라이언트란 사용자가 액세스하여 이메일을 전송하는 컴퓨터나 웹 응용 프로그램을 말한다. 메일 서버는 이메일의 전송, 수신, 전달을 위한 특화된 컴퓨터이다. 사용자는 메일 서버와 직접 상호 작용하지 않는다. 다음은 이메일 클라이언트와 전자 메일 서버 사이에서 이메일 전송이 시작되는 과정을 요약한 내용이다.

 

  • SMTP 연결 열림: SMTP는 전송 프로토콜로 전송 제어 프로토콜(TCP)을 이용하므로 첫 번째 단계는 클라이언트와 서버 간 TCP 연결로 시작된다. 그 다음 이메일 클라이언트가 특화된 "Hello" 명령(HELO 또는 EHLO, 아래 설명됨)으로 이메일 전송 프로세스를 시작한다.

 

  • 이메일 데이터 전송: 클라이언트가 이메일 헤더(대상 및 제목 줄 포함), 이메일 본문, 기타 추가 구성 요소로 이루어진 실제 이메일 콘텐츠와 함께 일련의 명령을 서버에 보낸다.

 

  • 메일 전송 에이전트(MTA): 서버가 메일 전송 에이전트(MTA)라는 프로그램을 실행한다. MTA는 수신자의 이메일 주소 도메인을 확인하고 발신자와 다를 경우 수신자의 IP 주소를 찾도록 도메인 네임 시스템(DNS)에 쿼리합니다. 이는 우체국에서 우편물 수신자의 우편번호를 조회하는 것과 비슷하다.

 

  • 연결 닫힘: 데이터 전송이 완료되면 클라이언트가 서버에 알림을 보내며 서버가 연결을 닫는다. 이때 클라이언트가 SMTP 연결을 새로 열지 않는 한 서버는 클라이언트로부터 이메일 데이터를 추가로 받지 않는다.

 

일반적으로, 이 첫 번째 이메일 서버는 이메일의 실제 최종 목적지가 아니다. 클라이언트로부터 이메일을 수신한 서버는 다른 메일 서버와 이러한 SMTP 연결 프로세스를 반복한다. 최종적으로 이메일이 수신자의 이메일 공급자가 제어하는 메일 서버 내 수신자의 받은메일함에 도착할 때까지, 두 번째 서버도 동일한 작업을 수행한다.

 

우편이 발신자에게서 수신자에게로 이동하는 과정과 이 프로세스를 비교해보자. 우편 집배원은 발신자에게서 수신자에게로 직접 편지를 전달하지 않는다. 우편 집배원은 그 대신 편지를 우체국으로 가져간다. 우체국은 편지를 다른 도시의 다른 우체국으로 보내고, 또 다른 우체국으로 가며, 이는 편지가 수신자에게 도착할 때까지 계속된다. 마찬가지로 이메일도 수신자의 받은메일함에 도착할 때까지 SMTP를 통해 서버에서 서버로 이동한다.

 

4. SMTP 봉투란?

SMTP "봉투"는 이메일 클라이언트가 이메일의 출발지와 목적지에 관해 메일 서버에게 보내는 정보의 집합이다. SMTP 봉투는 이메일 헤더 및 본문과 구별되며 이메일 수신자에게는 보이지 않는다.

 

5. SMTP 명령이란?

SMTP 명령은 클라이언트(이메일 보내는 쪽)가 서버(이메일 받는 쪽)에게 이메일을 어떻게 처리할지 지시하는 간단한 텍스트 명령이다. 이 명령들은 이메일 전송 과정의 각 단계를 나타낸다.

 

ELO/EHLO:

  • HELO: 기본적인 "Hello" 명령이다. 클라이언트가 서버에게 인사하고 연결을 시작한다.
  • EHLO: 확장된 "Hello" 명령이다. 추가 기능을 지원하는 서버와의 연결을 시작한다.
클라이언트: HELO client.example.com 
서버: 250 Hello client.example.com

 

 

MAIL FROM:

  • 이메일을 보내는 사람의 주소를 지원한다.
클라이언트: MAIL 
FROM:<alice@example.com>
서버: 250 OK

 

RCPT TO:

  • 이메일 수신자의 주소를 지정한다. 여러 수신자가 있을 경우 이 명령을 여러 번 사용할 수 있다.
클라이언트: RCPT 
TO:<bob@example.com> 
서버: 250 OK

 

DATA:

  • 이메일의 본문을 전송하기 시작한다. 본문 끝은 단독 점(.)으로 표시한다.
클라이언트: DATA 
서버: 354 Start mail input; end with <CRLF>.<CRLF> 
날짜: 2022년 4월 4일 월요일 
보낸 사람: Alice <alice@example.com> 
제목: 에그 베네딕트 캐서롤 
받는 사람: Bob <bob@example.com> 

안녕 Bob, 
금요일에 에그 베네딕트 캐서롤 레시피 가져갈게. 
-Alice . 

서버: 250 
OK: queued as 12345
.

 

RSET:

  • 현재 이메일 전송을 중지하고 연결을 초기화한다. 전송된 모든 데이터가 삭제된다.
클라이언트: RSET 
서버: 250 OK

 

QUIT:

  • 연결을 종료한다.
클라이언트: QUIT 
서버: 221 Bye

 

6. SMTP 서버란?

SMTP 서버는 SMTP 프로토콜을 사용해 이메일을 전송하고 수신할 수 있는 메일 서버이다. 이메일 클라이언트는 이메일 전송을 시작할 수 있게 이메일 공급자의 SMTP 서버와 직접 연결한다. SMTP 서버에서 실행되는 각기 다른 소프트웨어 프로그램은 다음과 같다.

  • 메일 제출 에이전트(MSA): MSA는 이메일 클라이언트로부터 이메일을 수신한다.
  • 메일 전송 에이전트(MTA): MTA는 전달망의 다음 서버로 이메일을 전송한다. 앞에서 설명한 대로 필요할 경우 수신자 도메인의 메일 교환(MX) DNS 레코드를 찾기 위해 DNS를 쿼리할 수 있다.
  • 메일 전달 에이전트(MDA): MDA는 MTA에서 이메일을 수신해서 수신자의 받은메일함에 보관한다.

 

7. SMTP에서 사용하는 포트는?

네트워킹에서 포트란 네트워크 데이터를 수신하는 가상 지점이다. 우편 주소에 있는 아파트 번호라고 생각해보자. 컴퓨터가 네트워킹 데이터를 올바른 응용 프로그램으로 정렬하는 데 포트가 도움을 준다. 방화벽과 같은 네트워크 보안 조치로 불필요한 포트를 차단하여 악의적 데이터의 전송 및 수신을 방지할 수 있다.

이전에는 SMTP에 포트 25만 사용했다. 현재도 SMTP에 포트 25를 여전히 사용하고 있으나 포트 465, 587, 2525를 사용할 수도 있다.

  • 포트 25는 SMTP 서버 사이를 연결하는 데 가장 많이 사용된다. 스팸 발송자가 이 포트를 악용해 스팸을 대량 전송하려고 하므로, 현재는 최종 사용자 네트워크의 방화벽에서 이 포트를 차단할 때가 많다.
  • 한때 포트 465는 보안 소켓 계층(SSL) 암호화와 함께 SMTP에 사용하도록 지정되었다. 하지만 SSL은 Transport Layer Security(TLS))으로 대체되어, 최신 이메일 시스템에서는 이 포트를 사용하지 않습니다. 레거시(이전) 시스템에서만 나타낸다.
  • 이제 포트 587이 이메일 제출용 기본 포트이다. 이 포트를 통과하는 SMTP 통신은 TLS 암호화를 이용한다.
  • 포트 2525는 SMTP와 공식적으로 연결되어 있지는 않지만, 일부 이메일 서비스에서는 앞서 언급한 포트가 차단되었을 경우 이 포트로 SMTP 전송이 제공된다.

 

8. SMTP vs. IMAP와 POP

인터넷 메시지 접속 프로토콜(IMAP)과 포스트 오피스 프로토콜(POP)은 최종 수신처로 이메일을 전달하는 데 사용된다. 사용자에게 이메일을 표시하려면 이메일 클라이언트가 망 내 최종 메일 서버에서 이메일을 검색해야 한다. 이러한 목적으로 클라이언트는 SMTP가 아닌 IMAP나 POP를 사용한다.

나무 토막과 밧줄의 차이점을 생각해보면 SMTP와 IMAP/POP 간의 차이를 이해할 수 있을 것이다. 나무 토막은 무언가를 앞으로 미는 데는 사용할 수는 있지만 끌어올 수는 없다. 밧줄은 물건을 끌어올 수는 있지만 밀 수는 없다. 유사하게 SMTP는 이메일을 메일 서버로 "푸시"하지만 IMAP와 POP은 사용자의 응용 프로그램으로 향하는 나머지 과정에서 이를 "끌어"온다.

 

9. 확장 SMTP(ESMTP)란?

확장 단순 전자우편 전송 프로토콜(ESMTP)은 기존 기능을 확장하여 이메일 첨부 파일 전송, TLS 사용, 기타 기능을 사용할 수 있게 해주는 프로토콜 버전이다. 대부분의 이메일 클라이언트와 이메일 서비스는 기본 SMTP가 아닌 ESMTP를 사용한다. ESMTP에는 "extended hello"인 "EHLO"를 포함해 연결을 시작할 때 ESMTP를 사용할 수 있도록 하는 추가 명령이 있다.

 

10. 외부 이메일 서버 선택하기

이메일 인증을 구현하기 위해서는 가장 첫 번째로 이메일 서버가 구축되어 있어야 한다. 이메일 서버는 위에서 학습한 것 처럼 MSA, MTA, MDA가 구현되어 있어야 하며 ESMTP처럼 더 부가적인 기능들을 구현해야 비로소 이메일 서버를 사용할 수 있다. 이처럼 우리가 이메일 서버를 직접 구현한다는 것은 많은 시간과 비용이 들어가게 된다. 따라서 이미 제공되고 있는 이메일 서버인 네이버 혹은 구글의 이메일 서버를 사용하는 것이 훨씬 합리적이며 보안적인 측면에서도 바람직하다. 따라서 외부 이메일 서버를 사용하여 이메일 인증을 구현한다.

 

11. 구글 이메일 서버 사용하기

구글 홈페이지에서 오른쪽 상단의 내 프로필을 클릭한다. 그리고 Google 계정 관리를 클릭한다.

구글의 내 프로필

 

Google 계정 페이지로 이동했다면 검색창에 앱 비밀번호를 작성한다. 그리고 검색창 하단에 등장한 앱 비밀번호를 클릭한다. 앱 비밀번호를 수행하기 전에 반드시 구글 계정의 2차 비밀번호까지 설정을 모두 마쳐야 한다.

검색창에 앱 비밀번호 입력 후 클릭하기

 

앱 이름을 입력하고 생성한다.

앱 이름을 입력하고 만들기를 클릭하기

 

 

생성된 앱 비밀번호는 타인에게 공유하지 않고 기억하거나 따로 저장한다.

생성된 앱 비밀번호 기억하기

 

앱 비밀번호 목록에 생성된 앱 이름과 생성일을 확인할 수 있다. 여기까지 잘 따라왔다면 구글 이메일 서버를 사용하기 위한 사전 준비는 끝이다. 그리고 쓰레기통 아이콘을 클릭하면 삭제된다.

앱 비밀번호 목록

 

12. 네이버 이메일 서버 사용하기

네이버 이메일 홈페이지로 이동한다. 그리고 왼쪽 탭에서 스크롤을 내려서 휴지통 아래의 환경설정을 클릭한다.

하단의 환경설정 클릭하기

 

환경 설정에서 POP3/IMAP를 클릭한다. 그리고 그림과 같이 POP3/SMTP을 설정하고 저장한다.

 

13. 코드 구현하기

 

구현해야 할 API는 이메일 인증 요청 API와 유효한 인증 번호인지 확인하기 위한 API 두 가지를 구현해야 한다. 이메일 인증 요청 API에서는 유효한 이메일인지 확인 및 해당 이메일로 인증 번호가 포함된 이메일을 전송한다. 회원의 이메일로 전송된 인증 번호는 유효한 인증 번호인지 확인하기 위한 API로 확인하면 된다.

 

13.1 Build.gradle 설정하기

오늘날 기준(2024.08)으로 가장 최신 버전의 스프링 부트(Spring Boot 3.3.2, Java21)로 진행한다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.house'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
...
    // 1. spring mail
    implementation 'org.springframework.boot:spring-boot-starter-mail'

    // 2. javax.mail
    implementation 'javax.mail:mail:1.4.7'

    // 3. Spring Context Support
    implementation 'org.springframework:spring-context-support:5.3.9'

    // 4. redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

13.2 Configuration 생성하기

 

구글 이메일 서버 사용하는 경우

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 EmailConfig {

    @Bean
    public JavaMailSender mailSender() {

        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost("smtp.gmail.com");
        mailSender.setPort(587);
        mailSender.setUsername("구글@gmail.com");
        mailSender.setPassword("아까 저장한 앱비밀번호");

        Properties javaMailProperties = new Properties();
        javaMailProperties.put("mail.transport.protocol", "smtp");
        javaMailProperties.put("mail.smtp.auth", "true");
        javaMailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
        javaMailProperties.put("mail.smtp.starttls.enable", "true");
        javaMailProperties.put("mail.debug", "true");
        javaMailProperties.put("mail.smtp.ssl.trust", "smtp.naver.com");
        javaMailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2");

        mailSender.setJavaMailProperties(javaMailProperties);

        return mailSender;
    }
}

JavaMailSender 인터페이스와 JavaMailSenderImpl 클래스를 사용하여 Gmail SMTP 서버를 통해 이메일을 전송하는 방법을 설정한다. 각각의 구성 요소에 대한 설명은 다음과 같다.

 

Spring 설정 클래스 선언 및 Spring 컨텍스트에 빈으로 등록하기

@Configuration
public class EmailConfig {
    ...

    @Bean
    public JavaMailSender mailSender() {
        ...
    }
}

@Configuration으로 EmailConfig 클래스가 스프링의 설정 클래스라는 것을 나타낸다. EmailConfig 클래스 안의 메서드들은 스프링 빈(Bean)으로 등록된다. 그래서 @Bean으로 JavaMailSender 반환 타입의 mailSender 메서드를 스프링 컨텍스트에 빈으로 등록한다.

 

JavaMailSenderImpl 구현체 생성 및 기본 설정하기

JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost("smtp.gmail.com");
mailSender.setPort(587);
mailSender.setUsername("구글@gmail.com");
mailSender.setPassword("아까 저장한 앱비밀번호");

호스트, 포트 번호를 입력한다. 그리고 송신자의 아이디와 비밀번호를 작성하면 된다.

 

STMP 프토토콜 및 속성 설정

Properties javaMailProperties = new Properties();
javaMailProperties.put("mail.transport.protocol", "smtp");
javaMailProperties.put("mail.smtp.auth", "true");
javaMailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
javaMailProperties.put("mail.smtp.starttls.enable", "true");
javaMailProperties.put("mail.debug", "true");
javaMailProperties.put("mail.smtp.ssl.trust", "smtp.naver.com");
javaMailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2");
mailSender.setJavaMailProperties(javaMailProperties);

return mailSender;

Properties 객체를 사용하여 추가적인 메일 속성을 설정한다.

  • mail.transport.protocol: 사용할 프로토콜을 지정한다. 여기서는 smtp를 사용한다.
  • mail.smtp.auth: SMTP 인증을 사용하도록 설정한다.
  • mail.smtp.socketFactory.class: SSL 소켓 팩토리를 설정한다.
  • mail.smtp.starttls.enable: STARTTLS를 사용하여 보안 연결을 설정한다.
  • mail.debug: 디버그 정보를 출력하도록 설정한다.
  • mail.smtp.ssl.trust: SSL을 사용할 때 신뢰할 서버를 설정한다. 여기서는 smtp.naver.com을 신뢰하도록 설정되어 있다.
  • mail.smtp.ssl.protocols: 사용할 SSL/TLS 프로토콜을 설정한다. 여기서는 TLSv1.2를 사용한다.
  • 설정이 완료된 JavaMailSenderImpl 객체를 반환한다. 이 객체는 Spring 컨텍스트에서 이메일 전송을 위해 사용될 것이다.

 

네이버 이메일 서버 사용하는 경우

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 EmailConfig {

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

    @Value("${spring.mail.port}")
    private Integer port;

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

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

    @Bean
    public JavaMailSender mailSender() {

        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password.substring(1));

        Properties javaMailProperties = new Properties();
        javaMailProperties.put("mail.transport.protocol", "smtp");
        javaMailProperties.put("mail.smtp.auth", "true");
        javaMailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
        javaMailProperties.put("mail.smtp.starttls.enable", "true");
        javaMailProperties.put("mail.debug", "true");
        javaMailProperties.put("mail.smtp.ssl.trust", "smtp.naver.com");
        javaMailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2");

        mailSender.setJavaMailProperties(javaMailProperties);

        return mailSender;
    }
}

 

application.yml

spring:
  mail:
    host: smtp.naver.com
    port: 465
    username: 네이버 아이디 입력하기
    password: 네이버 비밀번호 입력하기
    properties:
      mail:
        smtp:
          auth: true
          ssl:
            enable: true
          starttls:
            enable: true
            required: true
          connectionTimeout: 5000
          timeout: 5000
          writeTimeout: 5000
        debug: true

네이버 설정에 대해서는 넘어간다. 주의해야 할 점은 구글과 네이버의 이메일 서버 포트 번호는 서로 다르다.

 

13.3 DTO 생성하기

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record EmailVerificationRequest(
        @NotBlank(message = "이메일: 필수 정보입니다.")
        @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "이메일: 유효하지 않은 이메일입니다.")
        @Size(min = 6, max = 32, message = "이메일: 유효하지 않은 이메일입니다.")
        String email
) {
}

 

13.4 컨트롤러 생성하기

@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@RestController
public class AuthController {

    private final MailService mailService;

    ...

    @PostMapping("/mail/send")
    public ResponseEntity<HttpResponse<Void>> sendEmail(@RequestBody @Valid EmailVerificationRequest request, BindingResult result) {
        mailService.sendVerificationEmail(request.email());
        return new ResponseEntity<>(new HttpResponse<>(1, "유효한 이메일입니다.", null), HttpStatus.OK);
    }
}

클라이언트에게 공통 응답으로 전달하고자 HttpResponse를 구현했고, AOP를 구현하여 HTTP Body에 데이터가 포함되는 모든 HTTP Method에 대해 유효성 검사를 수행한다. 수행 조건은 BindingResult 타입을 매개변수로 선언하기만 하면 된다. 따라서 BindingResult를 사용하지 않아도 된다.

 

    @PostMapping("/mail/send")
    public ResponseEntity<Void> sendEmail(@RequestBody @Valid EmailVerificationRequest request) {
        mailService.sendVerificationEmail(request.email());
        return new ResponseEntity(HttpStatus.OK);
    }

수정하기 어렵다면 위의 코드로 사용하면 된다.

 

13.5 서비스 구현하기

@RequiredArgsConstructor
@Service
public class MailService {

    private final JavaMailSender mailSender;
    private final RedisUtil redisUtil;

    // 이메일 인증 번호 생성
    private Integer generateRandomNumber() {
        Random random = new Random();
        StringBuilder randomNumber = new StringBuilder();
        for (int i = 0; i < 6; i++) {
            randomNumber.append(random.nextInt(10));
        }
        return Integer.parseInt(randomNumber.toString());
    }

    // 이메일 전송 메서드
    public void sendVerificationEmail(String email) {
        Integer authNumber = generateRandomNumber();
        String setFrom = "개인 이메일 작성하시면 됩니다.";
        String title = "회원 가입 인증 이메일 입니다.";
        String content = "임대일 프로젝트를 방문해주셔서 감사합니다." +
                "<br><br>" +
                "인증 번호는 " + authNumber + "입니다." +
                "<br>" +
                "인증번호를 제대로 입력해주세요";

        sendEmail(setFrom, email, title, content);
        saveAuthNumberInCache(authNumber, email);
    }

    // 이메일 전송 로직
    private void sendEmail(String setFrom, String toMail, String title, String content) {
        MimeMessage message = mailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8");
            helper.setFrom(setFrom);
            helper.setTo(toMail);
            helper.setSubject(title);
            helper.setText(content, true);
            mailSender.send(message);
        } catch (MessagingException e) {
            throw new RuntimeException("Failed to send email", e);
        }
    }

    // 인증 번호를 캐시에 저장
    private void saveAuthNumberInCache(Integer authNumber, String email) {
        String authKey = authNumber.toString();
        redisUtil.setDataExpire(authKey, email, 60 * 5L); // 5분 동안 유효
    }
}

여기까지 구현했다면 회원에게 인증 번호를 전송할 수 있다. 회원의 인증 번호가 유효한지 아닌지 확인이 필요하다. 인증 번호를 프론트엔드로 전송할 것인지, 현재 구현한 코드 그대로 프론트엔드에서는 전송하지 않을지는 편한 방법을 선택하면 된다. 그리고 5분 동안 유효한 인증 번호는 RDBMS로 관리하는 것보다 레디스로 관리하는 것이 적절하다. RDBMS에서 요청마다 모두 저장하고 삭제를 관리하는 것은 많은 비용이 발생하기 때문이다.

 

13.6 레디스 설정하기

윈도우 환경에서 진행하고 있기 때문에 도커를 통해서 레디스를 실행한다. 레디스 설치 및 설정 명령은 모두 레디스 공식 홈페이지에서 쉽게 설정하는 방법을 알 수 있으며 해당 내용과 동일하다.

 

Run Redis Stack on Docker

How to install Redis Stack using Docker

redis.io

 

 

Redis-stack

Redis-stack 이미지를 사용하여 Redis-stack 컨테이너를 시작하려면 터미널에서 다음 명령을 실행한다.

docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest

 

docker ps

실행중인 도커 컨테이너를 확인한다. 정상적으로 명령어를 입력했다면 redis-stack 컨테이너가 실행 중인 것을 확인할 수 있다.

docker ps

 

 

redis-cli

Redis-cli를 사용하여 레디스 서버에 연결할 수 있다. Redis 인스턴스에 연결하는 것처럼 말이다. redis-cli가 로컬에 설치되어 있지 않은 경우 Docker 컨테이너에서 redis-cli를 실행할 수 있다:

docker exec -it redis-stack redis-cli

 

 

Redis insight 접속하기

웹 브라우저에 http://localhost:8001/를 입력하면 레디스를 GUI로 확인할 수 있다.

 

13.7 application.yml 설정하기

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

호스트와 포트 번호를 설정한다. 레디스도 아이디와 비밀번호를 설정해서 인증된 사용자만 접근해야 안전하다. 현재 레디스 공부가 아니기 때문에 생략한다.

 

13.8 DTO 생성하기

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record EmailCheckDTO(

        @NotBlank(message = "이메일: 필수 정보입니다.")
        @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "이메일: 유효하지 않은 이메일입니다.")
        @Size(min = 6, max = 32, message = "이메일: 유효하지 않은 이메일입니다.")
        String email,

        @NotBlank(message = "인증번호: 인증번호를 입력해 주세요")
        String authNum
) {

}

 

13.9 컨트롤러 생성하기

    @PostMapping("/mail/verify")
    public ResponseEntity<HttpResponse<Void>> authCheck(@RequestBody @Valid EmailCheckDTO emailCheckDTO) {
        mailService.verifyAuthNumber(emailCheckDTO);
        return new ResponseEntity<>(new HttpResponse<>(1, "인증 번호가 유효합니다.", null), HttpStatus.OK);
    }

 

13.10 서비스 구현하기

@RequiredArgsConstructor
@Service
public class MailService {

    private final JavaMailSender mailSender;
    private final RedisUtil redisUtil;

    // 이메일 인증 번호 생성
    private Integer generateRandomNumber() {
        Random random = new Random();
        StringBuilder randomNumber = new StringBuilder();
        for (int i = 0; i < 6; i++) {
            randomNumber.append(random.nextInt(10));
        }
        return Integer.parseInt(randomNumber.toString());
    }

    // 이메일 전송 메서드
    public void sendVerificationEmail(String email) {
        Integer authNumber = generateRandomNumber();
        String setFrom = "piay801@naver.com";
        String title = "회원 가입 인증 이메일 입니다.";
        String content = "임대일 프로젝트를 방문해주셔서 감사합니다." +
                "<br><br>" +
                "인증 번호는 " + authNumber + "입니다." +
                "<br>" +
                "인증번호를 제대로 입력해주세요";

        sendEmail(setFrom, email, title, content);
        saveAuthNumberInCache(authNumber, email);
    }

    // 이메일 전송 로직
    private void sendEmail(String setFrom, String toMail, String title, String content) {
        MimeMessage message = mailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8");
            helper.setFrom(setFrom);
            helper.setTo(toMail);
            helper.setSubject(title);
            helper.setText(content, true);
            mailSender.send(message);
        } catch (MessagingException e) {
            throw new RuntimeException("Failed to send email", e);
        }
    }

    // 인증 번호를 캐시에 저장
    private void saveAuthNumberInCache(Integer authNumber, String email) {
        String authKey = authNumber.toString();
        redisUtil.setDataExpire(authKey, email, 60 * 5L); // 5분 동안 유효
    }

    // 인증 번호 확인 메서드
    public void verifyAuthNumber(EmailCheckDTO emailCheckDTO) {
        String cachedEmail = redisUtil.getData(emailCheckDTO.authNum());
        if (!emailCheckDTO.email().equals(cachedEmail)) {
            throw new IllegalArgumentException("인증 번호가 유효하지 않습니다.");
        }
    }
}

인증 번호 확인 메서드를 구현했다. 레디스에 저장할 수 있도록 설정 클래스들을 구현한다.

 

13.11 레디스 Config 구현하기

@Configuration
public class RedisConfig {

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

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

    // Lettuce
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    // Redis Template
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        // Serializer: Key - Value 타입
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());

        // Serializer: Hash 타입
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());

        // Serializer: Default(모든 경우)
        redisTemplate.setDefaultSerializer(new StringRedisSerializer());

        return redisTemplate;
    }
}

 

13.12 레디스 Util 구현하기

@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 void setData(String key, String value) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        valueOperations.set(key, value);
    }

    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);
    }
}

 

14. 실행 결과

14.1 이메일 전송하기

 

성공적으로 이메일을 받을 수 있는 것을 확인할 수 있다.

 

14.2 인증 번호 확인하기

 

인증 번호가 유효하지 않으면 throw new IllegalArgumentException("인증 번호가 유효하지 않습니다.");으로 작성한 예외가 발생한다. 또는 5분이 지나면 레디스 안에는 더이상 인증 번호가 없기 때문에 다시 예외가 발생하게 된다. 지금은 IllegalArgumentException 예외를 호출하고 있어서 원하는대로 예외를 발생시킨다고 말하기 어렵다. 더 구체적으로 예외를 다루고 싶으면 별도로 예외 핸들러 클래스를 구현하면 될 것이다.