
이전 글에서는 SMTP가 애플리케이션과 메일 서버 사이에서 어떤 역할을 하는지 살펴보았습니다.
이번 글에서는
05-answer브랜치를 실행해 로컬 MySQL에 사용자를 저장하고,
Gmail SMTP로 비밀번호 재설정 메일을 전송한 뒤 실제 비밀번호 변경까지 확인합니다.
이번 글의 코드는 다음 브랜치를 기준으로 합니다.
https://github.com/stdiodh/spring-boot-db-access-lab/tree/05-answer
이번 실습의 목적은 단순히 JavaMailSender.send()를 한 번 호출하는 데 있지 않습니다.
LOCAL 계정 생성
→ MySQL에 사용자 저장
→ reset token 생성 및 hash 저장
→ token transaction commit
→ Gmail SMTP 동기 호출
→ 메일 링크의 token을 브라우저 메모리로 회수
→ 새 비밀번호 저장과 token 단일 사용 처리
→ 변경한 비밀번호로 다시 로그인
또한 Gmail 인증 실패를 일부러 발생시켜 다음 보상 흐름도 확인합니다.
reset token 발급 및 commit
→ Gmail SMTP 인증 실패
→ HTTP 424
→ 이번 요청에서 발급한 미사용 token 삭제
→ 즉시 다시 요청해도 429 cooldown이 아니라 다시 SMTP 호출
05-answer는 SMTP 종류에 따라 메일의 발신자를 분리합니다.
SMTP host가 smtp.gmail.com인 경우
→ From = SPRING_MAIL_USERNAME
그 밖의 SMTP인 경우
→ From = APP_RECOVERY_MAIL_FROM
즉, Gmail에서는 인증에 사용한 계정인 SPRING_MAIL_USERNAME을 그대로 발신자로 사용합니다.
APP_RECOVERY_MAIL_FROM은 Mailpit과 같은 비 Gmail SMTP의 발신자 설정입니다.
현재 구현의 핵심 부분은 다음과 같습니다.
@Component
class SmtpRecoveryMailSender(
private val javaMailSender: JavaMailSender,
@Value("\${app.recovery-mail-from}")
private val recoveryMailFrom: String,
@Value("\${spring.mail.host}")
private val smtpHost: String,
@Value("\${spring.mail.username}")
private val smtpUsername: String
) : RecoveryMailSender {
init {
if (smtpHost.equals(GMAIL_SMTP_HOST, ignoreCase = true)) {
check(smtpUsername.isNotBlank()) {
"Gmail SMTP 설정 오류: SPRING_MAIL_USERNAME이 필요합니다."
}
}
}
override fun sendPasswordResetMail(
recipientEmail: String,
resetLink: String
) {
val message = SimpleMailMessage().apply {
from = if (smtpHost.equals(GMAIL_SMTP_HOST, ignoreCase = true)) {
smtpUsername
} else {
recoveryMailFrom
}
setTo(recipientEmail)
subject = "[A&I] 비밀번호 재설정 안내"
text = """
비밀번호 재설정 요청을 받았습니다.
$resetLink
이 링크는 제한된 시간 동안 한 번만 사용할 수 있습니다.
요청하지 않았다면 이 메일을 무시하세요.
""".trimIndent()
}
try {
javaMailSender.send(message)
} catch (exception: MailAuthenticationException) {
throw RecoveryMailAuthenticationException(exception)
} catch (exception: MailException) {
throw RecoveryMailDeliveryException(exception)
}
}
private companion object {
const val GMAIL_SMTP_HOST = "smtp.gmail.com"
}
}
이 글의 Gmail 설정과 실패 테스트도 이 계약을 기준으로 진행합니다.
| 구분 | 사용 환경 |
|---|---|
| Language | Kotlin 2.2.21 |
| Java | Java 21 |
| Framework | Spring Boot 4.0.2 |
| Database | 이미 설치된 로컬 MySQL 8.x |
| ORM | Spring Data JPA |
Spring Mail JavaMailSender | |
| SMTP | Gmail SMTP |
| Local DB Port | localhost:3306 |
| Application | localhost:8080 |
이번 글은 MySQL이 PC에 이미 설치되어 있고 서버가 실행 중인 상태를 전제로 합니다.
로컬 MySQL의 계정이나 포트가 다르다면 이후 .env의 DB_USERNAME, DB_PASSWORD, DB_URL만 자신의 환경에 맞게 변경합니다.
05-answer 브랜치 준비저장소를 내려받고 완성 브랜치로 이동합니다.
git clone https://github.com/stdiodh/spring-boot-db-access-lab.git
cd spring-boot-db-access-lab
git checkout 05-answer
Java와 로컬 MySQL 실행 환경을 먼저 확인합니다.
java -version
mysql --version
MySQL 명령어가 PATH에 등록되어 있지 않다면 MySQL Workbench나 IntelliJ Database에서 로컬 서버 접속 여부를 확인해도 됩니다.
프로젝트 루트에 .env가 없다면 예제 파일을 복사합니다.
test -f .env || cp .env.example .env
.env.example은 로컬 학습용 값만 포함하며 실제 Secret은 포함하지 않습니다.
실제 .env는 .gitignore에 의해 커밋 대상에서 제외됩니다.
이미 설치되어 실행 중인 MySQL에 접속합니다. 아래 예시는 기본 포트 3306과 root 계정을 사용하지만,
실제 계정과 포트가 다르면 자신의 환경에 맞게 바꿉니다.
mysql -h 127.0.0.1 -P 3306 -u root -p
접속한 뒤 실습용 데이터베이스를 생성합니다.
CREATE DATABASE IF NOT EXISTS aandi_lab
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
SHOW DATABASES LIKE 'aandi_lab';
프로젝트 루트의 .env에서 Spring Boot가 로컬 MySQL에 연결하도록 설정합니다.
DB_URL=jdbc:mysql://localhost:3306/aandi_lab?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
DB_USERNAME=root
DB_PASSWORD=<로컬 MySQL 비밀번호>
저장소의 application.yaml은 DB_URL, DB_USERNAME, DB_PASSWORD 환경변수를 우선 사용합니다.
spring:
datasource:
url: ${DB_URL:jdbc:mysql://localhost:3307/aandi_lab?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8}
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:root}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
저장소의 기본 URL은 3307을 fallback으로 두고 있지만, 이번 실습에서는 .env의 DB_URL이 우선 적용되므로 이미 설치된 로컬 MySQL의 기본 포트 3306으로 연결됩니다.
이번 실습은 ddl-auto=update를 사용하므로 애플리케이션이 실행되면 users, password_reset_tokens 등의 테이블이 생성됩니다. 이는 실습 편의를 위한 설정이며, 운영 환경의 명시적인 마이그레이션을 대신하지는 않습니다.
Gmail SMTP에서 앱 비밀번호를 사용하려면 Google 계정의 2단계 인증이 활성화되어 있어야 합니다.
Google 계정 관리
→ 보안
→ Google에 로그인하는 방법
→ 2단계 인증

