Skip to content

Conversation

@soo0711
Copy link
Collaborator

@soo0711 soo0711 commented Nov 18, 2025

Related issue 🛠

  • closed #

작업 내용 💻

  • 기존 문제: DB 등록 전 S3 PUT 발생 시 Lambda 트리거가 먼저 실행됐었습니다.
  • 해결방법: 미디어 업로드 및 수정 시 임시 S3 경로 → 원본 경로(temp -> originals) 로 바꾼 후 S3 copy DB 업데이트 로직 적용했습니다.
  • 임시경로에 저장된 이미지는 원본 경로로 바뀌면 삭제됩니다.
  • profile, feed, leenk 모든 미디어 처리 로직 변경했습니다.
  • S3 copy 권한 추가했습니다.

스크린샷 📷

  • 프로필 등록 / 수정
스크린샷 2025-11-18 145207
  • 링크 등록 / 수정
스크린샷 2025-11-18 150524
  • 피드 등록 / 수정
스크린샷 2025-11-18 012403

같이 얘기해보고 싶은 내용이 있다면 작성 📢

원래 DB 등록 전 Lambda에서 대기를 주는 방법을 고려했지만

  1. 실행 중 지속적인 비용 발생과 동시 실행 제한 점유
  2. 재시도 시 같은 대기 시간이 반복되어 효율이 떨어짐
  3. DB 반영 시점을 정확히 예측하기 어려워 적절한 대기 시간을 정하기 어려움
  4. 타임아웃 위험도 존재

하여 임시 경로 방식을 적용했습니다.
혹시 더 나은 방법이나 개선 아이디어가 있다면 같이 논의해보고 싶습니다!

Summary by CodeRabbit

릴리스 노트

  • 새 기능

    • 업로드된 미디어를 임시 저장소에서 원본 저장소로 자동 이동하는 흐름 추가
    • 이동 후 미디어 URL을 갱신하는 처리 도입
    • S3 복사/삭제 및 URL 반환 기능 추가
  • 개선사항

    • 피드/Leenk 업로드와 프로필 이미지 업데이트 시 미디어 처리 일관성 향상
    • S3 클라이언트 구성 추가 및 오류 식별을 위한 전용 예외 및 에러 코드 도입

@soo0711 soo0711 self-assigned this Nov 18, 2025
@soo0711 soo0711 added 🐛BugFix 버그 수정 ♻️Refactor 리팩토링 labels Nov 18, 2025
@coderabbitai
Copy link

coderabbitai bot commented Nov 18, 2025

Walkthrough

업로드된 미디어를 임시(temp) 위치에서 originals로 복사·이동하고 엔티티 URL을 갱신하도록 S3 관련 서비스와 미디어 업데이트 흐름을 여러 유스케이스에 통합했습니다(새 서비스/예외/설정 추가 포함).

Changes

