diff --git a/build.gradle b/build.gradle index a0ae3b5..580edd5 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,14 @@ dependencies { // Firebase-admin implementation 'com.google.firebase:firebase-admin:9.2.0' + // AWS SDK + implementation 'software.amazon.awssdk:s3:2.25.33' + implementation 'software.amazon.awssdk:auth:2.25.33' // IAM 인증 관련 + implementation 'software.amazon.awssdk:sts:2.25.33' // STS (IAM Role 인증 필요시) + + // Tika : 이미지 타입 검사 + implementation 'org.apache.tika:tika-core:2.5.0' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.batch:spring-batch-test' diff --git a/src/main/java/com/ject/studytrip/global/config/CdnConfig.java b/src/main/java/com/ject/studytrip/global/config/CdnConfig.java new file mode 100644 index 0000000..d4a02fe --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/CdnConfig.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.global.config; + +import com.ject.studytrip.global.config.properties.CdnProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(CdnProperties.class) +public class CdnConfig {} diff --git a/src/main/java/com/ject/studytrip/global/config/S3Config.java b/src/main/java/com/ject/studytrip/global/config/S3Config.java new file mode 100644 index 0000000..f589e6e --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/S3Config.java @@ -0,0 +1,34 @@ +package com.ject.studytrip.global.config; + +import com.ject.studytrip.global.config.properties.S3Properties; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@EnableConfigurationProperties(S3Properties.class) +@RequiredArgsConstructor +public class S3Config { + private final S3Properties props; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(props.region())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(props.region())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/TikaConfig.java b/src/main/java/com/ject/studytrip/global/config/TikaConfig.java new file mode 100644 index 0000000..fb946b2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/TikaConfig.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.global.config; + +import org.apache.tika.Tika; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TikaConfig { + + @Bean + public Tika tika() { + return new Tika(); + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/properties/CdnProperties.java b/src/main/java/com/ject/studytrip/global/config/properties/CdnProperties.java new file mode 100644 index 0000000..b88de7a --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/properties/CdnProperties.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "cdn") +public record CdnProperties(String domain) {} diff --git a/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java b/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java new file mode 100644 index 0000000..adf2a4f --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/properties/S3Properties.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "aws.s3") +public record S3Properties(String bucket, String region, long presignExpiresInMinutes) {} diff --git a/src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java b/src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java new file mode 100644 index 0000000..066d55e --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/resolver/CdnUrlBuilder.java @@ -0,0 +1,15 @@ +package com.ject.studytrip.global.resolver; + +import com.ject.studytrip.global.config.properties.CdnProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CdnUrlBuilder { + private final CdnProperties props; + + public String build(String key) { + return props.domain() + "/" + key; + } +} diff --git a/src/main/java/com/ject/studytrip/global/util/FilenameUtil.java b/src/main/java/com/ject/studytrip/global/util/FilenameUtil.java new file mode 100644 index 0000000..218271e --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/util/FilenameUtil.java @@ -0,0 +1,23 @@ +package com.ject.studytrip.global.util; + +import java.util.UUID; + +public final class FilenameUtil { + private static final String FILENAME_PATTERN = "%s.%s"; + + private FilenameUtil() {} + + public static String createNewFilename(String ext) { + String newFilename = UUID.randomUUID().toString(); + return FILENAME_PATTERN.formatted(newFilename, ext); + } + + public static String extractExtension(String filename) { + if (filename == null) return null; + + int i = filename.lastIndexOf('.'); + if (i < 0 || i == filename.length() - 1) return null; + + return filename.substring(i + 1).toLowerCase(); + } +} diff --git a/src/main/java/com/ject/studytrip/image/application/dto/PresignedImageInfo.java b/src/main/java/com/ject/studytrip/image/application/dto/PresignedImageInfo.java new file mode 100644 index 0000000..ae369b6 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/application/dto/PresignedImageInfo.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.image.application.dto; + +public record PresignedImageInfo(String tmpKey, String presignedUrl) { + public static PresignedImageInfo of(String tmpKey, String presignedUrl) { + return new PresignedImageInfo(tmpKey, presignedUrl); + } +} diff --git a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java new file mode 100644 index 0000000..33258ef --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java @@ -0,0 +1,104 @@ +package com.ject.studytrip.image.application.service; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.global.util.FilenameUtil; +import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.domain.constants.ImageConstants; +import com.ject.studytrip.image.domain.factory.ImageKeyFactory; +import com.ject.studytrip.image.domain.policy.ImagePolicy; +import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; +import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private final S3ImageStorageProvider s3Provider; + private final TikaImageProbeProvider tikaProvider; + + // Presigned URL 발급 + public PresignedImageInfo presign(String keyPrefix, String id, String originFilename) { + // 키 prefix 검증 + ImagePolicy.validateKeyPrefix(keyPrefix); + + // 확장자 추출, 검증 + String ext = FilenameUtil.extractExtension(originFilename); + ImagePolicy.validateExtension(ext); + + // 새로운 파일명 생성 + String filename = FilenameUtil.createNewFilename(ext); + + // 임시 키 생성 + String tmpKey = ImageKeyFactory.createTmpKey(keyPrefix, id, filename); + + // Presigned URL 생성 + String presignedUrl = s3Provider.issuePresignedUrl(tmpKey); + + return PresignedImageInfo.of(tmpKey, presignedUrl); + } + + // 업로드된 이미지 확정 + // S3 자체 에러 시에는 cleanup 실행 X + // 이미지 파일 크기, MIME 등 도메인 정책 검증에 실패하면 cleanup 실행 + public String confirm(String tmpKey) { + // 임시 이미지 키 검증 + ImagePolicy.validateKey(tmpKey); + + // 업로드된 이미지 HEAD 조회 + ImageHeadInfo head = s3Provider.getHeadByKey(tmpKey); + + // 이미지 크기 검증, 검증 실패 시 이미지 삭제 + validateSizeWithCleanup(tmpKey, head.contentLength()); + + // MIME 추출 및 판별, 검증 실패 시 이미지 삭제 + validateMimeWithCleanup(tmpKey, head.contentLength()); + + // 임시 -> 최종 이미지 복사 및 키 반환 + return moveToFinalLocation(tmpKey); + } + + // 업로드 취소, 업로드된 이미지 삭제 + public void cancel(List uploadedKeys) { + s3Provider.deleteByKeys(uploadedKeys); + } + + // 이미지 사이즈 검증, 실패 시 삭제 + private void validateSizeWithCleanup(String tmpKey, long contentLength) { + try { + ImagePolicy.validateSize(contentLength); + } catch (CustomException e) { + cleanupAndThrow(tmpKey, e); + } + } + + // 이미지 MIME 추출 및 검증, 실패 시 삭제 + private void validateMimeWithCleanup(String tmpKey, long len) { + int maxBytes = (int) Math.min(len, ImageConstants.PROBE_BYTES); + byte[] prefix = s3Provider.readPrefix(tmpKey, maxBytes); + String mime = tikaProvider.detectMime(prefix); + + try { + ImagePolicy.validateMime(mime); + } catch (CustomException e) { + cleanupAndThrow(tmpKey, e); + } + } + + // 최종 경로에 이미지 복사 + private String moveToFinalLocation(String tmpKey) { + String finalKey = ImageKeyFactory.toFinalKey(tmpKey); + ImagePolicy.validateKey(finalKey); + s3Provider.copyByKey(tmpKey, finalKey); + return finalKey; + } + + // 삭제 및 예외 처리 + private void cleanupAndThrow(String tmpKey, CustomException exception) { + s3Provider.deleteByKey(tmpKey); + throw exception; + } +} diff --git a/src/main/java/com/ject/studytrip/image/domain/constants/ImageConstants.java b/src/main/java/com/ject/studytrip/image/domain/constants/ImageConstants.java new file mode 100644 index 0000000..a708f0c --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/domain/constants/ImageConstants.java @@ -0,0 +1,25 @@ +package com.ject.studytrip.image.domain.constants; + +import java.util.Set; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.unit.DataSize; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ImageConstants { + + public static final Set ALLOWED_MIME_TYPES = + Set.of("image/jpeg", "image/png", "image/webp"); + + public static final long MAX_IMAGE_SIZE_BYTES = DataSize.ofMegabytes(5).toBytes(); + public static final long MIN_IMAGE_SIZE_BYTES = 1L; + + public static final int PROBE_BYTES = 32 * 1024; // 32KB + + public static final String KEY_PATTERN = "%s/%s/%s"; + public static final String TMP_PREFIX = "tmp/"; + public static final Set ALLOWED_OBJECT_KEY_PREFIXES = + Set.of("members", "study-logs", "trip-reports"); + + public static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); +} diff --git a/src/main/java/com/ject/studytrip/image/domain/error/ImageErrorCode.java b/src/main/java/com/ject/studytrip/image/domain/error/ImageErrorCode.java new file mode 100644 index 0000000..1711111 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/domain/error/ImageErrorCode.java @@ -0,0 +1,34 @@ +package com.ject.studytrip.image.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum ImageErrorCode implements ErrorCode { + INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 확장자 입니다."), + INVALID_IMAGE_MIME(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 MIME 입니다."), + EMPTY_IMAGE(HttpStatus.BAD_REQUEST, "이미지 파일이 비어 있습니다."), + IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "이미지 파일 크기가 허용된 최대 크기(5MB)를 초과했습니다."), + INVALID_IMAGE_KEY_PREFIX(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키 PREFIX 입니다."), + INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키 입니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/image/domain/factory/ImageKeyFactory.java b/src/main/java/com/ject/studytrip/image/domain/factory/ImageKeyFactory.java new file mode 100644 index 0000000..8093591 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/domain/factory/ImageKeyFactory.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.image.domain.factory; + +import static com.ject.studytrip.image.domain.constants.ImageConstants.*; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ImageKeyFactory { + public static String createTmpKey(String keyPrefix, String id, String filename) { + return TMP_PREFIX + KEY_PATTERN.formatted(keyPrefix, id, filename); + } + + public static String toFinalKey(String tmpKey) { + if (tmpKey == null || tmpKey.isBlank() || !tmpKey.startsWith(TMP_PREFIX)) { + return null; + } + return tmpKey.substring(TMP_PREFIX.length()); + } +} diff --git a/src/main/java/com/ject/studytrip/image/domain/policy/ImagePolicy.java b/src/main/java/com/ject/studytrip/image/domain/policy/ImagePolicy.java new file mode 100644 index 0000000..a67116b --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/domain/policy/ImagePolicy.java @@ -0,0 +1,47 @@ +package com.ject.studytrip.image.domain.policy; + +import static com.ject.studytrip.image.domain.constants.ImageConstants.*; +import static org.springframework.util.StringUtils.hasText; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.image.domain.error.ImageErrorCode; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ImagePolicy { + + public static void validateExtension(String ext) { + if (!hasText(ext) || !ALLOWED_EXTENSIONS.contains(ext)) { + throw new CustomException(ImageErrorCode.INVALID_IMAGE_EXTENSION); + } + } + + public static void validateMime(String mime) { + if (!hasText(mime) || !ALLOWED_MIME_TYPES.contains(mime)) { + throw new CustomException(ImageErrorCode.INVALID_IMAGE_MIME); + } + } + + public static void validateSize(long contentLength) { + if (contentLength < MIN_IMAGE_SIZE_BYTES) { + throw new CustomException(ImageErrorCode.EMPTY_IMAGE); + } + + if (contentLength > MAX_IMAGE_SIZE_BYTES) { + throw new CustomException(ImageErrorCode.IMAGE_SIZE_EXCEEDED); + } + } + + public static void validateKeyPrefix(String keyPrefix) { + if (!hasText(keyPrefix) || !ALLOWED_OBJECT_KEY_PREFIXES.contains(keyPrefix)) { + throw new CustomException(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX); + } + } + + public static void validateKey(String key) { + if (!hasText(key)) { + throw new CustomException(ImageErrorCode.INVALID_IMAGE_KEY); + } + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java b/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java new file mode 100644 index 0000000..b911f16 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java @@ -0,0 +1,78 @@ +package com.ject.studytrip.image.infra.s3.client; + +import com.ject.studytrip.global.config.properties.S3Properties; +import com.ject.studytrip.image.infra.s3.error.S3ExceptionTranslator; +import java.time.Duration; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageStorageClient { + private final S3Properties props; + private final S3Presigner presigner; + private final S3Client client; + + public PresignedPutObjectRequest presignPut(String key) { + return S3ExceptionTranslator.executeWithExceptionTranslation( + () -> { + PutObjectRequest put = + PutObjectRequest.builder().bucket(props.bucket()).key(key).build(); + + Duration ttl = Duration.ofMinutes(props.presignExpiresInMinutes()); + PutObjectPresignRequest req = + PutObjectPresignRequest.builder() + .signatureDuration(ttl) + .putObjectRequest(put) + .build(); + + return presigner.presignPutObject(req); + }); + } + + public HeadObjectResponse getHeadObject(String key) { + return S3ExceptionTranslator.executeWithExceptionTranslation( + () -> client.headObject(builder -> builder.bucket(props.bucket()).key(key))); + } + + public ResponseBytes getObjectAsBytes(String key, String range) { + return S3ExceptionTranslator.executeWithExceptionTranslation( + () -> + client.getObjectAsBytes( + builder -> builder.bucket(props.bucket()).key(key).range(range))); + } + + public void deleteObject(String key) { + S3ExceptionTranslator.executeWithExceptionTranslation( + () -> client.deleteObject(builder -> builder.bucket(props.bucket()).key(key))); + } + + public void deleteObjects(List objects) { + S3ExceptionTranslator.executeWithExceptionTranslation( + () -> + client.deleteObjects( + builder -> + builder.bucket(props.bucket()) + .delete(d -> d.quiet(true).objects(objects)))); + } + + public void copyObject(String tmpKey, String finalKey) { + S3ExceptionTranslator.executeWithExceptionTranslation( + () -> + client.copyObject( + builder -> + builder.sourceBucket(props.bucket()) + .sourceKey(tmpKey) + .destinationBucket(props.bucket()) + .destinationKey(finalKey))); + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.java b/src/main/java/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.java new file mode 100644 index 0000000..830607b --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.image.infra.s3.dto; + +public record ImageHeadInfo(long contentLength) { + public static ImageHeadInfo of(long contentLength) { + return new ImageHeadInfo(contentLength); + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.java b/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.java new file mode 100644 index 0000000..7c04b60 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.image.infra.s3.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum S3ErrorCode implements ErrorCode { + S3_STORAGE_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "Storage 서버 에러가 발생했습니다."); + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.java b/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.java new file mode 100644 index 0000000..ffceab7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.java @@ -0,0 +1,32 @@ +package com.ject.studytrip.image.infra.s3.error; + +import com.ject.studytrip.global.exception.CustomException; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +public class S3ExceptionTranslator { + + public static T executeWithExceptionTranslation(S3Operation operation) { + try { + return operation.execute(); + } catch (S3Exception e) { + log.error("S3 service error: {}", e.getMessage(), e); + } catch (SdkClientException e) { + log.error("S3 client error: {}", e.getMessage(), e); + } catch (SdkException e) { + log.error("AWS SDK error: {}", e.getMessage(), e); + } catch (Exception e) { + log.error("Exception: {}", e.getMessage(), e); + } + + throw new CustomException(S3ErrorCode.S3_STORAGE_SERVER_ERROR); + } + + @FunctionalInterface + public interface S3Operation { + T execute() throws Exception; + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java b/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java new file mode 100644 index 0000000..773b2d2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java @@ -0,0 +1,49 @@ +package com.ject.studytrip.image.infra.s3.provider; + +import com.ject.studytrip.image.infra.s3.client.S3ImageStorageClient; +import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3ImageStorageProvider { + private final S3ImageStorageClient s3Client; + + public String issuePresignedUrl(String key) { + PresignedPutObjectRequest presignPut = s3Client.presignPut(key); + return presignPut.url().toString(); + } + + public ImageHeadInfo getHeadByKey(String key) { + HeadObjectResponse head = s3Client.getHeadObject(key); + return ImageHeadInfo.of(head.contentLength()); + } + + public byte[] readPrefix(String key, int maxBytes) { + String range = "bytes=0-" + (maxBytes - 1); + return s3Client.getObjectAsBytes(key, range).asByteArray(); + } + + public void deleteByKey(String key) { + s3Client.deleteObject(key); + } + + public void deleteByKeys(List keys) { + List objects = + keys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList(); + + s3Client.deleteObjects(objects); + } + + public void copyByKey(String tmpKey, String finalKey) { + s3Client.copyObject(tmpKey, finalKey); + s3Client.deleteObject(tmpKey); + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.java b/src/main/java/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.java new file mode 100644 index 0000000..adad9fd --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.java @@ -0,0 +1,29 @@ +package com.ject.studytrip.image.infra.tika.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum TikaErrorCode implements ErrorCode { + TIKA_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Tika 서버 에러가 발생했습니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.java b/src/main/java/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.java new file mode 100644 index 0000000..85fce9a --- /dev/null +++ b/src/main/java/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.image.infra.tika.provider; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.image.infra.tika.error.TikaErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.tika.Tika; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TikaImageProbeProvider { + private final Tika tika; + + public String detectMime(byte[] headBytes) { + try { + return tika.detect(headBytes); + } catch (Exception e) { + log.error("Tika Exception: {}", e.getMessage(), e); + throw new CustomException(TikaErrorCode.TIKA_SERVER_ERROR); + } + } +} diff --git a/src/main/resources/application-storage.yml b/src/main/resources/application-storage.yml new file mode 100644 index 0000000..0a28a75 --- /dev/null +++ b/src/main/resources/application-storage.yml @@ -0,0 +1,10 @@ +spring: + config: + activate: + on-profile: "storage" + +aws: + s3: + bucket: ${S3_BUCKET_NAME:} + region: ${AWS_REGION:} + presign-expires-in-minutes: ${PRESIGN_EXPIRES_IN_MINUTES:10} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b434f49..835de0e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,6 +6,7 @@ spring: - security - flyway - batch + - storage sql: init: @@ -29,3 +30,6 @@ springdoc: syntax-highlight: theme: none urls-primary-name: StudyTrip API DOCS + +cdn: + domain: ${CDN_DOMAIN:} diff --git a/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java new file mode 100644 index 0000000..bf5cabd --- /dev/null +++ b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java @@ -0,0 +1,267 @@ +package com.ject.studytrip.image.application.service; + +import static com.ject.studytrip.image.fixture.ImageTestConstants.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.image.application.dto.PresignedImageInfo; +import com.ject.studytrip.image.domain.error.ImageErrorCode; +import com.ject.studytrip.image.fixture.ImageHeadInfoFixture; +import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; +import com.ject.studytrip.image.infra.s3.error.S3ErrorCode; +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; +import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +@DisplayName("ImageService 단위 테스트") +class ImageServiceTest extends BaseUnitTest { + + @InjectMocks private ImageService imageService; + @Mock private S3ImageStorageProvider s3Provider; + @Mock private TikaImageProbeProvider tikaProvider; + + @Nested + @DisplayName("presign 메서드는") + class Presign { + + @Test + @DisplayName("유효한 파라미터로 presigned URL을 발급한다") + void shouldIssuePresignedUrlWithValidParameters() { + // given + given(s3Provider.issuePresignedUrl(anyString())).willReturn(PRESIGNED_URL); + + // when + PresignedImageInfo info = + imageService.presign(VALID_KEY_PREFIX, VALID_ID, ORIGINAL_FILENAME); + + // then + assertThat(info.presignedUrl()).isEqualTo(PRESIGNED_URL); + verify(s3Provider).issuePresignedUrl(anyString()); + } + + @Test + @DisplayName("유효하지 않은 키 Prefix로 호출하면 예외가 발생한다") + void shouldThrowExceptionWhenKeyPrefixIsInvalid() { + // when & then + assertThatThrownBy(() -> imageService.presign(null, VALID_ID, ORIGINAL_FILENAME)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX.getMessage()); + } + + @Test + @DisplayName("존재하지 않는 키 Prefix로 호출하면 예외가 발생한다") + void shouldThrowExceptionWhenKeyPrefixDoesNotExist() { + // when & then + assertThatThrownBy( + () -> + imageService.presign( + "invalid-key-prefix/", VALID_ID, ORIGINAL_FILENAME)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX.getMessage()); + } + + @Test + @DisplayName("유효하지 않은 이미지 파일 확장자라면 예외가 발생한다") + void shouldThrowExceptionWhenExtensionIsInvalid() { + // when & then + assertThatThrownBy( + () -> + imageService.presign( + VALID_KEY_PREFIX, VALID_ID, INVALID_ORIGINAL_FILENAME)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_EXTENSION.getMessage()); + } + } + + @Nested + @DisplayName("confirm 메서드는") + class Confirm { + + @Test + @DisplayName("유효한 이미지를 검증하고 최종 키를 반환한다") + void shouldConfirmValidImageAndReturnFinalKey() { + // given + ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(); + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); + given(s3Provider.readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH)) + .willReturn(JPEG_HEADER_BYTES); + given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(VALID_MIME); + + // when + String result = imageService.confirm(TMP_KEY); + + // then + assertThat(result).isEqualTo(FINAL_KEY); + verify(s3Provider).getHeadByKey(TMP_KEY); + verify(s3Provider).readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH); + verify(tikaProvider).detectMime(JPEG_HEADER_BYTES); + verify(s3Provider).copyByKey(TMP_KEY, FINAL_KEY); + } + + @Test + @DisplayName("tmpKey가 null이면 예외가 발생한다") + void shouldThrowExceptionWhenTmpKeyIsNull() { + // when & then + assertThatThrownBy(() -> imageService.confirm(null)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY.getMessage()); + + // cleanup 비호출 여부 + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("tmpKey가 빈 문자열이면 예외가 발생한다") + void shouldThrowExceptionWhenTmpKeyIsBlank() { + // when & then + assertThatThrownBy(() -> imageService.confirm("")) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY.getMessage()); + + // cleanup 비호출 여부 + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("이미지 크기가 유효하지 않으면 cleanup 후 예외가 발생한다") + void shouldCleanupAndThrowExceptionWhenImageSizeIsInvalid() { + // given + ImageHeadInfo headInfo = ImageHeadInfoFixture.createLargeImageHeadInfo(); + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); + + // when & then + assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.IMAGE_SIZE_EXCEEDED.getMessage()); + + // cleanup 호출 여부 + verify(s3Provider).deleteByKey(TMP_KEY); + } + + @Test + @DisplayName("이미지 크기가 0이면 cleanup 후 예외가 발생한다") + void shouldCleanupAndThrowExceptionWhenImageSizeIsZero() { + // given + ImageHeadInfo headInfo = ImageHeadInfoFixture.createEmptyImageHeadInfo(); + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); + + // when & then + assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.EMPTY_IMAGE.getMessage()); + + // cleanup 호출 여부 + verify(s3Provider).deleteByKey(TMP_KEY); + } + + @Test + @DisplayName("MIME 타입이 유효하지 않으면 cleanup 후 예외가 발생한다") + void shouldCleanupAndThrowExceptionWhenMimeIsInvalid() { + // given + ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(); + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); + given(s3Provider.readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH)) + .willReturn(JPEG_HEADER_BYTES); + given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(INVALID_MIME); + + // when & then + assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_MIME.getMessage()); + + // cleanup 호출 여부 + verify(s3Provider).deleteByKey(TMP_KEY); + } + + @Test + @DisplayName("빈 문자열 tmpKey로 호출하면 예외가 발생한다") + void shouldThrowExceptionWhenTmpKeyIsEmptyString() { + // when & then + assertThatThrownBy(() -> imageService.confirm("")) + .isInstanceOf(CustomException.class) + .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY.getMessage()); + + // cleanup 비호출 여부 + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("S3 작업 중 에러가 발생하면 예외가 발생한다") + void shouldThrowExceptionWhenS3OperationFails() { + // given + given(s3Provider.getHeadByKey(TMP_KEY)) + .willThrow(new CustomException(S3ErrorCode.S3_STORAGE_SERVER_ERROR)); + + // when & then + assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) + .isInstanceOf(CustomException.class) + .hasMessage(S3ErrorCode.S3_STORAGE_SERVER_ERROR.getMessage()); + + // cleanup 비호출 여부 + verify(s3Provider, never()).deleteByKey(anyString()); + } + + @Test + @DisplayName("이미지 크기가 PROBE_BYTES보다 작으면 전체 크기만큼만 읽는다") + void shouldReadOnlyActualSizeWhenSmallerThanProbeBytes() { + // given + long smallSize = 1024L; + ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(smallSize); + byte[] smallImageBytes = new byte[(int) smallSize]; + + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); + given(s3Provider.readPrefix(TMP_KEY, (int) smallSize)).willReturn(smallImageBytes); + given(tikaProvider.detectMime(smallImageBytes)).willReturn(VALID_MIME); + + // when + String result = imageService.confirm(TMP_KEY); + + // then + assertThat(result).isEqualTo(FINAL_KEY); + verify(s3Provider).readPrefix(TMP_KEY, (int) smallSize); + } + } + + @Nested + @DisplayName("cancel 메서드는") + class Cancel { + + @Test + @DisplayName("업로드된 키들을 삭제한다") + void shouldDeleteUploadedKeys() { + // given + List uploadedKeys = Arrays.asList(TMP_KEY, "tmp/profile/12345/test2.jpg"); + + // when + imageService.cancel(uploadedKeys); + + // then + verify(s3Provider).deleteByKeys(uploadedKeys); + } + + @Test + @DisplayName("빈 리스트로 호출해도 정상 동작한다") + void shouldHandleEmptyList() { + // given + List emptyKeys = Arrays.asList(); + + // when & then + imageService.cancel(emptyKeys); + + // then + verify(s3Provider).deleteByKeys(emptyKeys); + } + } +} diff --git a/src/test/java/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.java b/src/test/java/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.java new file mode 100644 index 0000000..e867117 --- /dev/null +++ b/src/test/java/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.java @@ -0,0 +1,27 @@ +package com.ject.studytrip.image.fixture; + +import static com.ject.studytrip.image.domain.constants.ImageConstants.MAX_IMAGE_SIZE_BYTES; + +import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; + +public class ImageHeadInfoFixture { + private static final long DEFAULT_CONTENT_LENGTH = 1024L; + private static final long LARGE_CONTENT_LENGTH = MAX_IMAGE_SIZE_BYTES + 1; + private static final long EMPTY_CONTENT_LENGTH = 0L; + + public static ImageHeadInfo createImageHeadInfo() { + return ImageHeadInfo.of(DEFAULT_CONTENT_LENGTH); + } + + public static ImageHeadInfo createImageHeadInfo(long contentLength) { + return ImageHeadInfo.of(contentLength); + } + + public static ImageHeadInfo createLargeImageHeadInfo() { + return ImageHeadInfo.of(LARGE_CONTENT_LENGTH); + } + + public static ImageHeadInfo createEmptyImageHeadInfo() { + return ImageHeadInfo.of(EMPTY_CONTENT_LENGTH); + } +} diff --git a/src/test/java/com/ject/studytrip/image/fixture/ImageTestConstants.java b/src/test/java/com/ject/studytrip/image/fixture/ImageTestConstants.java new file mode 100644 index 0000000..a7e3c9f --- /dev/null +++ b/src/test/java/com/ject/studytrip/image/fixture/ImageTestConstants.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.image.fixture; + +import static com.ject.studytrip.image.domain.constants.ImageConstants.*; + +public class ImageTestConstants { + public static final String VALID_KEY_PREFIX = "members"; + public static final String VALID_ID = "12345"; + public static final String ORIGINAL_FILENAME = "test.jpg"; + public static final String INVALID_ORIGINAL_FILENAME = "test.txt"; + public static final String TMP_KEY = TMP_PREFIX + "members/12345/test.jpg"; + public static final String FINAL_KEY = "members/12345/test.jpg"; + + // 크기 관련 (ImageConstants와 연동) + public static final long VALID_CONTENT_LENGTH = 1024L; + + // MIME 타입 + public static final String VALID_MIME = "image/jpeg"; + public static final String INVALID_MIME = "text/plain"; + + // URL + public static final String PRESIGNED_URL = "https://s3.amazonaws.com/test-presigned-url"; + + // 바이트 데이터 + public static final byte[] JPEG_HEADER_BYTES = + new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; // JPEG header +}