2단계 인증을 켰더라도 계정 유형에 따라 앱 비밀번호 메뉴가 보이지 않을 수 있습니다.
조직에서 관리하는 계정, 고급 보호 프로그램을 사용하는 계정, 일부 보안 키 전용 설정에서는 앱 비밀번호를 생성하지 못할 수 있습니다.
Google 계정 검색에서 앱 비밀번호를 검색합니다.

앱 이름은 어떤 용도로 만든 값인지 알아볼 수 있도록 지정합니다.
spring-mail-local

생성된 16자리 앱 비밀번호는 한 번만 확인하고 .env의 SPRING_MAIL_PASSWORD에 저장합니다.
SPRING_MAIL_PASSWORD=<Google 앱 비밀번호>
앱 비밀번호가 표시된 결과 화면은 블로그에 첨부하지 않습니다.
이미 캡처나 저장소를 통해 노출했다면 모자이크만 하고 계속 사용하는 것이 아니라
해당 앱 비밀번호를 폐기한 뒤 새로 발급해야 합니다.
일반 Google 계정 비밀번호를 SPRING_MAIL_PASSWORD에 넣는 것도 피해야 합니다.
이번 실습에서는 SMTP 인증 전용 앱 비밀번호만 사용합니다.
프로젝트 루트의 .env에서 메일 설정을 Gmail로 덮어씁니다.
# Gmail SMTP
SPRING_MAIL_HOST=smtp.gmail.com
SPRING_MAIL_PORT=587
SPRING_MAIL_USERNAME=your-account@gmail.com
SPRING_MAIL_PASSWORD=your-google-app-password
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT=5000
SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT=5000
SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT=5000
# Gmail이 아닌 SMTP에서만 사용할 fallback From
APP_RECOVERY_MAIL_FROM=no-reply@aandi.test
# Reset link와 정책
APP_PASSWORD_RESET_URL=http://localhost:8080/auth-practice/recovery.html
APP_PASSWORD_RESET_TOKEN_TTL=PT15M
APP_PASSWORD_RESET_RESEND_COOLDOWN=PT1M
여기서 가장 중요한 점은 다음과 같습니다.
SPRING_MAIL_HOST=smtp.gmail.com
→ Gmail 분기 사용
→ From = SPRING_MAIL_USERNAME
→ APP_RECOVERY_MAIL_FROM은 사용하지 않음
따라서 Gmail 계정 주소를 APP_RECOVERY_MAIL_FROM에 한 번 더 복사할 필요가 없습니다.
application.yaml은 .env를 properties 형식으로 읽고, spring.mail 설정을 JavaMailSender에 전달합니다.
spring:
config:
import: "optional:file:.env[.properties]"
mail:
host: ${SPRING_MAIL_HOST:localhost}
port: ${SPRING_MAIL_PORT:1025}
username: "${SPRING_MAIL_USERNAME:}"
password: "${SPRING_MAIL_PASSWORD:}"
properties:
"[mail.smtp.auth]": ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:false}
"[mail.smtp.starttls.enable]": ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:false}
"[mail.smtp.starttls.required]": ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED:false}
"[mail.smtp.connectiontimeout]": ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_CONNECTIONTIMEOUT:5000}
"[mail.smtp.timeout]": ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_TIMEOUT:5000}
"[mail.smtp.writetimeout]": ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT:5000}
연결·읽기·쓰기 timeout을 유한하게 두면 SMTP 서버가 응답하지 않을 때 요청 스레드가 무기한 기다리는 상황을 줄일 수 있습니다.
.env는 절대 저장소에 커밋하지 않습니다.
.env
.env.*
!.env.example
애플리케이션을 실행합니다.
./gradlew bootRun
브라우저에서 자체 로그인 실습 화면을 엽니다.
http://localhost:8080/auth-practice/index.html
SMTP 복구 화면은 다음 주소입니다.
http://localhost:8080/auth-practice/recovery.html
MySQL에 테이블이 생성됐는지 확인하려면 로컬 MySQL 클라이언트로 aandi_lab 데이터베이스에 접속합니다.
mysql -h 127.0.0.1 -P 3306 -u root -p aandi_lab
MySQL Workbench나 IntelliJ Database를 사용한다면 같은 로컬 연결에서 aandi_lab 스키마를 선택하면 됩니다.
SHOW TABLES;
다음 두 테이블이 이번 실습의 중심입니다.
users
password_reset_tokens
복구 요청은 이메일만 존재한다고 바로 메일을 보내지 않습니다.
val user = userRepository.findByEmailForUpdate(normalizedEmail)
.orElseThrow(::RecoveryMailNotSentException)
if (!user.localPasswordEnabled) {
throw RecoveryMailNotSentException()
}
이번 저장소에서 메일 복구가 가능한 대상은 다음과 같습니다.
LOCAL 회원가입 사용자
→ localPasswordEnabled = true
Google OAuth 사용자
→ 처음에는 localPasswordEnabled = false
→ POST /auth/local-password로 LOCAL 비밀번호를 등록한 뒤 true
따라서 이번 SMTP 실습에서는 가장 단순한 흐름을 위해 먼저 LOCAL 계정을 생성합니다.
index.html에서 테스트용 이메일과 비밀번호를 입력하고 계정 만들기를 누릅니다.
성공하면 다음 요청이 201 Created로 끝납니다.
POST /auth/signup

