Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ public interface BrandLikeRepository extends JpaRepository<BrandLike, Long> {
void deleteByUserIdAndBrandId(Long userId, Long brandId);

long countByBrandId(Long brandId);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ public interface BrandRepository extends JpaRepository<Brand, Long> {
where b.brandName like %:keyword%
""")
List<Long> findIdsByBrandNameContaining(@Param("keyword") String keyword);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ List<CollaborationProjection> findMyAppliedCollaborationsForSearch(
@Param("brandIds") List<Long> brandIds
);


void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand Down Expand Up @@ -59,4 +60,9 @@ List<Long> findReceivedProposalIds(
"WHERE p.id IN :ids")
List<CampaignProposal> findAllByIdWithDetails(@Param("ids") List<Long> ids);

// User가 연관된 Proposal 모두 삭제 (보낸/받은)
// 쿼리 최적화를 위해 @Modifying 어노테이션과 JPQL 사용
@Modifying
@Query("DELETE FROM CampaignProposal cp WHERE cp.senderUserId = :userId OR cp.receiverUserId = :userId")
void deleteByUserId(@Param("userId") Long userId);
Comment on lines 65 to 67
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

리포지토리 메서드에 있는 @Transactional 어노테이션은 중복되므로 제거하는 것이 좋습니다. 이 메서드를 호출하는 UserDeleteService.deleteUserImmediately 서비스 메서드에 이미 @Transactional이 선언되어 있어, 해당 트랜잭션에 자동으로 참여하게 됩니다.

트랜잭션 경계는 서비스 계층에서 관리하는 것으로 일관성을 유지하면 코드의 트랜잭션 동작을 더 명확하게 파악하고 관리할 수 있습니다.

Suggested change
@Modifying
@Transactional
@Query("DELETE FROM CampaignProposal cp WHERE cp.senderUserId = :userId OR cp.receiverUserId = :userId")
void deleteByUserId(@Param("userId") Long userId);
@Modifying
@Query("DELETE FROM CampaignProposal cp WHERE cp.senderUserId = :userId OR cp.receiverUserId = :userId")
void deleteByUserId(@Param("userId") Long userId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ select cl.campaign.id, count(cl)
group by cl.campaign.id
""")
List<Object[]> countByCampaignIdIn(@Param("campaignIds") List<Long> campaignIds);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ public interface MatchBrandHistoryRepository extends JpaRepository<MatchBrandHis
@Modifying
@Query("UPDATE MatchBrandHistory h SET h.isDeprecated = true WHERE h.user.id = :userId AND h.isDeprecated = false")
int bulkDeprecateByUserId(@Param("userId") Long userId);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ List<MatchCampaignHistory> findByUserIdWithCursor(
Long cursor,
Pageable pageable
);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.example.RealMatch.user.application.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.RealMatch.brand.domain.repository.BrandLikeRepository;
import com.example.RealMatch.brand.domain.repository.BrandRepository;
import com.example.RealMatch.business.domain.repository.CampaignApplyRepository;
import com.example.RealMatch.business.domain.repository.CampaignProposalRepository;
import com.example.RealMatch.campaign.domain.repository.CampaignLikeRepository;
import com.example.RealMatch.global.exception.CustomException;
import com.example.RealMatch.match.domain.repository.MatchBrandHistoryRepository;
import com.example.RealMatch.match.domain.repository.MatchCampaignHistoryRepository;
import com.example.RealMatch.tag.domain.repository.TagUserRepository;
import com.example.RealMatch.user.domain.entity.User;
import com.example.RealMatch.user.domain.repository.AuthenticationMethodRepository;
import com.example.RealMatch.user.domain.repository.NotificationSettingRepository;
import com.example.RealMatch.user.domain.repository.UserContentCategoryRepository;
import com.example.RealMatch.user.domain.repository.UserRepository;
import com.example.RealMatch.user.domain.repository.UserSignupPurposeRepository;
import com.example.RealMatch.user.domain.repository.UserTermRepository;
import com.example.RealMatch.user.presentation.code.UserErrorCode;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class UserDeleteService {

private final UserRepository userRepository;
private final AuthenticationMethodRepository authenticationMethodRepository;
private final BrandRepository brandRepository;
private final BrandLikeRepository brandLikeRepository;
private final CampaignApplyRepository campaignApplyRepository;
private final CampaignLikeRepository campaignLikeRepository;
private final CampaignProposalRepository campaignProposalRepository;
private final MatchBrandHistoryRepository matchBrandHistoryRepository;
private final MatchCampaignHistoryRepository matchCampaignHistoryRepository;
private final NotificationSettingRepository notificationSettingRepository;
private final TagUserRepository tagUserRepository;
private final UserContentCategoryRepository userContentCategoryRepository;
private final UserSignupPurposeRepository userSignupPurposeRepository;
private final UserTermRepository userTermRepository;

@Transactional
public void deleteUserImmediately(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));

deleteRelatedData(userId);
userRepository.delete(user);
}

