feat: 이메일 회원가입·로그인 및 비밀번호 재설정 - #142
Conversation
- 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 라 이미 적용된 버전보다 낮은 마이그레이션을 거부하므로, 뒤에 배포되는 쪽이 더 높은 번호를 가져간다.
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthrough이메일 인증번호 발송·확인 기능과 SMTP 메일 발송을 추가했습니다. 이메일 회원가입, 로그인, 비밀번호 재설정 API를 구현했습니다. 인증 데이터는 해시와 상태 정보로 관리하며, 발송 제한과 인증 시도 제한을 적용합니다. Changes이메일 인증 기반 인증
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: 인증 상태 갱신
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. Comment |
There was a problem hiding this comment.
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 winBCrypt 강도를 명시하고 더미 해시와의 결합을 문서화하십시오.
new BCryptPasswordEncoder()는 기본 강도 10을 사용합니다.AuthService의DUMMY_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에 중복되어 있습니다.
EmailSignupRequest와PasswordResetRequest가 같은 정규식과 같은 메시지를 각각 선언합니다. 한쪽만 정책이 바뀌면 회원가입과 재설정의 규칙이 달라집니다. 공용 상수 또는 커스텀 제약 애너테이션(@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 winBCrypt 비교가 쓰기 트랜잭션 안에서 실행됩니다.
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
📒 Files selected for processing (27)
.env.examplebuild.gradlesrc/main/java/com/slatto/domain/auth/controller/AuthController.javasrc/main/java/com/slatto/domain/auth/dto/EmailAuthResponse.javasrc/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.javasrc/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.javasrc/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmRequest.javasrc/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmResponse.javasrc/main/java/com/slatto/domain/auth/dto/EmailVerificationSendRequest.javasrc/main/java/com/slatto/domain/auth/dto/EmailVerificationSendResponse.javasrc/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.javasrc/main/java/com/slatto/domain/auth/entity/EmailVerification.javasrc/main/java/com/slatto/domain/auth/enums/VerificationPurpose.javasrc/main/java/com/slatto/domain/auth/exception/AuthErrorCode.javasrc/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.javasrc/main/java/com/slatto/domain/auth/service/AuthService.javasrc/main/java/com/slatto/domain/auth/service/EmailVerificationService.javasrc/main/java/com/slatto/domain/auth/service/VerificationMailSender.javasrc/main/java/com/slatto/domain/user/entity/Users.javasrc/main/java/com/slatto/global/config/AsyncConfig.javasrc/main/java/com/slatto/global/config/SecurityConfig.javasrc/main/java/com/slatto/global/config/properties/EmailVerificationProperties.javasrc/main/java/com/slatto/global/config/properties/MailSenderProperties.javasrc/main/resources/application.ymlsrc/main/resources/db/migration/V013__email_verification.sqlsrc/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.javasrc/test/resources/application.yml
| @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 | ||
| ); |
There was a problem hiding this comment.
🔒 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.
| @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.
| 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)) | ||
| ); |
There was a problem hiding this comment.
🎯 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/javaRepository: 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.javaRepository: 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.javaRepository: SLAT-TO/SLATE-TO-BE
Length of output: 30999
탈퇴 계정의 signup 중복 검사와 deleted_at 처리를 일치시키십시오.
Users.email은 unique = true이고, findByEmail(email)은 deletedAt 여부를 구분하지 않습니다. 탈퇴 계정이 deletedAt != null로 soft-delete되면 기존 계정만 제거하고 같은 이메일로 재가입할 수 없습니다. 탈퇴 시 행을 유지하는 정책이면 signup도 deletedAt 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.
| 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)); |
There was a problem hiding this comment.
🔒 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.
| private boolean shouldDeliver(String email, VerificationPurpose purpose) { | ||
| if (purpose != VerificationPurpose.PASSWORD_RESET) { | ||
| return true; | ||
| } | ||
|
|
||
| return userRepository.findByEmail(email).isPresent(); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
탈퇴 계정에는 비밀번호 재설정 메일을 발송하지 마세요.
shouldDeliver는 findByEmail(email).isPresent()만 검사합니다. 반면 AuthService.resetPassword는 deletedAt == 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.
| } catch (Exception exception) { | ||
| log.warn("[Mail] 인증번호 발송 실패. email={}, purpose={}", email, purpose, exception); |
There was a problem hiding this comment.
🔒 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.
| // 메일 전용 풀이다. 공용 풀을 쓰면 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(); |
There was a problem hiding this comment.
🩺 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.
| 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); |
There was a problem hiding this comment.
🗄️ 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 를 실행하지 않고 버린다. 공개 엔드포인트라 반복 호출로 요청 스레드가 묶일 수 있고, 발송 실패는 재발송으로 갈음하는 정책과도 맞다 - 발송 실패 로그의 이메일을 마스킹
🔗 관련 이슈 (Related Issue)
Closes #135
📝 작업 내용
구글 소셜 로그인 외에 이메일·비밀번호 기반 회원가입과 로그인을 추가했습니다. 비밀번호 찾기도 같은 인증 흐름을 재사용합니다.
엔드포인트 5개
/api/v1/auth/email/verification-codes/api/v1/auth/email/verification-codes/confirm/api/v1/auth/signup/api/v1/auth/login/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 체크리스트
리뷰어께 참고
인증번호 확인은
@Transactional(noRollbackFor = BaseException.class)입니다. 실패 시 롤백되면 시도 횟수 증가가 사라져 무제한 대입이 가능해집니다. 회귀 테스트로 고정해 두었습니다.마이그레이션 번호 —
V013을 사용합니다. #139가V012를 사용하고 먼저 배포될 예정이라, Flyway가 이미 적용된 버전보다 낮은 마이그레이션을 거부하지 않도록 뒤 번호를 잡았습니다. 머지 순서에 제약은 없습니다.로컬 검증 완료 — Flyway 적용,
ddl-auto=validate통과, 실제 Gmail 발송·수신, 회원가입 → 온보딩 → 프로필 수정까지 확인했습니다. 네이버 주소로도 정상 수신되었습니다.Summary by CodeRabbit