diff --git a/.gitignore b/.gitignore
index 96fc7f4..9ee5203 100644
--- a/.gitignore
+++ b/.gitignore
@@ -756,4 +756,8 @@ FodyWeavers.xsd
# Local Profile
/src/main/resources/application-local.yml
-# End of https://www.toptal.com/developers/gitignore/api/java,intellij+all,intellij+iml,netbeans,macos,windows,visualstudiocode,visualstudio,gradle,groovy,linux,sonarqube,sonar,dotenv,redis,git
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/java,intellij+all,intellij+iml,netbeans,macos,windows,visualstudiocode,visualstudio,gradle,groovy,linux,sonarqube,sonar,dotenv,redis,git
+
+# Personal (jeonghyeon)
+/pull_request_description.md
+/.claude/
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index f9ef164..ee7538c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -37,6 +37,9 @@ dependencies {
implementation 'com.twelvemonkeys.imageio:imageio-core:3.12.0'
implementation 'org.sejda.imageio:webp-imageio:0.1.6'
+ // Email
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+
// Auth
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
diff --git a/src/main/java/team/wego/wegobackend/auth/application/PasswordResetEmailService.java b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetEmailService.java
new file mode 100644
index 0000000..b3b9076
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetEmailService.java
@@ -0,0 +1,67 @@
+package team.wego.wegobackend.auth.application;
+
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.mail.MailException;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import team.wego.wegobackend.common.exception.AppErrorCode;
+import team.wego.wegobackend.common.exception.AppException;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PasswordResetEmailService {
+
+ private final JavaMailSender mailSender;
+
+ /**
+ * 비밀번호 재설정 이메일을 발송합니다.
+ *
+ * @param toEmail 수신자 이메일
+ * @param resetUrl 비밀번호 재설정 페이지 전체 URL (검증값 포함)
+ */
+ @Async("mailExecutor")
+ public void sendPasswordResetEmail(String toEmail, String resetUrl) {
+ try {
+ MimeMessage message = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
+
+ helper.setTo(toEmail);
+ helper.setSubject("[WeGo] 비밀번호 재설정 안내");
+ helper.setText(buildEmailBody(resetUrl), true);
+
+ mailSender.send(message);
+ log.info("비밀번호 재설정 이메일 발송 완료 -> {}", toEmail);
+
+ } catch (MessagingException | MailException e) {
+ log.error("비밀번호 재설정 이메일 발송 실패 -> {}", toEmail, e);
+ throw new AppException(AppErrorCode.DEPENDENCY_FAILURE);
+ }
+ }
+
+ private String buildEmailBody(String resetUrl) {
+ return """
+
+
비밀번호 재설정
+
안녕하세요. WeGo입니다.
+
아래 버튼을 클릭하여 비밀번호를 재설정하세요.
+
+
+ 비밀번호 재설정
+
+
+
+ 본 링크는 30분 동안만 유효합니다.
+ 본인이 요청하지 않은 경우 이 이메일을 무시하셔도 됩니다.
+
+
+ """.formatted(resetUrl);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/auth/application/PasswordResetService.java b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetService.java
new file mode 100644
index 0000000..96e372f
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/auth/application/PasswordResetService.java
@@ -0,0 +1,75 @@
+package team.wego.wegobackend.auth.application;
+
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import team.wego.wegobackend.auth.exception.InvalidResetTokenException;
+import team.wego.wegobackend.auth.infrastructure.redis.PasswordResetRedisRepository;
+import team.wego.wegobackend.user.domain.User;
+import team.wego.wegobackend.user.repository.UserRepository;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PasswordResetService {
+
+ @Value("${password-reset.frontend-url}")
+ private String frontendUrl;
+
+ private final UserRepository userRepository;
+ private final BCryptPasswordEncoder passwordEncoder;
+ private final PasswordResetRedisRepository redisRepository;
+ private final PasswordResetEmailService emailService;
+
+ /**
+ * 비밀번호 재설정 요청.
+ * 미가입 이메일이어도 200 OK를 반환하여 이메일 열거 공격을 방지합니다.
+ */
+ public void requestPasswordReset(String email) {
+ boolean exists = userRepository.existsByEmail(email);
+ if (!exists) {
+ log.debug("비밀번호 재설정 요청: 미가입 이메일 -> {}", email);
+ return;
+ }
+
+ String token = UUID.randomUUID().toString();
+ redisRepository.save(email, token);
+
+ String resetUrl = frontendUrl + "/password-reset?validationValue=" + token;
+ emailService.sendPasswordResetEmail(email, resetUrl);
+
+ log.info("비밀번호 재설정 토큰 발급 -> email={}", email);
+ }
+
+ /**
+ * 검증값 유효성 검사. 토큰을 소비하지 않습니다. (프론트 2단계 검증 1단계)
+ */
+ public void verifyToken(String token) {
+ redisRepository.findEmailByToken(token)
+ .orElseThrow(InvalidResetTokenException::new);
+ }
+
+ /**
+ * 비밀번호 변경. 토큰 유효성 재검사 후 비밀번호 변경 및 토큰·세션 폐기.
+ */
+ @Transactional
+ public void resetPassword(String token, String newPassword) {
+ String email = redisRepository.findEmailByToken(token)
+ .orElseThrow(InvalidResetTokenException::new);
+
+ User user = userRepository.findByEmail(email)
+ .orElseThrow(InvalidResetTokenException::new);
+
+ user.updatePassword(passwordEncoder.encode(newPassword));
+ user.updateCurrentSessionid(null);
+
+ redisRepository.deleteByToken(token);
+
+ log.info("비밀번호 재설정 완료 -> email={}", email);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetConfirmRequest.java b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetConfirmRequest.java
new file mode 100644
index 0000000..d814609
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetConfirmRequest.java
@@ -0,0 +1,14 @@
+package team.wego.wegobackend.auth.application.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record PasswordResetConfirmRequest(
+ @NotBlank(message = "토큰은 필수입니다.")
+ String token,
+
+ @NotBlank(message = "새 비밀번호는 필수입니다.")
+ @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
+ String newPassword
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetRequest.java b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetRequest.java
new file mode 100644
index 0000000..fd595b0
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/auth/application/dto/request/PasswordResetRequest.java
@@ -0,0 +1,11 @@
+package team.wego.wegobackend.auth.application.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record PasswordResetRequest(
+ @NotBlank(message = "이메일은 필수입니다.")
+ @Email(message = "올바른 이메일 형식이 아닙니다.")
+ String email
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/auth/exception/InvalidResetTokenException.java b/src/main/java/team/wego/wegobackend/auth/exception/InvalidResetTokenException.java
new file mode 100644
index 0000000..26a942d
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/auth/exception/InvalidResetTokenException.java
@@ -0,0 +1,11 @@
+package team.wego.wegobackend.auth.exception;
+
+import team.wego.wegobackend.common.exception.AppErrorCode;
+import team.wego.wegobackend.common.exception.AppException;
+
+public class InvalidResetTokenException extends AppException {
+
+ public InvalidResetTokenException() {
+ super(AppErrorCode.INVALID_RESET_TOKEN);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.java b/src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.java
new file mode 100644
index 0000000..14eba46
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/auth/infrastructure/redis/PasswordResetRedisRepository.java
@@ -0,0 +1,71 @@
+package team.wego.wegobackend.auth.infrastructure.redis;
+
+import java.time.Duration;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Repository;
+
+/**
+ * 비밀번호 재설정 토큰 Redis 저장소.
+ *
+ * 이중 키 구조로 "계정당 최신 토큰 1개만 유효" 정책을 구현합니다.
+ *
+ * - pwreset:token:{uuid} → email (토큰 검증용)
+ * - pwreset:email:{email} → uuid (이전 토큰 추적 및 폐기용)
+ *
+ */
+@Repository
+@RequiredArgsConstructor
+public class PasswordResetRedisRepository {
+
+ private static final String TOKEN_PREFIX = "pwreset:token:";
+ private static final String EMAIL_PREFIX = "pwreset:email:";
+
+ @Value("${password-reset.token-ttl-minutes:30}")
+ private long tokenTtlMinutes;
+
+ private final StringRedisTemplate stringRedisTemplate;
+
+ /**
+ * 토큰을 저장합니다. 기존 토큰이 있으면 먼저 폐기합니다.
+ */
+ public void save(String email, String token) {
+ // 기존 토큰 폐기
+ String oldToken = stringRedisTemplate.opsForValue().get(emailKey(email));
+ if (oldToken != null) {
+ stringRedisTemplate.delete(tokenKey(oldToken));
+ }
+
+ Duration ttl = Duration.ofMinutes(tokenTtlMinutes);
+ stringRedisTemplate.opsForValue().set(tokenKey(token), email, ttl);
+ stringRedisTemplate.opsForValue().set(emailKey(email), token, ttl);
+ }
+
+ /**
+ * 토큰으로 이메일을 조회합니다.
+ */
+ public Optional findEmailByToken(String token) {
+ return Optional.ofNullable(stringRedisTemplate.opsForValue().get(tokenKey(token)));
+ }
+
+ /**
+ * 사용 완료된 토큰을 폐기합니다. (토큰 키, 이메일 역방향 키 모두 삭제)
+ */
+ public void deleteByToken(String token) {
+ String email = stringRedisTemplate.opsForValue().get(tokenKey(token));
+ stringRedisTemplate.delete(tokenKey(token));
+ if (email != null) {
+ stringRedisTemplate.delete(emailKey(email));
+ }
+ }
+
+ private String tokenKey(String token) {
+ return TOKEN_PREFIX + token;
+ }
+
+ private String emailKey(String email) {
+ return EMAIL_PREFIX + email;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java b/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java
index 7374594..2d5dc2d 100644
--- a/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java
+++ b/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java
@@ -10,13 +10,18 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import team.wego.wegobackend.auth.application.AuthService;
+import team.wego.wegobackend.auth.application.PasswordResetService;
import team.wego.wegobackend.auth.application.dto.request.GoogleLoginRequest;
import team.wego.wegobackend.auth.application.dto.request.LoginRequest;
+import team.wego.wegobackend.auth.application.dto.request.PasswordResetConfirmRequest;
+import team.wego.wegobackend.auth.application.dto.request.PasswordResetRequest;
import team.wego.wegobackend.auth.application.dto.request.SignupRequest;
import team.wego.wegobackend.auth.application.dto.response.LoginResponse;
import team.wego.wegobackend.auth.application.dto.response.RefreshResponse;
@@ -34,6 +39,8 @@ public class AuthController implements AuthControllerDocs {
private final AuthService authService;
+ private final PasswordResetService passwordResetService;
+
private final JwtTokenProvider jwtTokenProvider;
/**
@@ -155,6 +162,49 @@ public ResponseEntity> refresh(
));
}
+ /**
+ * 비밀번호 재설정 요청 — 이메일 발송
+ * 미가입 이메일이어도 200 OK 반환 (이메일 열거 공격 방지)
+ */
+ @PostMapping("/password-reset/request")
+ public ResponseEntity> requestPasswordReset(
+ @Valid @RequestBody PasswordResetRequest request) {
+
+ passwordResetService.requestPasswordReset(request.email());
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(ApiResponse.success(200, true));
+ }
+
+ /**
+ * 검증값(validationValue) 유효성 검사
+ */
+ @GetMapping("/reset-verify")
+ public ResponseEntity> verifyResetToken(
+ @RequestParam String validationValue) {
+
+ passwordResetService.verifyToken(validationValue);
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(ApiResponse.success(200, true));
+ }
+
+ /**
+ * 비밀번호 변경
+ */
+ @PostMapping("/password-reset")
+ public ResponseEntity> resetPassword(
+ @Valid @RequestBody PasswordResetConfirmRequest request) {
+
+ passwordResetService.resetPassword(request.token(), request.newPassword());
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(ApiResponse.success(200, true));
+ }
+
/**
* Refresh Token HttpOnly 쿠키 생성
*/
diff --git a/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java b/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java
index d081029..30327c0 100644
--- a/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java
+++ b/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java
@@ -8,8 +8,11 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
import team.wego.wegobackend.auth.application.dto.request.GoogleLoginRequest;
import team.wego.wegobackend.auth.application.dto.request.LoginRequest;
+import team.wego.wegobackend.auth.application.dto.request.PasswordResetConfirmRequest;
+import team.wego.wegobackend.auth.application.dto.request.PasswordResetRequest;
import team.wego.wegobackend.auth.application.dto.request.SignupRequest;
import team.wego.wegobackend.auth.application.dto.response.LoginResponse;
import team.wego.wegobackend.auth.application.dto.response.RefreshResponse;
@@ -51,4 +54,28 @@ ResponseEntity> withDraw(
HttpServletResponse response
);
+ @Operation(
+ summary = "비밀번호 재설정 요청",
+ description = "이메일로 비밀번호 재설정 링크를 발송합니다. 가입 여부와 관계없이 200 OK를 반환합니다."
+ )
+ ResponseEntity> requestPasswordReset(
+ @Valid @RequestBody PasswordResetRequest request
+ );
+
+ @Operation(
+ summary = "비밀번호 재설정 토큰 유효성 검사",
+ description = "이메일 링크의 검증값(validationValue)이 유효한지 확인합니다. 토큰을 소비하지 않습니다."
+ )
+ ResponseEntity> verifyResetToken(
+ @RequestParam String validationValue
+ );
+
+ @Operation(
+ summary = "비밀번호 변경",
+ description = "검증값과 새 비밀번호를 전송하여 비밀번호를 변경합니다. 성공 시 토큰 및 세션이 폐기됩니다."
+ )
+ ResponseEntity> resetPassword(
+ @Valid @RequestBody PasswordResetConfirmRequest request
+ );
+
}
diff --git a/src/main/java/team/wego/wegobackend/common/config/AsyncConfig.java b/src/main/java/team/wego/wegobackend/common/config/AsyncConfig.java
new file mode 100644
index 0000000..d34339b
--- /dev/null
+++ b/src/main/java/team/wego/wegobackend/common/config/AsyncConfig.java
@@ -0,0 +1,23 @@
+package team.wego.wegobackend.common.config;
+
+import java.util.concurrent.Executor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean(name = "mailExecutor")
+ public Executor mailExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(4);
+ executor.setQueueCapacity(50);
+ executor.setThreadNamePrefix("mail-");
+ executor.initialize();
+ return executor;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java
index 9ef0286..cdc1ad8 100644
--- a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java
+++ b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java
@@ -42,7 +42,9 @@ public enum AppErrorCode implements ErrorCode {
DUPLICATE_LOGIN(HttpStatus.UNAUTHORIZED, "인증 : 다른 기기에서 로그인되었습니다."),
USER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "인증 : 해당 리소스에 접근할 권한이 없습니다."),
- NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "알림 : 알림을 찾을 수 없습니다.")
+ NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "알림 : 알림을 찾을 수 없습니다."),
+
+ INVALID_RESET_TOKEN(HttpStatus.BAD_REQUEST, "인증 : 유효하지 않거나 만료된 비밀번호 재설정 토큰입니다.")
;
private final HttpStatus httpStatus;
diff --git a/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java b/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java
index a0d7957..6b42a18 100644
--- a/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java
+++ b/src/main/java/team/wego/wegobackend/common/security/SecurityEndpoints.java
@@ -7,6 +7,9 @@ public class SecurityEndpoints {
"/api/v*/auth/login",
"/api/v*/auth/google",
"/api/v*/auth/refresh",
+ "/api/v*/auth/password-reset/request",
+ "/api/v*/auth/reset-verify",
+ "/api/v*/auth/password-reset",
"/api/v*/health",
"/h2-console/**",
"/error",
diff --git a/src/main/java/team/wego/wegobackend/user/domain/User.java b/src/main/java/team/wego/wegobackend/user/domain/User.java
index 09a86c7..a81e79c 100644
--- a/src/main/java/team/wego/wegobackend/user/domain/User.java
+++ b/src/main/java/team/wego/wegobackend/user/domain/User.java
@@ -195,4 +195,8 @@ public void updateCurrentSessionid(String sessionId) {
this.currentSessionid = sessionId;
}
+ public void updatePassword(String encodedPassword) {
+ this.password = encodedPassword;
+ }
+
}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index efc5950..f2139a1 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -63,6 +63,18 @@ spring:
min-idle: 1
max-wait: 2s
+ mail:
+ host: ${MAIL_HOST:smtp.gmail.com}
+ port: ${MAIL_PORT:587}
+ username: ${MAIL_USERNAME}
+ password: ${MAIL_PASSWORD}
+ properties:
+ mail:
+ smtp:
+ auth: true
+ starttls:
+ enable: true
+ required: true
aws:
s3:
@@ -111,6 +123,11 @@ chat:
delete-expired-chat:
cron: "0 0 * * * *" # 매 시간 정각
+# 비밀번호 재설정
+password-reset:
+ frontend-url: ${PASSWORD_RESET_FRONTEND_URL:https://wego.monster}
+ token-ttl-minutes: 30
+
# OAuth-Google
google:
oauth:
diff --git a/src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.java b/src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.java
new file mode 100644
index 0000000..f173e53
--- /dev/null
+++ b/src/test/java/team/wego/wegobackend/auth/PasswordResetEmailServiceTest.java
@@ -0,0 +1,48 @@
+package team.wego.wegobackend.auth;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration;
+import org.springframework.boot.autoconfigure.mail.MailSenderValidatorAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import team.wego.wegobackend.auth.application.PasswordResetEmailService;
+import team.wego.wegobackend.common.config.AsyncConfig;
+
+/**
+ * 실제 SMTP 서버로 이메일 발송을 검증하는 통합 테스트.
+ *
+ * 실행 전 .env 파일에 MAIL_USERNAME, MAIL_PASSWORD가 설정되어 있어야 합니다.
+ * 테스트 이메일은 MAIL_USERNAME 주소 본인에게 발송됩니다.
+ */
+@SpringBootTest(
+ classes = {PasswordResetEmailService.class, AsyncConfig.class}
+)
+@ImportAutoConfiguration({
+ MailSenderAutoConfiguration.class,
+ MailSenderValidatorAutoConfiguration.class
+})
+class PasswordResetEmailServiceTest {
+
+ @Autowired
+ private PasswordResetEmailService emailService;
+
+ @Value("${spring.mail.username}")
+ private String mailUsername;
+
+ @Test
+ @DisplayName("실제 SMTP 서버로 비밀번호 재설정 이메일 발송 검증")
+ void sendPasswordResetEmail_실제_발송() {
+ String testResetUrl = "https://wego.monster/password-reset?validationValue=test-token-1234";
+
+ assertThatCode(() ->
+ emailService.sendPasswordResetEmail(mailUsername, testResetUrl)
+ ).doesNotThrowAnyException();
+
+ System.out.println("✅ 이메일 발송 완료. 수신함 확인: " + mailUsername);
+ }
+}
\ No newline at end of file