diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 69dd5920..c8061b54 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -84,9 +84,10 @@ jobs: RABBITMQ_PASSWORD: guest # S3 S3_BUCKET_NAME: test-bucket-ci + S3_PUBLIC_BUCKET_NAME: test-public-bucket-ci + S3_CLOUDFRONT_BASE_URL: https://test.cloudfront.net S3_REGION: us-east-2 - S3_PUBLIC_BUCKET: "false" - S3_PRESIGNED_URL_EXPIRATION: "604800" + S3_PRESIGNED_URL_EXPIRATION: "86400" S3_MAX_IMAGE_SIZE: "10485760" S3_MAX_FILE_SIZE: "52428800" S3_KEY_PREFIX: attachment diff --git a/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentCleanupScheduler.java b/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentCleanupScheduler.java index 223e1594..16d5f976 100644 --- a/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentCleanupScheduler.java +++ b/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentCleanupScheduler.java @@ -123,7 +123,7 @@ private DeleteOutcome deleteStorageTargets(List targets continue; } try { - s3FileUploadService.get().deleteFile(storageKey); + s3FileUploadService.get().deleteFile(storageKey, target.getUsage()); outcome.successIds.add(target.getId()); } catch (Exception ex) { LOG.error("Attachment storage delete failed. attachmentId={}, storageKey={}", diff --git a/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentServiceImpl.java b/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentServiceImpl.java index 4b22edd1..da68a79b 100644 --- a/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentServiceImpl.java +++ b/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentServiceImpl.java @@ -10,6 +10,7 @@ import com.example.RealMatch.attachment.application.mapper.AttachmentResponseMapper; import com.example.RealMatch.attachment.code.AttachmentErrorCode; import com.example.RealMatch.attachment.domain.entity.Attachment; +import com.example.RealMatch.attachment.domain.enums.AttachmentUsage; import com.example.RealMatch.attachment.infrastructure.storage.S3CredentialsCondition; import com.example.RealMatch.attachment.infrastructure.storage.S3FileUploadService; import com.example.RealMatch.attachment.presentation.dto.request.AttachmentUploadRequest; @@ -62,11 +63,13 @@ public AttachmentUploadResponse uploadAttachment( try { // TX 밖: S3 업로드 (DB 커넥션 점유 없음) + // usage에 따라 PUBLIC → 퍼블릭 버킷, CHAT → 프라이빗 버킷 s3FileUploadService.uploadFile( fileInputStream, s3Key, normalizedContentType, - fileSize + fileSize, + request.usage() ); // TX#2: UPLOADED → READY (상태 전환만). markReady만 별도 catch → "S3 성공 + READY 전환 실패"일 때만 FAILED + S3 삭제 시도. @@ -75,7 +78,7 @@ public AttachmentUploadResponse uploadAttachment( } catch (CustomException readyEx) { // S3 성공 + READY 전환 실패 (DB 경합/상태 이상) → 여기서만 FAILED + S3 삭제, 그 다음 rethrow로 종료 safeMarkFailed(attachment.getId()); - safeDeleteS3(s3Key, attachment.getId()); + safeDeleteS3(s3Key, attachment.getId(), request.usage()); throw readyEx; } @@ -103,9 +106,9 @@ private void safeMarkFailed(Long attachmentId) { } } - private void safeDeleteS3(String s3Key, Long attachmentId) { + private void safeDeleteS3(String s3Key, Long attachmentId, AttachmentUsage usage) { try { - s3FileUploadService.deleteFile(s3Key); + s3FileUploadService.deleteFile(s3Key, usage); } catch (Exception ex) { LOG.warn("S3 즉시 삭제 실패. attachmentId={}, s3Key={} (배치에서 정리됨)", attachmentId, s3Key, ex); } diff --git a/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentUrlService.java b/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentUrlService.java index 08d7ec8b..18a756c2 100644 --- a/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentUrlService.java +++ b/src/main/java/com/example/RealMatch/attachment/application/service/AttachmentUrlService.java @@ -8,6 +8,7 @@ import com.example.RealMatch.attachment.code.AttachmentErrorCode; import com.example.RealMatch.attachment.domain.entity.Attachment; import com.example.RealMatch.attachment.domain.enums.AttachmentStatus; +import com.example.RealMatch.attachment.domain.enums.AttachmentUsage; import com.example.RealMatch.attachment.infrastructure.storage.S3FileUploadService; import com.example.RealMatch.attachment.infrastructure.storage.S3Properties; import com.example.RealMatch.global.exception.CustomException; @@ -24,6 +25,10 @@ public class AttachmentUrlService { private final S3FileUploadService s3FileUploadService; private final S3Properties s3Properties; + /** + * Attachment 엔티티 기반 URL 반환 + * PUBLIC → CloudFront URL, CHAT → presigned URL. + */ public String getAccessUrl(Attachment attachment) { if (attachment == null || attachment.getStatus() != AttachmentStatus.READY) { return null; @@ -33,13 +38,58 @@ public String getAccessUrl(Attachment attachment) { LOG.error("READY 상태인데 storageKey가 없습니다. attachmentId={}", attachment.getId()); return null; } - return getAccessUrl(storageKey); + return getAccessUrl(storageKey, attachment.getUsage()); } + /** + * storageKey + usage로 URL 반환 + * PUBLIC → CloudFront URL, CHAT → presigned URL + */ + public String getAccessUrl(String storageKey, AttachmentUsage usage) { + if (storageKey == null || storageKey.isBlank()) { + return null; + } + if (usage == AttachmentUsage.PUBLIC) { + return s3FileUploadService.buildPublicUrl(storageKey); + } + return generatePresignedUrl(storageKey); + } + + /** + * storageKey만으로 URL 반환 + * storageKey 경로에 /public/이 포함되면 PUBLIC으로 간주. + */ public String getAccessUrl(String storageKey) { if (storageKey == null || storageKey.isBlank()) { return null; } + // 이미 CloudFront URL이 저장된 경우, 신뢰 도메인(cloudfrontBaseUrl)인 경우만 그대로 반환 + if (storageKey.startsWith("http://") || storageKey.startsWith("https://")) { + return isTrustedRedirectUrl(storageKey) ? storageKey : null; + } + AttachmentUsage inferred = storageKey.contains("/public/") + ? AttachmentUsage.PUBLIC + : AttachmentUsage.CHAT; + return getAccessUrl(storageKey, inferred); + } + + /** + * DB에 저장된 URL이 우리 CloudFront 등 신뢰 도메인인지 검사. open redirect 방지. + */ + private boolean isTrustedRedirectUrl(String url) { + String base = s3Properties.getCloudfrontBaseUrl(); + if (base == null || base.isBlank()) { + return false; + } + String baseNormalized = base.trim().toLowerCase(); + if (baseNormalized.endsWith("/")) { + baseNormalized = baseNormalized.substring(0, baseNormalized.length() - 1); + } + String urlLower = url.trim().toLowerCase(); + return urlLower.equals(baseNormalized) || urlLower.startsWith(baseNormalized + "/"); + } + + private String generatePresignedUrl(String storageKey) { if (!s3FileUploadService.isAvailable()) { throw new CustomException(AttachmentErrorCode.STORAGE_UNAVAILABLE); } diff --git a/src/main/java/com/example/RealMatch/attachment/domain/enums/AttachmentUsage.java b/src/main/java/com/example/RealMatch/attachment/domain/enums/AttachmentUsage.java index 60a44d34..0519a012 100644 --- a/src/main/java/com/example/RealMatch/attachment/domain/enums/AttachmentUsage.java +++ b/src/main/java/com/example/RealMatch/attachment/domain/enums/AttachmentUsage.java @@ -1,11 +1,13 @@ package com.example.RealMatch.attachment.domain.enums; /** - * 첨부파일 용도 - * S3 경로 prefix 분리 및 향후 TTL·캐싱 정책 분리의 기준 - * - * CHAT: 채팅 첨부. 참여자만 접근 가능. 비공개 자산. - * PUBLIC: 브랜드/캠페인 등 상세페이지·홈 화면 노출용. 로그인 없이도 이미지 조회 가능해야 하는 공개 자산. + * 첨부파일의 용도를 정의합니다. + *

