Skip to content

[DEPLOY] production-release(v.0.3.2)#372

Merged
yaaan7 merged 9 commits into
mainfrom
develop
Jun 18, 2026
Merged

[DEPLOY] production-release(v.0.3.2)#372
yaaan7 merged 9 commits into
mainfrom
develop

Conversation

@yaaan7

@yaaan7 yaaan7 commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

🔗 Related Issue

✨ 작업 개요

작업 내용을 간략하게 작성해주세요.
v.0.3.2 배포...

체크리스트

  • Reviewers, Assignees, Labels를 모두 등록했나요?
  • .gitignore 설정을 하였나요?
  • PR 머지 전 반드시 CI가 정상적으로 작동하는지 확인해주세요!

📷 이미지 첨부 (선택)

  • 작업 결과를 확인할 수 있는 이미지나 GIF를 첨부해주세요.
  • UI 변경, API 응답 샘플, 테스트 결과 등이 포함되면 좋아요!

🧐 집중 리뷰 요청

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요.

@yaaan7 yaaan7 self-assigned this Jun 18, 2026
@yaaan7 yaaan7 added the 🚀 deploy 배포 label Jun 18, 2026
@yaaan7 yaaan7 merged commit 5df33a1 into main Jun 18, 2026
2 checks passed
@taerimiiii

Copy link
Copy Markdown
Contributor

LGTM~!!

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements automatic S3 object cleanup when deleting database records (such as pins, communities, and user accounts) by collecting S3 keys before deletion and deleting them after transaction commit. Feedback focuses on improving safety and performance: adding defensive null/blank checks on S3 keys, avoiding redundant transaction synchronization registrations when there are no keys to delete, and offloading blocking S3 network calls to an asynchronous executor to prevent thread blocking and connection pool exhaustion.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +60 to +80
List<String> 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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

community.getCardnewsImages()null을 반환할 가능성에 대비하여 안전하게 방어 코드를 작성하고, S3 키가 비어있거나 null인 경우를 필터링하는 것이 좋습니다. 또한, 삭제할 S3 키 목록(cardnewsKeys)이 비어있다면 불필요하게 트랜잭션 동기화(TransactionSynchronization)를 등록하지 않도록 개선할 수 있습니다.

추가로, 트랜잭션 커밋 후(afterCommit) 동기적으로 S3 삭제 API를 호출하면 외부 네트워크 지연으로 인해 요청 스레드가 오랫동안 차단(blocking)될 수 있습니다. 운영 환경의 부하가 높을 때는 커넥션 풀 고갈로 이어질 수 있으므로, 장기적으로는 S3 삭제 작업을 비동기 스레드 풀(TaskExecutor)이나 비동기 이벤트 리스너(@Async)를 통해 처리하는 구조로 개선하는 것을 권장합니다.

        List<String> cardnewsKeys = community.getCardnewsImages() == null ? List.of() : community.getCardnewsImages().stream()
                .map(CardnewsImageS3::getCardnewsImageS3Key)
                .filter(key -> key != null && !key.isBlank())
                .toList();
        if (!cardnewsKeys.isEmpty()) {
            cardnewsImageS3Repository.deleteByCommunity_CommunityId(communityId);
        }

        communityRepository.delete(community);

        if (!cardnewsKeys.isEmpty()) {
            if (TransactionSynchronizationManager.isSynchronizationActive()) {
                TransactionSynchronizationManager.registerSynchronization(
                        new TransactionSynchronization() {
                            @Override
                            public void afterCommit() {
                                cardnewsKeys.forEach(s3Utils::deleteIfNotReserved);
                            }
                        });
            } else {
                cardnewsKeys.forEach(s3Utils::deleteIfNotReserved);
            }
        }

Comment on lines +24 to +50
// DB 삭제 전에 pin_image S3 key 수집
List<String> 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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

데이터베이스에서 조회한 pinImageKeys 목록에 null이나 빈 문자열이 포함되어 있을 가능성을 방지하기 위해 필터링을 추가하고, 삭제할 키가 없을 때는 불필요한 트랜잭션 동기화 등록을 건너뛰도록 개선하는 것이 안전합니다.

        // DB 삭제 전에 pin_image S3 key 수집
        List<String> pinImageKeys = jdbcTemplate.queryForList(
                "SELECT pin_s3_key FROM pin_image WHERE pin_id = ?",
                String.class, pinId).stream()
                .filter(key -> key != null && !key.isBlank())
                .toList();

        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);

        // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 삭제
        if (!pinImageKeys.isEmpty()) {
            if (TransactionSynchronizationManager.isSynchronizationActive()) {
                TransactionSynchronizationManager.registerSynchronization(
                        new TransactionSynchronization() {
                            @Override
                            public void afterCommit() {
                                pinImageKeys.forEach(s3Utils::deleteIfNotReserved);
                            }
                        });
            } else {
                pinImageKeys.forEach(s3Utils::deleteIfNotReserved);
            }
        }

Comment on lines +298 to 319
// 제거 대상 key를 removeIf 전에 먼저 수집 (orphanRemoval로 DB 삭제되기 전)
List<String> 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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

removedAnytrue이더라도 실제 수집된 removedKeys가 비어있을 수 있습니다 (예: 삭제 대상 이미지들의 S3 key가 모두 비어있는 경우). 따라서 S3 삭제 동기화 등록은 removedAny 대신 !removedKeys.isEmpty() 조건을 기준으로 수행하는 것이 더 효율적입니다.

        // 제거 대상 key를 removeIf 전에 먼저 수집 (orphanRemoval로 DB 삭제되기 전)
        List<String> 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 (!removedKeys.isEmpty()) {
            if (TransactionSynchronizationManager.isSynchronizationActive()) {
                TransactionSynchronizationManager.registerSynchronization(
                        new TransactionSynchronization() {
                            @Override
                            public void afterCommit() {
                                removedKeys.forEach(s3Utils::deleteIfNotReserved);
                            }
                        });
            } else {
                removedKeys.forEach(s3Utils::deleteIfNotReserved);
            }
        }

Comment on lines +148 to 181
private List<String> collectS3Keys(String uid) {
List<String> 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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

jdbcTemplate.queryForList 결과로 반환되는 리스트에 데이터베이스 상의 NULL 값 등으로 인해 null이나 빈 문자열이 포함될 수 있습니다. S3 유틸리티로 전달하기 전에 유효한 키만 필터링하여 반환하도록 개선하는 것이 안전합니다.

    private List<String> collectS3Keys(String uid) {
        List<String> 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.stream()
                .filter(key -> key != null && !key.isBlank())
                .toList();
    }

Comment on lines +134 to +145
// DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 일괄 삭제
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved);
}
});
} else {
s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

회원 탈퇴 시에는 삭제해야 할 S3 객체의 수가 많을 수 있습니다. s3KeysToDelete가 비어있는 경우 불필요하게 트랜잭션 동기화를 등록하지 않도록 방어 조건을 추가하는 것이 좋습니다.

또한, 수십 개 이상의 S3 객체를 동기적으로 삭제하면 회원 탈퇴 요청 스레드가 오랫동안 차단되어 사용자 경험이 저하되고 커넥션 풀 고갈을 유발할 수 있으므로, 이 부분은 특히 비동기 이벤트나 비동기 스레드 풀을 통해 처리하는 것을 강력히 권장합니다.

        // DB 삭제 완료 후 트랜잭션 커밋이 성공하면 S3 객체 일괄 삭제
        if (!s3KeysToDelete.isEmpty()) {
            if (TransactionSynchronizationManager.isSynchronizationActive()) {
                TransactionSynchronizationManager.registerSynchronization(
                        new TransactionSynchronization() {
                            @Override
                            public void afterCommit() {
                                s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved);
                            }
                        });
            } else {
                s3KeysToDelete.forEach(s3Utils::deleteIfNotReserved);
            }
        }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants