gradle에 아래와 같은 코드를 추가해야 JavaMailSender를 사용할 수 있다.
gradle
implementation 'org.springframework.boot:spring-boot-starter-mail'
그 후 yml에 이메일 관련 서버 설정을 추가해야 한다.
application.yml
mail:
host: smtp.gmail.com # 사용할 서버
port: 587
username: ${mail.username} # 메일을 보내는 계정
password: ${mail.password}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
timeout: 5000
mail.smtp.auth: true - SMTP 서버에 인증이 필요함을 나타낸다.
mail.smtp.starttls.enable: true - STARTTLS 암호화를 활성화한다. 이는 처음에는 암호화되지 않은 연결로 시작하여 나중에 TLS로 업그레이드하는 방식이다.
SMTP 서버와의 연결 타임아웃을 5,000밀리초(5초)로 설정한다.
username은 구글 메일을 사용한다.
해당 구글 계정을 2단계 인증을 활성화 후 앱 비밀번호를 생성하여 password에 넣어준다.
EmailService
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender emailSender;
@Async
public void sendEmail(String to, String subject, String content) {
try {
MimeMessage message = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, true); // HTML 형식 활성화
emailSender.send(message);
log.info("이메일 전송 완료: {}", to);
} catch (MessagingException e) {
log.error("이메일 전송 실패: {}", e.getMessage(), e);
throw new RuntimeException("이메일 전송에 실패했습니다.", e);
}
}
public void sendPasswordResetEmail(String to, String resetToken) {
String subject = "FreeMarket 비밀번호 재설정";
String resetUrl = "http://localhost:8080/reset-password?token=" + resetToken;
String content = """
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>비밀번호 재설정</h2>
<p>안녕하세요. FreeMarket 비밀번호 재설정을 요청하셨습니다.</p>
<p>아래 링크를 클릭하여 비밀번호를 재설정해 주세요:</p>
<p><a href="%s" style="display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px;">비밀번호 재설정</a></p>
<p>이 링크는 30분 동안만 유효합니다.</p>
<p>비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시하셔도 됩니다.</p>
<p>감사합니다.<br>FreeMarket 팀</p>
</div>
""".formatted(resetUrl);
sendEmail(to, subject, content);
}
}
Spring Boot 애플리케이션에서 이메일 서비스를 구현한 EmailService 클래스다.
@Async: 비동기적으로 메서드를 실행하도록 지정한다. 이메일 전송이 오래 걸릴 수 있으므로 비동기로 처리한다.PasswordResetToken
@Entity
@Table(name = "password_reset_tokens")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PasswordResetToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private LocalDateTime expiryDate;
@Builder
public PasswordResetToken(String token, String email, LocalDateTime expiryDate) {
this.token = UUID.randomUUID().toString();
this.email = email;
// 토큰 만료시간 30분
this.expiryDate = LocalDateTime.now().plusMinutes(30);
}
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiryDate);
}
}
UUID.randomUUID(): 무작위로 생성된 범용 고유 식별자(Universally Unique Identifier)를 반환한다. UUID는 128비트(16바이트) 값으로, 충돌 가능성이 극히 낮은 고유한 식별자다.PasswordResetTokenRepository
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long>, PasswordResetTokenRepositoryCustom {
Optional<PasswordResetToken> findByToken(String token);
Optional<PasswordResetToken> findByEmail(String email);
}
PasswordResetTokenRepositoryCustom
public interface PasswordResetTokenRepositoryCustom {
void deleteExpiredTokens(LocalDateTime now);
}
PasswordResetTokenRepositoryImpl
@RequiredArgsConstructor
public class PasswordResetTokenRepositoryImpl implements PasswordResetTokenRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public void deleteExpiredTokens(LocalDateTime now) {
queryFactory
.delete(passwordResetToken)
.where(passwordResetToken.expiryDate.lt(now))
.execute();
}
}
비밀번호 재설정 토큰 관리를 위한 엔터티 및 저장소이다.
PasswordDto
public class PasswordDto {
public record PasswordResetRequest(
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email
) {}
public record PasswordResetVerifyRequest(
@NotBlank(message = "토큰은 필수 입력값입니다.")
String token,
@NotBlank(message = "새 비밀번호는 필수 입력값입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
String newPassword
) {}
@Builder
public record PasswordResetResponse(
boolean success,
String message
) {}
}
DTO를 한 곳에 모으는 이유
DTO들을 명확한 네임스페이스 아래 구성할 수 있다.DTO들이 여러 패키지나 클래스에 분산되지 않고 한 곳에 있어 유지보수가 용이하다.DTO들을 모아 높은 응집도를 유지할 수 있다.DTO를 사용하는 클래스에서 여러 개별 클래스를 임포트하지 않고 하나의 외부 클래스만 임포트하면 된다.PasswordResetService
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PasswordResetService {
private final UserRepository userRepository;
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
@Transactional
public void requestPasswordReset(PasswordDto.PasswordResetRequest request) {
String email = request.email();
// 사용자 존재 여부 확인
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UserException.UserNotFoundException(email));
// 기존 토큰이 있으면 삭제
passwordResetTokenRepository.findByEmail(email)
.ifPresent(passwordResetTokenRepository::delete);
// 새 토큰 생성
PasswordResetToken resetToken = PasswordResetToken.builder()
.email(email)
.build();
passwordResetTokenRepository.save(resetToken);
// 이메일 발송
emailService.sendPasswordResetEmail(email, resetToken.getToken());
log.info("비밀번호 재설정 요청 처리 완료: {}", email);
}
@Transactional
public void resetPassword(PasswordDto.PasswordResetVerifyRequest request) {
String token = request.token();
String newPassword = request.newPassword();
// 토큰 검증
PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(token)
.orElseThrow(() -> new AuthException.PasswordResetTokenNotFoundException());
if (resetToken.isExpired()) {
throw new AuthException.PasswordResetTokenExpiredException();
}
// 사용자 정보 조회
User user = userRepository.findByEmail(resetToken.getEmail())
.orElseThrow(() -> new UserException.UserNotFoundException(resetToken.getEmail()));
// 비밀번호 변경
user.changePassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
// 사용한 토큰 삭제
passwordResetTokenRepository.delete(resetToken);
log.info("비밀번호 재설정 완료: {}", resetToken.getEmail());
}
@Scheduled(cron = "0 */30 * * * *") // 30분마다 실행
@Transactional
public void cleanupExpiredTokens() {
log.info("만료된 비밀번호 재설정 토큰 정리 시작");
passwordResetTokenRepository.deleteExpiredTokens(LocalDateTime.now());
log.info("만료된 비밀번호 재설정 토큰 정리 완료");
}
}
이 서비스는 다음과 같은 비밀번호 재설정 프로세스를 구현한다
public static class PasswordResetTokenNotFoundException extends AuthException {
public PasswordResetTokenNotFoundException() {
super("비밀번호 재설정 토큰을 찾을 수 없습니다.", HttpStatus.NOT_FOUND, "PASSWORD_RESET_TOKEN_NOT_FOUND");
}
}
public static class PasswordResetTokenExpiredException extends AuthException {
public PasswordResetTokenExpiredException() {
super("비밀번호 재설정 토큰이 만료되었습니다.", HttpStatus.BAD_REQUEST, "PASSWORD_RESET_TOKEN_EXPIRED");
}
}
PasswordResetController
@Slf4j
@RestController
@RequestMapping("/api/auth/password")
@RequiredArgsConstructor
@Tag(name = "비밀번호 재설정", description = "비밀번호 찾기 및 재설정 관련 API")
public class PasswordResetController {
private final PasswordResetService passwordResetService;
@Operation(summary = "비밀번호 재설정 요청", description = "이메일 주소를 입력받아 비밀번호 재설정 링크를 발송합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "비밀번호 재설정 이메일 발송 성공"),
@ApiResponse(responseCode = "404", description = "사용자 정보를 찾을 수 없음"),
@ApiResponse(responseCode = "400", description = "잘못된 요청")
})
@PostMapping("/reset-request")
public ResponseEntity<ResponseDTO<PasswordDto.PasswordResetResponse>> requestPasswordReset(
@Parameter(description = "비밀번호 재설정 요청 정보", required = true)
@Valid @RequestBody PasswordDto.PasswordResetRequest request) {
log.info("비밀번호 재설정 요청: {}", request.email());
passwordResetService.requestPasswordReset(request);
PasswordDto.PasswordResetResponse response = PasswordDto.PasswordResetResponse.builder()
.success(true)
.message("비밀번호 재설정 이메일이 발송되었습니다.")
.build();
return ResponseEntity.ok(ResponseDTO.success(response, "비밀번호 재설정 이메일이 발송되었습니다."));
}
@Operation(summary = "비밀번호 재설정", description = "토큰과 새 비밀번호를 입력받아 비밀번호를 재설정합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"),
@ApiResponse(responseCode = "404", description = "유효하지 않은 토큰"),
@ApiResponse(responseCode = "400", description = "만료된 토큰 또는 잘못된 요청")
})
@PostMapping("/reset-verify")
public ResponseEntity<ResponseDTO<PasswordDto.PasswordResetResponse>> resetPassword(
@Parameter(description = "비밀번호 재설정 정보", required = true)
@Valid @RequestBody PasswordDto.PasswordResetVerifyRequest request) {
log.info("비밀번호 재설정 확인");
passwordResetService.resetPassword(request);
PasswordDto.PasswordResetResponse response = PasswordDto.PasswordResetResponse.builder()
.success(true)
.message("비밀번호가 성공적으로 재설정되었습니다.")
.build();
return ResponseEntity.ok(ResponseDTO.success(response, "비밀번호가 성공적으로 재설정되었습니다."));
}
}
전체 API 흐름
첫 번째 단계 ("/reset-request")
두 번째 단계 ("/reset-verify")
WebSecurityConfig
.requestMatchers("/api/auth/password/**").permitAll()
EmailConfig
@Configuration
@EnableAsync
public class EmailConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("EmailThread-");
executor.initialize();
return executor;
}
}
비동기 이메일 처리를 위한 스레드 풀 설정을 정의하는 구성 클래스이다.
@EnableAsync: Spring에서 비동기 메서드 실행을 활성화한다. 이 어노테이션은 @Async 어노테이션이 있는 메서드가 별도의 스레드에서 실행되도록 한다.
setCorePoolSize(2)
setMaxPoolSize(4)
setQueueCapacity(100)
setThreadNamePrefix("EmailThread-")
"EmailThread-"로 설정한다.initialize()
ThreadPoolTaskExecutor를 초기화한다.사용 목적
이 설정은 이메일 전송과 같은 시간이 걸리고 I/O 집약적인 작업을 비동기적으로 처리하기 위한 것이다. 기존의 EmailService 클래스에서 @Async 어노테이션이 있는 메서드는 이 스레드 풀을 사용하여 비동기적으로 실행된다.