MySQL에서는 이메일이나 비밀번호 hash를 화면에 노출하지 않고 다음 쿼리만 확인해도 충분합니다.
SELECT
id,
auth_provider,
local_password_enabled
FROM users
ORDER BY id DESC
LIMIT 1;
예상할 수 있는 상태는 다음과 같습니다.
auth_provider = LOCAL
local_password_enabled = 1
비밀번호 재설정 링크에는 서버가 예측하기 어려운 원본 token이 필요합니다.
하지만 데이터베이스에 원본 token까지 저장하면 DB가 노출됐을 때 아직 사용하지 않은 링크를 그대로 사용할 수 있습니다.
따라서 이번 구현은 원본과 저장값의 책임을 나눕니다.
rawToken
- SecureRandom 32-byte
- Base64URL without padding
- 메일 링크에만 포함
- DB에 원문 저장하지 않음
tokenHash
- SHA-256(rawToken)
- DB에 64자리 hex 문자열로 저장
- 확정 요청의 token 검증에 사용
Token 생성 코드는 다음과 같습니다.
@Component
class PasswordResetTokenCodec {
private val secureRandom = SecureRandom()
fun generateRawToken(): String {
val bytes = ByteArray(TOKEN_BYTES)
secureRandom.nextBytes(bytes)
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes)
}
fun hash(rawToken: String): String {
val digest = MessageDigest.getInstance("SHA-256")
.digest(rawToken.toByteArray(StandardCharsets.UTF_8))
return HexFormat.of().formatHex(digest)
}
private companion object {
const val TOKEN_BYTES = 32
}
}
password_reset_tokens는 사용자당 한 행만 유지합니다.
새 token을 재발급하면 기존 행의 hash와 시간을 회전시키므로 이전 링크는 무효가 됩니다.
createdAt : 발급 시각
expiresAt : 15분 뒤
usedAt : 사용 전 null, 성공 후 사용 시각
복구 요청 Service는 사용자를 잠금 조회하고 cooldown을 확인한 뒤 token을 저장합니다.
@Transactional
fun requestPasswordReset(email: String): PasswordResetMailCommand {
val normalizedEmail = email.lowercase(Locale.ROOT)
val user = userRepository.findByEmailForUpdate(normalizedEmail)
.orElseThrow(::RecoveryMailNotSentException)
if (!user.localPasswordEnabled) {
throw RecoveryMailNotSentException()
}
val now = clock.instant()
val existingToken = passwordResetTokenRepository
.findByUserIdForUpdate(user.id)
.orElse(null)
val cooldownEndsAt = existingToken?.createdAt?.plus(resendCooldown)
if (
existingToken != null &&
cooldownEndsAt != null &&
existingToken.isRecentlyIssuedAndActive(now, cooldownEndsAt)
) {
throw RecoveryMailCooldownException(
retryAfterSeconds(now, cooldownEndsAt)
)
}
val rawToken = tokenCodec.generateRawToken()
val tokenHash = tokenCodec.hash(rawToken)
val expiresAt = now.plus(tokenTtl)
val token = existingToken?.apply {
rotate(tokenHash, now, expiresAt)
} ?: PasswordResetToken(
user = user,
tokenHash = tokenHash,
createdAt = now,
expiresAt = expiresAt
)
val savedToken = passwordResetTokenRepository.saveAndFlush(token)
return PasswordResetMailCommand(
tokenId = savedToken.id,
tokenHash = savedToken.tokenHash,
recipientEmail = user.email,
resetLink = createResetLink(rawToken)
)
}
핵심은 SMTP 호출이 이 메서드 안에 없다는 점입니다.
Spring proxy가 requestPasswordReset()의 트랜잭션을 commit한 뒤
Controller가 PasswordResetMailCommand를 받아 실제 SMTP 발송을 시작합니다.
@PostMapping("/password-reset")
fun requestPasswordReset(
@Valid @RequestBody request: PasswordResetMailRequest
): ResponseEntity<PasswordResetMailResponse> {
val command = accountRecoveryService
.requestPasswordReset(request.email)
dispatchOrDiscard(command)
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(
PasswordResetMailResponse(
code = "RECOVERY_MAIL_SENT",
message = "SMTP 서버가 비밀번호 재설정 메일 요청을 수락했습니다."
)
)
}
순서를 나누는 이유는 다음과 같습니다.
잘못된 순서
SMTP 발송 성공
→ 이후 DB commit 실패
→ 사용자가 받은 링크는 처음부터 무효
현재 순서
DB token commit 완료
→ SMTP 호출
→ 사용자가 받은 링크가 DB에 존재하는 상태 보장
복구 화면을 열고 앞에서 생성한 LOCAL 계정의 이메일을 입력합니다.
http://localhost:8080/auth-practice/recovery.html
비밀번호 재설정 메일 요청을 누르면 다음 API를 호출합니다.
POST /account-recovery/password-reset
Content-Type: application/json
{
"email": "<LOCAL 계정 이메일>"
}
성공 응답은 다음과 같습니다.
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{
"code": "RECOVERY_MAIL_SENT",
"message": "SMTP 서버가 비밀번호 재설정 메일 요청을 수락했습니다."
}

