Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@
import leets.leenk.domain.feed.domain.entity.Reaction;
import leets.leenk.domain.feed.domain.service.*;
import leets.leenk.domain.feed.domain.service.dto.FeedNavigationResult;
import leets.leenk.domain.media.application.dto.request.FeedMediaRequest;
import leets.leenk.domain.media.application.mapper.MediaMapper;
import leets.leenk.domain.media.domain.entity.Media;
import leets.leenk.domain.media.domain.service.MediaDeleteService;
import leets.leenk.domain.media.domain.service.MediaGetService;
import leets.leenk.domain.media.domain.service.MediaSaveService;
import leets.leenk.domain.media.domain.service.*;
import leets.leenk.domain.notification.application.usecase.FeedNotificationUsecase;
import leets.leenk.domain.user.domain.entity.User;
import leets.leenk.domain.user.domain.entity.UserBlock;
Expand Down Expand Up @@ -58,6 +57,8 @@ public class FeedUsecase {
private final MediaGetService mediaGetService;
private final MediaSaveService mediaSaveService;
private final MediaDeleteService mediaDeleteService;
private final MediaUpdateService mediaUpdateService;
private final MediaS3Service mediaS3Service;

private final LinkedUserGetService linkedUserGetService;
private final LinkedUserSaveService linkedUserSaveService;
Expand Down Expand Up @@ -181,6 +182,11 @@ public void uploadFeed(long userId, FeedUploadRequest request) {
.toList();
mediaSaveService.saveAll(medias);

medias.forEach(media -> {
String newMediaUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
mediaUpdateService.updateMediaUrl(media, newMediaUrl);
});
Comment on lines +185 to +188
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.


List<LinkedUser> linkedUsers = getLinkedUsers(author, request.userIds(), feed);
linkedUserSaveService.saveAll(linkedUsers);

Expand Down Expand Up @@ -250,6 +256,11 @@ public void updateFeed(long userId, long feedId, FeedUpdateRequest request) {
.map(mediaRequest -> mediaMapper.toMedia(feed, mediaRequest))
.toList();
mediaSaveService.saveAll(newMedias);

newMedias.forEach(media -> {
String originalsUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
mediaUpdateService.updateMediaUrl(media, originalsUrl);
});
}

if (request.userIds() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@
import leets.leenk.domain.leenk.domain.service.LocationSaveService;
import leets.leenk.domain.media.application.mapper.MediaMapper;
import leets.leenk.domain.media.domain.entity.Media;
import leets.leenk.domain.media.domain.service.MediaDeleteService;
import leets.leenk.domain.media.domain.service.MediaGetService;
import leets.leenk.domain.media.domain.service.MediaSaveService;
import leets.leenk.domain.media.domain.service.*;
import leets.leenk.domain.notification.application.usecase.LeenkNotificationUsecase;
import leets.leenk.domain.user.domain.entity.User;
import leets.leenk.domain.user.domain.service.NotionDatabaseService;
Expand Down Expand Up @@ -75,6 +73,8 @@ public class LeenkUsecase {
private final MediaSaveService mediaSaveService;
private final MediaGetService mediaGetService;
private final MediaDeleteService mediaDeleteService;
private final MediaUpdateService mediaUpdateService;
private final MediaS3Service mediaS3Service;

private final UserGetService userGetService;
private final SlackWebhookService slackWebhookService;
Expand Down Expand Up @@ -105,6 +105,9 @@ public LeenkCreateResponse uploadLeenk(Long userId, LeenkUploadRequest request)
.ifPresent(url -> {
Media media = mediaMapper.toMedia(leenk, url);
mediaSaveService.save(media);

String originalsUrl = mediaS3Service.moveToOriginals(media.getMediaUrl());
mediaUpdateService.updateMediaUrl(media, originalsUrl);
});

leenkNotificationUsecase.saveNewLeenkNotification(leenk);
Expand Down Expand Up @@ -137,10 +140,14 @@ public void updateLeenk(Long userId, Long leenkId, LeenkUpdateRequest request) {
}
} else {
if (media != null) {
media.updateMediaUrl(newUrl);
String originalsUrl = mediaS3Service.moveToOriginals(newUrl);
media.updateMediaUrl(originalsUrl);
} else {
Media newMedia = mediaMapper.toMedia(leenk, newUrl);
mediaSaveService.save(newMedia);

String originalsUrl = mediaS3Service.moveToOriginals(newMedia.getMediaUrl());
newMedia.updateMediaUrl(originalsUrl);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
public enum ErrorCode implements ErrorCodeInterface {

MEDIA_NOT_FOUND(2500, HttpStatus.NOT_FOUND, "존재하지 않는 미디어입니다."),
MEDIA_UPDATE_FAILED(2501, HttpStatus.INTERNAL_SERVER_ERROR, "미디어 업데이트에 실패했습니다.");
MEDIA_UPDATE_FAILED(2501, HttpStatus.INTERNAL_SERVER_ERROR, "미디어 업데이트에 실패했습니다."),
S3_COPY_FAILED(2502, HttpStatus.INTERNAL_SERVER_ERROR, "파일 복사에 실패했습니다.");

private final int code;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package leets.leenk.domain.media.application.exception;

import leets.leenk.global.common.exception.BaseException;

public class S3CopyException extends BaseException {
public S3CopyException() {
super(ErrorCode.S3_COPY_FAILED);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,4 @@ public void updateMediaUrl(String mediaUrl) {
public void updateThumbnailUrl(String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package leets.leenk.domain.media.domain.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.util.List;

@Service
@RequiredArgsConstructor
public class MediaS3Service {

private final S3PresignedUrlService s3PresignedUrlService;

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);
}
Comment on lines +15 to +36
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.

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ public class MediaUpdateService {
public void update(Media media, String thumbnailUrl){
media.updateThumbnailUrl(thumbnailUrl);
}

public void updateMediaUrl(Media media, String mediaUrl){
media.updateMediaUrl(mediaUrl);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package leets.leenk.domain.media.domain.service;

import leets.leenk.domain.media.application.dto.response.MediaUrlResponse;
import leets.leenk.domain.media.application.exception.S3CopyException;
import leets.leenk.domain.media.domain.entity.enums.DomainType;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
Expand All @@ -21,9 +26,14 @@ public class S3PresignedUrlService {

private final S3Presigner s3Presigner;

private final S3Client s3Client;

@Value("${cloud.aws.s3.bucket}")
private String bucket;

@Value("${cloud.aws.region.static}")
private String region;


public List<MediaUrlResponse> generateUrlList(DomainType domainType, List<String> fileNames) {
return IntStream.range(0, fileNames.size())
Expand Down Expand Up @@ -61,8 +71,37 @@ private String generateKey(DomainType domainType, String fileName, int index) {
String typePath = domainType.name().toLowerCase();
String prefix = (index == 0) ? "thumbnail_" : "";

return String.format("originals/%s/%s%s", typePath, prefix, filename);
return String.format("temp/%s/%s%s", typePath, prefix, filename);
}

public void copyObject(String sourceKey, String destinationKey) {
try {
CopyObjectRequest copyRequest = CopyObjectRequest.builder()
.sourceBucket(bucket)
.sourceKey(sourceKey)
.destinationBucket(bucket)
.destinationKey(destinationKey)
.build();

s3Client.copyObject(copyRequest);

} catch (SdkException e) {
throw new S3CopyException();
}
}

public void deleteObject(String key) {
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();

s3Client.deleteObject(deleteRequest);
}
Comment on lines +93 to +100
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.


public String getUrl(String key) {
return String.format("https://%s.s3.%s.amazonaws.com/%s",
bucket, region, key);
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package leets.leenk.domain.user.application.usecase;

import leets.leenk.domain.media.domain.service.MediaS3Service;
import leets.leenk.domain.user.application.dto.request.*;
import leets.leenk.domain.user.application.dto.response.UserInfoResponse;
import leets.leenk.domain.user.application.exception.SelfBlockNotAllowedException;
Expand Down Expand Up @@ -37,6 +38,8 @@ public class UserUsecase {
private final UserBlockMapper userBlockMapper;
private final UserBlockService userBlockService;

private final MediaS3Service mediaS3Service;

@Transactional
public void initialAgreement(long userId, AgreementRequest request) {
User user = userGetService.findById(userId);
Expand Down Expand Up @@ -69,7 +72,8 @@ public void updateKakaoTalkId(long userId, KakaoTalkIdRequest request) {
public void updateProfileImage(long userId, ProfileImageRequest request) {
User user = userGetService.findById(userId);

userUpdateService.updateProfileImage(user, request.profileImage());
String originalsUrl = mediaS3Service.moveToOriginals(request.profileImage());
userUpdateService.updateProfileImage(user, originalsUrl);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package leets.leenk.domain.user.domain.service.user;

import leets.leenk.domain.media.domain.service.MediaS3Service;
import leets.leenk.domain.user.application.dto.request.AgreementRequest;
import leets.leenk.domain.user.application.dto.request.RegisterRequest;
import leets.leenk.domain.user.domain.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
@RequiredArgsConstructor
public class UserUpdateService {

private final MediaS3Service mediaS3Service;

public void updateAgreement(User user, AgreementRequest request) {
user.updateAgreement(request.termsService(), request.privacyPolicy());
}
Expand All @@ -22,7 +27,8 @@ public void completeProfile(User user, RegisterRequest request) {
}

if (request.profileImage() != null) {
user.updateProfileImage(request.profileImage());
String originalsUrl = mediaS3Service.moveToOriginals(request.profileImage());
user.updateProfileImage(originalsUrl);
}

if(request.birthday() != null) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/leets/leenk/global/config/AwsS3Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
Expand All @@ -31,4 +32,14 @@ public S3Presigner s3Presigner() {
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}

@Bean
public S3Client s3Client() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, accessSecret);

return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
}
}