Skip to content
Open
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies {
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.testcontainers:localstack'

// AWS
implementation("software.amazon.awssdk:s3:2.21.0")
Expand Down
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 @@ -17,6 +17,7 @@
import be.dash.dashserver.core.exception.BadRequestException;
import be.dash.dashserver.core.exception.ConflictException;
import be.dash.dashserver.core.exception.ForbiddenException;
import be.dash.dashserver.core.exception.ImageStorageException;
import be.dash.dashserver.core.exception.NotFoundException;
import be.dash.dashserver.core.exception.PaymentClientException;
import be.dash.dashserver.core.log.LogForm;
Expand Down Expand Up @@ -118,6 +119,12 @@ public ResponseEntity<ErrorMessage> handleDashApiException(DashApiException e) {
return ResponseEntity.badRequest().body(new ErrorMessage(e.getMessage()));
}

@ExceptionHandler(ImageStorageException.class)
public ResponseEntity<ErrorMessage> handleImageStorageException(ImageStorageException e) {
log.error("handleImageStorageException in GlobalExceptionHandler throw {} : {}", e.getClass(), e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorMessage(e.getMessage()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorMessage> handleException(Exception e) {
log.error(LogForm.ERROR_LOGGING_FORM, e.getClass(), e.getMessage(), e.getStackTrace());
Expand Down
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;
}
}
7 changes: 7 additions & 0 deletions src/main/java/be/dash/dashserver/core/image/ImageDeleter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package be.dash.dashserver.core.image;

import java.util.List;

public interface ImageDeleter {
void deleteAllByKeys(List<String> keysToDelete);
}
7 changes: 7 additions & 0 deletions src/main/java/be/dash/dashserver/core/image/ImageReader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package be.dash.dashserver.core.image;

import java.util.List;

public interface ImageReader {
List<String> getAllKeys();
}
33 changes: 27 additions & 6 deletions src/main/java/be/dash/dashserver/core/image/ImageService.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
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.exception.BadGatewayException;
import be.dash.dashserver.core.domain.member.service.MemberRepository;
import be.dash.dashserver.core.exception.BadRequestException;
import be.dash.dashserver.core.log.annotation.Trace;
import lombok.RequiredArgsConstructor;

@Trace
@Service
@RequiredArgsConstructor
public class ImageService {

private static final List<String> IMAGE_EXTENSIONS = List.of(
"image/jpeg", "image/png", "image/jpg", "image/webp", "image/heic", "image/heif"
);

private final ImageUploader imageUploader;
private final ImageReader imageReader;
private final ImageDeleter imageDeleter;
private final MemberRepository memberRepository;

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

public void cleanUpUnusedProfileImages() {
List<String> profileImages = memberRepository.findAllProfileImages();
List<String> keysToDelete = imageReader.getAllKeys().stream()
// images 도메인 용도별로 분리 후 도메인 로직으로 넣기
.filter(key -> !profileImages.contains(key)).toList();
imageDeleter.deleteAllByKeys(keysToDelete);
}

private void validateExtension(MultipartFile image) {
String contentType = image.getContentType();
if (!IMAGE_EXTENSIONS.contains(contentType)) {
throw new BadRequestException("이미지 확장자는 jpg, png, webp, heic, heif만 가능합니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package be.dash.dashserver.core.image;

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

public interface ImageUploader {
String uploadImage(MultipartFile file) throws IOException;
String upload(MultipartFile file);
}
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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package be.dash.dashserver.external.config.s3;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.localstack.LocalStackContainer;
import org.testcontainers.utility.DockerImageName;
import lombok.RequiredArgsConstructor;
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 static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;

@Configuration
@Profile({"local", "test"})
@RequiredArgsConstructor
@EnableConfigurationProperties(S3Properties.class)
public class LocalStackConfig {

private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:3.5.0");

private final S3Properties s3Properties;

@Bean(initMethod = "start", destroyMethod = "stop")
public LocalStackContainer localStackContainer() {
return new LocalStackContainer(LOCALSTACK_IMAGE)
.withServices(S3);
}

@Bean
public S3Client s3Client(LocalStackContainer localStackContainer) {
S3Client client = S3Client.builder()
.endpointOverride(localStackContainer.getEndpoint())
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(localStackContainer.getAccessKey(), localStackContainer.getSecretKey())
)
)
.region(Region.of(localStackContainer.getRegion()))
.build();
client.createBucket(b -> b.bucket(s3Properties.s3BucketName()));
return client;
}
}
23 changes: 21 additions & 2 deletions src/main/java/be/dash/dashserver/external/config/s3/S3Config.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package be.dash.dashserver.external.config.s3;

import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import java.time.Duration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import lombok.RequiredArgsConstructor;
import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider;
import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
import software.amazon.awssdk.core.retry.RetryPolicy;
import software.amazon.awssdk.core.retry.backoff.BackoffStrategy;
import software.amazon.awssdk.core.retry.conditions.RetryCondition;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
@Profile("develop")
@RequiredArgsConstructor
@ConfigurationPropertiesScan(basePackages = "be.dash.dashserver.external.config.s3")
@EnableConfigurationProperties(S3Properties.class)
public class S3Config {

private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId";
Expand All @@ -31,9 +38,21 @@ public Region getRegion() {

@Bean
public S3Client getS3Client() {
ClientOverrideConfiguration overrideConfig = ClientOverrideConfiguration.builder()
.apiCallAttemptTimeout(Duration.ofSeconds(3))
.apiCallTimeout(Duration.ofSeconds(14))
.retryPolicy(
RetryPolicy.builder()
.numRetries(3)
.retryCondition(RetryCondition.defaultRetryCondition())
.backoffStrategy(BackoffStrategy.defaultStrategy())
.build()
)
.build();
return S3Client.builder()
.region(getRegion())
.credentialsProvider(systemPropertyCredentialsProvider())
.overrideConfiguration(overrideConfig)
.build();
}
}
63 changes: 63 additions & 0 deletions src/main/java/be/dash/dashserver/external/s3/S3ImageDeleter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package be.dash.dashserver.external.s3;

import java.util.List;
import org.springframework.stereotype.Component;
import be.dash.dashserver.core.exception.ImageStorageException;
import be.dash.dashserver.core.image.ImageDeleter;
import be.dash.dashserver.external.config.s3.S3Properties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkClientException;
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.ObjectIdentifier;
import software.amazon.awssdk.services.s3.model.S3Error;

@Component
@RequiredArgsConstructor
@Slf4j
public class S3ImageDeleter implements ImageDeleter {
private final S3Properties s3Properties;
private final S3Client s3Client;

@Override
public void deleteAllByKeys(List<String> keysToDelete) {
if (keysToDelete.isEmpty()) {
return;
}
DeleteObjectsRequest request = buildRequest(keysToDelete);
DeleteObjectsResponse response = performDeleteAllByKeys(request);
handlePartialDeleteErrors(response);
}

private DeleteObjectsResponse performDeleteAllByKeys(DeleteObjectsRequest deleteObjectRequest) {
try {
return s3Client.deleteObjects(deleteObjectRequest);
} catch (AwsServiceException e) {
log.error("S3 삭제 실패 - 상태코드 : {}, 에러메시지 : {}", e.statusCode(), e.awsErrorDetails().errorMessage());
throw new ImageStorageException("이미지 삭제에 실패했습니다.(서비스 오류)");
} catch (SdkClientException e) {
log.error("S3 삭제 실패 - 에러메시지 : {}", e.getMessage());
throw new ImageStorageException("이미지 삭제에 실패했습니다.(내부 네트워크 오류)");
}
}

private DeleteObjectsRequest buildRequest(List<String> keysToDelete) {
List<ObjectIdentifier> list = keysToDelete.stream()
.map(key -> ObjectIdentifier.builder().key(key).build()).toList();
return DeleteObjectsRequest.builder()
.bucket(s3Properties.s3BucketName())
.delete(Delete.builder().objects(list).build())
.build();
}

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