여기서 200 OK가 의미하는 범위를 구분해야 합니다.
확인한 것
- JavaMailSender.send()가 예외 없이 반환됨
- 이번 HTTP 계약에서 SMTP 요청 수락으로 판단함
아직 확인하지 못한 것
- 받은편지함 도착
- 스팸함 분류 여부
- 이후 반송 여부
- SPF·DKIM·DMARC 결과
따라서 다음 단계에서는 API 화면만 보지 않고 실제 Gmail 수신함을 확인합니다.
재설정 링크는 다음 형태입니다.
http://localhost:8080/auth-practice/recovery.html#reset_token=<raw-token>
# 뒤의 fragment는 일반적인 HTTP 요청 경로로 서버에 전송되지 않습니다.
실습 화면의 초기 스크립트는 HTML 본문이 처리되기 전에 token을 메모리로 옮기고 현재 URL에서 제거합니다.
const query = new URLSearchParams(window.location.search);
const fragment = new URLSearchParams(
window.location.hash.replace(/^#/, "")
);
const payload = {
reset_token: fragment.get("reset_token")
};
window.history.replaceState(
null,
document.title,
window.location.pathname
);
이후 token은 다음 위치에 저장하지 않습니다.
localStorage
sessionStorage
cookie
DOM의 HTTP 증거 패널
다만 fragment도 JavaScript가 회수하고 URL을 지우기 전까지는 브라우저에 노출되는 경계입니다. 운
영 환경에서는 일회용 교환 code나 별도의 보안 전달 방식을 추가로 검토해야 합니다.

메일 링크에서 새 비밀번호를 입력하면 다음 API를 호출합니다.
POST /account-recovery/password-reset/confirm
Content-Type: application/json
{
"token": "<raw-token>",
"newPassword": "<8~64자 새 비밀번호>"
}
Service는 제출받은 원본 token을 다시 SHA-256 hash로 만들고, 사용자와 활성 token을 잠금 조회합니다.
@Transactional
fun confirmPasswordReset(request: PasswordResetConfirmRequest) {
val now = clock.instant()
val tokenHash = tokenCodec.hash(request.token)
val initialToken = passwordResetTokenRepository
.findByTokenHash(tokenHash)
.orElseThrow(::InvalidPasswordResetTokenException)
val user = userRepository
.findByIdForUpdate(initialToken.user.id)
.orElseThrow(::InvalidPasswordResetTokenException)
val lockedToken = passwordResetTokenRepository
.findActiveByTokenHashForUpdate(tokenHash, now)
.orElseThrow(::InvalidPasswordResetTokenException)
if (
lockedToken.user.id != user.id ||
!user.localPasswordEnabled ||
lockedToken.usedAt != null ||
!lockedToken.expiresAt.isAfter(now)
) {
throw InvalidPasswordResetTokenException()
}
user.password = requireNotNull(
passwordEncoder.encode(request.newPassword)
)
lockedToken.markUsed(now)
passwordResetTokenRepository.save(lockedToken)
userRepository.saveAndFlush(user)
}
비밀번호 BCrypt hash 변경과 usedAt 기록을 하나의 트랜잭션에서 처리합니다.
둘 다 성공
→ 새 비밀번호 저장
→ token 사용 완료
→ 204 No Content
하나라도 실패
→ 전체 rollback

MySQL에서는 token 원문이나 hash 전체를 출력하지 않고 수명주기만 확인할 수 있습니다.
SELECT
id,
user_id,
CHAR_LENGTH(token_hash) AS hash_length,
created_at,
expires_at,
used_at
FROM password_reset_tokens
ORDER BY id DESC
LIMIT 1;
확인할 값은 다음과 같습니다.
hash_length = 64
used_at = 비밀번호 변경 시각
LOCAL 로그인 화면으로 돌아가 변경한 비밀번호를 입력합니다.
POST /auth/login
GET /auth/me
로그인 성공 후 발급받은 JWT로 /auth/me가 200 OK를 반환하면 비밀번호 변경이 실제 인증 흐름에 반영된 것입니다.

여기까지가 정상 흐름입니다.
계정 생성 201
→ 복구 메일 요청 200
→ Gmail 수신
→ URL token 제거
→ 비밀번호 변경 204
→ 새 비밀번호 로그인 200
정상 흐름만 확인하면 SMTP 인증 실패와 token 정리 코드가 실제로 연결됐는지 알기 어렵습니다.
이번에는 .env의 앱 비밀번호를 의도적으로 잘못된 값으로 바꿉니다.
SPRING_MAIL_PASSWORD=<의도적으로 잘못된 16자리 값>
환경변수는 애플리케이션 시작 시 읽으므로 서버를 재시작합니다.
./gradlew bootRun
복구 메일을 다시 요청하면 Gmail 인증이 실패하고 다음 응답을 받습니다.
HTTP/1.1 424 Failed Dependency
Cache-Control: no-store
{
"code": "RECOVERY_MAIL_AUTHENTICATION_FAILED",
"message": "Gmail 앱 비밀번호가 올바르지 않거나 사용할 수 없습니다."
}

MailAuthenticationException의 provider 응답을 그대로 사용자에게 노출하지 않고,
고정된 공개 오류로 바꾸는 것도 중요합니다.
catch (exception: MailAuthenticationException) {
throw RecoveryMailAuthenticationException(exception)
}
복구 요청 Service는 SMTP 호출 전에 token을 commit합니다.
그렇다면 SMTP가 실패한 뒤 해당 token을 그대로 남겨두면 다음 요청이 1분 cooldown에 걸릴 수 있습니다.
현재 Controller는 인증 실패나 일반 전송 실패를 받으면 이번 발급 건의 token 정리를 요청합니다.
private fun dispatchOrDiscard(command: PasswordResetMailCommand) {
try {
recoveryMailDispatcher.dispatch(command)
} catch (exception: RecoveryMailAuthenticationException) {
accountRecoveryService.discardUndeliveredToken(
command.tokenId,
command.tokenHash
)
throw exception
} catch (exception: RecoveryMailDeliveryException) {
accountRecoveryService.discardUndeliveredToken(
command.tokenId,
command.tokenHash
)
throw exception
}
}
정리 메서드는 새 트랜잭션으로 실행됩니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun discardUndeliveredToken(
tokenId: Long,
tokenHash: String
): Boolean {
return passwordResetTokenRepository
.deleteUnusedByIdAndTokenHash(tokenId, tokenHash) == 1
}
삭제 조건은 다음 세 가지를 함께 확인합니다.
id 일치
+ tokenHash 일치
+ usedAt is null
따라서 SMTP 실패 사이에 더 최신 token으로 회전됐거나 이미 사용된 token을 잘못 삭제하지 않습니다.
앱 비밀번호를 잘못 둔 상태에서 즉시 다시 요청합니다.
token 정리가 없었다면
→ 기존 활성 token 때문에 429 cooldown 가능
현재 구현
→ 실패한 token 삭제
→ 즉시 새 token 발급 및 SMTP 재시도
→ 앱 비밀번호가 여전히 틀렸으므로 다시 424

이 화면은 단순히 같은 오류가 두 번 발생했다는 뜻이 아닙니다.
두 번째 요청이 429에서 멈추지 않고 다시 Gmail SMTP 인증 단계까지 도달했다는 점이 핵심입니다.
실패 테스트가 끝났다면 올바른 앱 비밀번호로 복구하고 서버를 다시 시작합니다.
| 상황 | HTTP 상태 | 공개 코드 | 의미 |
|---|---|---|---|
| SMTP 호출 정상 반환 | 200 | RECOVERY_MAIL_SENT | SMTP 서버가 요청을 오류 없이 수락한 범위 |
| 계정 없음 또는 LOCAL 비밀번호 미등록 | 422 | RECOVERY_MAIL_NOT_SENT | SMTP를 호출하지 않음 |
| 1분 이내 활성 token 재요청 | 429 | RECOVERY_MAIL_COOLDOWN | Retry-After 포함 |
| Gmail 앱 비밀번호 인증 실패 | 424 | RECOVERY_MAIL_AUTHENTICATION_FAILED | 실패 token 정리 후 반환 |
| 그 밖의 SMTP 전송 실패 | 424 | RECOVERY_MAIL_DELIVERY_FAILED | 실패 token 정리 후 반환 |
| 비밀번호 변경 성공 | 204 | 응답 body 없음 | password 변경과 token 사용 처리 완료 |
| 만료·재사용·회전된 token | 400 | INVALID_PASSWORD_RESET_TOKEN | 같은 공개 오류로 처리 |
이 상태 코드 구분은 실습 중 어느 경계에서 실패했는지 관찰하기 위한 계약입니다.
공개 운영 API에서는 계정 존재 여부나 복구 가능 상태를 추측하기 어렵도록 응답을 더 균일하게 만드는 방안도 고려해야 합니다.
이번 실습에서는
05-answer브랜치를 로컬 MySQL과 연결하고, Gmail SMTP를 통해 비밀번호 재설정 메일을 실제로 전송했습니다.Gmail host를 사용할 때는
SPRING_MAIL_USERNAME이 곧 발신자가 되며,APP_RECOVERY_MAIL_FROM은 비 Gmail SMTP에서만 사용된다는 현재 계약도 확인했습니다.Reset Token은 메일 링크에만 원문으로 전달하고 MySQL에는 SHA-256 hash만 저장했습니다. Token transaction이 commit된 뒤 SMTP를 동기로 호출하고, 발송 실패 시
id + tokenHash + 미사용조건에 맞는 이번 token만 별도 트랜잭션으로 정리했습니다.마지막으로 Gmail 인증 실패를 424로 재현하고 즉시 재요청이 다시 SMTP 단계까지 진행되는 것을 통해 실패 token 보상 로직도 확인했습니다.
05-answer: https://github.com/stdiodh/spring-boot-db-access-lab/tree/05-answer