+ * 이 값에 따라 파일이 저장되는 S3 버킷(private/public)과 접근 URL 생성 방식(presigned/CDN)이 결정됩니다. + *

*/ public enum AttachmentUsage { CHAT, diff --git a/src/main/java/com/example/RealMatch/attachment/domain/repository/AttachmentRepository.java b/src/main/java/com/example/RealMatch/attachment/domain/repository/AttachmentRepository.java index 371a804c..dc552f1d 100644 --- a/src/main/java/com/example/RealMatch/attachment/domain/repository/AttachmentRepository.java +++ b/src/main/java/com/example/RealMatch/attachment/domain/repository/AttachmentRepository.java @@ -11,6 +11,7 @@ import com.example.RealMatch.attachment.domain.entity.Attachment; import com.example.RealMatch.attachment.domain.enums.AttachmentStatus; +import com.example.RealMatch.attachment.domain.enums.AttachmentUsage; public interface AttachmentRepository extends JpaRepository { @@ -29,7 +30,7 @@ List findIdsByStatusAndCreatedAtBefore( ); @Query(""" - select a.id as id, a.storageKey as storageKey + select a.id as id, a.storageKey as storageKey, a.usage as usage from Attachment a where a.id in :ids and a.status = :status @@ -117,5 +118,6 @@ int softDeleteByIdsAndStatus( interface AttachmentCleanupTarget { Long getId(); String getStorageKey(); + AttachmentUsage getUsage(); } } diff --git a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/NoOpS3FileUploadService.java b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/NoOpS3FileUploadService.java index 939f71fa..481494ac 100644 --- a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/NoOpS3FileUploadService.java +++ b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/NoOpS3FileUploadService.java @@ -16,7 +16,7 @@ public class NoOpS3FileUploadService implements S3FileUploadService { @Override - public String uploadFile(InputStream inputStream, String key, String contentType, long fileSize) { + public String uploadFile(InputStream inputStream, String key, String contentType, long fileSize, AttachmentUsage usage) { throw new CustomException(AttachmentErrorCode.S3_UPLOAD_FAILED, "S3 is not configured."); } @@ -31,10 +31,15 @@ public String generateS3Key(AttachmentUsage usage, Long userId, Long attachmentI } @Override - public void deleteFile(String key) { + public void deleteFile(String key, AttachmentUsage usage) { throw new CustomException(AttachmentErrorCode.S3_DELETE_FAILED, "S3 is not configured."); } + @Override + public String buildPublicUrl(String storageKey) { + return null; + } + @Override public boolean isAvailable() { return false; diff --git a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadService.java b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadService.java index 3d6d114e..2f2a4506 100644 --- a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadService.java +++ b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadService.java @@ -6,13 +6,22 @@ public interface S3FileUploadService { - String uploadFile(InputStream inputStream, String key, String contentType, long fileSize); + /** + * 파일을 S3에 업로드한다. + * usage에 따라 PUBLIC → 퍼블릭 버킷, CHAT → 프라이빗 버킷에 저장. + */ + String uploadFile(InputStream inputStream, String key, String contentType, long fileSize, AttachmentUsage usage); + /** CHAT 전용: 프라이빗 버킷 객체에 대한 presigned URL 생성 */ String generatePresignedUrl(String key, int expirationSeconds); String generateS3Key(AttachmentUsage usage, Long userId, Long attachmentId, String originalFilename); - void deleteFile(String key); + /** 파일 삭제. usage에 따라 대상 버킷 결정. */ + void deleteFile(String key, AttachmentUsage usage); + + /** PUBLIC 전용: CloudFront base URL + storageKey로 공개 URL 생성 */ + String buildPublicUrl(String storageKey); default boolean isAvailable() { return true; diff --git a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadServiceImpl.java b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadServiceImpl.java index 015d9759..7a429d3f 100644 --- a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadServiceImpl.java +++ b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3FileUploadServiceImpl.java @@ -38,23 +38,25 @@ public class S3FileUploadServiceImpl implements S3FileUploadService { private final S3FileNameSanitizer fileNameSanitizer; @Override - public String uploadFile(InputStream inputStream, String key, String contentType, long fileSize) { + public String uploadFile(InputStream inputStream, String key, String contentType, long fileSize, AttachmentUsage usage) { + String bucket = resolveBucket(usage); try { PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(s3Properties.getBucketName()) + .bucket(bucket) .key(key) .contentType(contentType) .contentLength(fileSize) .build(); s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, fileSize)); + LOG.info("S3 업로드 완료. bucket={}, key={}, usage={}", bucket, key, usage); return null; } catch (S3Exception e) { - handleS3Exception("파일 업로드", key, e); + handleS3Exception("파일 업로드", key, bucket, e); throw new CustomException(AttachmentErrorCode.S3_UPLOAD_FAILED); } catch (Exception e) { - LOG.error("S3 파일 업로드 중 예상치 못한 오류 발생. key={}", key, e); + LOG.error("S3 파일 업로드 중 예상치 못한 오류 발생. bucket={}, key={}", bucket, key, e); throw new CustomException(AttachmentErrorCode.S3_UPLOAD_FAILED); } } @@ -76,7 +78,7 @@ public String generatePresignedUrl(String key, int expirationSeconds) { return presignedRequest.url().toString(); } catch (S3Exception e) { - handleS3Exception("Presigned URL 생성", key, e); + handleS3Exception("Presigned URL 생성", key, s3Properties.getBucketName(), e); throw new CustomException(AttachmentErrorCode.S3_UPLOAD_FAILED); } catch (Exception e) { LOG.error("Presigned URL 생성 중 예상치 못한 오류 발생. key={}", key, e); @@ -103,30 +105,61 @@ public String generateS3Key(AttachmentUsage usage, Long userId, Long attachmentI } @Override - public void deleteFile(String key) { + public void deleteFile(String key, AttachmentUsage usage) { + String bucket = resolveBucket(usage); try { DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() - .bucket(s3Properties.getBucketName()) + .bucket(bucket) .key(key) .build(); s3Client.deleteObject(deleteObjectRequest); } catch (S3Exception e) { - handleS3Exception("파일 삭제", key, e); + handleS3Exception("파일 삭제", key, bucket, e); throw new CustomException(AttachmentErrorCode.S3_DELETE_FAILED); } catch (Exception e) { - LOG.error("S3 파일 삭제 중 예상치 못한 오류 발생. key={}", key, e); + LOG.error("S3 파일 삭제 중 예상치 못한 오류 발생. bucket={}, key={}", bucket, key, e); throw new CustomException(AttachmentErrorCode.S3_DELETE_FAILED); } } - private void handleS3Exception(String operation, String key, S3Exception e) { - LOG.error("S3 {} 실패. key={}, errorCode={}, statusCode={}, requestId={}, bucket={}", + @Override + public String buildPublicUrl(String storageKey) { + String base = s3Properties.getCloudfrontBaseUrl(); + if (base == null || base.isBlank()) { + LOG.warn("CloudFront base URL이 설정되지 않았습니다. storageKey={}", storageKey); + return null; + } + if (storageKey != null && storageKey.contains("..")) { + LOG.warn("storageKey contains path traversal sequence. storageKey={}", storageKey); + return null; + } + return base.endsWith("/") ? base + storageKey : base + "/" + storageKey; + } + + /** + * usage에 따라 대상 버킷 결정. + * PUBLIC → publicBucketName, CHAT → bucketName (private). + */ + private String resolveBucket(AttachmentUsage usage) { + if (usage == AttachmentUsage.PUBLIC) { + String publicBucket = s3Properties.getPublicBucketName(); + if (publicBucket == null || publicBucket.isBlank()) { + LOG.error("PUBLIC usage attachment requires a public bucket, but 'app.s3.public-bucket-name' is not configured."); + throw new IllegalStateException("Public bucket is not configured for PUBLIC attachment usage."); + } + return publicBucket; + } + return s3Properties.getBucketName(); + } + + private void handleS3Exception(String operation, String key, String bucket, S3Exception e) { + LOG.error("S3 {} 실패. key={}, bucket={}, errorCode={}, statusCode={}, requestId={}", operation, key, + bucket, e.awsErrorDetails().errorCode(), e.statusCode(), e.requestId(), - s3Properties.getBucketName(), e); if (e.statusCode() == 403 || e.statusCode() == 401) { diff --git a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3Properties.java b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3Properties.java index 252fbf8b..9266ae51 100644 --- a/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3Properties.java +++ b/src/main/java/com/example/RealMatch/attachment/infrastructure/storage/S3Properties.java @@ -12,9 +12,15 @@ @ConfigurationProperties(prefix = "app.s3") public class S3Properties { + // private 버킷 private String bucketName; + // PUBLIC 버킷 (CloudFront 원본) + private String publicBucketName; + // CloudFront 배포 도메인 + private String cloudfrontBaseUrl; + private String region; - private int presignedUrlExpirationSeconds = 604800; + private int presignedUrlExpirationSeconds = 86400; private long maxImageSizeBytes = 10485760L; private long maxFileSizeBytes = 52428800L; private String keyPrefix = "attachment"; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 074e4bd0..61579bcf 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -145,8 +145,10 @@ app: url: ${FRONT_DOMAIN_URL} s3: bucket-name: ${S3_BUCKET_NAME:realmatch-s3} + public-bucket-name: ${S3_PUBLIC_BUCKET_NAME:realmatch-public-s3} + cloudfront-base-url: ${S3_CLOUDFRONT_BASE_URL:} region: ${S3_REGION:us-east-2} - presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION:604800} + presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION:86400} max-image-size-bytes: ${S3_MAX_IMAGE_SIZE:10485760} max-file-size-bytes: ${S3_MAX_FILE_SIZE:52428800} key-prefix: ${S3_KEY_PREFIX:attachment} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9f59c3b0..9deae6f4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -155,8 +155,10 @@ app: url: ${FRONT_DOMAIN_URL:https://realmatch.co.kr} s3: bucket-name: ${S3_BUCKET_NAME:realmatch-s3} + public-bucket-name: ${S3_PUBLIC_BUCKET_NAME:realmatch-public-s3} + cloudfront-base-url: ${S3_CLOUDFRONT_BASE_URL:} region: ${S3_REGION:us-east-2} - presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION:604800} + presigned-url-expiration-seconds: ${S3_PRESIGNED_URL_EXPIRATION:86400} max-image-size-bytes: ${S3_MAX_IMAGE_SIZE:10485760} max-file-size-bytes: ${S3_MAX_FILE_SIZE:52428800} key-prefix: ${S3_KEY_PREFIX:attachment}