Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
@@ -0,0 +1,33 @@
package be.dash.dashserver.api.shceduler;

import java.time.LocalDateTime;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import be.dash.dashserver.core.exception.ImageStorageException;
import be.dash.dashserver.core.image.ImageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Profile("dev")
@Component
@Slf4j
@RequiredArgsConstructor
public class S3CleanUpScheduler {

private final ImageService imageService;

@Scheduled(cron = "0 0 3 * * *")
public void cleanUp() {
log.info("S3 정리 작업 시작 {}", LocalDateTime.now());
try {
imageService.cleanUpUnusedProfileImages();
} catch (ImageStorageException e) {
log.error("S3 정리 작업 실패: {}", e.getMessage());
if (!e.getFailedKeys().isEmpty()) {
log.error("삭제 실패한 키: {}", e.getFailedKeys());
}
}
log.info("S3 정리 작업 끝 {}", LocalDateTime.now());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ public interface MemberRepository {
void update(Member member);

Optional<String> findNicknameById(long memberId);

List<String> findAllProfileImages();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package be.dash.dashserver.core.exception;

import java.util.List;
import lombok.Getter;

@Getter
public class ImageStorageException extends RuntimeException {
private List<String> failedKeys;
public ImageStorageException(String message) {
super(message);
}

public ImageStorageException(String message, List<String> failedKeys) {
super(message);
this.failedKeys = failedKeys;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package be.dash.dashserver.core.image;

import java.io.IOException;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;

public interface ImageUploader {
public interface ImageManager {
String uploadImage(MultipartFile file) throws IOException;
List<String> getAllKeys();
void deleteAllByKeys(List<String> keysToDelete);
}
14 changes: 12 additions & 2 deletions src/main/java/be/dash/dashserver/core/image/ImageService.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package be.dash.dashserver.core.image;

import java.io.IOException;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import be.dash.dashserver.core.domain.member.service.MemberRepository;
import be.dash.dashserver.core.exception.BadGatewayException;
import be.dash.dashserver.core.log.annotation.Trace;
import lombok.RequiredArgsConstructor;
Expand All @@ -11,13 +13,21 @@
@Service
@RequiredArgsConstructor
public class ImageService {
private final ImageUploader imageUploader;
private final ImageManager imageManager;
private final MemberRepository memberRepository;

public String upload(MultipartFile file) {
try {
return imageUploader.uploadImage(file);
return imageManager.uploadImage(file);
} catch (IOException e) {
throw new BadGatewayException("이미지 업로드 중에 오류가 발생했습니다.");
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

전역 핸들러에서 BadGatewayException을 잡고있는 부분이 없기에, 아래와 같이 새롭게 정의한 ImageStorageException 으로 처리해도 괜찮을 것 같습니다!
전역 예외 핸들러에도 해당 예외를 추가해주세요!

} catch (IOException e) {
            throw new ImageStorageException("이미지 업로드 중에 오류가 발생했습니다.");
        }

}

public void cleanUpUnusedProfileImages() {
List<String> profileImages = memberRepository.findAllProfileImages();
List<String> keysToDelete = imageManager.getAllKeys().stream()
.filter(key -> !profileImages.contains(key)).toList();
imageManager.deleteAllByKeys(keysToDelete);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package be.dash.dashserver.database.core.member;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
Expand All @@ -16,4 +17,7 @@ public interface MemberJpaRepository extends JpaRepository<MemberJpaEntity, Long

@Query("select m.nickname from MemberJpaEntity m where m.id = :memberId")
Optional<String> findNicknameById(long memberId);

@Query("select m.profileImageUrl from MemberJpaEntity m")
List<String> findAllProfileImages();
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,9 @@ public void update(Member member) {
public Optional<String> findNicknameById(long memberId) {
return memberJpaRepository.findNicknameById(memberId);
}

@Override
public List<String> findAllProfileImages() {
return memberJpaRepository.findAllProfileImages();
}
}
115 changes: 115 additions & 0 deletions src/main/java/be/dash/dashserver/external/s3/S3ImageManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package be.dash.dashserver.external.s3;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import be.dash.dashserver.core.exception.BadRequestException;
import be.dash.dashserver.core.exception.ImageStorageException;
import be.dash.dashserver.core.image.ImageManager;
import be.dash.dashserver.external.config.s3.S3Config;
import be.dash.dashserver.external.config.s3.S3Properties;
import lombok.RequiredArgsConstructor;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.Delete;
import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Error;
import software.amazon.awssdk.services.s3.model.S3Object;

@Component
@RequiredArgsConstructor
public class S3ImageManager implements ImageManager {
private final S3Properties s3Properties;
private final S3Config s3Config;
private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp", "image/heic", "image/heif");

@Override
public String uploadImage(MultipartFile image) throws IOException {
final String key = generateImageFileName();
final S3Client s3Client = s3Config.getS3Client();

validateExtension(image);

PutObjectRequest request = PutObjectRequest.builder()
.bucket(s3Properties.s3BucketName())
.key(key)
.contentType(image.getContentType())
.contentDisposition("inline")
.build();

RequestBody requestBody = RequestBody.fromBytes(image.getBytes());
s3Client.putObject(request, requestBody);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Retry로직이 S3Client에 적용된다면 아래와 같은 후속작업이 있어야 할 것 같슴다

try {
    s3Client.putObject(request, requestBody);
} catch (S3Exception | SdkClientException e) {
    // 이 블록에 들어온 순간 모든 자동 재시도를 다 했지만 실패했다는 의미
    log.error("S3 업로드 실패: 모든 재시도 실패", e);
    throw e;
}

Copy link
Contributor Author

@Parkjyun Parkjyun Apr 29, 2025

Choose a reason for hiding this comment

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

재시도 모두 실패 시 네트워크 오류인지, 서비스 문제(권한, 버킷 존재 유무)인지 구분해서 로깅하도록 반영했습니다!
s3sdk에서 알아서 재시도 할만한 오류인지 판별해서 재시도를 해주는데..
사진 올리기에도 재시도 여부에 따른 응답/로깅 구분이 필요할까요?

return s3Properties.s3Endpoint() + key;
}

@Override
public List<String> getAllKeys() {
//여기도 retrey붙여야 되나? 어차피 실패하면 내일 해버리면 떙 아닌감..
Copy link
Collaborator

Choose a reason for hiding this comment

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

Retry를 여러번 수행하였지만 실패하는 경우는 한번 네트워크 지연으로 실패하는 경우 보다 더 심각한 경우일 것 같아용. 구분하기 위해서 Retry를 도입하는 것도 괜찮을 것 같아요!

생각해보니 이미지 업로드하는 부분에도 Retry를 안붙였었네용

    @Bean
    public S3Client getS3Client() {
        return S3Client.builder()
                .region(getRegion())
                .credentialsProvider(systemPropertyCredentialsProvider())
                .overrideConfiguration(
                        ClientOverrideConfiguration.builder()
                                .retryPolicy(RetryPolicy.defaultRetryPolicy()) // 기본 Retry 정책 적용
                                .build()
                )
                .build();
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

여기서 구분한다는게 retry 여러번 후 실패, 1회 후 실패를 구분하자는 말씀이신가용??

Copy link
Collaborator

Choose a reason for hiding this comment

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

1회 실패 후 재시도 했을 때 성공이 되는 경우와 3번 모두 실패하는 장애상황을 구분하고자 했습니다!
아래와 같이 logback에 설정을 추가하면 로깅이 남는다고는 하는데.. 상세로깅 남기는 부분은 더 알아봐야할것같아요

<logger name="software.amazon.awssdk.core.retry" level="DEBUG"/>
<logger name="software.amazon.awssdk.core.internal.http.pipeline.stages.RetryableStage" level="DEBUG"/>

Copy link
Collaborator

Choose a reason for hiding this comment

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

저희 logback-spring.xml에 아래로 변경하면 될 것 같아요

<springProfile name="dev">
    <include resource="console-appender.xml"/>
    <include resource="error-appender.xml"/>
    <include resource="warn-appender.xml"/>
    <include resource="info-appender.xml"/>

    <!-- Retry 로그 레벨 INFO로 설정 -->
    <logger name="software.amazon.awssdk.core.retry" level="INFO" additivity="false">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="INFO"/>
    </logger>

    <logger name="software.amazon.awssdk.core.internal.http.pipeline.stages.RetryableStage" level="INFO" additivity="false">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="INFO"/>
    </logger>

    <!-- 루트 로거 통합 -->
    <root level="info">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="INFO"/>
        <appender-ref ref="ERROR"/>
        <appender-ref ref="WARN"/>
    </root>
</springProfile>

List<String> keys = new ArrayList<>();
ListObjectsV2Request request = ListObjectsV2Request.builder()
.bucket(s3Properties.s3BucketName())
.build();

ListObjectsV2Response response;
S3Client s3Client = s3Config.getS3Client();

do {
response = s3Client.listObjectsV2(request);
keys.addAll(
response.contents()
.stream()
.map(S3Object::key)
.toList()
);
request = request.toBuilder()
.continuationToken(response.nextContinuationToken())
.build();
} while (response.isTruncated());
return keys;
}

@Override
public void deleteAllByKeys(List<String> keysToDelete) {
S3Client s3Client = s3Config.getS3Client();

List<ObjectIdentifier> list = keysToDelete.stream()
.map(key -> ObjectIdentifier.builder().key(key).build()).toList();
DeleteObjectsRequest deleteObjectRequest = DeleteObjectsRequest.builder()
.bucket(s3Properties.s3BucketName())
.delete(Delete.builder().objects(list).build())
.build();
DeleteObjectsResponse response;
try {
response = s3Client.deleteObjects(deleteObjectRequest);
} catch (AwsServiceException | SdkClientException e) {
throw new ImageStorageException("S3 객체 삭제에 실패했습니다.");
}

List<S3Error> errors = response.errors();
if(!errors.isEmpty()) {
throw new ImageStorageException("이미지 삭제에 실패했습니다.", errors.stream().map(S3Error::key).toList());
}
}

private String generateImageFileName() {
return UUID.randomUUID() + ".jpg";
}

private void validateExtension(MultipartFile image) {
String contentType = image.getContentType();
if (!IMAGE_EXTENSIONS.contains(contentType)) {
throw new BadRequestException("이미지 확장자는 jpg, png, webp, heic, heif만 가능합니다.");
}
}
}
55 changes: 0 additions & 55 deletions src/main/java/be/dash/dashserver/external/s3/S3ImageUploader.java

This file was deleted.