본문 바로가기
사이드 프로젝트 기록

[EasyMarket] 스프링 이메일 인증 구현 (Redis/OTP)

by 제우제우 2025. 3. 27.

https://20240228.tistory.com/443

 

[EasyMarket] Github Actions 사용으로 CI/CD 구축하기 (EC2 생성)

https://20240228.tistory.com/442 [EasyMarket] 프로젝트 시작, CORS 해결하기프로젝트 환경 설명 spring boot(html, css, javascript, thymeleaf) 프론트spring boot 백엔드 나는 백엔드 개발자여서 vue, react 같은 프론트 프

20240228.tistory.com

이전 내용 

순서 

이메일 인증 → 필요한 의존성 추가 → 구현

이메일 인증?

나는 이메일이 인증된 사용자만 회원가입 할 수 있도록 OTP(One-Time Password)를 발급하여 사용자가 본인의 이메일을 통해 인증을 받을 수 있도록 할 계획이다. OTP는 구글 이메일(Gmail)을 통해 인증 요청을 보내, 해당 이메일에 일회성 비밀번호를 전달하는 방식으로 이메일 인증 절차를 처리할 예정이다. 

필요한 의존성 추가 

스프링 데이터 레디스(OTP 저장)

스프링 데이터 JPA(사용자 정보 저장)

MySQL 드라이버(MySQL 연결)

스프링 메일 (이메일 전송)

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    runtimeOnly 'com.mysql:mysql-connector-j'

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

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

구현

Controller

@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // 이메일 인증: OTP 요청
    @PostMapping("/email/otp")
    public ResponseEntity<ApiResponse> verifyEmail(@RequestBody @Valid EmailRequest email) {
        userService.verifyEmail(email.getEmail());
        ApiResponse apiResponse = new ApiResponse("이메일 인증 OTP 전송");
        return ResponseEntity.ok(apiResponse);
    }
}

@Data
public class EmailRequest {
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String email;
}

@Data
@AllArgsConstructor
public class ApiResponse {
    private String message;
}

 

EmailRequest 객체는 이메일 인증을 위해 프론트에서 json 형태로 날아오는 객체이다. 

@Email을 통해 이메일 형식에 맞는지 검사하고 있다. 현재는 해당 검사에 대한 예외 처리는 하지 않은 상태이다.

추후에 RestControllerAdvice 또는 ExceptionHandler를 통해 처리할 예정 

참고로 @Valid 검사를 통해 발생하는 예외는 MethodArgumentNotValidException이다. 

EmailService, EmailConfig

JavaMailSender & Google SMTP

JavaMailSender는 이메일 발송 기능을 구현하는 데 사용된다. 이메일을 보내는 작업을 위임할 SMTP 서버에 연결하여 이메일을 전송한다. Google의 SMTP 서버(smtp.gmail.com)는 이메일 발송을 위한 표준 SMTP 서버로 사용된다. 

SMTP(간단한 메일 전송 프로토콜)은 이메일 클라이언트가 이메일을 서버에 보내는 방법을 정의하는 프로토콜이다. 

간단하게 정리하면 JavaMailSender는 이메일 발송을 SMTP 서버에 위임하고 SMTP 서버는 클라이언트의 Gmail을 사용하여 이메일을 보낸다.

 

https://green-bin.tistory.com/83

 

Spring - 이메일 인증 구현해보기 (랜덤 인증번호 보내기)

배경새로 시작하게 된 프로젝트에서 회원가입 중 이메일 인증을 하도록 했다.Spring에서 제공하는 API를 사용하면 생각보다 쉽게 구현할 수 있다. 나는 Google SMTP 서버를 이용해서 이메일 인증을 진

green-bin.tistory.com

참고한 자료: SMTP 사용하려면 구글 계정 설정을 해줘야 하니 해당 블로그를 참고하자

 

EmailConfig

역할: application.yml에 정의된 SMTP 관련 설정을 읽어온다. 여러가지 설정을 적용하고 JavaMailSender 빈으로 등록 

@Configuration
public class EmailConfig {
    @Value("${spring.mail.host}")
    private String host;
    @Value("${spring.mail.port}")
    private int port;
    @Value("${spring.mail.username}")
    private String username;
    @Value("${spring.mail.password}")
    private String password;
    @Value("${spring.mail.properties.mail.smtp.auth}")
    private boolean auth;
    @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
    private boolean starttlsEnable;
    @Value("${spring.mail.properties.mail.smtp.starttls.required}")
    private boolean starttlsRequired;
    @Value("${spring.mail.properties.mail.smtp.connection.timeout}")
    private int connectionTimeout;
    @Value("${spring.mail.properties.mail.smtp.timeout}")
    private int timeout;
    @Value("${spring.mail.properties.mail.smtp.write.timeout}")
    private int writeTimeout;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");
        mailSender.setJavaMailProperties(properties());
        return mailSender;
    }

    private Properties properties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        properties.put("mail.smtp.starttls.required", starttlsRequired);
        properties.put("mail.smtp.connection.timeout", connectionTimeout);
        properties.put("mail.smtp.timeout", timeout);
        properties.put("mail.smtp.write.timeout", writeTimeout);
        return properties;
    }
}

 

옵션 설명 

application.yml (일부)

spring:
  config:
  mail:
    host: smtp.gmail.com
    port: 587
    username: "비밀"
    password: "비밀"
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connection:
              timeout: 5000
          timeout: 5000
          write:
            timeout: 5000

 

spring.mail.host / spring.mail.port

이메일 발송을 위한 SMTP 서버 주소와 포트를 설정한다. 

spring.mail.username / spring.mail.password

이메일 발송을 위한 인증 정보를 설정(발송할 이메일의 이메일 주소/앱 비밀번호)

