feat: 회원 탈퇴 및 비밀번호 변경 API 추가와 정책 반영 - #150
Conversation
- Vite 기본 포트(5173)와 Vercel dev 프리뷰 주소 추가 - 이 목록은 CookieCsrfProtectionFilter 의 신뢰 출처로도 쓰인다
- DELETE /api/v1/users/me 추가, 동의하지 않으면 400 - 물리 삭제 대신 deleted_at 기록과 개인정보 익명화로 처리한다. 연관 데이터가 유저를 FK 로 참조하고 있어 행을 지우면 이력이 끊긴다 - email 과 nickname 이 NOT NULL 이라 NULL 대신 id 를 섞은 고유 값으로 덮는다. email 유니크 제약을 피하면서 원래 주소가 사라져 같은 이메일로 재가입할 수 있다 - 포트폴리오 일괄 soft delete, 리프레시 토큰 삭제, 쿠키 만료
탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과해 그 시간 동안 API 가 열려 있었다. 인증 필터에서 삭제 여부를 확인해 인증을 세우지 않도록 한다. 인증된 요청마다 PK 조회가 한 번 늘어난다.
- PATCH /api/v1/auth/password 추가, 로그인 상태에서만 호출한다 - 현재 비밀번호를 확인한다. 세션이 탈취된 상태에서 비밀번호까지 바뀌면 계정을 통째로 빼앗기므로 생략할 수 없다 - 비밀번호가 없는 소셜 전용 계정은 거부하고 비밀번호 찾기로 유도한다 - 기존과 같은 비밀번호는 거부한다 - 변경 성공 시 리프레시 토큰을 새로 발급한다. 유출을 가정해 세션을 끊는 비밀번호 재설정과 달리 본인이 로그인한 상태라 끊을 이유가 없다
- 탈퇴 시 익명화, 유저 행 보존, 포트폴리오 동반 삭제, 이메일 해제, 토큰 삭제 검증 - 비밀번호 변경 시 현재 비밀번호 검증, 소셜 전용 계정 거부, 동일 비밀번호 거부 검증 - 비밀번호 검증은 목이 아닌 실제 BCrypt 로 확인한다
- 활성 유저는 인증을 세우고 탈퇴 유저는 세우지 않는지 검증 - 토큰이 없거나 유효하지 않으면 유저 조회를 건너뛰는지 확인해 불필요한 쿼리가 늘지 않도록 고정한다
작성자가 없는 공고가 목록에 남으면 지원자가 응답받을 수 없는 곳에 지원하게 된다. 포트폴리오와 같은 기준으로 공고도 soft delete 한다. 내 지원 목록 조회가 공고의 deleted_at 을 함께 확인하므로, 해당 공고에 지원했던 이력은 목록에서 빠진다. 지원 데이터 자체는 남는다.
정책 문서 기준으로 맞춘다. - 필수는 제목, 모집 파트, 상세 내용, 연락처만 남긴다. 카테고리·영상 길이·지역· 촬영 기간·급여·마감일은 선택으로 연다. 컬럼이 모두 nullable 이라 스키마 변경은 없다 - 공고 제목을 5~50자로 조정한다 - 마감일을 비우면 수동으로 마감할 때까지 모집이 유지된다 - 마감된 공고는 내용을 수정할 수 없다. 다만 상태 변경이 같은 API 를 쓰므로 다시 모집중으로 되돌리는 요청은 통과시킨다
- 프로필 이미지 제한을 10MB 에서 2MB 로 낮추고 허용 형식을 jpg·jpeg·png·gif 로 조정 - 이름과 닉네임을 특수문자 없이 2~20자로 검증 - 소개는 200자 이하로 제한 - 탈퇴 시 비밀번호를 재확인한다. 세션이 탈취된 상태에서 탈퇴까지 되면 계정을 통째로 지워버릴 수 있다. 비밀번호가 없는 소셜 전용 계정은 확인할 값이 없어 필수로 걸지 않는다
정책 문서는 gif 를 포함하지만 기존 구현의 webp 를 유지한다. 용량 제한 2MB 조정은 그대로 둔다.
BCryptPasswordEncoder.matches 는 raw 가 null 이면 IllegalArgumentException 을 던져 서버 오류로 나갔다. 비밀번호가 설정된 계정이 값을 보내지 않은 경우도 불일치와 같은 401 로 응답한다.
|
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비밀번호 변경과 회원 탈퇴 API가 추가되었습니다. 탈퇴 시 개인정보를 익명화하고 연관 데이터를 소프트 삭제합니다. 공고 및 회원 입력 검증을 강화하고, 탈퇴 사용자의 JWT 인증을 차단합니다. 관련 통합 테스트를 추가했습니다. Changes계정 관리 및 도메인 검증
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 |
프로필 이미지 업로드가 PUT 이라 preflight 단계에서 차단되고 있었다.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java (1)
103-114: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win리프레시 토큰 재발급 결과를 검증하세요.
changesPassword는EmailAuthResult를 무시합니다. 따라서 비밀번호 해시만 변경되고 새 액세스 토큰 또는 리프레시 토큰이 반환되지 않아도 테스트가 통과합니다. 성공 결과의accessToken과refreshToken을 검증하세요.수정 예시
- authService.changePassword(emailUserId, CURRENT_PASSWORD, NEW_PASSWORD); + AuthService.EmailAuthResult result = + authService.changePassword(emailUserId, CURRENT_PASSWORD, NEW_PASSWORD); entityManager.flush(); entityManager.clear(); + assertThat(result.accessToken()).isEqualTo("access-token"); + assertThat(result.refreshToken()).isEqualTo("refresh-token"); + String stored = userRepository.findById(emailUserId).orElseThrow().getPassword();🤖 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/PasswordChangeTest.java` around lines 103 - 114, Update changesPassword to capture the EmailAuthResult returned by authService.changePassword and assert that the successful result contains non-empty accessToken and refreshToken values, while preserving the existing password-hash assertions.
🤖 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/recruitment/repository/RecruitmentRepository.java`:
- Around line 21-31: Update softDeleteAllByWriterId to protect deletedAt against
concurrent recruitment edits by adding database-level locking or an
optimistic-lock/check condition that serializes the soft-delete and update
paths. Ensure the bulk update cannot leave a subsequently flushed entity
restoring deletedAt to null, while preserving the existing writerId and
deletedAt-is-null filtering.
In `@src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java`:
- Around line 111-115: Update the closed-recruitment validation around isClosed
and RecruitmentStatus.RECRUITING to calculate the effective deadline for the
requested update, using the existing deadline when the request omits one. Reject
the transition unless that final deadline is today or later, while preserving
the existing allowance for valid future-dated RECRUITING transitions and
blocking other edits to closed recruitments.
In `@src/main/java/com/slatto/domain/user/service/UserService.java`:
- Around line 293-297: Update the withdrawal flow in UserService around
user.withdraw() to capture the managed profile-image storage key before the URL
is cleared, then schedule deletion of that object only after the transaction
commits. Reuse the existing storage deletion and transaction-event mechanisms,
and extend the withdrawal integration test to verify the profile image object is
deleted after commit.
---
Nitpick comments:
In `@src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java`:
- Around line 103-114: Update changesPassword to capture the EmailAuthResult
returned by authService.changePassword and assert that the successful result
contains non-empty accessToken and refreshToken values, while preserving the
existing password-hash assertions.
🪄 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: dcd59751-77ac-4b65-80b9-72e179db10a4
📒 Files selected for processing (25)
.env.examplesrc/main/java/com/slatto/domain/auth/controller/AuthController.javasrc/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.javasrc/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.javasrc/main/java/com/slatto/domain/auth/exception/AuthErrorCode.javasrc/main/java/com/slatto/domain/auth/service/AuthService.javasrc/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.javasrc/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.javasrc/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.javasrc/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.javasrc/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.javasrc/main/java/com/slatto/domain/recruitment/service/RecruitmentService.javasrc/main/java/com/slatto/domain/user/controller/UserController.javasrc/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.javasrc/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.javasrc/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.javasrc/main/java/com/slatto/domain/user/entity/Users.javasrc/main/java/com/slatto/domain/user/exception/UserErrorCode.javasrc/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.javasrc/main/java/com/slatto/domain/user/service/UserService.javasrc/main/java/com/slatto/global/security/JwtAuthenticationFilter.javasrc/test/java/com/slatto/domain/auth/service/PasswordChangeTest.javasrc/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.javasrc/test/java/com/slatto/domain/user/service/UserWithdrawTest.javasrc/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java
| // 작성자가 탈퇴하면 공고도 함께 내린다. 연락받을 사람이 없는 공고가 목록에 남으면 | ||
| // 지원자가 응답 없는 공고에 지원하게 된다. | ||
| // clearAutomatically 는 쓰지 않는다. 같은 트랜잭션에 로딩된 엔티티가 detach 된다. | ||
| @Modifying | ||
| @Query(""" | ||
| update Recruitment r | ||
| set r.deletedAt = :deletedAt | ||
| where r.writer.id = :writerId | ||
| and r.deletedAt is null | ||
| """) | ||
| int softDeleteAllByWriterId(@Param("writerId") Long writerId, @Param("deletedAt") LocalDateTime deletedAt); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C 8 \
'`@Version`|`@DynamicUpdate`|`@Lock`|softDeleteAllByWriterId|findActiveWithWriterById|deletedAt' \
src/main/java/com/slatto/domain/recruitment \
src/main/java/com/slatto/domain/userRepository: SLAT-TO/SLATE-TO-BE
Length of output: 50375
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '== files ==\n'
git ls-files 'src/main/java/com/slatto/domain/recruitment/*' 'src/main/java/com/slatto/domain/recruitment/**/*.java' | sort
printf '\n== Recruitment.java base annotations and updated_at mapping ==\n'
sed -n '1,120p' src/main/java/com/slatto/domain/recruitment/entity/Recruitment.java
printf '\n== RecruitmentService update path relevant lines ==\n'
sed -n '99,150p' src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java
printf '\n== repository select/update query patterns ==\n'
rg -n --glob '*.java' '`@Lock`|findActiveWithWriterById|softDeleteAllByWriterId|update Recruitment|set (r|\w+)\.' src/main/java/com/slatto -C 3Repository: SLAT-TO/SLATE-TO-BE
Length of output: 21131
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '== BaseEntity.java candidates ==\n'
git ls-files | rg 'src/main/java/.*/BaseEntity|src/main/java/.*/entity/BaseEntity\.java$' || true
printf '\n== Recruitment.update implementation ==\n'
sed -n '150,195p' src/main/java/com/slatto/domain/recruitment/entity/Recruitment.java
printf '\n== User update/delete persistence mappings around entities ==\n'
rg -n '`@Entity`|`@Table`|updated_at|lastUpdated|createdAt|deleted_at|BaseEntity' src/main/java/com/slatto/domain/common src/main/java/com/slatto/domain/user src/main/java/com/slatto/domain/project -g '*.java' | head -n 200
printf '\n== Base entity files ==\n'
for f in $(git ls-files | rg 'BaseEntity\.java$'); do
echo "--- $f"
sed -n '1,140p' "$f"
doneRepository: SLAT-TO/SLATE-TO-BE
Length of output: 10805
동시 탈퇴와 공고 수정의 race를 막기 위해 deletedAt도 함께 잠금하세요.
@DynamicUpdate는 비dirty한 컬럼을 exclude하지만, 벌크 UPDATE 이후 같은 트랜잭션에서 엔티티를 load하고 수정하면 flush 때 deletedAt이 null로 다시 덮어써질 수 있습니다. recruitment에 적어도 DB 잠금이나 deletedAt를 가진 optimistic lock/check 조건을 포함해 두 경로를 직렬화하거나 soft delete 조건을 보호하세요.
🤖 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/recruitment/repository/RecruitmentRepository.java`
around lines 21 - 31, Update softDeleteAllByWriterId to protect deletedAt
against concurrent recruitment edits by adding database-level locking or an
optimistic-lock/check condition that serializes the soft-delete and update
paths. Ensure the bulk update cannot leave a subsequently flushed entity
restoring deletedAt to null, while preserving the existing writerId and
deletedAt-is-null filtering.
| // 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다. | ||
| // 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다. | ||
| if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) { | ||
| throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
자동 마감 공고의 재개 조건을 보완해야 합니다.
현재 조건은 request.getStatus() == RecruitmentStatus.RECRUITING이면 deadline을 확인하지 않습니다. 자동 마감 공고에 status=RECRUITING만 보내거나 과거 deadline을 보내도 검증을 통과할 수 있습니다. 이후 recruitment.update(...)가 같은 요청의 내용 변경을 반영하므로, src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java Line 129-134의 정책을 우회할 수 있습니다.
RECRUITING 전환 시 적용될 최종 deadline을 계산하세요. 기존 deadline을 유지하는 경우에도 그 날짜가 오늘보다 과거이면 거부하세요.
수정 예시
+ LocalDate today = recruitmentConverter.currentDate();
+ LocalDate effectiveDeadline = request.getDeadline() != null
+ ? request.getDeadline()
+ : recruitment.getDeadline();
+
- if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) {
+ if (isClosed(recruitment)
+ && (request.getStatus() != RecruitmentStatus.RECRUITING
+ || (effectiveDeadline != null && effectiveDeadline.isBefore(today)))) {
throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE);
}자동 마감 공고에서 status=RECRUITING과 오늘 이후의 deadline을 함께 보내는 경우, 그리고 deadline을 생략하거나 과거로 보내는 경우를 각각 테스트하세요.
📝 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.
| // 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다. | |
| // 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다. | |
| if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) { | |
| throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); | |
| } | |
| // 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다. | |
| // 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다. | |
| LocalDate today = recruitmentConverter.currentDate(); | |
| LocalDate effectiveDeadline = request.getDeadline() != null | |
| ? request.getDeadline() | |
| : recruitment.getDeadline(); | |
| if (isClosed(recruitment) | |
| && (request.getStatus() != RecruitmentStatus.RECRUITING | |
| || (effectiveDeadline != null && effectiveDeadline.isBefore(today)))) { | |
| throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); | |
| } |
🤖 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/recruitment/service/RecruitmentService.java`
around lines 111 - 115, Update the closed-recruitment validation around isClosed
and RecruitmentStatus.RECRUITING to calculate the effective deadline for the
requested update, using the existing deadline when the request omits one. Reject
the transition unless that final deadline is today or later, while preserving
the existing allowance for valid future-dated RECRUITING transitions and
blocking other edits to closed recruitments.
| userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt); | ||
| recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt); | ||
| refreshTokenRepository.deleteByUser(user); | ||
|
|
||
| user.withdraw(withdrawnAt); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
탈퇴 후 프로필 이미지 객체를 삭제해야 합니다.
user.withdraw()는 profileImageUrl만 null로 변경합니다. 외부 스토리지의 기존 객체는 삭제하지 않습니다. 기존 공개 URL을 가진 사용자는 탈퇴 후에도 이미지를 조회할 수 있습니다.
user.withdraw() 전에 관리 대상 스토리지 키를 추출하고, 트랜잭션 커밋 후 객체를 삭제하십시오. 객체 삭제도 탈퇴 통합 테스트로 검증하십시오.
수정 예시
LocalDateTime withdrawnAt = LocalDateTime.now();
+String profileImageStorageKey = extractManagedStorageKey(user.getProfileImageUrl());
userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt);
recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt);
refreshTokenRepository.deleteByUser(user);
user.withdraw(withdrawnAt);
+registerPreviousFileDeletionAfterCommit(profileImageStorageKey);🤖 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/user/service/UserService.java` around lines
293 - 297, Update the withdrawal flow in UserService around user.withdraw() to
capture the managed profile-image storage key before the URL is cleared, then
schedule deletion of that object only after the transaction commits. Reuse the
existing storage deletion and transaction-event mechanisms, and extend the
withdrawal integration test to verify the profile image object is deleted after
commit.
- 마감 공고 재개 조건 보완. status 만 RECRUITING 으로 보내도 적용될 마감일이 과거면 여전히 마감이라 거부한다. 이전에는 같은 요청에 실린 내용 변경까지 반영돼 마감 공고 수정 금지가 우회됐다 - 탈퇴 시 프로필 이미지 객체를 스토리지에서 삭제한다. URL 만 비우면 기존 공개 URL 을 아는 사람이 탈퇴 후에도 사진을 조회할 수 있었다 - 위 두 건과 마감 공고 내용 수정 차단을 테스트로 고정
.env.example 은 로컬 개발용 템플릿이라 Vercel 프리뷰 주소가 들어갈 자리가 아니다. 외부 오리진이 로컬 API 를 호출할 수 없어 값으로서 의미가 없다. 배포 환경에 넣어야 하는 값이라는 안내와, 목록을 덮어쓰지 말라는 주의만 남긴다.
This reverts commit bb04463.
🔗 관련 이슈 (Related Issue)
Closes #58
📝 작업 내용
신규 API 2개
/api/v1/users/me/api/v1/auth/password회원 탈퇴
물리 삭제 대신
deleted_at기록과 개인정보 익명화로 처리합니다. 프로젝트·공고·피드백이 유저를 FK로 참조하고 있어 행을 지우면 이력이 끊깁니다.email과nickname이NOT NULL이라 NULL로 비울 수 없어, id를 섞은 고유 값(withdrawn_{id}@slatto.invalid,탈퇴한 사용자_{id})으로 덮습니다. 이 방식으로 스키마 변경 없이 익명화와 유니크 제약을 동시에 만족하며, 원래 이메일이 사라지므로 같은 주소로 재가입할 수 있습니다.포트폴리오와 작성한 공고도 함께 내려갑니다. 작성자가 없는 공고가 목록에 남으면 지원해도 응답받을 수 없기 때문입니다. 비밀번호가 설정된 계정은 탈퇴 시 비밀번호를 재확인합니다.
비밀번호 변경
로그인 상태에서 현재 비밀번호를 확인하고 바꿉니다. 비밀번호를 잊어 인증번호로 재설정하는
POST /auth/password/reset과는 다른 경로입니다. 유출을 가정해 세션을 끊는 재설정과 달리, 본인이 로그인한 상태이므로 토큰을 새로 발급해 그대로 이어 쓰게 합니다.탈퇴 유저 접근 차단
탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과해 최대 1시간 API가 열려 있었습니다. 인증 필터에서 삭제 여부를 확인하도록 했습니다.
정책 문서 반영
기획 정책 문서와 대조해 어긋난 값을 맞췄습니다.
공고 컬럼이 모두 nullable이라 필수 완화에도 스키마 변경이 없었습니다.
기타
CORS 허용 오리진에 프론트 개발 주소(Vite 5173, Vercel dev 프리뷰)를 추가했습니다.
✅ PR 체크리스트
Summary by CodeRabbit
새 기능
개선
문서