Skip to content

feat: 이메일 회원가입·로그인 및 비밀번호 재설정 - #142

Merged
sangwon02 merged 10 commits into
developfrom
feature/135-email-auth
Aug 7, 2026
Merged

feat: 이메일 회원가입·로그인 및 비밀번호 재설정#142
sangwon02 merged 10 commits into
developfrom
feature/135-email-auth

Conversation

@sangwon02

@sangwon02 sangwon02 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🔗 관련 이슈 (Related Issue)

Closes #135

📝 작업 내용

구글 소셜 로그인 외에 이메일·비밀번호 기반 회원가입과 로그인을 추가했습니다. 비밀번호 찾기도 같은 인증 흐름을 재사용합니다.

엔드포인트 5개

메서드 경로 설명
POST /api/v1/auth/email/verification-codes 인증번호 발송
POST /api/v1/auth/email/verification-codes/confirm 인증번호 확인
POST /api/v1/auth/signup 회원가입
POST /api/v1/auth/login 로그인
POST /api/v1/auth/password/reset 비밀번호 재설정

액세스 토큰은 응답 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 내려갑니다. 기존 구글 로그인과 같은 규약입니다.

기반 작업

  • spring-boot-starter-mail 추가 및 Gmail SMTP 설정
  • BCryptPasswordEncoder 빈 등록 (기존에 PasswordEncoder가 없었습니다)
  • 메일 발송 전용 비동기 스레드풀 구성. 발송은 트랜잭션 커밋 이후에 실행합니다
  • V013__email_verification 마이그레이션 및 EmailVerification 엔티티 추가

인증번호 정책

6자리 숫자 / 유효 5분 / 재발송 60초 쿨다운 / 시간당 5회 발송 / 코드당 5회 시도. 코드는 SHA-256 해시로 저장하며 평문을 남기지 않습니다. 만료된 행은 발송 시점에 함께 정리하고 별도 스케줄러를 두지 않습니다.

✅ PR 체크리스트

  • PR 제목은 커밋 컨벤션을 따랐습니다.
  • 관련 이슈를 연결했습니다.
  • 변경 사항에 대한 테스트를 진행했습니다.

리뷰어께 참고

  • 인증번호 발송은 가입 여부와 무관하게 항상 201을 반환합니다. 계정이 없는 비밀번호 재설정 요청은 메일만 보내지 않습니다.
  • 회원가입은 인증 확인을 중복 검사보다 먼저 수행합니다. 순서를 뒤집으면 인증 없이 409/400 차이만으로 가입 여부를 알아낼 수 있습니다.
  • 로그인은 계정이 없어도 비밀번호 해시 비교를 한 번 수행합니다. 건너뛰면 응답 시간 차이로 가입 여부가 드러납니다.

인증번호 확인은 @Transactional(noRollbackFor = BaseException.class)입니다. 실패 시 롤백되면 시도 횟수 증가가 사라져 무제한 대입이 가능해집니다. 회귀 테스트로 고정해 두었습니다.

마이그레이션 번호V013을 사용합니다. #139가 V012를 사용하고 먼저 배포될 예정이라, Flyway가 이미 적용된 버전보다 낮은 마이그레이션을 거부하지 않도록 뒤 번호를 잡았습니다. 머지 순서에 제약은 없습니다.

로컬 검증 완료 — Flyway 적용, ddl-auto=validate 통과, 실제 Gmail 발송·수신, 회원가입 → 온보딩 → 프로필 수정까지 확인했습니다. 네이버 주소로도 정상 수신되었습니다.

Summary by CodeRabbit

  • 새 기능
    • 이메일 인증번호 발송 및 확인 기능을 추가했습니다.
    • 이메일 기반 회원가입과 로그인 기능을 제공합니다.
    • 비밀번호 재설정 기능을 추가했습니다.
    • 인증번호 재발송 간격, 발송 횟수, 입력 시도 횟수를 제한합니다.
    • 로그인 시 액세스 토큰과 리프레시 토큰을 발급합니다.
  • 보안
    • 비밀번호 형식 검증과 안전한 암호화를 적용했습니다.
    • 인증번호 만료 및 사용 완료 상태를 관리합니다.

