웹 백엔드 개발/Spring Boot

[SpringBoot/Java] JavaMailSender로 인증 메일 전송하기

iinana 2025. 4. 4. 08:34
728x90

 회원가입 및 로그인 기능을 구현하면서, 인증코드를 담은 메일을 보내는 기능을 구현해야 했다. JavaMailSender를 이용하면 어렵지 않게 구현할 수 있다. 크게 아래 두 가지 기능을 구현해 보았다.

1. 사용자의 이메일로 6자리의 인증코드를 담은 메일 전송
  - 인증 코드 생성
  - 인증 코드 저장
  - 메일 전송
2. 사용자가 입력한 코드가 저장된 인증코드와 일치하는지 확인

 

 구현은 아래와 같이 했다. 우선 build.gradle 파일에 JavaMailSener를 사용하기 위한 의존성을 추가해 준다. 

/** build.gradle */
dependencies {
    // java mail sender 의존성 추가
    implementation 'org.springframework.boot:spring-boot-starter-mail' 
}

 

 

 그리고 application.yml 파일에 메일 전송 관련 설정을 작성해 준다. host는 보내려고 하는 메일의 도메인에 따라 작성하면 된다. 나는 이 코드를 작성할 때 속해있던 랩실 이메일이 naver 메일이었기 때문에 naver를 선택했다. username에는 보내는 사람, 즉 내 이메일을 작성하면 되고, password에는 해당 이메일에 설정된 비밀번호를 작성하면 된다.

 port의 경우 사용 가능한 몇가지 옵션이 있지만, 최근 587이 가장 많이 사용되고 추천된다.

/** application.yml */
spring:
  mail:
    host: smtp.naver.com
    port: 587
    username: {sender_email}
    password: {sender_password}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true

 

 그리고 인증 코드를 저장할 repository 만들기 위해 RedisConfig를 먼주 구축해준다.

package com.survey.server.config;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
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.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;

@Configuration
public class RedisConfig {
    // Redis 서버 연결을 위한 ConnectionFactory Bean
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory("localhost", 6379);
    }

    // LocalDateTime을 위한 커스텀 TypeAdapter
    private static class LocalDateTimeAdapter extends TypeAdapter<LocalDateTime> {
        @Override
        public void write(JsonWriter out, LocalDateTime value) throws IOException {
            if (value == null) {
                out.nullValue();
            } else {
                out.value(value.toString());
            }
        }

        @Override
        public LocalDateTime read(JsonReader in) throws IOException {
            String value = in.nextString();
            return value == null ? null : LocalDateTime.parse(value);
        }
    }

    // RedisSerializer Bean
    @Bean
    public RedisSerializer<Object> gsonRedisSerializer() {
        return new RedisSerializer<>() {
            private final Gson gson = new GsonBuilder()
                    .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
                    .create();

            @Override
            public byte[] serialize(Object source) {
                if (source == null) {
                    return new byte[0];
                }
                return gson.toJson(source).getBytes(StandardCharsets.UTF_8);
            }

            @Override
            public Object deserialize(byte[] bytes) {
                if (bytes == null || bytes.length == 0) {
                    return null;
                }
                String json = new String(bytes, StandardCharsets.UTF_8);
                return gson.fromJson(json, Object.class);
            }
        };
    }

    // RedisTemplate Bean
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        StringRedisSerializer serializer = new StringRedisSerializer();
        template.setKeySerializer(serializer);
        template.setValueSerializer(serializer);
        template.setHashKeySerializer(serializer);
        template.setHashValueSerializer(serializer);

        return template;
    }
}

 

 이제 인증코드를 저장해 둘 repository를 작성한다. 만료시간이 있는 인증코드의 특성상, 편리하게 사용할 수 있는 RedisTemplate으로 설정했다. 유효시간(TTL)은 5분으로 설정했다. 

/** VerificationRepository.java */
package com.survey.server.repository;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.concurrent.TimeUnit;

@Repository
@RequiredArgsConstructor
public class VerificationRepository {
    private final RedisTemplate<String, Object> redisTemplate;
    private static final Long TTL_MINUTES = 5L; // 인증번호 유효시간 5분
    private static final String KEY_PREFIX = "verification: ";

    /**
     * 인증 코드 저장
     * - 이메일, 전송된 랜덤 인증코드, TTL 설정
     */
    public void saveVerifyCode(String userEmail, String verifyCode) {
        redisTemplate.opsForValue()
                .set(getVerificationKey(userEmail), verifyCode,
                        TTL_MINUTES * 60, TimeUnit.SECONDS);
    }

    public Optional<Object> getVerifyCode(String userEmail) {
        Object code = redisTemplate.opsForValue()
                .get(getVerificationKey(userEmail));
        return Optional.ofNullable(code);
    }

    public void deleteVerifyCode(String userEmail) {
        redisTemplate.delete(getVerificationKey(userEmail));
    }

    public String getVerificationKey(String userEmail) {
        return KEY_PREFIX + userEmail;
    }
}

 

 

 마지막으로 아래는 이메일을 전송하고, 인증코드를 확인하는 MailService이다. 내가 짠 프로그램에서는 UserService를 거쳐서 사용되기 때문에 예외처리는 주로 UserService에서 해주었다. 

 내용 자체는 어렵지 않다. @Value 어노테이션으로 application.yml 파일에서 작성했던 username을 보내는 사람의 이메일로 불러온다. 그리고 6자리 코드를 생성해서 앞서 생성한 레포지토리에 저장하고 이를 바탕으로 메일을 전송해 주면 된다. 

 인증 번호를 확인할 때는 레포지토리에서 메일 주소를 key로 하여 저장된 인증 번호를 가져와주고, 사용자의 입력값과 일치하는지 확인해 준다. 일치하면 레포지토리에서 해당 코드를 삭제해 주며 true를 리턴하고 일치하지 않으면 false를 리턴한다. 

/** MailService.java */
package com.survey.server.service;

import com.survey.server.dto.CodeVerifyDTO;
import com.survey.server.dto.VerificationEmailSendDTO;
import com.survey.server.repository.VerificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;

import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import java.util.NoSuchElementException;

@Service
@RequiredArgsConstructor
public class MailService {
    private final VerificationRepository verificationRepository;
    private final JavaMailSender javaMailSender;

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

    public void sendVerificationEmail(String email) {
        String code = String.valueOf(Math.random() * 1000000);
        verificationRepository.saveVerifyCode(email, code);

        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom(sender);
        message.setSubject("[서비스 이름] 이메일 인증 코드");
        message.setTo(email);
        message.setText("[인증코드] " + code);

        javaMailSender.send(message);
    }

    /**
     * 인증 코드 확인
     * @param email 확인 요청자 이메일
     * @param inputCode 사용자가 입력한 코드
     * @return 사용자 입력 코드가 저장된 인증 코드와 일치하면 true
     */
    public Boolean verifycode(String email, String inputCode) {
        String code = verificationRepository.getVerifyCode(email)
                .map(Object::toString)
                .orElseThrow(() -> new NoSuchElementException("Verification code not found"));
        if (code.equals(inputCode)) {
            verificationRepository.deleteVerifyCode(email);
            return true;
        }
        return false;
    }
}
728x90