Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ private DeleteOutcome deleteStorageTargets(List<AttachmentCleanupTarget> 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={}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 삭제 시도.
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.example.RealMatch.attachment.domain.enums;

/**
* 첨부파일 용도
* S3 경로 prefix 분리 및 향후 TTL·캐싱 정책 분리의 기준
*
* CHAT: 채팅 첨부. 참여자만 접근 가능. 비공개 자산.
* PUBLIC: 브랜드/캠페인 등 상세페이지·홈 화면 노출용. 로그인 없이도 이미지 조회 가능해야 하는 공개 자산.
* 첨부파일의 용도를 정의합니다.
* <p>
* 이 값에 따라 파일이 저장되는 S3 버킷(private/public)과 접근 URL 생성 방식(presigned/CDN)이 결정됩니다.
* <ul>
* <li>{@code CHAT}: 채팅 등 비공개 컨텍스트에서 사용되는 파일. private 버킷에 저장되며, presigned URL로 접근.</li>
* <li>{@code PUBLIC}: 브랜드 로고, 캠페인 이미지 등 공개 자산. public 버킷에 저장되며, CloudFront(CDN) URL로 접근.</li>
* </ul>
*/
public enum AttachmentUsage {
CHAT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Attachment, Long> {

Expand All @@ -29,7 +30,7 @@ List<Long> 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
Expand Down Expand Up @@ -117,5 +118,6 @@ int softDeleteByIdsAndStatus(
interface AttachmentCleanupTarget {
Long getId();
String getStorageKey();
AttachmentUsage getUsage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 3 additions & 1 deletion src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading