diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 01911e5..794598d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,11 @@ on: permissions: contents: read + id-token: write + +concurrency: + group: issueissyu-prod-deploy + cancel-in-progress: false jobs: build: @@ -18,10 +23,16 @@ jobs: github.event.pull_request.base.ref == 'main') || github.event_name == 'workflow_dispatch' + env: + AWS_DEFAULT_REGION: ap-northeast-2 + HEALTH_URL: ${{ vars.PROD_HEALTH_URL }} + steps: # 코드 체크아웃 - - name: Checkout + - name: Checkout main uses: actions/checkout@v4 + with: + ref: main # JDK 21 설치 - name: Set up JDK 21 @@ -80,6 +91,15 @@ jobs: unzip -l deploy/deploy.zip | head -n 50 unzip -l deploy/deploy.zip | egrep -i "pom.xml|Buildfile|src/|repository/" && exit 1 || echo "OK" + # OIDC 기반 AWS Role Assume + - name: Configure AWS credentials + id: aws-creds + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ap-northeast-2 + output-credentials: true + # 현재 배포 버전 저장 (롤백용) - name: Save current version run: | @@ -87,18 +107,22 @@ jobs: --environment-names issueissyu-backend-prod-env \ --query 'Environments[0].VersionLabel' \ --output text) + + echo "CURRENT_VERSION=$CURRENT" + + if [ "$CURRENT" = "None" ] || [ "$CURRENT" = "null" ]; then + CURRENT="" + fi + echo "PREVIOUS_VERSION=$CURRENT" >> $GITHUB_ENV - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-northeast-2 - name: Deploy to Elastic Beanstalk id: deploy uses: einaregilsson/beanstalk-deploy@v22 with: - aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws_access_key: ${{ steps.aws-creds.outputs.aws-access-key-id }} + aws_secret_key: ${{ steps.aws-creds.outputs.aws-secret-access-key }} + aws_session_token: ${{ steps.aws-creds.outputs.aws-session-token }} application_name: 'issueissyu-backend-prod' environment_name: 'issueissyu-backend-prod-env' region: ap-northeast-2 @@ -108,32 +132,38 @@ jobs: wait_for_deployment: true wait_for_environment_recovery: 180 - # EB 상태와 무관하게 /health 응답으로만 배포 성공 판단 + # EB 상태와 무관하게 운영 HTTPS URL의 /health 응답으로 배포 성공 판단 # 최대 3회 재시도, 각 시도마다 30회 health check - name: Smoke test with retry id: smoke_test run: | MAX_RETRY=3 - CNAME=$(aws elasticbeanstalk describe-environments \ - --environment-names issueissyu-backend-prod-env \ - --query 'Environments[0].CNAME' \ - --output text) - HEALTH_URL="http://${CNAME}/health" + + if [ -z "$HEALTH_URL" ]; then + echo "PROD_HEALTH_URL GitHub Actions Variable이 설정되지 않았습니다." + exit 1 + fi + echo "HEALTH_URL=$HEALTH_URL" for attempt in $(seq 1 $MAX_RETRY); do echo "=== 스모크 테스트 시도 $attempt / $MAX_RETRY ===" + for i in {1..30}; do - code="$(curl -sS -o /dev/null -w '%{http_code}' $HEALTH_URL || true)" + code="$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)" echo "health check $i: $code" + if [ "$code" = "200" ]; then echo "health OK (시도 $attempt)" exit 0 fi + sleep 10 done + echo "시도 $attempt 실패" - if [ $attempt -lt $MAX_RETRY ]; then + + if [ "$attempt" -lt "$MAX_RETRY" ]; then echo "30초 후 재시도..." sleep 30 fi @@ -141,20 +171,36 @@ jobs: echo "최대 재시도 횟수($MAX_RETRY) 초과 - 배포 실패" exit 1 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-northeast-2 # 스모크 테스트 최종 실패 시 이전 버전으로 롤백 + # 롤백 명령 후 EB 환경 업데이트 완료까지 대기하고, 운영 HTTPS URL로 다시 검증 - name: Rollback on failure - if: failure() && env.PREVIOUS_VERSION != '' && (steps.deploy.outcome == 'failure' || steps.smoke_test.outcome == 'failure') + if: failure() && env.PREVIOUS_VERSION != '' && env.PREVIOUS_VERSION != 'None' && (steps.deploy.outcome == 'failure' || steps.smoke_test.outcome == 'failure') run: | - echo "배포 실패 - ${{ env.PREVIOUS_VERSION }} 으로 롤백" + echo "배포 실패 - $PREVIOUS_VERSION 으로 롤백" + aws elasticbeanstalk update-environment \ --environment-name issueissyu-backend-prod-env \ - --version-label ${{ env.PREVIOUS_VERSION }} - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: ap-northeast-2 \ No newline at end of file + --version-label "$PREVIOUS_VERSION" + + echo "롤백 배포 완료 대기" + aws elasticbeanstalk wait environment-updated \ + --environment-names issueissyu-backend-prod-env + + echo "롤백 후 smoke test 시작" + echo "HEALTH_URL=$HEALTH_URL" + + for i in {1..30}; do + code="$(curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)" + echo "rollback health check $i: $code" + + if [ "$code" = "200" ]; then + echo "rollback health OK" + exit 0 + fi + + sleep 10 + done + + echo "롤백 후에도 health check 실패" + exit 1 \ No newline at end of file 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 e2f34c2..99df8fa 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,52 @@ 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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +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 객체 삭제 + 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 a3d8d7c..437f2a8 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,13 +1,20 @@ 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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; @Service @RequiredArgsConstructor @@ -16,6 +23,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 +54,29 @@ 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 객체 삭제 + 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/issue/repository/ProblemSolverImageRepository.java b/src/main/java/issueissyu/backend/domain/issue/repository/ProblemSolverImageRepository.java index 67de1e4..73b5cdb 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 findAllByProblemSolver_ProblemSolverIdIn(@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 e750662..66bb03e 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; @@ -293,9 +295,27 @@ 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; + 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 d00f7e2..f3a8715 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,25 @@ 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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.ArrayList; import java.util.List; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -52,9 +62,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 +105,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 +134,40 @@ 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 객체 일괄 삭제 + 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) { + 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.findAllByProblemSolver_ProblemSolverIdIn(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 48fdf05..8eaa76c 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,19 @@ 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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.ArrayList; +import java.util.List; + +@Slf4j @Component @RequiredArgsConstructor public class UserSignOutCleaner { @@ -23,9 +31,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 +130,53 @@ 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 객체 일괄 삭제 + 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) { + 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 83f096e..fd2a06c 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,38 @@ @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 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 등)이면 삭제를 건너뛰고, + // 그 외에는 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;