spring.mail.properties.mail.smtp.auth

SMTP 서버에 인증이 필요한지 여부를 설정 Google SMTP 서버는 인증을 요구하므로 true로 설정해야 한다.

spring.mail.properties.mail.smtp.starttls.enable

STARTTLS를 사용하여 이메일 서버와의 연결을 보안 연결로 업그레이드할지 여부를 설정 true일 경우 TLS(Transport Layer Security)로 암호화

spring.mail.properties.mail.smtp.starttls.required

SMTP 서버와의 연결이 TLS로 암호화되도록 요구할지 여부를 설정한다. true로 설정하면, 서버가 STARTTLS를 지원하지 않는 경우 연결을 거부

spring.mail.properties.mail.smtp.connection.timeout

SMTP 서버와의 연결 타임아웃 시간(ms)을 설정

spring.mail.properties.mail.smtp.timeout

서버에서 이메일을 처리하는 동안의 타임아웃 시간(ms)을 설정

spring.mail.properties.mail.smtp.write.timeout

이메일을 작성할 때의 타임아웃 시간(ms)을 설정. 이메일을 보내는 동안 데이터가 너무 오래 걸리면 타임아웃이 발생

 

TLS(Transport Layer Security)?

1. 중간자 공격 방어(공격자가 두 통신자 간의 데이터를 가로채고 수정할 수 있지만) TLS는 인증과 암호화를 사용하여 수정할 수 없게 한다. 

2. 스니핑 공격 방어(공격자가 네트워크 트래픽을 몰래 훔쳐보는 방식) TLS는 암호화를 통해 전송된 데이터를 읽을 수 없게 만든다.

 

EmilService

@Service
@Slf4j
@RequiredArgsConstructor
public class EmailService {
    private final JavaMailSender mailSender;

    public void sendEmail(EmailMessage emailMessage) {
        var message = createSimpleMailMessage(emailMessage);
        try {
            mailSender.send(message);
        }
        catch (MailException e){
            Supplier<String> errorMessage = () -> emailMessage.getContent() + "전송 실패";
            throw new RuntimeException(errorMessage.get());
        }
    }
    private SimpleMailMessage createSimpleMailMessage(EmailMessage emailMessage) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(emailMessage.getTo());
        message.setSubject(emailMessage.getSubject());
        message.setText(emailMessage.getContent());
        return message;
    }
}

 

JavaMailSendersend() 메서드를 통해 메일 전송을 하고 보내는 객체는 SimpleMailMessage 이다.

만약 전송에 실패하면?(write timeout / connection timeout 등등..) MailException 발생 

해당 예외에 대한 처리를 현재는 RuntimeExcption으로 하고 있지만 CustomException을 발생 시키고 CustomException에 대한 예외 처리를 하여 사용자에게 알려줄 예정이다.

 

OtpRepository

@Repository
@RequiredArgsConstructor
public class OtpRepository {
    private final RedisTemplate<String, String> redisTemplate;
    private static final long OTP_EXPIRATION_MINUTES = 5;

    public Optional<String> getOtp(String email){
        return Optional.ofNullable(redisTemplate.opsForValue().get(email));
    }

    public void setOtp(String email, String otp){
        redisTemplate.opsForValue().set(email, otp, OTP_EXPIRATION_MINUTES, TimeUnit.MINUTES);
    }
}

 

이메일 인증을 위한 OTP를 레디스에 저장하고 관리하는 역할이다. Spring Data Redis의 RedisTemplate 사용 

RedisTemplate<String, String>: key, value 모두 문자열 

OTP를 저장할 때 만료 시간을 5분 설정  

Service

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final OtpRepository otpRepository;
    private final EmailService emailService;
    private final Random random = new Random();

    // 이메일 존재 확인 & OTP 발급, 전송
    @Transactional(readOnly = true)
    public void verifyEmail(String email) {
        checkDuplicateEmail(email);
        var otp = createOtp();
        otpRepository.setOtp(email, otp);
        sendEmail(email, "EasyMarket 이메일 인증번호", otp);
    }
    private void checkDuplicateEmail(String email) {
        if(userRepository.existsByEmail(email)){
            throw new RuntimeException("이미 존재하는 이메일입니다.");
        }
    }
    private String createOtp(){
        int code = random.nextInt(1000000);
        return String.format("%06d", code);
    }
    private void sendEmail(String to, String subject, String content) {
        emailService.sendEmail(new EmailMessage(to, subject, content));
    }
}

 

이미 존재하는 이메일인지 확인하고 존재하면 RuntimeException 발생 -> CustomExcpetion으로 교체 예정 

OTP를 발급한다. 발급한 OTP를 Redis에 저장한다. 레디스에 저장하고 메일 서비스에 OTP 전송을 위임한다.

 

 간단한 테스트 

 

성공!

 

Redis 저장 확인 

127.0.0.1:6379> monitor
OK
1743066567.978739 [0 172.17.0.1:62326] "HELLO" "3"
1743066567.984832 [0 172.17.0.1:62326] "CLIENT" "SETINFO" "lib-name" "Lettuce"
1743066567.984871 [0 172.17.0.1:62326] "CLIENT" "SETINFO" "lib-ver" "6.4.2.RELEASE/f4dfb40"
1743066568.002765 [0 172.17.0.1:62326] "SET" "baejeu@naver.com" "106969" "EX" "300"

# 모니터 종료 

127.0.0.1:6379> get baejeu@naver.com
"106969"
127.0.0.1:6379>

 

다음 목표

이메일 인증 예외 처리, 테스트 컨테이너를 사용한 서비스 테스트, 깃허브 액션에서 테스트 컨테이너 활용 테스트