Skip to content

Commit cc43b19

Browse files
authored
Merge pull request #130 from prgrms-web-devcourse-final-project/feature/EA3-126-image-storage
[EA3-126] feature: 파일 업로드 기능 구현 (GCP/Local 공통 처리 포함)
2 parents d10cc99 + 5bea2bd commit cc43b19

File tree

11 files changed

+391
-10
lines changed

11 files changed

+391
-10
lines changed

build.gradle

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ dependencies {
8484

8585

8686
// 배포 관련 의존성
87-
// runtimeOnly 'org.postgresql:postgresql'
88-
// implementation 'org.springframework.boot:spring-boot-devtools'
87+
// runtimeOnly 'org.postgresql:postgresql'
88+
implementation 'org.springframework.boot:spring-boot-devtools'
8989

90-
// implementation 'com.google.cloud:spring-cloud-gcp-starter-secretmanager:4.9.1'
91-
// implementation 'com.google.cloud:google-cloud-storage:2.38.0'
90+
implementation 'com.google.cloud:spring-cloud-gcp-starter-secretmanager:4.9.1'
91+
implementation 'com.google.cloud:google-cloud-storage:2.38.0'
9292
}
9393

9494
tasks.named('test') {
@@ -101,7 +101,7 @@ dependencyManagement {
101101
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
102102

103103
// GCP 관련 스타터(BOM) 관리 - Secret Manager, GCS 등
104-
// mavenBom "com.google.cloud:spring-cloud-gcp-dependencies:${springCloudGcpVersion}"
104+
mavenBom "com.google.cloud:spring-cloud-gcp-dependencies:${springCloudGcpVersion}"
105105
}
106106
}
107107

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package grep.neogul_coder.global.init;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.boot.context.event.ApplicationReadyEvent;
5+
import org.springframework.context.event.EventListener;
6+
import org.springframework.core.env.Environment;
7+
import org.springframework.stereotype.Component;
8+
9+
@Slf4j
10+
@Component
11+
public class ActiveProfileLogger {
12+
private final Environment environment;
13+
14+
public ActiveProfileLogger(Environment environment) {
15+
this.environment = environment;
16+
}
17+
18+
@EventListener(ApplicationReadyEvent.class)
19+
public void logActiveProfiles() {
20+
String[] activeProfiles = environment.getActiveProfiles();
21+
log.info("==============================================================================");
22+
log.info("★ ★ ★ ★ ★ [실행 프로파일] Active Spring Profiles: {} ★ ★ ★ ★ ★ ", (Object) activeProfiles);
23+
log.info("==============================================================================");
24+
}
25+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package grep.neogul_coder.global.utils.upload;
2+
3+
import grep.neogul_coder.global.utils.upload.exception.FileUploadErrorCode;
4+
import grep.neogul_coder.global.utils.upload.exception.FileUploadException;
5+
import java.io.IOException;
6+
import java.time.LocalDate;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.UUID;
10+
import org.springframework.web.multipart.MultipartFile;
11+
12+
public abstract class AbstractFileManager {
13+
14+
// 여러 개의 파일을 업로드 처리하고, 결과를 리스트로 반환
15+
public List<FileUploadResponse> upload(List<MultipartFile> files, Long uploaderId, FileUsageType usageType, Long usageRefId) throws IOException {
16+
List<FileUploadResponse> uploadResults = new ArrayList<>();
17+
18+
if (files == null || files.isEmpty() || files.getFirst().isEmpty()) {
19+
throw new FileUploadException(FileUploadErrorCode.FILE_EMPTY);
20+
}
21+
22+
for (MultipartFile file : files) {
23+
uploadResults.add(upload(file, uploaderId, usageType, usageRefId));
24+
}
25+
26+
return uploadResults;
27+
}
28+
29+
// 단일 파일 업로드 처리 및 결과 반환
30+
public FileUploadResponse upload(MultipartFile file, Long uploaderId, FileUsageType usageType, Long usageRefId) throws IOException {
31+
if (file == null || file.isEmpty()) {
32+
throw new FileUploadException(FileUploadErrorCode.FILE_EMPTY);
33+
}
34+
35+
String contentType = file.getContentType();
36+
if(contentType == null || !ALLOWED_TYPES.contains(contentType)) {
37+
throw new FileUploadException(FileUploadErrorCode.FILE_TYPE_INVALID);
38+
}
39+
40+
String originFileName = file.getOriginalFilename();
41+
if (originFileName == null) {
42+
throw new FileUploadException(FileUploadErrorCode.FILE_NAME_INVALID);
43+
}
44+
45+
String renameFileName = generateRenameFileName(originFileName); // 파일명 UUID로 변경
46+
String savePath = generateSavePath(usageType, uploaderId, null, usageRefId); // 저장 경로 생성
47+
String fileUrl = generateFileUrl(savePath, renameFileName); // 저장소별 파일 URL 생성
48+
49+
uploadFile(file, buildFullPath(savePath, renameFileName)); // 실제 파일 업로드(구현체에서 구현)
50+
51+
return new FileUploadResponse(
52+
originFileName,
53+
renameFileName,
54+
usageType,
55+
fileUrl,
56+
savePath,
57+
uploaderId,
58+
usageRefId
59+
);
60+
}
61+
62+
// 실제 파일 업로드를 수행
63+
protected abstract void uploadFile(MultipartFile file, String fullPath) throws IOException;
64+
65+
// 파일 저장 경로를 생성 (용도/참조 ID/날짜 기반)
66+
public static String generateSavePath(FileUsageType usageType, Long uploaderId, Long studyId, Long usageRefId) {
67+
LocalDate now = LocalDate.now();
68+
if (usageType == FileUsageType.PROFILE && usageRefId != null) {
69+
// 프로필: user/profile/{userId}/...
70+
return "user/profile/" + usageRefId + "/" +
71+
now.getYear() + "/" +
72+
now.getMonthValue() + "/" +
73+
now.getDayOfMonth() + "/";
74+
} else {
75+
// 스터디 커버, 게시글 경로: {usageType}/{usageRefId}/...
76+
return usageType.name().toLowerCase() + "/" + usageRefId + "/" +
77+
now.getYear() + "/" +
78+
now.getMonthValue() + "/" +
79+
now.getDayOfMonth() + "/";
80+
}
81+
}
82+
83+
// 파일명을 UUID 기반으로 리네이밍
84+
protected String generateRenameFileName(String originFileName) {
85+
String ext = originFileName.substring(originFileName.lastIndexOf("."));
86+
return UUID.randomUUID() + ext;
87+
}
88+
89+
// 저장된 파일의 URL 을 반환
90+
protected abstract String generateFileUrl(String savePath, String renameFileName);
91+
92+
// 전체 저장 경로(폴더+파일명)를 반환
93+
protected String buildFullPath(String savePath, String renameFileName) {
94+
return savePath + "/" + renameFileName;
95+
}
96+
97+
// MIME 타입 허용 목록 (이미지 파일만 허용)
98+
protected static final List<String> ALLOWED_TYPES = List.of(
99+
"image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"
100+
);
101+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package grep.neogul_coder.global.utils.upload;
2+
3+
public record FileUploadResponse(
4+
String originFileName, // 원본 파일명
5+
String renameFileName, // UUID로 변경된 파일명
6+
FileUsageType usageType, // 파일 사용 목적
7+
String savePath, // 저장 경로
8+
String fileUrl, // 전체 URL
9+
Long uploaderId, // 업로더 ID
10+
Long usageRefId // 파일이 참조되는 도메인 ID
11+
) {
12+
13+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package grep.neogul_coder.global.utils.upload;
2+
3+
public enum FileUsageType {
4+
PROFILE, POST, STUDY_COVER
5+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package grep.neogul_coder.global.utils.upload.entity;
2+
3+
import grep.neogul_coder.global.entity.BaseEntity;
4+
import grep.neogul_coder.global.utils.upload.FileUsageType;
5+
import jakarta.persistence.*;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Entity
11+
@Getter
12+
@NoArgsConstructor
13+
public class UploadImage extends BaseEntity {
14+
@Id
15+
@GeneratedValue(strategy = GenerationType.IDENTITY)
16+
private Long id;
17+
18+
@Column(nullable = false)
19+
private String originFileName;
20+
21+
@Column(nullable = false)
22+
private String renameFileName;
23+
24+
@Column(nullable = false, length = 1000)
25+
private String fileUrl;
26+
27+
@Column(nullable = false)
28+
private String savePath;
29+
30+
@Column(nullable = false)
31+
private Long uploaderId;
32+
33+
@Enumerated(EnumType.STRING)
34+
@Column(nullable = false)
35+
private FileUsageType usageType;
36+
37+
private Long usageRefId; // 파일이 속한 도메인 ID(게시글 ID 등)
38+
39+
@Builder
40+
public UploadImage(String originFileName, String renameFileName, String fileUrl, String savePath,
41+
Long uploaderId, FileUsageType usageType, Long usageRefId) {
42+
this.originFileName = originFileName;
43+
this.renameFileName = renameFileName;
44+
this.fileUrl = fileUrl;
45+
this.savePath = savePath;
46+
this.uploaderId = uploaderId;
47+
this.usageType = usageType;
48+
this.usageRefId = usageRefId;
49+
}
50+
51+
public static UploadImage of(String originFileName, String renameFileName, String fileUrl,
52+
String savePath, Long uploaderId, FileUsageType usageType, Long usageRefId) {
53+
return builder()
54+
.originFileName(originFileName)
55+
.renameFileName(renameFileName)
56+
.fileUrl(fileUrl)
57+
.savePath(savePath)
58+
.uploaderId(uploaderId)
59+
.usageType(usageType)
60+
.usageRefId(usageRefId)
61+
.build();
62+
}
63+
64+
// 파일의 참조 ID(usageRefId)를 변경(파일 이동 등)
65+
public void updateUsageRefId(Long usageRefId) {
66+
this.usageRefId = usageRefId;
67+
}
68+
69+
// 파일의 저장 경로와 URL 을 변경(파일 이동 등)
70+
public void updateSavePathAndFileUrl(String savePath, String fileUrl) {
71+
this.savePath = savePath;
72+
this.fileUrl = fileUrl;
73+
}
74+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package grep.neogul_coder.global.utils.upload.exception;
2+
3+
import grep.neogul_coder.global.response.code.ErrorCode;
4+
import lombok.Getter;
5+
import org.springframework.http.HttpStatus;
6+
7+
@Getter
8+
public enum FileUploadErrorCode implements ErrorCode {
9+
10+
FILE_EMPTY("UPLOAD_001", HttpStatus.BAD_REQUEST, "업로드할 파일이 비어있습니다."),
11+
FILE_NAME_INVALID("UPLOAD_002", HttpStatus.BAD_REQUEST, "파일 이름이 유효하지 않습니다."),
12+
FILE_UPLOAD_FAIL("UPLOAD_003", HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 중 오류가 발생했습니다."),
13+
FILE_TYPE_INVALID("UPLOAD_004", HttpStatus.BAD_REQUEST, "지원하지 않는 파일 타입입니다."),
14+
FILE_NOT_FOUND("UPLOAD_005", HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다."),
15+
16+
GCP_UPLOAD_FAIL("UPLOAD_GCP_001", HttpStatus.INTERNAL_SERVER_ERROR, "GCP 업로드 중 오류가 발생했습니다."),
17+
LOCAL_UPLOAD_FAIL("UPLOAD_LOCAL_001", HttpStatus.INTERNAL_SERVER_ERROR, "로컬 업로드 중 오류가 발생했습니다.");
18+
19+
private final String code;
20+
private final HttpStatus status;
21+
private final String message;
22+
23+
FileUploadErrorCode(String code, HttpStatus status, String message) {
24+
this.code = code;
25+
this.status = status;
26+
this.message = message;
27+
}
28+
29+
@Override
30+
public String getCode() {
31+
return code;
32+
}
33+
34+
@Override
35+
public HttpStatus getStatus() {
36+
return status;
37+
}
38+
39+
@Override
40+
public String getMessage() {
41+
return message;
42+
}
43+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package grep.neogul_coder.global.utils.upload.exception;
2+
3+
public class FileUploadException extends RuntimeException {
4+
5+
private final FileUploadErrorCode errorCode;
6+
7+
public FileUploadException(FileUploadErrorCode errorCode) {
8+
super(errorCode.getMessage());
9+
this.errorCode = errorCode;
10+
}
11+
12+
public FileUploadException(FileUploadErrorCode errorCode, Throwable cause) {
13+
super(errorCode.getMessage(), cause);
14+
this.errorCode = errorCode;
15+
}
16+
17+
public FileUploadException(FileUploadErrorCode errorCode, String message) {
18+
super(message);
19+
this.errorCode = errorCode;
20+
}
21+
22+
public FileUploadException(FileUploadErrorCode errorCode, String message, Throwable cause) {
23+
super(message, cause);
24+
this.errorCode = errorCode;
25+
}
26+
27+
public FileUploadErrorCode getErrorCode() {
28+
return errorCode;
29+
}
30+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package grep.neogul_coder.global.utils.upload.uploader;
2+
3+
import com.google.cloud.storage.BlobId;
4+
import com.google.cloud.storage.BlobInfo;
5+
import com.google.cloud.storage.Storage;
6+
import com.google.cloud.storage.StorageOptions;
7+
import grep.neogul_coder.global.utils.upload.exception.FileUploadException;
8+
import grep.neogul_coder.global.utils.upload.AbstractFileManager;
9+
import grep.neogul_coder.global.utils.upload.exception.FileUploadErrorCode;
10+
import java.io.IOException;
11+
import java.io.InputStream;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.context.annotation.Profile;
15+
import org.springframework.stereotype.Component;
16+
import org.springframework.web.multipart.MultipartFile;
17+
18+
@Component
19+
@Profile("prod")
20+
@Slf4j
21+
public class GcpFileUploader extends AbstractFileManager {
22+
23+
@Value("${google.cloud.storage.bucket}")
24+
private String bucket;
25+
26+
private static final String STORAGE_BASE_URL = "https://storage.googleapis.com/";
27+
28+
// GCP Cloud Storage 에 파일을 업로드
29+
@Override
30+
protected void uploadFile(MultipartFile file, String fullPath) throws IOException {
31+
try (InputStream inputStream = file.getInputStream()) {
32+
Storage storage = StorageOptions.getDefaultInstance().getService();
33+
BlobId blobId = BlobId.of(bucket, fullPath);
34+
BlobInfo blobInfo = BlobInfo.newBuilder(blobId)
35+
.setContentType(file.getContentType())
36+
.build();
37+
storage.createFrom(blobInfo, inputStream);
38+
} catch (IOException e) {
39+
log.error("GCP 파일 업로드 실패 - 원본 파일명: {}", file.getOriginalFilename(), e);
40+
throw new FileUploadException(FileUploadErrorCode.GCP_UPLOAD_FAIL, e);
41+
}
42+
}
43+
44+
// 업로드된 파일의 GCP Storage URL 을 생성
45+
// 전체 파일 URL (https://storage.googleapis.com/bucket/경로/파일명)
46+
@Override
47+
public String generateFileUrl(String savePath, String renameFileName) {
48+
return STORAGE_BASE_URL + bucket + "/" + savePath + "/" + renameFileName;
49+
}
50+
}

0 commit comments

Comments
 (0)