Cohort / File(s) Change Summary
S3 presigner/클라이언트 확장
src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java, src/main/java/leets/leenk/global/config/AwsS3Config.java
S3Client 빈 추가 및 주입, S3 복사/삭제/URL 반환 메서드(copyObject, deleteObject, getUrl) 구현, 키 생성 로직 조정(원래 temp/ 경로 사용)
새 미디어 S3 이동 서비스
src/main/java/leets/leenk/domain/media/domain/service/MediaS3Service.java
신규 서비스 추가: moveToOriginals(String tempUrl) — temp URL 무시/중복체크, S3 복사→삭제 수행, originals URL 반환
미디어 URL 업데이트 서비스
src/main/java/leets/leenk/domain/media/domain/service/MediaUpdateService.java
updateMediaUrl(Media, String) 메서드 추가 — Media 엔티티의 URL 갱신 위임
S3 복사 실패 예외 & 에러코드
src/main/java/leets/leenk/domain/media/application/exception/S3CopyException.java, src/main/java/leets/leenk/domain/media/application/exception/ErrorCode.java
S3CopyException 추가 및 ErrorCodeS3_COPY_FAILED(2502, 500) 항목 추가
Feed 유스케이스 변경
src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
MediaUpdateService, MediaS3Service 의존성 추가; uploadFeed/updateFeed 후 저장된 미디어에 대해 temp→originals 복사 및 엔티티 URL 업데이트 호출 추가
Leenk 유스케이스 변경
src/main/java/leets/leenk/domain/leenk/application/usecase/LeenkUsecase.java
MediaUpdateService, MediaS3Service 의존성 추가; upload/update 흐름에서 저장된 새 미디어에 대해 temp→originals 복사 및 URL 업데이트 추가
User 유스케이스 변경
src/main/java/leets/leenk/domain/user/application/usecase/UserUsecase.java
MediaS3Service 의존성 추가; 프로필 이미지 갱신 시 moveToOriginals 호출 결과 URL을 사용하도록 변경
UserUpdateService 변경
src/main/java/leets/leenk/domain/user/domain/service/user/UserUpdateService.java
@RequiredArgsConstructor 추가 및 MediaS3Service 주입; 프로필 이미지 처리 시 originals로 이동 후 user 엔티티 갱신
미디어 엔티티 포매팅
src/main/java/leets/leenk/domain/media/domain/entity/Media.java
후행 공백 제거(포매팅)

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Usecase
    participant MediaSaveService
    participant MediaS3Service
    participant MediaUpdateService
    participant S3PresignedUrlService

    Client->>Usecase: uploadXXX(request)
    Usecase->>MediaSaveService: save(medias)
    MediaSaveService-->>Usecase: savedMedias

    loop per savedMedia
        Usecase->>MediaS3Service: moveToOriginals(tempUrl)
        MediaS3Service->>S3PresignedUrlService: copyObject(tempKey, originalsKey)
        S3PresignedUrlService-->>MediaS3Service: copied
        MediaS3Service->>S3PresignedUrlService: deleteObject(tempKey)
        S3PresignedUrlService-->>MediaS3Service: deleted
        MediaS3Service->>S3PresignedUrlService: getUrl(originalsKey)
        S3PresignedUrlService-->>MediaS3Service: originalsUrl
        MediaS3Service-->>Usecase: originalsUrl

        Usecase->>MediaUpdateService: updateMediaUrl(media, originalsUrl)
        MediaUpdateService-->>Usecase: updated
    end

    Usecase-->>Client: completed
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • 추가로 검토할 지점:
    • S3PresignedUrlService의 키 생성/경로 변경(원래 temp/originals 혼선 가능)과 기존 presigned URL 로직 호환성
    • S3 복사/삭제 실패 시 예외(S3CopyException) 처리 흐름 검증
    • 여러 유스케이스에 반복된 moveToOriginals + updateMediaUrl 패턴의 중복성 및 트랜잭션 경계
    • Lombok @RequiredArgsConstructor 추가로 생성자 시그니처가 바뀐 서비스들의 DI 영향

Possibly related PRs

Suggested labels

✨Feat

Suggested reviewers

  • hyxklee
  • jj0526
  • 1winhyun

Poem

🐰 임시 길 건너 원본 집으로 달려가요,
S3 바람 따라 파일들이 춤추고,
피드·린크·프로필 모두 한집에 모여,
URL 고쳐 입고 반짝이며,
작은 당근으로 축하를 나눕니다 🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive 제목은 'LNK-33 이미지 리사이징 로직 수정'이며, 이는 이미지 리사이징 로직 수정을 나타내지만 실제 변경사항은 S3 임시 경로에서 원본 경로로의 미디어 이동 및 DB 업데이트 로직 적용입니다. 제목을 '이미지 리사이징'에서 실제 변경사항인 'S3 임시 경로에서 원본 경로로 미디어 이동 및 DB 업데이트 로직'으로 수정하는 것을 권장합니다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명은 필수 섹션(관련 이슈, 작업 내용, 스크린샷)을 충실히 포함하고 있으며, 상세한 기술 설명 및 디자인 결정 이유까지 명시되어 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/LNK-33-Leenk-이미지-리사이징-로직-수정

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java (1)

260-263: 동일한 에러 처리 및 트랜잭션 일관성 문제가 존재합니다.

Lines 185-188과 동일한 문제가 있습니다. S3 작업 실패 시 에러 처리와 트랜잭션 롤백 시 고아 파일 처리를 위한 로직이 필요합니다.

동일한 에러 처리 패턴을 적용하세요:

 newMedias.forEach(media -> {
-    String originalsUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
-    mediaUpdateService.updateMediaUrl(media, originalsUrl);
+    try {
+        String originalsUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
+        mediaUpdateService.updateMediaUrl(media, originalsUrl);
+    } catch (Exception e) {
+        throw new MediaProcessingException("미디어 업데이트 중 오류 발생", e);
+    }
 });
🧹 Nitpick comments (4)
src/main/java/leets/leenk/global/config/AwsS3Config.java (1)

36-44: S3Client 빈 정의는 적절하지만 종료 시 자원 정리를 명시해 두면 더 안전합니다.

s3Presigner()와 동일한 방식으로 자격 증명과 리전을 설정한 s3Client() 빈 추가는 괜찮아 보입니다.
다만 AWS SDK v2 S3Client는 내부적으로 커넥션 풀을 가지는 Closeable 객체라, 애플리케이션 종료 시 @Bean(destroyMethod = "close") 형태로 정리까지 명시해 두면 장기 실행 시 리소스 관리 측면에서 조금 더 안전합니다.

src/main/java/leets/leenk/domain/leenk/application/usecase/LeenkUsecase.java (1)

73-151: Leenk 미디어 temp → originals 플로우 일관성은 좋고, URL 업데이트 방식만 약간 정리 여지가 있습니다.

uploadLeenk()에서 Media 저장 직후 mediaS3Service.moveToOriginals() + mediaUpdateService.updateMediaUrl()로 최종 URL을 반영하고, updateLeenk()에서도 새 URL이 있는 경우 동일한 temp → originals 정책을 따르게 한 구조는 이번 PR 목표(임시 경로 사용 후 원본으로 이동)에 잘 맞습니다.

다만 updateLeenk()에서는 기존 미디어/새 미디어 모두 media.updateMediaUrl(...)를 직접 호출하고 있어, MediaUpdateService를 사용하는 경우와 엔티티 직접 호출이 혼재되어 있습니다. 도메인 수정 진입점을 서비스 레이어로 통일하고 싶다면, 여기서도 mediaUpdateService.updateMediaUrl(...)를 사용하는 쪽으로 맞추는 리팩터를 한 번에 해 두어도 좋겠습니다(필수는 아님).

Based on learnings

src/main/java/leets/leenk/domain/user/domain/service/user/UserUpdateService.java (1)

3-32: 회원 프로필 초기 설정도 temp → originals로 맞춘 점은 좋고, 의존성 변경 영향만 한 번 확인해 주세요.

completeProfile()에서 mediaS3Service.moveToOriginals()를 사용해 가입 시 프로필 이미지도 temp 경로를 거쳐 originals로 정규화하는 흐름으로 맞춘 것은 다른 유즈케이스와의 일관성 측면에서 좋습니다.

이제 UserUpdateServiceMediaS3Service를 생성자 주입으로 요구하므로, 테스트나 설정 코드에서 new UserUpdateService(...)처럼 수동 생성하던 부분이 있었다면 컴파일/런타임 에러가 없는지 한 번 점검해 보시는 게 좋겠습니다. 또한, 추후에 updateProfileImage(User, String)에서도 moveToOriginals 호출을 캡슐화하도록 리팩터링하면, 프로필 이미지 업데이트 경로를 서비스 내부로 더 잘 숨길 수 있을 것 같습니다(이번 PR에서는 선택 사항).

src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java (1)

95-98: URL 생성 시 region 및 bucket 값 검증을 고려하세요.

현재 region과 bucket 값으로 URL을 생성하지만, 값이 올바른지 검증하지 않습니다. 설정 오류 시 잘못된 URL이 생성될 수 있습니다.

애플리케이션 시작 시 region과 bucket 값이 올바르게 설정되었는지 검증하는 로직을 추가하는 것을 고려하세요:

