From f5382fa629d2de97093912115f9e2a882f9aad51 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 22:22:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20s3=20=EC=82=AD=EC=A0=9C=20=EA=B1=B4?= =?UTF-8?q?=EB=84=88=EB=9B=B0=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/CommunicationPinCleaner.java | 30 ++++++++---- .../command/CommunityCommandServiceImpl.java | 20 ++++++++ .../ProblemSolverImageRepository.java | 4 ++ .../PinCommunicationCommandServiceImpl.java | 8 ++++ .../command/PinDeleteCommandServiceImpl.java | 44 ++++++++++++++--- .../user/service/UserSignOutCleaner.java | 48 +++++++++++++++++++ .../issueissyu/backend/utils/S3/S3Utils.java | 27 +++++++++++ 7 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java b/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java index e2f34c29..b82a0e15 100644 --- a/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java +++ b/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java @@ -1,26 +1,40 @@ package issueissyu.backend.domain.community.service.command; +import issueissyu.backend.utils.S3.S3Utils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Slf4j @Component @RequiredArgsConstructor public class CommunicationPinCleaner { private final JdbcTemplate jdbcTemplate; + private final S3Utils s3Utils; @Transactional public void deleteByPinId(Long pinId) { - jdbcTemplate.update("DELETE FROM pin_emoji WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM declaration WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM \"comment\" WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM pin_like WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM community WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM pin_location WHERE pin_id = ?", pinId); + // DB 삭제 전에 pin_image S3 key 수집 + List pinImageKeys = jdbcTemplate.queryForList( + "SELECT pin_s3_key FROM pin_image WHERE pin_id = ?", + String.class, pinId); + + jdbcTemplate.update("DELETE FROM pin_emoji WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM declaration WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM \"comment\" WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM pin_like WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM community WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM pin_location WHERE pin_id = ?", pinId); jdbcTemplate.update("DELETE FROM communication_pin WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM pin_image WHERE pin_id = ?", pinId); - jdbcTemplate.update("DELETE FROM pin WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM pin_image WHERE pin_id = ?", pinId); + jdbcTemplate.update("DELETE FROM pin WHERE pin_id = ?", pinId); + + // DB 삭제 완료 후 S3 객체 삭제 + pinImageKeys.forEach(s3Utils::deleteIfNotReserved); } } diff --git a/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java index a3d8d7cf..6afe224a 100644 --- a/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java @@ -1,14 +1,19 @@ package issueissyu.backend.domain.community.service.command; +import issueissyu.backend.domain.community.entity.CardnewsImageS3; import issueissyu.backend.domain.community.entity.Community; import issueissyu.backend.domain.community.enums.CommunityType; import issueissyu.backend.domain.community.exception.CommunityException; import issueissyu.backend.domain.community.exception.code.CommunityErrorCode; +import issueissyu.backend.domain.community.repository.CardnewsImageS3Repository; import issueissyu.backend.domain.community.repository.CommunityRepository; +import issueissyu.backend.utils.S3.S3Utils; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional @@ -16,6 +21,8 @@ public class CommunityCommandServiceImpl implements CommunityCommandService { private final CommunityRepository communityRepository; private final CommunicationPinCleaner communicationPinCleaner; + private final CardnewsImageS3Repository cardnewsImageS3Repository; + private final S3Utils s3Utils; @Override public void deleteCommunity(Long communityId, String uid) { @@ -45,6 +52,19 @@ public void takedownCommunity(Long communityId, String uid) { if (!community.getPin().getUser().getUid().equals(uid)) { throw CommunityException.of(CommunityErrorCode.COMMUNITY_403_3); } + + // cardnews_image_s3는 Community에 cascade가 없으므로 명시적으로 먼저 삭제 + // findDetailById에서 left join fetch로 로드된 데이터를 활용 + List cardnewsKeys = community.getCardnewsImages().stream() + .map(CardnewsImageS3::getCardnewsImageS3Key) + .toList(); + if (!cardnewsKeys.isEmpty()) { + cardnewsImageS3Repository.deleteByCommunity_CommunityId(communityId); + } + communityRepository.delete(community); + + // DB 삭제 완료 후 S3 객체 삭제 + cardnewsKeys.forEach(s3Utils::deleteIfNotReserved); } } diff --git a/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java b/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java index 67de1e4d..3b8b4c2c 100644 --- a/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java +++ b/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java @@ -2,6 +2,7 @@ import issueissyu.backend.domain.issue.entity.ProblemSolverImage; import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -12,6 +13,9 @@ public interface ProblemSolverImageRepository extends JpaRepository findByProblemSolver_ProblemSolverId(Long problemSolverId); + @Query("SELECT psi FROM ProblemSolverImage psi WHERE psi.problemSolver.problemSolverId IN :ids") + List findAllByProblemSolverIdIn(@Param("ids") Collection ids); + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("DELETE FROM ProblemSolverImage psi WHERE psi.problemSolver.problemSolverId IN :ids") void deleteAllByProblemSolver_ProblemSolverIdIn(@Param("ids") Collection ids); diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java index e7506621..0296ea35 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java @@ -293,9 +293,17 @@ private boolean syncPinImages(Pin pin, List items) { boolean changed = false; Set keepUrls = items.stream().map(PinImageItemReqDTO::pinImageUrl).collect(Collectors.toSet()); + // 제거 대상 key를 removeIf 전에 먼저 수집 (orphanRemoval로 DB 삭제되기 전) + List removedKeys = pin.getPinImages().stream() + .filter(pi -> !keepUrls.contains(pi.getPinS3Url())) + .map(PinImage::getPinS3Key) + .filter(key -> key != null && !key.isBlank()) + .toList(); + boolean removedAny = pin.getPinImages().removeIf(pi -> !keepUrls.contains(pi.getPinS3Url())); if (removedAny) { changed = true; + removedKeys.forEach(s3Utils::deleteIfNotReserved); } for (PinImageItemReqDTO item : items) { diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java index d00f7e26..8c41d8ca 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java @@ -1,9 +1,11 @@ package issueissyu.backend.domain.pin.service.command; +import issueissyu.backend.domain.community.entity.CardnewsImageS3; import issueissyu.backend.domain.community.repository.CardnewsImageS3Repository; import issueissyu.backend.domain.community.repository.CommunityRepository; import issueissyu.backend.domain.alarm.service.command.PinAlarmCleaner; import issueissyu.backend.domain.issue.entity.IssuePin; +import issueissyu.backend.domain.issue.entity.ProblemSolverImage; import issueissyu.backend.domain.issue.repository.ComplaintPetitionRepository; import issueissyu.backend.domain.issue.repository.IssuePinRepository; import issueissyu.backend.domain.issue.repository.IssuePetitionRepository; @@ -11,6 +13,7 @@ import issueissyu.backend.domain.issue.repository.ProblemSolverRepository; import issueissyu.backend.domain.map.repository.NoticeRepository; import issueissyu.backend.domain.pin.entity.Pin; +import issueissyu.backend.domain.pin.entity.PinImage; import issueissyu.backend.domain.pin.enums.PinType; import issueissyu.backend.domain.pin.exception.PinException; import issueissyu.backend.domain.pin.exception.code.PinErrorCode; @@ -19,18 +22,23 @@ import issueissyu.backend.domain.pin.repository.DeclarationRepository; import issueissyu.backend.domain.pin.repository.EventPinRepository; import issueissyu.backend.domain.pin.repository.PinEmojiRepository; +import issueissyu.backend.domain.pin.repository.PinImageRepository; import issueissyu.backend.domain.pin.repository.PinLikeRepository; import issueissyu.backend.domain.pin.repository.PinRepository; import issueissyu.backend.domain.pin.repository.StoreImageRepository; import issueissyu.backend.domain.location.repository.PinLocationRepository; import issueissyu.backend.domain.user.enums.UserRole; import issueissyu.backend.domain.user.repository.UserRepository; +import issueissyu.backend.utils.S3.S3Utils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -52,9 +60,11 @@ public class PinDeleteCommandServiceImpl implements PinDeleteCommandService { private final CommunicationPinRepository communicationPinRepository; private final EventPinRepository eventPinRepository; private final StoreImageRepository storeImageRepository; + private final PinImageRepository pinImageRepository; private final PinLocationRepository pinLocationRepository; private final UserRepository userRepository; private final PinAlarmCleaner pinAlarmCleaner; + private final S3Utils s3Utils; @Override public void deletePin(String uid, Long pinId) { @@ -93,17 +103,25 @@ public void deletePin(String uid, Long pinId) { Long communityId = communityOpt.map(c -> c.getCommunityId()).orElse(null); pinAlarmCleaner.deleteByPinId(pinId, communityId); - communityOpt.ifPresent( - c -> - cardnewsImageS3Repository.deleteByCommunity_CommunityId( - c.getCommunityId())); + // S3 정리 대상 key 수집 (DB 삭제 전에 미리 조회) + List s3KeysToDelete = new ArrayList<>(); + + communityOpt.ifPresent(c -> { + cardnewsImageS3Repository.findAllByCommunityCommunityId(c.getCommunityId()) + .stream() + .map(CardnewsImageS3::getCardnewsImageS3Key) + .forEach(s3KeysToDelete::add); + cardnewsImageS3Repository.deleteByCommunity_CommunityId(c.getCommunityId()); + }); communityRepository.deleteByPin_PinId(pinId); issuePinRepository .findByPin_PinId(pinId) - .ifPresent(this::deleteIssueAssociations); + .ifPresent(ip -> deleteIssueAssociations(ip, s3KeysToDelete)); issuePinRepository.deleteByPin_PinId(pinId); + storeImageRepository.findByEventPin_Pin_PinId(pinId) + .ifPresent(si -> s3KeysToDelete.add(si.getImageS3Key())); storeImageRepository.deleteByEventPin_Pin_PinId(pinId); eventPinRepository.deleteByPin_PinId(pinId); @@ -114,16 +132,30 @@ public void deletePin(String uid, Long pinId) { pinEmojiRepository.deleteByPin_PinId(pinId); communicationPinRepository.deleteByPin_PinId(pinId); pinLocationRepository.deleteByPin_PinId(pinId); + + // pin_image는 pinRepository.delete(pin) cascade로 삭제되므로 key를 먼저 수집 + pinImageRepository.findByPin_PinIdOrderByPinImageIdAsc(pinId) + .stream() + .map(PinImage::getPinS3Key) + .forEach(s3KeysToDelete::add); + pinRepository.delete(pin); + + // DB 삭제 완료 후 S3 객체 일괄 삭제 + s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); } - private void deleteIssueAssociations(IssuePin issuePin) { + private void deleteIssueAssociations(IssuePin issuePin, List s3KeysToDelete) { Long issuePinId = issuePin.getIssuePinId(); issuePetitionRepository.deleteByIssuePin_IssuePinId(issuePinId); complaintPetitionRepository.deleteByIssuePin_IssuePinId(issuePinId); List solverIds = problemSolverRepository.findAllProblemSolverIdsByIssuePin_IssuePinId(issuePinId); if (!solverIds.isEmpty()) { + problemSolverImageRepository.findAllByProblemSolverIdIn(solverIds) + .stream() + .map(ProblemSolverImage::getProblemSolverImageS3Key) + .forEach(s3KeysToDelete::add); problemSolverImageRepository.deleteAllByProblemSolver_ProblemSolverIdIn(solverIds); } problemSolverRepository.deleteAllByIssuePin_IssuePinId(issuePinId); diff --git a/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java b/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java index 48fdf05c..878be881 100644 --- a/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java +++ b/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java @@ -1,11 +1,17 @@ package issueissyu.backend.domain.user.service; import issueissyu.backend.domain.alarm.service.command.PinAlarmCleaner; +import issueissyu.backend.utils.S3.S3Utils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + +@Slf4j @Component @RequiredArgsConstructor public class UserSignOutCleaner { @@ -23,9 +29,13 @@ public class UserSignOutCleaner { private final JdbcTemplate jdbcTemplate; private final PinAlarmCleaner pinAlarmCleaner; + private final S3Utils s3Utils; @Transactional public void deleteRowsReferencingUser(String uid) { + // DB 삭제 전에 S3 key 일괄 수집 + List s3KeysToDelete = collectS3Keys(uid); + jdbcTemplate.update( "DELETE FROM problem_solver_image WHERE problem_solver_id IN (" + "SELECT problem_solver_id FROM problem_solver WHERE uid = ? " @@ -118,5 +128,43 @@ public void deleteRowsReferencingUser(String uid) { jdbcTemplate.update("DELETE FROM user_custom_collection WHERE uid = ?", uid); jdbcTemplate.update("DELETE FROM user_emoji WHERE uid = ?", uid); + + // DB 삭제 완료 후 S3 객체 일괄 삭제 + s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + } + + private List collectS3Keys(String uid) { + List keys = new ArrayList<>(); + + // problem_solver_image: 본인이 참여한 solver + 본인 핀에 달린 solver 이미지 + keys.addAll(jdbcTemplate.queryForList( + "SELECT problem_solver_image_s3_key FROM problem_solver_image" + + " WHERE problem_solver_id IN (" + + "SELECT problem_solver_id FROM problem_solver WHERE uid = ?" + + " OR issue_pin_id IN " + ISSUE_PIN_IDS_FOR_OWNED_PINS + + ")", + String.class, uid, uid)); + + // cardnews_image_s3: 본인 핀의 커뮤니티 카드뉴스 이미지 + keys.addAll(jdbcTemplate.queryForList( + "SELECT cardnews_image_s3_key FROM cardnews_image_s3" + + " WHERE community_id IN (" + + "SELECT community_id FROM community WHERE pin_id IN " + + PIN_IDS_OWNED_BY_USER + ")", + String.class, uid)); + + // store_image: 본인 핀의 이벤트 핀 스토어 이미지 + keys.addAll(jdbcTemplate.queryForList( + "SELECT store_image_s3_key FROM store_image" + + " WHERE event_pin_id IN " + EVENT_PIN_IDS_FOR_OWNED_PINS, + String.class, uid)); + + // pin_image: 본인 핀의 일반 이미지 + keys.addAll(jdbcTemplate.queryForList( + "SELECT pin_s3_key FROM pin_image" + + " WHERE pin_id IN " + PIN_IDS_OWNED_BY_USER, + String.class, uid)); + + return keys; } } diff --git a/src/main/java/issueissyu/backend/utils/S3/S3Utils.java b/src/main/java/issueissyu/backend/utils/S3/S3Utils.java index 83f096e4..5b9efa6b 100644 --- a/src/main/java/issueissyu/backend/utils/S3/S3Utils.java +++ b/src/main/java/issueissyu/backend/utils/S3/S3Utils.java @@ -13,6 +13,8 @@ import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.InputStream; +import java.util.Locale; +import java.util.Set; import java.util.UUID; import static issueissyu.backend.utils.exception.UtilException.Reason.*; @@ -22,9 +24,34 @@ @RequiredArgsConstructor public class S3Utils { + private static final Set RESERVED_PREFIXES = Set.of("festival", "contest"); + private final S3Client s3Client; private final AmazonConfig config; + // festival/contest 등 시스템 전용 prefix를 가진 키인지 확인합니다. + // 이 키들은 관리자가 직접 관리하는 고정 에셋이므로 자동 삭제 대상에서 제외합니다. + public boolean isReservedKey(String key) { + if (key == null || key.isBlank()) return false; + String first = key.contains("/") ? key.substring(0, key.indexOf('/')) : key; + return RESERVED_PREFIXES.contains(first.toLowerCase(Locale.ROOT)); + } + + // Reserved key(festival/contest 등)이면 삭제를 건너뛰고, + // 그 외에는 S3 객체를 삭제합니다. 삭제 실패는 warn 로그만 남기고 예외를 전파하지 않습니다. + public void deleteIfNotReserved(String key) { + if (key == null || key.isBlank()) return; + if (isReservedKey(key)) { + log.debug("S3 delete skipped (reserved key): {}", key); + return; + } + try { + deleteFile(key); + } catch (Exception e) { + log.warn("S3 delete failed for key={}: {}", key, e.getMessage()); + } + } + // 파일 키 생성 (원본 파일명 그대로 사용) private String generateFileKey(String fileName) { return UUID.randomUUID() + "-" + fileName; From 9a6fa254108cfae15af1580a85a23bb88f439f57 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 22:32:37 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20s3=20=ED=82=A4=20=EC=84=B8=EA=B7=B8?= =?UTF-8?q?=EB=A8=BC=ED=8A=B8=20=EC=B6=94=EC=B6=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/issueissyu/backend/utils/S3/S3Utils.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/issueissyu/backend/utils/S3/S3Utils.java b/src/main/java/issueissyu/backend/utils/S3/S3Utils.java index 5b9efa6b..fd2a06c3 100644 --- a/src/main/java/issueissyu/backend/utils/S3/S3Utils.java +++ b/src/main/java/issueissyu/backend/utils/S3/S3Utils.java @@ -33,8 +33,12 @@ public class S3Utils { // 이 키들은 관리자가 직접 관리하는 고정 에셋이므로 자동 삭제 대상에서 제외합니다. public boolean isReservedKey(String key) { if (key == null || key.isBlank()) return false; - String first = key.contains("/") ? key.substring(0, key.indexOf('/')) : key; - return RESERVED_PREFIXES.contains(first.toLowerCase(Locale.ROOT)); + String cleanedKey = key.trim(); + if (cleanedKey.startsWith("/")) { + cleanedKey = cleanedKey.substring(1).trim(); + } + String first = cleanedKey.contains("/") ? cleanedKey.substring(0, cleanedKey.indexOf('/')) : cleanedKey; + return RESERVED_PREFIXES.contains(first.trim().toLowerCase(Locale.ROOT)); } // Reserved key(festival/contest 등)이면 삭제를 건너뛰고, From bd4e1c87b05096a3c2ec22fa8e0d286e89c54203 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 22:34:02 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=A7=A4=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/issue/repository/ProblemSolverImageRepository.java | 2 +- .../domain/pin/service/command/PinDeleteCommandServiceImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java b/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java index 3b8b4c2c..73b5cdb3 100644 --- a/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java +++ b/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java @@ -14,7 +14,7 @@ public interface ProblemSolverImageRepository extends JpaRepository findByProblemSolver_ProblemSolverId(Long problemSolverId); @Query("SELECT psi FROM ProblemSolverImage psi WHERE psi.problemSolver.problemSolverId IN :ids") - List findAllByProblemSolverIdIn(@Param("ids") Collection ids); + List findAllByProblemSolver_ProblemSolverIdIn(@Param("ids") Collection ids); @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("DELETE FROM ProblemSolverImage psi WHERE psi.problemSolver.problemSolverId IN :ids") diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java index 8c41d8ca..af982596 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java @@ -152,7 +152,7 @@ private void deleteIssueAssociations(IssuePin issuePin, List s3KeysToDel List solverIds = problemSolverRepository.findAllProblemSolverIdsByIssuePin_IssuePinId(issuePinId); if (!solverIds.isEmpty()) { - problemSolverImageRepository.findAllByProblemSolverIdIn(solverIds) + problemSolverImageRepository.findAllByProblemSolver_ProblemSolverIdIn(solverIds) .stream() .map(ProblemSolverImage::getProblemSolverImageS3Key) .forEach(s3KeysToDelete::add); From 00fb6f342c36aeb5a978ddfab936d16f67941674 Mon Sep 17 00:00:00 2001 From: taerimiiii Date: Wed, 17 Jun 2026 22:39:48 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20DB=20=EC=82=AD=EC=A0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=9B=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=EC=9D=B4=20=EC=84=B1=EA=B3=B5=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20S3=20=EA=B0=9D=EC=B2=B4=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/command/CommunicationPinCleaner.java | 16 ++++++++++++++-- .../command/CommunityCommandServiceImpl.java | 16 ++++++++++++++-- .../PinCommunicationCommandServiceImpl.java | 14 +++++++++++++- .../command/PinDeleteCommandServiceImpl.java | 16 ++++++++++++++-- .../domain/user/service/UserSignOutCleaner.java | 16 ++++++++++++++-- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java b/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java index b82a0e15..99df8fad 100644 --- a/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java +++ b/src/main/java/issueissyu/backend/domain/community/service/command/CommunicationPinCleaner.java @@ -6,6 +6,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.List; @@ -34,7 +36,17 @@ public void deleteByPinId(Long pinId) { jdbcTemplate.update("DELETE FROM pin_image WHERE pin_id = ?", pinId); jdbcTemplate.update("DELETE FROM pin WHERE pin_id = ?", pinId); - // DB 삭제 완료 후 S3 객체 삭제 - pinImageKeys.forEach(s3Utils::deleteIfNotReserved); + // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 삭제 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + pinImageKeys.forEach(s3Utils::deleteIfNotReserved); + } + }); + } else { + pinImageKeys.forEach(s3Utils::deleteIfNotReserved); + } } } diff --git a/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java index 6afe224a..437f2a8c 100644 --- a/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/community/service/command/CommunityCommandServiceImpl.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.List; @@ -64,7 +66,17 @@ public void takedownCommunity(Long communityId, String uid) { communityRepository.delete(community); - // DB 삭제 완료 후 S3 객체 삭제 - cardnewsKeys.forEach(s3Utils::deleteIfNotReserved); + // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 삭제 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + cardnewsKeys.forEach(s3Utils::deleteIfNotReserved); + } + }); + } else { + cardnewsKeys.forEach(s3Utils::deleteIfNotReserved); + } } } diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java index 0296ea35..66bb03ec 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinCommunicationCommandServiceImpl.java @@ -42,6 +42,8 @@ import org.postgresql.geometric.PGpoint; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.ArrayList; import java.time.LocalDateTime; @@ -303,7 +305,17 @@ private boolean syncPinImages(Pin pin, List items) { boolean removedAny = pin.getPinImages().removeIf(pi -> !keepUrls.contains(pi.getPinS3Url())); if (removedAny) { changed = true; - removedKeys.forEach(s3Utils::deleteIfNotReserved); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + removedKeys.forEach(s3Utils::deleteIfNotReserved); + } + }); + } else { + removedKeys.forEach(s3Utils::deleteIfNotReserved); + } } for (PinImageItemReqDTO item : items) { diff --git a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java index af982596..f3a8715a 100644 --- a/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java +++ b/src/main/java/issueissyu/backend/domain/pin/service/command/PinDeleteCommandServiceImpl.java @@ -34,6 +34,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.ArrayList; import java.util.List; @@ -141,8 +143,18 @@ public void deletePin(String uid, Long pinId) { pinRepository.delete(pin); - // DB 삭제 완료 후 S3 객체 일괄 삭제 - s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 일괄 삭제 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + } + }); + } else { + s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + } } private void deleteIssueAssociations(IssuePin issuePin, List s3KeysToDelete) { diff --git a/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java b/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java index 878be881..8eaa76cb 100644 --- a/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java +++ b/src/main/java/issueissyu/backend/domain/user/service/UserSignOutCleaner.java @@ -7,6 +7,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.ArrayList; import java.util.List; @@ -129,8 +131,18 @@ public void deleteRowsReferencingUser(String uid) { jdbcTemplate.update("DELETE FROM user_custom_collection WHERE uid = ?", uid); jdbcTemplate.update("DELETE FROM user_emoji WHERE uid = ?", uid); - // DB 삭제 완료 후 S3 객체 일괄 삭제 - s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 일괄 삭제 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + } + }); + } else { + s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved); + } } private List collectS3Keys(String uid) {