private void deleteRelatedData(Long userId) {
tagUserRepository.deleteByUserId(userId);
userContentCategoryRepository.deleteByUserId(userId);
userSignupPurposeRepository.deleteByUserId(userId);
userTermRepository.deleteByUserId(userId);
notificationSettingRepository.deleteByUserId(userId);
matchBrandHistoryRepository.deleteByUserId(userId);
matchCampaignHistoryRepository.deleteByUserId(userId);
brandLikeRepository.deleteByUserId(userId);
campaignLikeRepository.deleteByUserId(userId);
campaignApplyRepository.deleteByUserId(userId);
campaignProposalRepository.deleteByUserId(userId);
brandRepository.deleteByUserId(userId);
authenticationMethodRepository.deleteByUserId(userId);
}
Comment on lines +54 to +68
Copy link
Contributor

Choose a reason for hiding this comment

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

high

현재 유저 관련 데이터 삭제 구현이 불완전합니다. 특히, 유저가 'Brand'의 소유주일 경우, 해당 브랜드에 'Campaign'과 같은 다른 엔티티가 연결되어 있다면 66라인의 brandRepository.deleteByUserId(userId) 호출 시 데이터베이스 제약 조건 위반으로 실패하게 됩니다. 이는 처리되지 않은 500 에러로 이어져 사용자 경험을 해칩니다.

Swagger 문서에 이 가능성을 언급하셨지만, 애플리케이션 레벨에서 이 경우를 더 안정적으로 처리하는 것이 바람직합니다. 브랜드에 종속된 데이터를 먼저 삭제하거나, 삭제가 불가능한 경우 명확한 에러 메시지(예: 409 Conflict)를 반환하여 유저가 상황을 인지할 수 있도록 해야 합니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"이 API는 개발/테스트용 헬퍼로 실제 프로덕션에서는 사용되지 않습니다. 실제 서비스에서는 soft delete를 사용합니다."

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ public interface AuthenticationMethodRepository extends JpaRepository<Authentica

boolean existsByProviderAndProviderId(AuthProvider provider, String providerId);


void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public interface NotificationSettingRepository extends JpaRepository<Notificatio
Optional<NotificationSetting> findByUserId(Long userId);
Optional<NotificationSetting> findOneByUserId(Long userId);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ public interface UserContentCategoryRepository
where ucc.user.id = :userId
""")
List<UserContentCategory> findByUserId(@Param("userId") Long userId);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ public interface UserTermRepository extends JpaRepository<UserTerm, Long> {
List<UserTerm> findByUserIdAndIsAgreed(Long userId, boolean isAgreed);

Optional<UserTerm> findByUserIdAndTermName(Long userId, TermName termName);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.example.RealMatch.match.domain.entity.enums.BrandSortType;
import com.example.RealMatch.match.domain.entity.enums.CampaignSortType;
import com.example.RealMatch.match.presentation.dto.request.MatchRequestDto;
import com.example.RealMatch.user.application.service.UserDeleteService;
import com.example.RealMatch.user.application.service.UserFavoriteService;
import com.example.RealMatch.user.application.service.UserFeatureService;
import com.example.RealMatch.user.application.service.UserService;
Expand Down Expand Up @@ -47,6 +48,7 @@ public class UserController implements UserSwagger {
private final UserFeatureService userFeatureService;
private final UserWithdrawService userWithdrawService;
private final UserFavoriteService userFavoriteService;
private final UserDeleteService userDeleteService;

@Override
@GetMapping("/me")
Expand Down Expand Up @@ -190,4 +192,13 @@ public CustomResponse<MyProfileCardResponseDto> updateMySns(
userService.updateSns(userDetails.getUserId(), request)
);
}

@Override
@DeleteMapping("/me/delete-immediately")
public CustomResponse<Void> deleteUserImmediately(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
userDeleteService.deleteUserImmediately(userDetails.getUserId());
return CustomResponse.ok(null);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.RealMatch.user.presentation.swagger;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
Expand Down Expand Up @@ -241,4 +242,27 @@ CustomResponse<MyProfileCardResponseDto> updateMySns(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
@Valid @RequestBody MyInstagramUpdateRequestDto request
);

@Operation(
summary = "회원 즉시 삭제 API By 고경수",
description = """
로그인한 사용자의 데이터를 즉시 물리 삭제합니다. (복구 불가)

삭제 순서:
- 유저 관련 자식 데이터 삭제 후
- users 물리 삭제

주의:
- 브랜드 오너/참조 FK가 존재하는 경우 DB 제약조건에 따라 실패할 수 있습니다.
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "즉시 삭제 성공"),
@ApiResponse(responseCode = "404", description = "USER_NOT_FOUND"),
@ApiResponse(responseCode = "500", description = "연관 데이터 FK 제약으로 삭제 실패 가능")
})
@DeleteMapping("/me/delete-immediately")
CustomResponse<Void> deleteUserImmediately(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails
);
}
Loading