- spring-boot-starter-mail 의존성 및 Gmail SMTP 설정 추가
- BCryptPasswordEncoder 빈 등록
- 메일 발송 전용 비동기 스레드풀 구성
- 이메일 인증 정책 값을 app.email-verification 으로 분리
- 신규 인증 엔드포인트 5개를 permitAll 에 등록
- V011__email_verification 마이그레이션 추가
- EmailVerification 엔티티와 VerificationPurpose enum 추가
- 발송 이력 조회, 시간당 발송 집계, 만료 행 정리 쿼리 추가
- Users 에 이메일 가입 정적 팩토리와 비밀번호 변경 메서드 추가
- POST /auth/email/verification-codes, /confirm 추가
- 인증번호는 SHA-256 해시로 저장하고 평문을 남기지 않는다
- 재발송 60초 쿨다운, 시간당 5회 발송 제한, 코드당 5회 시도 제한
- 발송은 커밋 이후 비동기로 처리하고 실패는 로그만 남긴다
- 계정이 없는 비밀번호 재설정 요청은 메일만 보내지 않고 응답은 동일하게 성공
- AuthErrorCode 에 인증·가입·로그인 코드 7개 추가
- POST /auth/signup, /auth/login 추가
- 액세스 토큰은 본문, 리프레시 토큰은 HttpOnly 쿠키로 전달
- 회원가입은 인증 확인을 중복 검사보다 먼저 해 이메일 열거를 막는다
- 이미 가입된 이메일과 소셜 전용 계정을 다른 코드로 구분해 프론트가 분기할 수 있게 한다
- 로그인 실패는 원인을 구분하지 않고 계정 미존재에도 해시 비교를 수행해 응답 시간을 맞춘다
- POST /auth/password/reset 추가
- PASSWORD_RESET 인증을 소진해야 통과하며 소셜 전용 계정도 비밀번호를 설정할 수 있다
- 재설정 성공 시 해당 유저의 리프레시 토큰을 전부 삭제해 기존 세션을 끊는다
- 재발송 쿨다운, 시도 횟수 유지, 시도 소진 시 무효화, 미인증 소진 거부 검증
- 확인 실패가 롤백되면 시도 횟수가 사라져 무제한 대입이 가능해지므로 이를 회귀 테스트로 고정
- 테스트 메일 설정에 짧은 타임아웃을 넣어 실수로 발송이 일어나도 즉시 실패하게 한다
V012 를 사용하는 project_file 레거시 컬럼 제거 작업이 먼저 배포될 예정이다.
Flyway 는 outOfOrder 가 기본 false 라 이미 적용된 버전보다 낮은 마이그레이션을
거부하므로, 뒤에 배포되는 쪽이 더 높은 번호를 가져간다.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ae6cc8ec-16c3-4406-88ef-18f4193b45d2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

이메일 인증번호 발송·확인 기능과 SMTP 메일 발송을 추가했습니다. 이메일 회원가입, 로그인, 비밀번호 재설정 API를 구현했습니다. 인증 데이터는 해시와 상태 정보로 관리하며, 발송 제한과 인증 시도 제한을 적용합니다.

Changes

이메일 인증 기반 인증