@PostConstruct
public void validateConfiguration() {
    if (bucket == null || bucket.isBlank()) {
        throw new IllegalStateException("S3 bucket 설정이 없습니다");
    }
    if (region == null || region.isBlank()) {
        throw new IllegalStateException("AWS region 설정이 없습니다");
    }
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a60b56f and 9087559.

📒 Files selected for processing (9)
  • src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java (4 hunks)
  • src/main/java/leets/leenk/domain/leenk/application/usecase/LeenkUsecase.java (4 hunks)
  • src/main/java/leets/leenk/domain/media/domain/entity/Media.java (0 hunks)
  • src/main/java/leets/leenk/domain/media/domain/service/MediaS3Service.java (1 hunks)
  • src/main/java/leets/leenk/domain/media/domain/service/MediaUpdateService.java (1 hunks)
  • src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java (3 hunks)
  • src/main/java/leets/leenk/domain/user/application/usecase/UserUsecase.java (3 hunks)
  • src/main/java/leets/leenk/domain/user/domain/service/user/UserUpdateService.java (2 hunks)
  • src/main/java/leets/leenk/global/config/AwsS3Config.java (2 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/leets/leenk/domain/media/domain/entity/Media.java
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java:175-181
Timestamp: 2025-08-12T14:03:25.193Z
Learning: In the Leenk-BE project, for media updates in feeds, the team prefers complete deletion and recreation over differential updates to maintain data integrity, especially given the low-traffic nature of the service. Performance optimization is deprioritized in favor of data consistency and simpler logic.
📚 Learning: 2025-08-12T14:03:25.193Z
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java:175-181
Timestamp: 2025-08-12T14:03:25.193Z
Learning: In the Leenk-BE project, for media updates in feeds, the team prefers complete deletion and recreation over differential updates to maintain data integrity, especially given the low-traffic nature of the service. Performance optimization is deprioritized in favor of data consistency and simpler logic.

Applied to files:

  • src/main/java/leets/leenk/domain/leenk/application/usecase/LeenkUsecase.java
  • src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
🧬 Code graph analysis (2)
src/main/java/leets/leenk/domain/media/domain/service/MediaS3Service.java (1)
src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java (1)
  • Service (21-100)
src/main/java/leets/leenk/domain/user/domain/service/user/UserUpdateService.java (1)
src/main/java/leets/leenk/domain/user/application/usecase/UserUsecase.java (1)
  • Service (25-144)
🔇 Additional comments (5)
src/main/java/leets/leenk/domain/media/domain/service/MediaUpdateService.java (1)

15-17: 미디어 URL 전용 업데이트 메서드 분리는 방향성 좋습니다.

update()와 대칭되는 형태로 updateMediaUrl()을 분리해 둔 덕분에, 도메인 수정 진입점이 서비스 레이어에 모여 있어 이후 로직(이벤트 발행, 감사 로그 등)을 추가하기도 좋아 보입니다.

src/main/java/leets/leenk/domain/user/application/usecase/UserUsecase.java (1)

41-77: 프로필 이미지도 temp → originals 플로우를 타도록 정렬된 점이 좋습니다.

updateProfileImage()에서 바로 mediaS3Service.moveToOriginals()를 호출한 뒤, 정제된 URL만 UserUpdateService에 넘기는 구조라 유즈케이스 레벨에서 S3 경로 정책이 명확해졌습니다. moveToOriginals()가 null/blank, 이미 /originals/인 경우를 처리해 주기 때문에 호출 측에서 별도 분기 없이 사용하기에도 적절해 보입니다.

src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java (1)

60-61: 새로운 서비스 의존성 추가가 적절합니다.

temp → originals 플로우를 구현하기 위해 필요한 서비스들이 올바르게 주입되었습니다.

src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java (2)

72-72: 핵심 변경사항: temp 경로로 업로드됩니다.

presigned URL이 이제 temp/ 경로를 가리키므로, 파일이 임시 위치에 먼저 업로드됩니다. 이는 PR 목표인 "Lambda 트리거 타이밍 제어"를 달성하기 위한 핵심 변경사항입니다.


27-27: S3Client 빈 설정이 올바르게 구현되어 있습니다.

검증 결과, AwsS3Config.java(Line 36-43)에서 S3Client 빈이 @Bean 메서드로 정의되어 있으며, AwsBasicCredentialsRegion을 사용하여 올바르게 구성되어 있습니다. S3PresignedUrlService에서 이 빈을 주입받아 사용하고 있으므로 설정에 문제가 없습니다.

Comment on lines +185 to +188
medias.forEach(media -> {
String newMediaUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
mediaUpdateService.updateMediaUrl(media, newMediaUrl);
});
Copy link

@coderabbitai coderabbitai bot Nov 18, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

S3 작업의 에러 처리와 트랜잭션 일관성을 보장해야 합니다.

현재 구현에는 다음과 같은 문제가 있습니다:

  1. 에러 처리 부재: moveToOriginalsupdateMediaUrl이 실패해도 예외가 처리되지 않아 부분 실패 시 일관성이 깨질 수 있습니다.
  2. 트랜잭션 경계 문제: S3 복사가 성공한 후 트랜잭션이 롤백되면 originals에 고아 파일이 남게 됩니다.
  3. 원자성 부족: forEach에서 하나의 미디어 처리가 실패해도 다른 미디어는 계속 처리되어 불일치 상태가 발생합니다.

다음 개선 방안을 고려하세요:

 medias.forEach(media -> {
-    String newMediaUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
-    mediaUpdateService.updateMediaUrl(media, newMediaUrl);
+    try {
+        String newMediaUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
+        mediaUpdateService.updateMediaUrl(media, newMediaUrl);
+    } catch (Exception e) {
+        // 실패 시 이미 처리된 미디어들도 롤백하거나, 전체 작업 실패 처리
+        throw new MediaProcessingException("미디어 처리 중 오류 발생", e);
+    }
 });

추가로, S3 작업 실패 시 이미 복사된 파일들을 정리하는 보상 트랜잭션(compensating transaction) 로직도 고려해보세요.

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

🤖 Prompt for AI Agents
In src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
around lines 185-188, replace the blind medias.forEach(...) with a safe,
transactional flow: perform S3 moves sequentially (use a for-loop) and collect
the successfully moved object keys; if any move fails, stop further processing,
delete any already moved objects (compensating cleanup) and rethrow the
exception; once all S3 moves succeed, open a DB transaction and perform all
mediaUpdateService.updateMediaUrl calls inside it; if the DB transaction fails
or is rolled back, catch the failure, delete the moved S3 objects (compensating
transaction), and rethrow or return an error; ensure all exceptions are logged
with details and avoid swallowing them so callers can handle retries or alerts.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

copy 작업 실패 시 예외 처리가 되고 Transaction어노테이션으로 전체 트랜잭션이 롤백되어서 일관성이 보장 돼

Copy link

Choose a reason for hiding this comment

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

@soo0711, 말씀하신 부분이 부분적으로 맞지만, 중요한 분산 트랜잭션 문제가 여전히 존재합니다.

@transactional의 한계:

@Transactional 어노테이션은 데이터베이스 트랜잭션만 제어합니다. S3와 같은 외부 시스템 작업은 Spring의 트랜잭션 관리 범위 밖에 있어서 롤백할 수 없습니다.

문제 시나리오:

medias.forEach(media -> {
    // 1. S3 복사 성공 (temp → originals)
    String newMediaUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
    
    // 2. DB 업데이트 시도
    mediaUpdateService.updateMediaUrl(media, newMediaUrl);
    // 3. 이후 다른 DB 작업 실패 → 트랜잭션 롤백
});

위 경우:

  • ✅ S3에 파일은 이미 originals 경로로 복사됨
  • ❌ DB 트랜잭션 롤백 → DB에는 새 URL이 저장되지 않음
  • ⚠️ 결과: originals에 고아 파일 발생

개선 방안:

  1. 보상 트랜잭션 패턴: S3 작업 후 DB 실패 시 이미 복사한 파일들을 삭제
  2. 작업 순서 변경: DB 업데이트를 먼저 수행하고 커밋 후 S3 작업 (단, 다른 트레이드오프 존재)
  3. 재시도 메커니즘: 실패 시 전체 작업을 재시도하는 로직 추가

현재 구현에서 S3CopyException으로 인한 즉각적인 실패는 잘 처리되지만, S3 성공 후 DB 실패 시나리오에 대한 정리 로직이 필요합니다. 이 부분을 어떻게 처리할 계획이신가요?


🧠 Learnings used
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java:175-181
Timestamp: 2025-08-12T14:03:25.193Z
Learning: In the Leenk-BE project, for media updates in feeds, the team prefers complete deletion and recreation over differential updates to maintain data integrity, especially given the low-traffic nature of the service. Performance optimization is deprioritized in favor of data consistency and simpler logic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

originals에 고아 파일이 남는 건 맞지만 서비스 동작에 영향 없을 것 같고 DB 커밋 실패는 매우 드물게 발생하기 때문에 보상 트랜잭션 구현 복잡도가 얻는 실익보다 크다고 생각해

Copy link

Choose a reason for hiding this comment

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

@soo0711, 합리적인 판단이라고 생각합니다. 말씀하신 대로 DB 커밋 실패는 매우 드물고, 고아 파일이 서비스 동작에는 영향을 주지 않으니 보상 트랜잭션의 복잡도가 실익을 초과할 수 있겠네요.

다만 장기적으로 고아 파일이 누적될 경우를 대비해, 필요하다면 추후 S3 라이프사이클 정책이나 주기적인 정리 스크립트로 처리하는 것도 고려해볼 수 있을 것 같습니다. 현재 구현은 이대로 괜찮아 보입니다! 👍


🧠 Learnings used
Learnt from: hyxklee
Repo: Leets-Makers/Leenk-BE PR: 54
File: src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java:175-181
Timestamp: 2025-08-12T14:03:25.193Z
Learning: In the Leenk-BE project, for media updates in feeds, the team prefers complete deletion and recreation over differential updates to maintain data integrity, especially given the low-traffic nature of the service. Performance optimization is deprioritized in favor of data consistency and simpler logic.

Comment on lines +15 to +36
public String moveToOriginals(String tempUrl) {
if (tempUrl == null || tempUrl.isBlank()) {
return tempUrl;
}

if (tempUrl.contains("/originals/")) {
return tempUrl;
}

String tempKey = extractKeyFromUrl(tempUrl);
String originalsKey = tempKey.replace("temp/", "originals/");

s3PresignedUrlService.copyObject(tempKey, originalsKey);
s3PresignedUrlService.deleteObject(tempKey);

return s3PresignedUrlService.getUrl(originalsKey);
}

private String extractKeyFromUrl(String url) {
URI uri = URI.create(url);
return uri.getPath().substring(1);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

extractKeyFromUrl가 상대 경로나 비표준 URL을 받을 경우 키가 잘릴 수 있습니다.

현재 구현은 URI.create(url).getPath().substring(1)에 의존해서, 경로가 항상 /temp/...처럼 /로 시작한다고 가정하고 있습니다. 만약 실수로 temp/...와 같은 상대 경로나 스킴 없는 문자열이 넘어오면 getPath()"temp/..."를 반환해 "emp/..."로 잘려 잘못된 키로 S3 copy/delete를 시도하게 됩니다.

아래처럼 경로가 /로 시작하는 경우에만 substring(1)을 적용하도록 방어 코드를 두면, 예외적인 입력에도 더 안전합니다.

-    private String extractKeyFromUrl(String url) {
-        URI uri = URI.create(url);
-        return uri.getPath().substring(1);
-    }
+    private String extractKeyFromUrl(String url) {
+        URI uri = URI.create(url);
+        String path = uri.getPath();
+
+        if (path == null || path.isBlank()) {
+            return path;
+        }
+
+        return path.startsWith("/") ? path.substring(1) : path;
+    }

현재 호출부들은 모두 정규화된 URL을 넘길 것으로 보이지만, 향후 유지보수나 재사용성을 고려하면 이 정도 방어는 해 두는 편이 좋겠습니다.



---

🏁 Script executed:

```shell
#!/bin/bash
# Find all usages of moveToOriginals method
rg -nP 'moveToOriginals\s*\(' --type=java

Length of output: 1312


🏁 Script executed:

#!/bin/bash
# Find test files for MediaS3Service
fd -e java -p '**/test/**' -x rg -l 'MediaS3Service|moveToOriginals' {}

Length of output: 289


🏁 Script executed:

#!/bin/bash
# Check the S3PresignedUrlService to understand URL format it returns
rg -nP -A 10 'getUrl\s*\(' --type=java | head -50

Length of output: 3664


🏁 Script executed:

#!/bin/bash
# Look for any URL creation or S3 URL construction in the codebase
rg -nP 's3.*url|url.*s3' -i --type=java | head -30

Length of output: 3222



---

🏁 Script executed:

```shell
#!/bin/bash
# Find Media entity to see how mediaUrl is stored/validated
rg -nP 'class Media\s*\{' -A 20 --type=java

Length of output: 1884


🏁 Script executed:

#!/bin/bash
# Check UserUpdateRequest to see profileImage field definition
rg -nP 'profileImage' --type=java -B 2 -A 2 | head -40

Length of output: 3227


🏁 Script executed:

#!/bin/bash
# Find any test files for media or S3 related functionality
fd '\.java$' -path '*/test*' -type f | xargs rg -l 'moveToOriginals|extractKeyFromUrl' 2>/dev/null

Length of output: 550


🏁 Script executed:

#!/bin/bash
# Check if there's any URL validation logic before calling moveToOriginals
rg -nP -B 5 'moveToOriginals\(' --type=java | head -60

Length of output: 5897



---

🏁 Script executed:

```shell
#!/bin/bash
# Check what format request.profileImage comes in - look for ProfileImageRequest DTO
rg -nP 'class ProfileImageRequest|record ProfileImageRequest' -A 5 --type=java

Length of output: 424


🏁 Script executed:

#!/bin/bash
# Check if URI.getPath() can return null and what substring(1) would do
cat > /tmp/test_uri.java << 'EOF'
import java.net.URI;

public class TestURI {
    public static void main(String[] args) {
        // Test case 1: absolute URL (S3)
        String url1 = "https://bucket.s3.amazonaws.com/temp/file123";
        URI uri1 = URI.create(url1);
        String path1 = uri1.getPath();
        System.out.println("URL: " + url1);
        System.out.println("Path: " + path1);
        System.out.println("After substring(1): " + (path1 != null ? path1.substring(1) : "NULL"));
        System.out.println();

        // Test case 2: relative path (potential bug)
        String url2 = "temp/file123";
        URI uri2 = URI.create(url2);
        String path2 = uri2.getPath();
        System.out.println("URL: " + url2);
        System.out.println("Path: " + path2);
        System.out.println("After substring(1): " + (path2 != null && path2.length() > 0 ? path2.substring(1) : "INVALID"));
        System.out.println();

        // Test case 3: just filename
        String url3 = "file123";
        URI uri3 = URI.create(url3);
        String path3 = uri3.getPath();
        System.out.println("URL: " + url3);
        System.out.println("Path: " + path3);
        System.out.println("After substring(1): " + (path3 != null && path3.length() > 0 ? path3.substring(1) : "INVALID"));
    }
}
EOF
cd /tmp && javac test_uri.java && java TestURI

Length of output: 44


extractKeyFromUrl 메서드의 입력 검증 부족 - 방어 코드 추가 필요

현재 구현은 URI.create(url).getPath().substring(1)에 의존하면서, 경로가 항상 절대 경로 형식(예: /temp/...)을 따른다고 가정합니다. 만약 상대 경로나 형식이 잘못된 URL(예: temp/...)이 전달되면:

  • getPath()"temp/..."를 반환
  • substring(1)은 첫 문자를 제거하여 "emp/..." 생성 → S3 작업 실패 또는 데이터 손상

특히 request.profileImage()는 사용자 입력인데 현재 moveToOriginals 호출 전 형식 검증이 없습니다.

-    private String extractKeyFromUrl(String url) {
-        URI uri = URI.create(url);
-        return uri.getPath().substring(1);
-    }
+    private String extractKeyFromUrl(String url) {
+        URI uri = URI.create(url);
+        String path = uri.getPath();
+
+        if (path == null || path.isBlank()) {
+            return path;
+        }
+
+        return path.startsWith("/") ? path.substring(1) : path;
+    }

현재 호출부에서 정규화된 S3 URL을 전달하지만, 향후 재사용성과 입력 안정성을 위해 방어 코드 추가를 권장합니다.

🤖 Prompt for AI Agents
In src/main/java/leets/leenk/domain/media/domain/service/MediaS3Service.java
around lines 15 to 36, the extractKeyFromUrl method assumes URI.getPath() always
returns an absolute path and blindly calls substring(1), which corrupts inputs
like "temp/..." or throws on invalid URIs; change it to be defensive: validate
input (null/blank already handled upstream), wrap URI.create in try/catch for
IllegalArgumentException/URISyntaxException, retrieve path and if path is null
treat the input as a raw key, only strip the leading '/' when
path.startsWith("/"), and for strings that are not full URLs (no scheme) treat
them as keys directly; ensure any parsing errors return or propagate a clear
exception rather than producing a malformed key.

Comment on lines 75 to 84
public void copyObject(String sourceKey, String destinationKey) {
CopyObjectRequest copyRequest = CopyObjectRequest.builder()
.sourceBucket(bucket)
.sourceKey(sourceKey)
.destinationBucket(bucket)
.destinationKey(destinationKey)
.build();

s3Client.copyObject(copyRequest);
}
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

S3 복사 작업의 에러 처리가 필요합니다.

s3Client.copyObject()가 실패하면 SdkException을 던지지만, 현재 메서드는 이를 처리하지 않습니다. 호출자는 작업 성공 여부를 알 수 없어 불일치 상태가 발생할 수 있습니다.

다음과 같이 에러 처리를 추가하거나 명시적으로 예외를 전파하세요:

 public void copyObject(String sourceKey, String destinationKey) {
-    CopyObjectRequest copyRequest = CopyObjectRequest.builder()
-            .sourceBucket(bucket)
-            .sourceKey(sourceKey)
-            .destinationBucket(bucket)
-            .destinationKey(destinationKey)
-            .build();
-
-    s3Client.copyObject(copyRequest);
+    try {
+        CopyObjectRequest copyRequest = CopyObjectRequest.builder()
+                .sourceBucket(bucket)
+                .sourceKey(sourceKey)
+                .destinationBucket(bucket)
+                .destinationKey(destinationKey)
+                .build();
+        
+        s3Client.copyObject(copyRequest);
+    } catch (SdkException e) {
+        throw new S3OperationException("S3 객체 복사 실패: " + sourceKey + " -> " + destinationKey, e);
+    }
 }

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

🤖 Prompt for AI Agents
In
src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java
around lines 75 to 84, the copyObject method currently calls
s3Client.copyObject(...) without handling failures; add error handling by
catching SdkException (or AwsServiceException/SdkClientException as
appropriate), log the error with context (sourceKey, destinationKey, bucket) and
either rethrow it or wrap it in a domain-specific unchecked exception so callers
can react to failure; ensure you preserve the original exception as the cause
when rethrowing and avoid swallowing the exception so the caller can detect and
handle inconsistent state.

Comment on lines +86 to +93
public void deleteObject(String key) {
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();

s3Client.deleteObject(deleteRequest);
}
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

S3 삭제 작업의 에러 처리가 필요합니다.

copyObject와 마찬가지로 삭제 작업 실패 시 에러 처리가 없습니다. 삭제 실패 시 temp 파일이 남게 되어 스토리지 비용이 증가할 수 있습니다.

에러 처리를 추가하세요:

 public void deleteObject(String key) {
-    DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
-            .bucket(bucket)
-            .key(key)
-            .build();
-
-    s3Client.deleteObject(deleteRequest);
+    try {
+        DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
+                .bucket(bucket)
+                .key(key)
+                .build();
+        
+        s3Client.deleteObject(deleteRequest);
+    } catch (SdkException e) {
+        // 삭제 실패는 로그만 남기고 계속 진행할 수도 있음 (temp 파일은 lifecycle policy로 정리 가능)
+        throw new S3OperationException("S3 객체 삭제 실패: " + key, e);
+    }
 }

또는 S3 버킷에 lifecycle policy를 설정하여 temp/ 폴더의 오래된 파일을 자동 삭제하는 방안도 고려하세요.

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

🤖 Prompt for AI Agents
src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java
around lines 86-93: deleteObject currently calls
s3Client.deleteObject(deleteRequest) with no error handling; add try-catch
around the delete call to catch relevant AWS SDK exceptions (e.g., S3Exception,
AwsServiceException, SdkClientException or generic Exception), log the failure
with the key and exception details, and decide/implement a retry or compensating
action (e.g., mark temp file for later cleanup or enqueue a background cleanup
task); optionally, ensure callers receive a meaningful result/boolean or rethrow
a wrapped exception so higher layers can react; consider adding/mentioning a
lifecycle policy on the S3 bucket for automatic removal of old temp/ files as a
backup.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/main/java/leets/leenk/domain/media/application/exception/S3CopyException.java (1)

1-9: S3CopyException에 원인(cause) 전달용 생성자 추가를 고려해보면 좋겠습니다.

현재 기본 생성자만 있어 S3 SDK에서 던지는 원본 예외를 체이닝하기 어렵습니다. BaseException이 message/cause를 받는 생성자를 지원한다면, 아래처럼 오버로드를 추가해 두면 장애 시 추적이 훨씬 수월할 것 같습니다.

public class S3CopyException extends BaseException {
    public S3CopyException() {
        super(ErrorCode.S3_COPY_FAILED);
    }

    public S3CopyException(Throwable cause) {
        super(ErrorCode.S3_COPY_FAILED, cause);
    }

    public S3CopyException(String message, Throwable cause) {
        super(ErrorCode.S3_COPY_FAILED, message, cause);
    }
}

필수는 아니고, 운영/디버깅 편의성을 위한 선택적 개선 포인트로 보입니다.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9087559 and f819ea5.

📒 Files selected for processing (3)
  • src/main/java/leets/leenk/domain/media/application/exception/ErrorCode.java (1 hunks)
  • src/main/java/leets/leenk/domain/media/application/exception/S3CopyException.java (1 hunks)
  • src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/leets/leenk/domain/media/domain/service/S3PresignedUrlService.java
🔇 Additional comments (1)
src/main/java/leets/leenk/domain/media/application/exception/ErrorCode.java (1)

13-18: S3 복사 실패 에러코드 추가는 일관성 있게 잘 정의되어 있습니다.

미디어 도메인 250x 코드 체계, INTERNAL_SERVER_ERROR 매핑, 한글 메시지 모두 기존 스타일과 잘 맞습니다. 별도 수정 필요 없어 보입니다.

@soo0711 soo0711 closed this Nov 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛BugFix 버그 수정 ♻️Refactor 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants