Skip to content
Open
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 @@ -40,4 +40,6 @@ default Application getApplicationBySiteUserIdAndTermId(long siteUserId, long te
return findBySiteUserIdAndTermId(siteUserId, termId)
.orElseThrow(() -> new CustomException(APPLICATION_NOT_FOUND));
}

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatParticipant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ChatParticipantRepository extends JpaRepository<ChatParticipant, Long> {

boolean existsByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId);

Optional<ChatParticipant> findByChatRoomIdAndSiteUserId(long chatRoomId, long siteUserId);

void deleteAllBySiteUserId(long siteUserId);

@Query("SELECT cp.id FROM ChatParticipant cp WHERE cp.siteUserId = :siteUserId")
List<Long> findAllIdsBySiteUserId(@Param("siteUserId") long siteUserId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatReadStatus;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -15,4 +16,6 @@ INSERT INTO chat_read_status (chat_room_id, chat_participant_id, created_at, upd
ON DUPLICATE KEY UPDATE updated_at = NOW(6)
""", nativeQuery = true)
void upsertReadStatus(@Param("chatRoomId") long chatRoomId, @Param("chatParticipantId") long chatParticipantId);

void deleteAllByChatParticipantIdIn(List<Long> chatParticipantIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ default Comment getById(Long id) {
return findById(id)
.orElseThrow(() -> new CustomException(INVALID_COMMENT_ID));
}

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ default PostLike getByPostAndSiteUserId(Post post, long siteUserId) {
return findPostLikeByPostAndSiteUserId(post, siteUserId)
.orElseThrow(() -> new CustomException(INVALID_POST_LIKE));
}

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,6 @@ default Post getById(Long id) {
return findById(id)
.orElseThrow(() -> new CustomException(INVALID_POST_ID));
}

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
public interface MentorApplicationRepository extends JpaRepository<MentorApplication, Long> {

boolean existsBySiteUserIdAndMentorApplicationStatusIn(long siteUserId, List<MentorApplicationStatus> mentorApplicationStatuses);

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ public interface MentorRepository extends JpaRepository<Mentor, Long> {
Slice<Mentor> findAllByRegion(@Param("region") Region region, Pageable pageable);

List<Mentor> findAllBySiteUserIdIn(Set<Long> siteUserIds);

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ public interface MentoringRepository extends JpaRepository<Mentoring, Long> {

@Query("SELECT m FROM Mentoring m WHERE m.menteeId = :menteeId AND m.verifyStatus = :verifyStatus")
Slice<Mentoring> findApprovedMentoringsByMenteeId(long menteeId, @Param("verifyStatus") VerifyStatus verifyStatus, Pageable pageable);

void deleteAllByMenteeId(long menteeId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public interface LikedNewsRepository extends JpaRepository<LikedNews, Long> {
boolean existsByNewsIdAndSiteUserId(long newsId, long siteUserId);

Optional<LikedNews> findByNewsIdAndSiteUserId(long newsId, long siteUserId);

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
public interface NewsRepository extends JpaRepository<News, Long>, NewsCustomRepository {

List<News> findAllBySiteUserIdOrderByUpdatedAtDesc(long siteUserId);

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
public interface ReportRepository extends JpaRepository<Report, Long> {

boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId);

void deleteAllByReporterId(long reporterId);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
package com.example.solidconnection.scheduler;

import com.example.solidconnection.application.repository.ApplicationRepository;
import com.example.solidconnection.chat.repository.ChatParticipantRepository;
import com.example.solidconnection.chat.repository.ChatReadStatusRepository;
import com.example.solidconnection.community.comment.repository.CommentRepository;
import com.example.solidconnection.community.post.repository.PostLikeRepository;
import com.example.solidconnection.community.post.repository.PostRepository;
import com.example.solidconnection.location.country.repository.InterestedCountryRepository;
import com.example.solidconnection.location.region.repository.InterestedRegionRepository;
import com.example.solidconnection.mentor.repository.MentorApplicationRepository;
import com.example.solidconnection.mentor.repository.MentorRepository;
import com.example.solidconnection.mentor.repository.MentoringRepository;
import com.example.solidconnection.news.repository.LikedNewsRepository;
import com.example.solidconnection.news.repository.NewsRepository;
import com.example.solidconnection.report.repository.ReportRepository;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.score.repository.GpaScoreRepository;
import com.example.solidconnection.score.repository.LanguageTestScoreRepository;
import com.example.solidconnection.siteuser.domain.SiteUser;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.siteuser.repository.UserBlockRepository;
import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Component
Expand All @@ -16,14 +36,67 @@ public class UserRemovalScheduler {
public static final int ACCOUNT_RECOVER_DURATION = 30;

private final SiteUserRepository siteUserRepository;
private final InterestedCountryRepository interestedCountryRepository;
private final InterestedRegionRepository interestedRegionRepository;
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final PostLikeRepository postLikeRepository;
private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository;
private final ApplicationRepository applicationRepository;
private final GpaScoreRepository gpaScoreRepository;
private final LanguageTestScoreRepository languageTestScoreRepository;
private final MentorRepository mentorRepository;
private final MentoringRepository mentoringRepository;
private final NewsRepository newsRepository;
private final LikedNewsRepository likedNewsRepository;
private final ChatParticipantRepository chatParticipantRepository;
private final ChatReadStatusRepository chatReadStatusRepository;
private final ReportRepository reportRepository;
private final UserBlockRepository userBlockRepository;
private final MentorApplicationRepository mentorApplicationRepository;
private final S3Service s3Service;

/*
* 탈퇴 후 계정 복구 기한까지 방문하지 않은 사용자를 삭제한다.
* */
@Scheduled(cron = EVERY_MIDNIGHT)
@Transactional
public void scheduledUserRemoval() {
LocalDate cutoffDate = LocalDate.now().minusDays(ACCOUNT_RECOVER_DURATION);
List<SiteUser> usersToRemove = siteUserRepository.findUsersToBeRemoved(cutoffDate);
siteUserRepository.deleteAll(usersToRemove);

usersToRemove.forEach(this::deleteUserAndRelatedData);
}
Comment on lines 62 to +69
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

2. 트랜잭션 경계 문제

@Transactional 애노테이션이 scheduledUserRemoval 메서드에 추가되어 모든 사용자 삭제가 하나의 트랜잭션으로 처리됩니다. 이는 심각한 문제를 초래할 수 있습니다:

  1. 대량 데이터 처리 시 트랜잭션 타임아웃: 삭제할 사용자가 많을 경우 트랜잭션이 너무 길어져 타임아웃이 발생할 수 있습니다.
  2. 부분 실패 시 전체 롤백: 100명 중 99번째 사용자 삭제 중 오류가 발생하면 앞의 98명도 모두 롤백됩니다.
  3. 테이블 락 장기 점유: 긴 트랜잭션으로 인해 다른 작업들이 대기하게 됩니다.
🔎 개별 사용자별 트랜잭션 처리로 개선
  @Scheduled(cron = EVERY_MIDNIGHT)
- @Transactional
  public void scheduledUserRemoval() {
      LocalDate cutoffDate = LocalDate.now().minusDays(ACCOUNT_RECOVER_DURATION);
      List<SiteUser> usersToRemove = siteUserRepository.findUsersToBeRemoved(cutoffDate);
      usersToRemove.forEach(this::deleteUserAndRelatedData);
  }

+ @Transactional
  private void deleteUserAndRelatedData(SiteUser user) {
+     try {
          long siteUserId = user.getId();
          
          likedNewsRepository.deleteAllBySiteUserId(siteUserId);
          // ... 나머지 삭제 로직
          
          siteUserRepository.delete(user);
+     } catch (Exception e) {
+         log.error("Failed to delete user and related data for userId: {}", user.getId(), e);
+         // 개별 사용자 삭제 실패 시에도 다음 사용자 처리 계속
+     }
  }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java
around lines 60 to 67, the @Transactional on scheduledUserRemoval makes the
entire batch run in one transaction; remove the @Transactional from this method,
ensure each deletion runs in its own transaction (e.g., annotate
deleteUserAndRelatedData with @Transactional(propagation =
Propagation.REQUIRES_NEW) or execute each delete via TransactionTemplate), and
wrap each per-user delete call in a try/catch to log failures and continue so a
single failure does not roll back or stop the whole job.


private void deleteUserAndRelatedData(SiteUser user) {
long siteUserId = user.getId();

likedNewsRepository.deleteAllBySiteUserId(siteUserId);
newsRepository.deleteAllBySiteUserId(siteUserId);

postLikeRepository.deleteAllBySiteUserId(siteUserId);
commentRepository.deleteAllBySiteUserId(siteUserId);
postRepository.deleteAllBySiteUserId(siteUserId);

mentoringRepository.deleteAllByMenteeId(siteUserId);
mentorRepository.deleteAllBySiteUserId(siteUserId);
mentorApplicationRepository.deleteAllBySiteUserId(siteUserId);

List<Long> chatParticipantIds = chatParticipantRepository.findAllIdsBySiteUserId(siteUserId);
chatReadStatusRepository.deleteAllByChatParticipantIdIn(chatParticipantIds);
chatParticipantRepository.deleteAllBySiteUserId(siteUserId);
reportRepository.deleteAllByReporterId(siteUserId);
userBlockRepository.deleteAllByBlockerIdOrBlockedId(siteUserId, siteUserId);

applicationRepository.deleteAllBySiteUserId(siteUserId);
gpaScoreRepository.deleteAllBySiteUserId(siteUserId);
languageTestScoreRepository.deleteAllBySiteUserId(siteUserId);
likedUnivApplyInfoRepository.deleteAllBySiteUserId(siteUserId);
interestedCountryRepository.deleteAllBySiteUserId(siteUserId);
interestedRegionRepository.deleteAllBySiteUserId(siteUserId);

s3Service.deleteExProfile(siteUserId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

4. S3 삭제 실패 처리

Line 94에서 S3 프로필 삭제 중 오류가 발생하면 현재 트랜잭션이 롤백되어 DB 삭제도 취소됩니다. S3 삭제는 외부 서비스 호출이므로:

  1. 네트워크 오류 가능성: S3 서비스가 일시적으로 불안정하면 전체 사용자 삭제가 실패합니다.
  2. 트랜잭션 일관성 문제: S3는 트랜잭션에 참여하지 않으므로, 롤백 시에도 S3에서는 파일이 삭제된 상태로 남을 수 있습니다.
🔎 S3 삭제 오류 격리 처리
+ try {
      s3Service.deleteExProfile(siteUserId);
+ } catch (Exception e) {
+     log.warn("Failed to delete S3 profile for userId: {}, continuing with user deletion", siteUserId, e);
+     // S3 삭제 실패해도 사용자 삭제는 진행
+ }
  
  siteUserRepository.delete(user);

또는 S3 삭제를 별도의 보상 트랜잭션으로 처리하는 방안을 고려하세요.

📝 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
s3Service.deleteExProfile(siteUserId);
try {
s3Service.deleteExProfile(siteUserId);
} catch (Exception e) {
log.warn("Failed to delete S3 profile for userId: {}, continuing with user deletion", siteUserId, e);
// S3 삭제 실패해도 사용자 삭제는 진행
}
🤖 Prompt for AI Agents
In src/main/java/com/example/solidconnection/scheduler/UserRemovalScheduler.java
around line 94, calling s3Service.deleteExProfile(siteUserId) directly can cause
DB transaction rollback when S3 fails; change to perform S3 deletion outside the
DB transaction (e.g., register an after-commit action via
TransactionSynchronizationManager or publish a transactional
event/@TransactionalEventListener that runs after commit), wrap the S3 call in
retry logic and exception handling so failures do not throw into the DB
transaction, log detailed errors, and if deletion still fails enqueue a
compensating job or mark the user record with a flag for async cleanup to ensure
idempotent retry and eventual consistency.


siteUserRepository.delete(user);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public interface GpaScoreRepository extends JpaRepository<GpaScore, Long>, GpaSc
Optional<GpaScore> findGpaScoreBySiteUserIdAndId(long siteUserId, Long id);

List<GpaScore> findBySiteUserId(long siteUserId);

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface LanguageTestScoreRepository extends JpaRepository<LanguageTestS
Optional<LanguageTestScore> findLanguageTestScoreBySiteUserIdAndId(long siteUserId, Long id);

List<LanguageTestScore> findBySiteUserId(long siteUserId);

void deleteAllBySiteUserId(long siteUserId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ public interface UserBlockRepository extends JpaRepository<UserBlock, Long> {
WHERE ub.blockerId = :blockerId
""")
Slice<UserBlockResponse> findBlockedUsersWithNickname(@Param("blockerId") long blockerId, Pageable pageable);

void deleteAllByBlockerIdOrBlockedId(long blockerId, long blockedId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public interface LikedUnivApplyInfoRepository extends JpaRepository<LikedUnivApp
List<UnivApplyInfo> findUnivApplyInfosBySiteUserId(@Param("siteUserId") long siteUserId);

boolean existsBySiteUserIdAndUnivApplyInfoId(long siteUserId, long univApplyInfoId);

void deleteAllBySiteUserId(long siteUserId);
}