Layer / File(s) Summary
인증 계약과 저장 구조
build.gradle, .env.example, src/main/java/com/slatto/domain/auth/dto/*, src/main/java/com/slatto/domain/auth/entity/EmailVerification.java, src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java, src/main/java/com/slatto/domain/auth/enums/VerificationPurpose.java, src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java, src/main/java/com/slatto/global/config/properties/*, src/main/resources/application.yml, src/main/resources/db/migration/V013__email_verification.sql
이메일 인증 요청·응답 DTO, 인증 목적, 인증 엔티티와 저장소를 추가했습니다. SMTP 및 인증 정책 설정과 데이터베이스 테이블을 추가했습니다.
인증 처리와 메일 발송
src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java, src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java, src/main/java/com/slatto/global/config/AsyncConfig.java, src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java, src/test/resources/application.yml
인증번호 생성·해시 저장, 재발송·발송 횟수·시도 횟수 제한, 인증 확인·소비를 구현했습니다. 비동기 SMTP 메일 발송과 관련 테스트를 추가했습니다.
회원가입과 인증 API
src/main/java/com/slatto/domain/auth/controller/AuthController.java, src/main/java/com/slatto/domain/auth/service/AuthService.java, src/main/java/com/slatto/domain/user/entity/Users.java, src/main/java/com/slatto/global/config/SecurityConfig.java
이메일 회원가입, 로그인, 인증번호 발송·확인, 비밀번호 재설정 API를 추가했습니다. BCrypt 비밀번호 처리, 토큰 응답, 리프레시 토큰 삭제, 인증 경로 허용을 연결했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AuthController
  participant EmailVerificationService
  participant EmailVerificationRepository
  participant VerificationMailSender
  Client->>AuthController: 인증번호 발송 요청
  AuthController->>EmailVerificationService: 이메일과 인증 목적 전달
  EmailVerificationService->>EmailVerificationRepository: 인증 기록 저장
  EmailVerificationService->>VerificationMailSender: 인증 메일 발송
  Client->>AuthController: 인증번호 확인 요청
  AuthController->>EmailVerificationService: 코드 확인 요청
  EmailVerificationService->>EmailVerificationRepository: 인증 상태 갱신
Loading

Possibly related PRs

  • SLAT-TO/SLATE-TO-BE#18: AuthController, AuthService, Users, AuthErrorCode, 인증 설정을 공유하며 이메일·비밀번호 인증과 Google OAuth 흐름을 연결합니다.

Suggested reviewers: young0206

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning 대부분의 요구사항은 구현되었지만 회원가입 성공 시 기본 알림 설정 생성이 변경 요약에서 확인되지 않습니다 [#135]. 회원가입 처리에 기본 알림 설정 생성을 추가하고 관련 테스트를 작성하세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 25.49% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed 이메일 회원가입, 로그인, 비밀번호 재설정이라는 주요 변경 사항을 정확하고 간결하게 설명합니다.
Description check ✅ Passed 관련 이슈, 작업 내용, 정책, 테스트 결과와 체크리스트를 포함해 템플릿 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 모든 변경 사항이 이메일 인증 기반 인증 기능과 이슈 #135의 설정, 스키마, API, 테스트 범위에 포함됩니다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (5)
src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java (1)

52-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

성공 경로 테스트를 추가하세요.

현재 테스트는 실패 경로만 검증합니다. 유효한 인증번호의 confirm 성공과 이후 consumeVerified 성공을 검증하세요.

테스트는 인증 완료 시각, 소진 시각, 재소진 거부도 확인해야 합니다. 그러면 코드 해시 비교와 인증 상태 전환의 회귀를 탐지할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java`
around lines 52 - 108, 이메일 인증 성공 경로 테스트를 추가하세요. 기존 EmailVerificationService
테스트에서 유효한 인증번호로 confirm이 성공하고 인증 완료 시각이 기록되는지, 이후 consumeVerified가 성공하며 소진 시각이
기록되는지 검증하세요. 같은 인증을 다시 consumeVerified할 때 EMAIL_NOT_VERIFIED 오류로 거부되는지도 확인해 코드
해시 비교와 상태 전환을 검증하세요.
src/main/java/com/slatto/domain/auth/controller/AuthController.java (1)

210-215: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

비밀번호 재설정 후 리프레시 토큰 쿠키를 만료시키십시오.

resetPassword는 서버에 저장된 리프레시 토큰을 모두 삭제합니다. 클라이언트의 쿠키는 그대로 남습니다. 로그인 상태에서 재설정하면, 브라우저는 이미 무효한 토큰을 계속 전송합니다. logout과 동일하게 만료 쿠키를 내려 상태를 일치시키십시오.

♻️ 제안 수정
 	`@PostMapping`("/password/reset")
-	public ApiResponse<Void> resetPassword(`@Valid` `@RequestBody` PasswordResetRequest request) {
+	public ResponseEntity<ApiResponse<Void>> resetPassword(`@Valid` `@RequestBody` PasswordResetRequest request) {
 		authService.resetPassword(request.email(), request.newPassword());
 
-		return ApiResponse.success(CommonSuccessCode.OK, null);
+		return ResponseEntity
+			.ok()
+			.header(HttpHeaders.SET_COOKIE, authCookieFactory.expiredRefreshToken().toString())
+			.body(ApiResponse.<Void>success(CommonSuccessCode.OK, null));
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/controller/AuthController.java` around
lines 210 - 215, Update AuthController.resetPassword to expire the refresh-token
cookie after authService.resetPassword succeeds, using the same
cookie-expiration behavior and established helper or constants as logout.
Preserve the existing success response while adding the expired cookie to the
response.
src/main/java/com/slatto/global/config/SecurityConfig.java (1)

86-89: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

BCrypt 강도를 명시하고 더미 해시와의 결합을 문서화하십시오.

new BCryptPasswordEncoder()는 기본 강도 10을 사용합니다. AuthServiceDUMMY_PASSWORD_HASH$2a$10$로 강도 10에 고정되어 있습니다. 이 두 값은 일치해야 합니다. 여기서 강도만 올리면 존재하지 않는 계정의 비교가 더 빨리 끝납니다. 그러면 응답 시간 차이로 가입 여부가 드러나고, login의 타이밍 방어가 무력화됩니다.

강도를 상수로 명시하고, 더미 해시도 같은 강도를 쓴다는 점을 주석으로 남기십시오.

🔒️ 제안 수정
 	`@Bean`
 	public PasswordEncoder passwordEncoder() {
-		return new BCryptPasswordEncoder();
+		// 강도를 바꾸면 AuthService.DUMMY_PASSWORD_HASH 도 같은 강도로 다시 생성해야 한다.
+		// 두 값이 어긋나면 미존재 계정의 로그인 응답이 빨라져 가입 여부가 드러난다.
+		return new BCryptPasswordEncoder(BCRYPT_STRENGTH);
 	}

private static final int BCRYPT_STRENGTH = 10; 을 클래스 상수로 추가하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/global/config/SecurityConfig.java` around lines 86 -
89, Update SecurityConfig.passwordEncoder() to construct BCryptPasswordEncoder
with an explicit strength of 10, using a class-level BCRYPT_STRENGTH constant.
Add a concise comment documenting that this value must match
AuthService.DUMMY_PASSWORD_HASH’s BCrypt cost.
src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java (1)

13-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

비밀번호 정책 정규식이 두 DTO에 중복되어 있습니다.

EmailSignupRequestPasswordResetRequest가 같은 정규식과 같은 메시지를 각각 선언합니다. 한쪽만 정책이 바뀌면 회원가입과 재설정의 규칙이 달라집니다. 공용 상수 또는 커스텀 제약 애너테이션(@ValidPassword)으로 한 곳에서 관리하십시오.

♻️ 커스텀 제약 애너테이션 예시
// src/main/java/com/slatto/domain/auth/validation/ValidPassword.java
`@Documented`
`@Constraint`(validatedBy = {})
`@Target`({ElementType.RECORD_COMPONENT, ElementType.FIELD, ElementType.PARAMETER})
`@Retention`(RetentionPolicy.RUNTIME)
`@NotBlank`
`@Pattern`(
	regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$",
	message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다."
)
public `@interface` ValidPassword {
	String message() default "비밀번호 형식이 올바르지 않습니다.";
	Class<?>[] groups() default {};
	Class<? extends Payload>[] payload() default {};
}
-	`@NotBlank`(message = "새 비밀번호는 필수입니다.")
-	`@Pattern`(
-		regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$",
-		message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다."
-	)
+	`@ValidPassword`
 	String newPassword
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java` around
lines 13 - 18, Extract the duplicated password policy from EmailSignupRequest
and PasswordResetRequest into one shared definition, preferably a `@ValidPassword`
custom constraint or common constants. Replace both DTOs’ separate `@NotBlank` and
`@Pattern` declarations with that shared validation while preserving the current
regex and validation message.
src/main/java/com/slatto/domain/auth/service/AuthService.java (1)

116-135: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

BCrypt 비교가 쓰기 트랜잭션 안에서 실행됩니다.

login@Transactional이며, findByEmail 호출로 커넥션을 이미 확보한 상태에서 passwordEncoder.matches를 실행합니다. BCrypt cost 10 은 요청당 수십 밀리초를 소비하고, 그동안 JDBC 커넥션이 점유됩니다. 이 엔드포인트는 인증 없이 열려 있으므로, 실패하는 요청도 동일하게 커넥션을 점유합니다. 대량 요청 시 커넥션 풀이 먼저 고갈됩니다.

조회는 읽기 전용으로 분리하고, 해시 비교는 트랜잭션 밖에서 수행한 뒤 토큰 발급만 쓰기 트랜잭션으로 처리하십시오.

♻️ 트랜잭션 경계 분리 예시
-	`@Transactional`
 	public EmailAuthResult login(String email, String rawPassword) {
-		Users user = userRepository.findByEmail(email)
-			.filter(it -> it.getDeletedAt() == null)
-			.orElse(null);
+		Users user = findActiveUserForLogin(email);
 
 		boolean hasPassword = user != null && user.hasPassword();
 		boolean matched = passwordEncoder.matches(
 			rawPassword,
 			hasPassword ? user.getPassword() : DUMMY_PASSWORD_HASH
 		);
 
 		// 미존재·비밀번호 불일치·소셜 전용 계정을 구분하지 않는다.
 		// "구글로 가입된 계정입니다" 같은 안내는 이메일 열거를 그대로 허용한다.
 		if (!hasPassword || !matched) {
 			throw new BaseException(AuthErrorCode.LOGIN_FAILED);
 		}
 
-		return toEmailAuthResult(user);
+		return issueEmailAuthResult(user.getId());
 	}

findActiveUserForLogin@Transactional(readOnly = true)로, issueEmailAuthResult@Transactional로 선언하십시오. 두 메서드는 프록시를 거치도록 별도 빈으로 분리하거나 TransactionTemplate을 사용하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/service/AuthService.java` around lines
116 - 135, Split the login flow in AuthService.login so findActiveUserForLogin
performs the user lookup under a read-only transaction, then execute
passwordEncoder.matches outside any transaction, and move successful
token/result issuance into issueEmailAuthResult under a write transaction.
Ensure both transactional methods are invoked through a proxy or equivalent
transaction boundary rather than self-invocation, while preserving the existing
failure behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java`:
- Around line 33-43: Update deleteDeadRows in EmailVerificationRepository so
expired records older than the threshold are deleted regardless of verifiedAt or
consumedAt state. Remove the status-dependent condition while preserving the
email and expiresAt filters.

In `@src/main/java/com/slatto/domain/auth/service/AuthService.java`:
- Around line 99-110: Update signup’s duplicate-email handling around
userRepository.findByEmail so it aligns with the soft-delete policy: either
query only users whose deletedAt is null while preserving the unique constraint
strategy, or restore the matched withdrawn account by clearing deletedAt before
completing signup. Ensure deleted accounts can re-register without leaving
conflicting rows, while active and social-account duplicate checks remain
unchanged.

In `@src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java`:
- Around line 124-130: Update shouldDeliver in EmailVerificationService so
PASSWORD_RESET emails are delivered only when the repository result contains a
user with deletedAt == null, matching AuthService.resetPassword; preserve
unconditional delivery for other VerificationPurpose values.
- Around line 49-66: Serialize the resend flow in EmailVerificationService for
each email-and-purpose pair using a unique lock record or database lock. Ensure
cleanup, cooldown and hourly-limit checks, prior-code invalidation, and new-code
persistence execute within one serialized transaction/critical section so
concurrent requests cannot bypass limits or invalidate an earlier request’s
code.

In `@src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java`:
- Around line 43-44: Update the failure log in VerificationMailSender’s
exception handler to stop recording the raw email PII. Replace email with the
existing masking utility or a non-reversible correlation identifier, while
preserving the purpose and exception details in the warning log.

In `@src/main/java/com/slatto/global/config/AsyncConfig.java`:
- Around line 17-27: Update mailExecutor() to remove CallerRunsPolicy so
rejected SMTP tasks never execute on the request thread. Replace it with
rejection handling that routes failed
VerificationMailSender.sendVerificationCode work to a durable mail queue with
retry state, or otherwise handles rejection asynchronously without blocking the
caller.

In `@src/main/resources/db/migration/V013__email_verification.sql`:
- Around line 3-21: Update the email_verification migration and
EmailVerificationService.send flow to add and acquire a persistent serialization
lock keyed by (email, purpose) before checking limits or invalidating prior
codes. Ensure the lock exists even for the first request, and hold it through
the transaction so concurrent sends for the same pair cannot bypass cooldown or
hourly limits.

---

Nitpick comments:
In `@src/main/java/com/slatto/domain/auth/controller/AuthController.java`:
- Around line 210-215: Update AuthController.resetPassword to expire the
refresh-token cookie after authService.resetPassword succeeds, using the same
cookie-expiration behavior and established helper or constants as logout.
Preserve the existing success response while adding the expired cookie to the
response.

In `@src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java`:
- Around line 13-18: Extract the duplicated password policy from
EmailSignupRequest and PasswordResetRequest into one shared definition,
preferably a `@ValidPassword` custom constraint or common constants. Replace both
DTOs’ separate `@NotBlank` and `@Pattern` declarations with that shared validation
while preserving the current regex and validation message.

In `@src/main/java/com/slatto/domain/auth/service/AuthService.java`:
- Around line 116-135: Split the login flow in AuthService.login so
findActiveUserForLogin performs the user lookup under a read-only transaction,
then execute passwordEncoder.matches outside any transaction, and move
successful token/result issuance into issueEmailAuthResult under a write
transaction. Ensure both transactional methods are invoked through a proxy or
equivalent transaction boundary rather than self-invocation, while preserving
the existing failure behavior.

In `@src/main/java/com/slatto/global/config/SecurityConfig.java`:
- Around line 86-89: Update SecurityConfig.passwordEncoder() to construct
BCryptPasswordEncoder with an explicit strength of 10, using a class-level
BCRYPT_STRENGTH constant. Add a concise comment documenting that this value must
match AuthService.DUMMY_PASSWORD_HASH’s BCrypt cost.

In
`@src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java`:
- Around line 52-108: 이메일 인증 성공 경로 테스트를 추가하세요. 기존 EmailVerificationService 테스트에서
유효한 인증번호로 confirm이 성공하고 인증 완료 시각이 기록되는지, 이후 consumeVerified가 성공하며 소진 시각이 기록되는지
검증하세요. 같은 인증을 다시 consumeVerified할 때 EMAIL_NOT_VERIFIED 오류로 거부되는지도 확인해 코드 해시 비교와
상태 전환을 검증하세요.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7cabb242-bc70-42e5-b1cd-3d1d7a8c55d8

📥 Commits

Reviewing files that changed from the base of the PR and between 394099c and cd0f976.

📒 Files selected for processing (27)
  • .env.example
  • build.gradle
  • src/main/java/com/slatto/domain/auth/controller/AuthController.java
  • src/main/java/com/slatto/domain/auth/dto/EmailAuthResponse.java
  • src/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.java
  • src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java
  • src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmRequest.java
  • src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmResponse.java
  • src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendRequest.java
  • src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendResponse.java
  • src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java
  • src/main/java/com/slatto/domain/auth/entity/EmailVerification.java
  • src/main/java/com/slatto/domain/auth/enums/VerificationPurpose.java
  • src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java
  • src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java
  • src/main/java/com/slatto/domain/auth/service/AuthService.java
  • src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java
  • src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java
  • src/main/java/com/slatto/domain/user/entity/Users.java
  • src/main/java/com/slatto/global/config/AsyncConfig.java
  • src/main/java/com/slatto/global/config/SecurityConfig.java
  • src/main/java/com/slatto/global/config/properties/EmailVerificationProperties.java
  • src/main/java/com/slatto/global/config/properties/MailSenderProperties.java
  • src/main/resources/application.yml
  • src/main/resources/db/migration/V013__email_verification.sql
  • src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java
  • src/test/resources/application.yml

Comment on lines +33 to +43
@Modifying
@Query("""
delete from EmailVerification ev
where ev.email = :email
and ev.expiresAt < :threshold
and (ev.verifiedAt is null or ev.consumedAt is not null)
""")
int deleteDeadRows(
@Param("email") String email,
@Param("threshold") LocalDateTime threshold
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

만료된 미소비 인증 레코드를 삭제하세요.

verifiedAt이 있고 consumedAt이 없는 레코드는 Line 38의 조건 때문에 삭제되지 않습니다. 인증 완료 후 후속 작업을 하지 않은 이메일과 인증 이력은 이후 발송 요청에서도 계속 남습니다.

현재 정책에서 인증 완료 상태는 30분만 유효합니다. 시간당 발송 집계 구간보다 오래된 레코드는 인증 상태와 관계없이 삭제하세요.

수정 예시
 		delete from EmailVerification ev
 		where ev.email = :email
 			and ev.expiresAt < :threshold
-			and (ev.verifiedAt is null or ev.consumedAt is not null)
 		""")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Modifying
@Query("""
delete from EmailVerification ev
where ev.email = :email
and ev.expiresAt < :threshold
and (ev.verifiedAt is null or ev.consumedAt is not null)
""")
int deleteDeadRows(
@Param("email") String email,
@Param("threshold") LocalDateTime threshold
);
`@Modifying`
`@Query`("""
delete from EmailVerification ev
where ev.email = :email
and ev.expiresAt < :threshold
""")
int deleteDeadRows(
`@Param`("email") String email,
`@Param`("threshold") LocalDateTime threshold
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java`
around lines 33 - 43, Update deleteDeadRows in EmailVerificationRepository so
expired records older than the threshold are deleted regardless of verifiedAt or
consumedAt state. Remove the status-dependent condition while preserving the
email and expiresAt filters.

Comment on lines +99 to +110
public EmailAuthResult signup(String name, String email, String rawPassword) {
emailVerificationService.consumeVerified(email, VerificationPurpose.SIGNUP);

userRepository.findByEmail(email).ifPresent(existing -> {
throw new BaseException(existing.hasPassword()
? AuthErrorCode.SIGNUP_DUPLICATE_EMAIL
: AuthErrorCode.SIGNUP_SOCIAL_ACCOUNT_EXISTS);
});

Users user = userRepository.save(
Users.createEmailUser(email, name, passwordEncoder.encode(rawPassword))
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# findByEmail 선언 확인
rg -n -C3 'findByEmail' src/main/java/com/slatto/domain/user/repository

# 탈퇴(soft delete) 처리 지점 확인
rg -n -C5 'deletedAt|deleted_at' --type=java src/main/java

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant user/auth files.
printf '--- user entity ---\n'
sed -n '1,120p' src/main/java/com/slatto/domain/user/entity/Users.java

printf '\n--- user repository ---\n'
sed -n '1,120p' src/main/java/com/slatto/domain/user/repository/UserRepository.java

printf '\n--- auth service signup/login/resetPassword context ---\n'
sed -n '80,170p' src/main/java/com/slatto/domain/auth/service/AuthService.java

printf '\n--- targeted deletedAt/user deletion references ---\n'
rg -n -C4 'deletedAt|delete|withdraw|탈퇴|unique|uniqueKey|uniqueIndexes|`@unique`\(' \
  src/main/java/com/slatto/domain/user \
  src/main/java/com/slatto/domain/auth/service/AuthService.java

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- user entity ---'
sed -n '1,120p' src/main/java/com/slatto/domain/user/entity/Users.java

echo
echo '--- user repository ---'
sed -n '1,120p' src/main/java/com/slatto/domain/user/repository/UserRepository.java

echo
echo '--- auth service signup/login/resetPassword context ---'
sed -n '80,170p' src/main/java/com/slatto/domain/auth/service/AuthService.java

echo
echo '--- targeted deletedAt/user deletion references ---'
rg -n -C4 'deletedAt|delete|withdraw|탈퇴|unique|uniqueKey|uniqueIndexes|`@unique`\(' \
  src/main/java/com/slatto/domain/user \
  src/main/java/com/slatto/domain/auth/service/AuthService.java

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 30999


탈퇴 계정의 signup 중복 검사와 deleted_at 처리를 일치시키십시오.

Users.emailunique = true이고, findByEmail(email)deletedAt 여부를 구분하지 않습니다. 탈퇴 계정이 deletedAt != null로 soft-delete되면 기존 계정만 제거하고 같은 이메일로 재가입할 수 없습니다. 탈퇴 시 행을 유지하는 정책이면 signupdeletedAt IS NULL 기준으로 중복을 판단하고, deletedAt = NULL로 복구하는 정책이면 해당 branch에서 deletedAt을 초기화해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/service/AuthService.java` around lines
99 - 110, Update signup’s duplicate-email handling around
userRepository.findByEmail so it aligns with the soft-delete policy: either
query only users whose deletedAt is null while preserving the unique constraint
strategy, or restore the matched withdrawn account by clearing deletedAt before
completing signup. Ensure deleted accounts can re-register without leaving
conflicting rows, while active and social-account duplicate checks remain
unchanged.

Comment on lines +49 to +66
emailVerificationRepository.findFirstByEmailAndPurposeOrderByIdDesc(email, purpose)
.ifPresent(latest -> {
if (latest.getCreatedAt().plus(properties.resendCooldown()).isAfter(now)) {
throw new BaseException(AuthErrorCode.VERIFICATION_RESEND_TOO_SOON);
}
latest.invalidate(now);
});

long sentInWindow = emailVerificationRepository.countByEmailAndPurposeAndCreatedAtAfter(
email, purpose, now.minus(RATE_LIMIT_WINDOW)
);
if (sentInWindow >= properties.maxSendPerHour()) {
throw new BaseException(AuthErrorCode.VERIFICATION_SEND_LIMIT_EXCEEDED);
}

String code = generateCode();
LocalDateTime expiresAt = now.plus(properties.codeValidity());
emailVerificationRepository.save(EmailVerification.issue(email, purpose, hash(code), expiresAt));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

이메일과 인증 목적 단위로 발송 상태를 직렬화하세요.

동시 요청은 모두 같은 이전 레코드를 읽은 뒤 재발송 제한과 시간당 제한을 통과할 수 있습니다. 각 요청은 새 레코드를 저장하므로 제한을 우회하고 여러 유효 코드를 발급합니다. 최신 레코드만 확인하므로 먼저 도착한 메일의 코드는 즉시 사용할 수 없게 됩니다.

고유한 이메일·목적 잠금 레코드 또는 데이터베이스 잠금을 사용하세요. 정리, 제한 검사, 기존 코드 무효화, 새 코드 저장을 하나의 직렬화된 임계 구역에서 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java`
around lines 49 - 66, Serialize the resend flow in EmailVerificationService for
each email-and-purpose pair using a unique lock record or database lock. Ensure
cleanup, cooldown and hourly-limit checks, prior-code invalidation, and new-code
persistence execute within one serialized transaction/critical section so
concurrent requests cannot bypass limits or invalidate an earlier request’s
code.

Comment on lines +124 to +130
private boolean shouldDeliver(String email, VerificationPurpose purpose) {
if (purpose != VerificationPurpose.PASSWORD_RESET) {
return true;
}

return userRepository.findByEmail(email).isPresent();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

탈퇴 계정에는 비밀번호 재설정 메일을 발송하지 마세요.

shouldDeliverfindByEmail(email).isPresent()만 검사합니다. 반면 AuthService.resetPassworddeletedAt == null인 사용자만 허용합니다. 따라서 탈퇴 계정은 코드를 받고 확인에도 성공하지만 비밀번호 재설정은 항상 실패합니다.

비밀번호 재설정 발송 여부에도 비삭제 사용자 조건을 동일하게 적용하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java`
around lines 124 - 130, Update shouldDeliver in EmailVerificationService so
PASSWORD_RESET emails are delivered only when the repository result contains a
user with deletedAt == null, matching AuthService.resetPassword; preserve
unconditional delivery for other VerificationPurpose values.

Comment on lines +43 to +44
} catch (Exception exception) {
log.warn("[Mail] 인증번호 발송 실패. email={}, purpose={}", email, purpose, exception);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

실패 로그에서 원본 이메일 주소를 제거하세요.

email은 PII입니다. 메일 전송 실패 로그에 원본 값을 기록하면 로그 저장소에도 이메일 주소가 보관됩니다.

마스킹한 주소 또는 비가역 상관 식별자를 기록하세요. 운영자가 필요할 때만 제한된 감사 경로에서 원본 값을 조회하게 하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java`
around lines 43 - 44, Update the failure log in VerificationMailSender’s
exception handler to stop recording the raw email PII. Replace email with the
existing masking utility or a non-reversible correlation identifier, while
preserving the purpose and exception details in the warning log.

Comment on lines +17 to +27
// 메일 전용 풀이다. 공용 풀을 쓰면 SMTP 지연이 다른 비동기 작업까지 함께 막는다.
// 큐가 차면 호출 스레드가 직접 실행한다. 인증번호는 버리는 것보다 늦게라도 나가는 편이 낫다.
@Bean(name = MAIL_EXECUTOR)
public Executor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("mail-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

포화 시 SMTP 발송을 요청 스레드에서 실행하지 마세요.

CallerRunsPolicy는 메일 풀과 큐가 포화되면 VerificationMailSender.sendVerificationCode를 요청 스레드에서 실행합니다. 이 작업은 SMTP I/O를 수행합니다. 공개 인증 API를 반복 호출하면 SMTP 지연이 웹 요청 스레드를 점유할 수 있습니다.

거부된 작업을 요청 스레드에서 실행하지 마세요. 내구성 있는 메일 작업 큐와 재시도 상태를 사용하거나, 거부를 별도 처리하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/global/config/AsyncConfig.java` around lines 17 -
27, Update mailExecutor() to remove CallerRunsPolicy so rejected SMTP tasks
never execute on the request thread. Replace it with rejection handling that
routes failed VerificationMailSender.sendVerificationCode work to a durable mail
queue with retry state, or otherwise handles rejection asynchronously without
blocking the caller.

Comment on lines +3 to +21
CREATE TABLE email_verification
(
id BIGINT NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
purpose VARCHAR(30) NOT NULL,
code_hash VARCHAR(64) NOT NULL,
expires_at DATETIME(6) NOT NULL,
verified_at DATETIME(6) NULL,
consumed_at DATETIME(6) NULL,
attempt_count INT NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;

-- 최신 발송 건 조회와 쿨다운/시간당 한도 계산이 모두 (email, purpose) 로 걸린다.
CREATE INDEX idx_email_verification_email_purpose
ON email_verification (email, purpose, id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

동일 이메일의 동시 발송을 직렬화하세요.

EmailVerificationService.send는 잠금 없이 제한을 조회한 뒤 새 이력을 저장합니다. 이 테이블도 (email, purpose)별 단일 잠금 행 또는 고유 제약을 제공하지 않습니다.

동일한 (email, purpose) 요청 두 개가 동시에 시작되면 둘 다 쿨다운과 시간당 한도를 통과할 수 있습니다. 두 인증번호가 모두 발송되고, 최신 이력만 확인하므로 먼저 발송된 인증번호는 사용할 수 없습니다. 시간당 발송 제한도 우회됩니다.

(email, purpose)별 잠금 테이블 또는 동등한 영속 직렬화 키를 추가하세요. send 트랜잭션은 제한 확인과 이전 코드 무효화 전에 그 키를 잠가야 합니다. 이력 행만 잠그면 첫 발송에는 잠글 행이 없습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/db/migration/V013__email_verification.sql` around lines 3
- 21, Update the email_verification migration and EmailVerificationService.send
flow to add and acquire a persistent serialization lock keyed by (email,
purpose) before checking limits or invalidating prior codes. Ensure the lock
exists even for the first request, and hold it through the transaction so
concurrent sends for the same pair cannot bypass cooldown or hourly limits.

- 만료 행 정리에서 인증 여부 조건을 제거해 인증만 하고 가입하지 않은 행이
  영구히 남던 문제를 수정
- 비밀번호 재설정 메일 발송 대상에서 탈퇴 계정을 제외해 resetPassword 조건과 일치시킴
- 메일 큐 포화 시 호출 스레드에서 SMTP 를 실행하지 않고 버린다. 공개 엔드포인트라
  반복 호출로 요청 스레드가 묶일 수 있고, 발송 실패는 재발송으로 갈음하는 정책과도 맞다
- 발송 실패 로그의 이메일을 마스킹
@sangwon02
sangwon02 merged commit 466680e into develop Aug 7, 2026
2 checks passed
@guingguing
guingguing deleted the feature/135-email-auth branch August 11, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FEAT: 이메일 회원가입·로그인 및 비밀번호 재설정

2 participants