Springboot에서 이메일 전송하기

Yong·2025년 2월 27일

Spring

목록 보기
2/3

bookiki 서비스를 개발하면서 회원 관련해서 인증을 받아야하는 경우가 생겼다.
OAuth 2.0을 통해서 로그인을 하는 경우도 있었지만 서비스 자체에서 회원가입 해서 로그인을 하는 경우도 생각했었고, 좀 더 많은 사용자가 편하게 사용하게 하기 위해 사용자 인증을 이메일 인증으로 하기로 했다.
또한 비밀번호를 잊어버렸을 경우 재설정 할 수 있는 링크를 이메일로 보내서 비밀번호를 재설정 할 수 있게 했다.

Gmail 설정하기

Chrome 브라우저에서 로그인을 Google 계정 관리에 들어간다.

앱 비밀번호를 설정하기 위해서는 2단계 인증을 설정해야 한다.

앱 비밀번호를 검색하고 앱 비밀번호를 생성한다.

앱 이름을 입력하고 16자리의 앱 비밀번호를 얻는다. 이 비밀번호는 구글 계정 비밀번호와 동일한 권한을 가지고 있기 때문에 외부로 유출 되어서는 안된다.

Springboot 개발

build.gradle에 종속성 설정

dependencies {
	```
	// Mail
	implementation 'org.springframework.boot:spring-boot-starter-mail'	
	```
}

Spring에서는 Java Mail Sender 인터페이스를 활용해서 이메일을 전송할 수 있다. 해당 의존성을 주입하면 Java Mail Sender 인테페이스를 구현한 구현체를 직접적으로 활용할 수 있게 된다.

application.yml 설정

spring:
  mail:
    host: smtp.gmail.com 
    port: 587
    username: ${mail.username}
    password: ${mail.password}
    properties:
      mail:
        smtp:
          auth: true
          timeout: 1000    // 1초
          starttls:
            enable: true

host : SMTP 서버의 호스트
port : SMTP 서버에서 사용하는 포트 (Google의 경우 587, SSL을 사용하려면 starttls를 false로 하고 465 사용)
username : SMTP 서버 로그인 ID (Gmail 계정의 ID이므로 @gmail.com 까지 작성해야한다.)
password : 앱 비밀번호
auth : 사용자 인증을 시도할 것인지 체크하는 것이고 default가 false이기 때문에 true로 설정하여 인증 후 메일이 발송될 수 있다.
timeout : 이메일을 보낼 떄 SMTP 서버가 응답할 때 까지 기다리는 시간.
startttls : SSL / TLS 기반으로 SMTP를 활용 여부를 선택하는 변수로, default가 false이기 때문에 true로 설정하여 보안성을 높인다.

username과 password는 .env파일을 만들어서 관리할 수 있다.

SendEmailRequest.java

package com.corp.bookiki.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Schema(description = "이메일 인증 요청")
public class SendEmailRequest {

	@Email(message = "올바른 이메일 형식이 아닙니다.")
	@NotBlank(message = "이메일은 필수입니다.")
	private String email;
}

SendEmailRequest라는 dto를 만들어서 사용했다.

VerifyCodeRequest.java

package com.corp.bookiki.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Schema(description = "이메일 인증 코드 확인 요청")
public class VerifyCodeRequest {

	@Schema(
		description = "인증 코드",
		example = "123456",
		required = true,
		pattern = "^[0-9]{6}$"
	)
	@NotBlank(message = "인증코드는 필수입니다.")
	@Pattern(regexp = "^[0-9]{6}$", message = "인증코드는 6자리 숫자여야 합니다.")
	private String code;
}

6자리의 인증코드를 받는 VerifyCodeRequest dto를 만들었다.

EmailVerificationService.java

package com.corp.bookiki.user.service;

import java.time.Duration;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import com.corp.bookiki.global.error.code.ErrorCode;
import com.corp.bookiki.global.error.exception.EmailAuthenticationException;
import com.corp.bookiki.user.repository.UserRepository;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class EmailVerificationService {

	private static final String EMAIL_CODE_PREFIX = "EMAIL_CODE:";
	private static final String VERIFIED_EMAIL_PREFIX = "VERIFIED_EMAIL:";
	private static final Duration CODE_EXPIRATION = Duration.ofMinutes(5);

	private final StringRedisTemplate redisTemplate;
	private final JavaMailSender mailSender;
	private final UserRepository userRepository;

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

	public void sendVerificationCode(String email) {
    // 이미 메일이 db에 있는 경우 이메일이 중복되므로 예외처리
		if (userRepository.existsByEmail(email)) {
			throw new EmailAuthenticationException(ErrorCode.DUPLICATE_EMAIL);
		}

		String code = generateVerificationCode();    // 6자리 인증 코드생성
		saveVerificationCode(email, code);    // redis에 email:code를 key:value로 설정

		try {
			sendEmail(email, code);    // 이메일 전송
		} catch (MessagingException e) {
		  // 이메일 전송 실패 시 예외처리
			throw new EmailAuthenticationException(ErrorCode.FAIL_EMAIL_SEND);
		}
	}
	
	private String generateVerificationCode() {
	  // 6자리 인증코드 랜덤 생성
		return String.format("%06d", (int)(Math.random() * 1000000));
	}

	private void saveVerificationCode(String email, String code) {
	  // redis에 이메일 및 인증코드 저장, 일정 시간이 지나면 expire
		redisTemplate.opsForValue().set(
			EMAIL_CODE_PREFIX + email,
			code,
			CODE_EXPIRATION
		);
	}
	
	private void sendEmail(String to, String code) throws MessagingException {
	  // 인증코드를 담아서 이메일 전송
		MimeMessage message = mailSender.createMimeMessage();
		MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

		helper.setFrom(fromEmail);
		helper.setTo(to);    // 메일 수신자
		helper.setSubject("[Bookiki] 이메일 인증번호");    // 메일 제목
		helper.setText("인증번호: " + code + "\n\n해당 인증번호는 5분간 유효합니다.", false);  // 메일 본문 내용

		mailSender.send(message);   // 메일전송
	}

	public void verifyCode(String email, String code) {
		// redis에 저장된 인증 코드
		String savedCode = redisTemplate.opsForValue().get(EMAIL_CODE_PREFIX + email);
	  
	  // 저장된 코드가 없거나, 입력받은 코드와 다른 경우 예외처리
		if (savedCode == null || !savedCode.equals(code)) {
			throw new EmailAuthenticationException(ErrorCode.INVALID_EMAIL_VERIFICATION);
		}
		// redis에 저장된 정보 delete
		redisTemplate.delete(EMAIL_CODE_PREFIX + email);
	}
	
}

redis를 사용해 이메일 인증코드 관리를 진행했다.

Service에서 만든 로직을 Controller나 Schedular등을 이용해 실행하면 된다.

다음과 같이 이메일이 전송된 것을 확인할 수 있다.

비밀번호 재설정 이메일 전송

사용자가 이름과 이메일을 입력하면 비밀번호 재설정 할 수 있는 링크를 담은 메일을 전송하도록 했다.

기존 비밀번호를 잊어버렸을 때 실행되는 로직이기 때문에 이메일을 UUID를 활용해 사용자별로 이메일에 알맞는 링크를 보낼 수 있게 했다.

PasswordResetEmailRequest.java

package com.corp.bookiki.user.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class PasswordResetEmailRequest {

	
	@NotBlank(message = "이메일은 필수입니다")
	@Email(message = "올바른 이메일 형식이 아닙니다")
	private String email;

	@NotBlank(message = "사용자 이름은 필수입니다")
	private String userName;
}

PasswordResetEmailRequest 라는 dto를 만들어 사용했다.

PasswordResetRequest.java

package com.corp.bookiki.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@Schema(description = "비밀번호 재설정 요청")
public class PasswordResetRequest {

	@NotBlank(message = "새 비밀번호는 필수입니다.")
	private String newPassword;

	@NotBlank(message = "새 비밀번호 확인은 필수입니다.")
	private String newPasswordConfirm;
}

비밀번호 재설정에는 새 비밀번호와 새 비밀번호 확인만 사용하기 때문에 PasswordResetRequest dto를 만들었다.

PasswordService.java


import java.time.Duration;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.corp.bookiki.global.error.code.ErrorCode;
import com.corp.bookiki.global.error.exception.PasswordUpdateException;
import com.corp.bookiki.user.entity.Provider;
import com.corp.bookiki.user.entity.UserEntity;
import com.corp.bookiki.user.repository.UserRepository;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PasswordService {

	private static final String PASSWORD_RESET_PREFIX = "PASSWORD_RESET:";
	private static final Duration RESET_TOKEN_TTL = Duration.ofMinutes(10);

	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	private final JavaMailSender mailSender;
	private final StringRedisTemplate redisTemplate;

	@Value("${MAIL_USERNAME}")
	private String fromEmail;

	@Value("${FRONTEND_URL}")
	private String frontendUrl;

	@Transactional
	public void sendPasswordResetEmail(String email, String userName) {
		// 비밀번호를 재설정할 사용자의 이메일이 없는 경우 예외처리
		UserEntity user = userRepository.findByEmail(email)
			.orElseThrow(() -> new PasswordUpdateException(ErrorCode.USER_NOT_FOUND));
	
	  // OAuth 2.0 로그인인 경우 예외처리
		if (!user.getProvider().equals(Provider.BOOKIKI)) {
			throw new PasswordUpdateException(ErrorCode.UNVALID_PROVIDER);
		}
	  // 비밀번호를 재설정할 사용자의 이름이 없는 경우 예외처리
		if (!userName.equals(user.getUserName())) {
			throw new PasswordUpdateException(ErrorCode.USERNAME_EMAIL_MISMATCH);
		}
	  // 사용자 Email을 UUID를 활용해 resetToken으로 변환 후 redis에 저장
		String resetToken = UUID.randomUUID().toString();
		redisTemplate.opsForValue().set(PASSWORD_RESET_PREFIX + resetToken, email, RESET_TOKEN_TTL);
	  // resetToken을 포함한 url 생성
		String resetUrl = frontendUrl + "/reset-password/" + resetToken;

		try {
			sendPasswordResetMail(email, resetUrl);    // 링크 포함한 이메일 전송
		} catch (MessagingException e) {
			// 이메일 전송 실패 시 예외처리
			throw new PasswordUpdateException(ErrorCode.FAIL_EMAIL_SEND);
		}
	}

	private void sendPasswordResetMail(String to, String resetUrl) throws MessagingException {
		MimeMessage message = mailSender.createMimeMessage();
		MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

		helper.setFrom(fromEmail);
		helper.setTo(to);
		helper.setSubject("[Bookiki] 비밀번호 재설정");
		helper.setText("비밀번호 재설정을 위해 아래 링크를 클릭해주세요:\n\n" + resetUrl + "\n\n" + "본 링크는 10분간 유효하며, 1회만 사용 가능합니다.", false);

		mailSender.send(message);
	}

	@Transactional
	public void resetPassword(String resetToken, String newPassword, String newPasswordConfirm) {
		String email = redisTemplate.opsForValue().get(PASSWORD_RESET_PREFIX + resetToken);
		if (email == null) {
			throw new PasswordUpdateException(ErrorCode.INVALID_TOKEN);
		}

		if (!newPassword.equals(newPasswordConfirm)) {
			throw new PasswordUpdateException(ErrorCode.PASSWORD_MISMATCH);
		}

		UserEntity user = userRepository.findByEmail(email)
			.orElseThrow(() -> new PasswordUpdateException(ErrorCode.USER_NOT_FOUND));

		user.changePassword(passwordEncoder.encode(newPassword));
		userRepository.save(user);

		redisTemplate.delete(PASSWORD_RESET_PREFIX + resetToken);
	}

}

이메일 인증과 마찬가지로 redis를 사용해 resetToken을 관리했다.

다음과 같이 이메일이 전송된 것을 확인할 수 있다.

추가 키워드

@Async로 이메일 전송 비동기 처리

이메일에 들어갈 html 파싱(thymeleaf)

참고

https://cn-c.tistory.com/95

https://velog.io/@dl-00-e8/%EA%B3%B5%EC%9E%91%EC%86%8C-Spring-Boot%EC%97%90%EC%84%9C-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EC%A0%84%EC%86%A1%ED%95%98%EA%B8%B0

profile
dev

0개의 댓글