From 2a8a81d08d02054722c1b1c7d48776abde9816ea Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:10:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 11 +++ .../back/domain/user/service/UserService.java | 78 +++++++++++++------ .../oauth/CustomOAuth2UserService.java | 1 - 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index f82920c1..d8870294 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -42,4 +42,15 @@ public ResponseEntity> updateMyProfile( ) ); } + + @DeleteMapping("/me") + public ResponseEntity> deleteMyAccount( + @AuthenticationPrincipal CustomUserDetails user + ) { + userService.deleteUser(user.getUserId()); + return ResponseEntity + .ok(RsData.success( + "회원 탈퇴가 완료되었습니다." + )); + } } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index fd374a86..ce5661ca 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -27,17 +27,8 @@ public class UserService { */ public UserDetailResponse getUserInfo(Long userId) { - // userId로 User 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // UserStatus가 DELETED, SUSPENDED면 예외 처리 - if (user.getUserStatus() == UserStatus.DELETED) { - throw new CustomException(ErrorCode.USER_DELETED); - } - if (user.getUserStatus() == UserStatus.SUSPENDED) { - throw new CustomException(ErrorCode.USER_SUSPENDED); - } + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); // UserDetailResponse로 변환하여 반환 return UserDetailResponse.from(user); @@ -52,17 +43,8 @@ public UserDetailResponse getUserInfo(Long userId) { */ public UserDetailResponse updateUserProfile(Long userId, UpdateUserProfileRequest request) { - // userId로 User 조회 - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // UserStatus가 DELETED, SUSPENDED면 예외 처리 - if (user.getUserStatus() == UserStatus.DELETED) { - throw new CustomException(ErrorCode.USER_DELETED); - } - if (user.getUserStatus() == UserStatus.SUSPENDED) { - throw new CustomException(ErrorCode.USER_SUSPENDED); - } + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); // 닉네임 중복 검사 (본인 제외) if (userProfileRepository.existsByNicknameAndUserIdNot(request.nickname(), userId)) { @@ -79,4 +61,56 @@ public UserDetailResponse updateUserProfile(Long userId, UpdateUserProfileReques // UserDetailResponse로 변환하여 반환 return UserDetailResponse.from(user); } + + /** + * 사용자 탈퇴 서비스 (soft delete) + * 1. 사용자 조회 및 상태 검증 + * 2. UserStatus를 DELETED로 변경 + */ + public void deleteUser(Long userId) { + + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); + + // 상태 변경 (soft delete) + user.setUserStatus(UserStatus.DELETED); + + // 식별 정보 변경 (username, email, provider, providerId) + user.setUsername("deleted_" + user.getUsername()); + user.setEmail("deleted_" + user.getEmail()); + user.setProvider("deleted_" + user.getProvider()); + user.setProviderId("deleted_" + user.getProviderId()); + + // 개인정보 마스킹 + UserProfile profile = user.getUserProfile(); + if (profile != null) { + profile.setNickname("탈퇴한 회원"); + profile.setProfileImageUrl(null); + profile.setBio(null); + profile.setBirthDate(null); + } + } + + /** + * 유효한 사용자 조회 및 상태 검증 + * + * @param userId 사용자 ID + * @return user 조회된 사용자 엔티티 + */ + private User getValidUser(Long userId) { + + // userId로 User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // UserStatus가 DELETED, SUSPENDED면 예외 처리 + if (user.getUserStatus() == UserStatus.DELETED) { + throw new CustomException(ErrorCode.USER_DELETED); + } + if (user.getUserStatus() == UserStatus.SUSPENDED) { + throw new CustomException(ErrorCode.USER_SUSPENDED); + } + + return user; + } } diff --git a/src/main/java/com/back/global/security/oauth/CustomOAuth2UserService.java b/src/main/java/com/back/global/security/oauth/CustomOAuth2UserService.java index d239af0d..4fedf037 100644 --- a/src/main/java/com/back/global/security/oauth/CustomOAuth2UserService.java +++ b/src/main/java/com/back/global/security/oauth/CustomOAuth2UserService.java @@ -2,7 +2,6 @@ import com.back.domain.user.entity.User; import com.back.domain.user.entity.UserProfile; -import com.back.domain.user.repository.UserProfileRepository; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; From a86aee1003695d3faa3ed58fc5c8855e3543c7cd Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:19:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 3 + .../user/controller/UserControllerDocs.java | 123 +++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index d8870294..9f48d2d6 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -17,6 +17,7 @@ public class UserController implements UserControllerDocs { private final UserService userService; + // 내 정보 조회 @GetMapping("/me") public ResponseEntity> getMyInfo ( @AuthenticationPrincipal CustomUserDetails user @@ -29,6 +30,7 @@ public ResponseEntity> getMyInfo ( )); } + // 내 정보 수정 @PatchMapping("/me") public ResponseEntity> updateMyProfile( @AuthenticationPrincipal CustomUserDetails user, @@ -43,6 +45,7 @@ public ResponseEntity> updateMyProfile( ); } + // 내 계정 삭제 @DeleteMapping("/me") public ResponseEntity> deleteMyAccount( @AuthenticationPrincipal CustomUserDetails user diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index 3ff36f40..3963c7aa 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -87,7 +87,7 @@ public interface UserControllerDocs { ), @ApiResponse( responseCode = "401", - description = "인증 실패 (Access Token 문제)", + description = "인증 실패 (토큰 없음/잘못됨/만료)", content = @Content( mediaType = "application/json", examples = { @@ -99,7 +99,7 @@ public interface UserControllerDocs { "data": null } """), - @ExampleObject(name = "유효하지 않은 토큰", value = """ + @ExampleObject(name = "잘못된 토큰", value = """ { "success": false, "code": "AUTH_002", @@ -303,4 +303,123 @@ ResponseEntity> updateMyProfile( @AuthenticationPrincipal CustomUserDetails user, @Valid @RequestBody UpdateUserProfileRequest request ); + + @Operation( + summary = "내 계정 삭제", + description = "로그인한 사용자의 계정을 탈퇴 처리합니다. " + + "탈퇴 시 사용자 상태는 DELETED로 변경되며, 프로필 정보는 마스킹 처리됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "회원 탈퇴 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "회원 탈퇴가 완료되었습니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity> deleteMyAccount( + @AuthenticationPrincipal CustomUserDetails user + ); } From b93dd18e22cd9b7939a2ef15dd17dd71149eb063 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 28 Sep 2025 22:32:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerTest.java | 117 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 69 +++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index 3dd2a0fe..de63c18b 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -21,6 +21,8 @@ import java.time.LocalDate; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -330,4 +332,119 @@ void updateMyProfile_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_004")) .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } + + // ====================== 내 계정 삭제 테스트 ====================== + + @Test + @DisplayName("회원 탈퇴 성공 → 200 OK") + void deleteMyAccount_success() throws Exception { + // given: 정상 유저 저장 + User user = User.createUser("deleteuser", "delete@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", "https://cdn.example.com/1.png", "소개글", LocalDate.of(1990, 1, 1), 100)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/users/me") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("회원 탈퇴가 완료되었습니다.")); + + // DB 반영 확인 + User deleted = userRepository.findById(user.getId()).orElseThrow(); + assertThat(deleted.getUserStatus()).isEqualTo(UserStatus.DELETED); + assertThat(deleted.getUsername()).startsWith("deleted_"); + assertThat(deleted.getEmail()).startsWith("deleted_"); + assertThat(deleted.getProvider()).startsWith("deleted_"); + assertThat(deleted.getProviderId()).startsWith("deleted_"); + assertThat(deleted.getUserProfile().getNickname()).isEqualTo("탈퇴한 회원"); + } + + @Test + @DisplayName("이미 탈퇴한 계정 탈퇴 시도 → 410 Gone") + void deleteMyAccount_alreadyDeleted() throws Exception { + // given: DELETED 상태 유저 저장 + User user = User.createUser("alreadydeleted", "already@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/users/me") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 탈퇴 시도 → 403 Forbidden") + void deleteMyAccount_suspendedUser() throws Exception { + // given: SUSPENDED 상태 유저 저장 + User user = User.createUser("suspendeddelete", "suspendeddelete@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(delete("/api/users/me") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음으로 회원 탈퇴 시도 → 401 Unauthorized") + void deleteMyAccount_noAccessToken() throws Exception { + mvc.perform(delete("/api/users/me")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken으로 회원 탈퇴 시도 → 401 Unauthorized (AUTH_002)") + void deleteMyAccount_invalidAccessToken() throws Exception { + mvc.perform(delete("/api/users/me") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken으로 회원 탈퇴 시도 → 401 Unauthorized (AUTH_004)") + void deleteMyAccount_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expiredDelete", "expireddelete@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken( + user.getId(), user.getUsername(), user.getRole().name() + ); + + // when & then + mvc.perform(delete("/api/users/me") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } } diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index 556fe690..9522ac92 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -178,4 +178,73 @@ void updateUserProfile_suspendedUser() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); } + + // ====================== 사용자 탈퇴 테스트 ====================== + + @Test + @DisplayName("정상 회원 탈퇴 성공") + void deleteUser_success() { + // given: 정상 상태의 유저 저장 + User user = User.createUser("deleteuser", "delete@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", "https://cdn.example.com/profile.png", "소개글", LocalDate.of(1995, 3, 15), 500)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // when: 탈퇴 처리 + userService.deleteUser(user.getId()); + + // then: 상태 및 개인정보 마스킹 검증 + User deleted = userRepository.findById(user.getId()).orElseThrow(); + assertThat(deleted.getUserStatus()).isEqualTo(UserStatus.DELETED); + assertThat(deleted.getUsername()).startsWith("deleted_"); + assertThat(deleted.getEmail()).startsWith("deleted_"); + assertThat(deleted.getProvider()).startsWith("deleted_"); + assertThat(deleted.getProviderId()).startsWith("deleted_"); + + UserProfile profile = deleted.getUserProfile(); + assertThat(profile.getNickname()).isEqualTo("탈퇴한 회원"); + assertThat(profile.getProfileImageUrl()).isNull(); + assertThat(profile.getBio()).isNull(); + assertThat(profile.getBirthDate()).isNull(); + } + + @Test + @DisplayName("이미 탈퇴된 회원 탈퇴 시도 → USER_ALREADY_DELETED 예외") + void deleteUser_alreadyDeleted() { + // given: 상태 DELETED 유저 저장 + User user = User.createUser("deleteduser", "deleteduser@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + // when & then + assertThatThrownBy(() -> userService.deleteUser(user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 회원 탈퇴 시도 → USER_SUSPENDED 예외") + void deleteUser_suspendedUser() { + // given: 상태 SUSPENDED 유저 저장 + User user = User.createUser("suspendeduser", "suspendeduser@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + // when & then + assertThatThrownBy(() -> userService.deleteUser(user.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 회원 탈퇴 시도 → USER_NOT_FOUND 예외") + void deleteUser_notFound() { + // when & then + assertThatThrownBy(() -> userService.deleteUser(999L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + }