Skip to content

Commit 20f01e0

Browse files
committed
[feat] Presigned URL 파일 업로드 API 구현
1 parent 5236106 commit 20f01e0

10 files changed

Lines changed: 422 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.example.muneoserver.domain.file.controller;
2+
3+
import com.example.muneoserver.domain.file.controller.docs.FileControllerDocs;
4+
import com.example.muneoserver.domain.file.dto.PresignedUrlRequest;
5+
import com.example.muneoserver.domain.file.dto.PresignedUrlResponse;
6+
import com.example.muneoserver.domain.file.service.FileService;
7+
import com.example.muneoserver.global.dto.ApiResponse;
8+
import com.example.muneoserver.global.security.auth.AuthUser;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12+
import org.springframework.web.bind.annotation.GetMapping;
13+
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RequestParam;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
@RestController
18+
@RequestMapping("/api/v1/files")
19+
@RequiredArgsConstructor
20+
public class FileController implements FileControllerDocs {
21+
22+
private final FileService fileService;
23+
24+
@Override
25+
@GetMapping("/presigned-url")
26+
public ResponseEntity<ApiResponse<PresignedUrlResponse>> issuePresignedUrl(
27+
@AuthenticationPrincipal AuthUser authUser,
28+
@RequestParam
29+
String type,
30+
@RequestParam
31+
String filename,
32+
@RequestParam
33+
String contentType
34+
) {
35+
PresignedUrlRequest request = new PresignedUrlRequest(type, filename, contentType);
36+
return ResponseEntity.ok(ApiResponse.success(fileService.issuePresignedUrl(authUser, request), "Presigned URL 발급에 성공했습니다."));
37+
}
38+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.example.muneoserver.domain.file.domain;
2+
3+
import com.example.muneoserver.global.error.exception.CommonException;
4+
import com.example.muneoserver.global.error.exception.ErrorCode;
5+
import java.util.Arrays;
6+
7+
public enum FileUploadType {
8+
ESTIMATE("Estimate"),
9+
POST("Post");
10+
11+
private final String requestType;
12+
13+
FileUploadType(String requestType) {
14+
this.requestType = requestType;
15+
}
16+
17+
public String requestType() {
18+
return requestType;
19+
}
20+
21+
public static FileUploadType from(String type) {
22+
return Arrays.stream(values())
23+
.filter(value -> value.requestType.equalsIgnoreCase(type) || value.name().equalsIgnoreCase(type))
24+
.findFirst()
25+
.orElseThrow(() -> new CommonException(ErrorCode.INVALID_FILE_UPLOAD_TYPE));
26+
}
27+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.example.muneoserver.domain.file.dto;
2+
3+
public record PresignedUrlRequest(
4+
String type,
5+
String filename,
6+
String contentType
7+
) {
8+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.example.muneoserver.domain.file.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import java.time.Instant;
5+
6+
public record PresignedUrlResponse(
7+
@Schema(description = "업로드 타입", example = "Post")
8+
String type,
9+
@Schema(description = "S3에 저장될 object key", example = "post/1/550e8400-e29b-41d4-a716-446655440000.jpg")
10+
String key,
11+
@Schema(description = "프론트엔드가 PUT 요청으로 업로드할 presigned URL")
12+
String uploadUrl,
13+
@Schema(description = "업로드 완료 후 접근 가능한 S3 파일 URL")
14+
String fileUrl,
15+
@Schema(description = "업로드 시 사용할 HTTP 메서드", example = "PUT")
16+
String uploadMethod,
17+
@Schema(description = "업로드 시 반드시 동일하게 전송해야 하는 Content-Type", example = "image/jpeg")
18+
String contentType,
19+
@Schema(description = "Presigned URL 만료 시각(UTC)")
20+
Instant expiresAt
21+
) {
22+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.example.muneoserver.domain.file.service;
2+
3+
import com.example.muneoserver.domain.file.dto.PresignedUrlRequest;
4+
import com.example.muneoserver.domain.file.dto.PresignedUrlResponse;
5+
import com.example.muneoserver.global.security.auth.AuthUser;
6+
7+
public interface FileService {
8+
9+
PresignedUrlResponse issuePresignedUrl(AuthUser authUser, PresignedUrlRequest request);
10+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.example.muneoserver.domain.file.service;
2+
3+
import com.example.muneoserver.domain.file.domain.FileUploadType;
4+
import com.example.muneoserver.domain.file.dto.PresignedUrlRequest;
5+
import com.example.muneoserver.domain.file.dto.PresignedUrlResponse;
6+
import com.example.muneoserver.global.config.s3.S3Properties;
7+
import com.example.muneoserver.global.error.exception.CommonException;
8+
import com.example.muneoserver.global.error.exception.ErrorCode;
9+
import com.example.muneoserver.global.security.auth.AuthUser;
10+
import java.time.Instant;
11+
import java.util.Map;
12+
import java.util.UUID;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.util.StringUtils;
15+
import org.springframework.stereotype.Service;
16+
import software.amazon.awssdk.core.exception.SdkException;
17+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
18+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
19+
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
20+
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
21+
22+
@Service
23+
@RequiredArgsConstructor
24+
public class FileServiceImpl implements FileService {
25+
26+
private final S3Presigner s3Presigner;
27+
private final S3Properties s3Properties;
28+
29+
@Override
30+
public PresignedUrlResponse issuePresignedUrl(AuthUser authUser, PresignedUrlRequest request) {
31+
validateAuthenticated(authUser);
32+
validateS3Configuration();
33+
validateRequest(request);
34+
35+
FileUploadType uploadType = FileUploadType.from(request.type());
36+
String extension = extractExtension(request.filename());
37+
String key = buildKey(uploadType, authUser.id(), extension);
38+
Instant expiresAt = Instant.now().plus(s3Properties.presignedUrlExpiration());
39+
40+
try {
41+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
42+
.bucket(s3Properties.bucket())
43+
.key(key)
44+
.contentType(request.contentType())
45+
.build();
46+
47+
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
48+
.signatureDuration(s3Properties.presignedUrlExpiration())
49+
.putObjectRequest(putObjectRequest)
50+
.build();
51+
52+
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
53+
54+
return new PresignedUrlResponse(
55+
uploadType.requestType(),
56+
key,
57+
presignedRequest.url().toString(),
58+
buildFileUrl(key),
59+
"PUT",
60+
request.contentType(),
61+
expiresAt
62+
);
63+
} catch (SdkException e) {
64+
throw new CommonException(ErrorCode.FILE_UPLOAD_PRESIGNED_URL_GENERATION_FAILED);
65+
}
66+
}
67+
68+
private String buildKey(FileUploadType uploadType, Long userId, String extension) {
69+
String directory = switch (uploadType) {
70+
case ESTIMATE -> s3Properties.estimateDirectory();
71+
case POST -> s3Properties.postDirectory();
72+
};
73+
74+
return directory + "/" + userId + "/" + UUID.randomUUID() + "." + extension;
75+
}
76+
77+
private String buildFileUrl(String key) {
78+
return "https://" + s3Properties.bucket() + ".s3." + s3Properties.region() + ".amazonaws.com/" + key;
79+
}
80+
81+
private void validateAuthenticated(AuthUser authUser) {
82+
if (authUser == null) {
83+
throw new CommonException(ErrorCode.UNAUTHORIZED);
84+
}
85+
}
86+
87+
private void validateS3Configuration() {
88+
if (isBlank(s3Properties.bucket())
89+
|| isBlank(s3Properties.region())
90+
|| isBlank(s3Properties.estimateDirectory())
91+
|| isBlank(s3Properties.postDirectory())
92+
|| s3Properties.presignedUrlExpiration() == null
93+
|| s3Properties.presignedUrlExpiration().isZero()
94+
|| s3Properties.presignedUrlExpiration().isNegative()) {
95+
throw new CommonException(ErrorCode.FILE_UPLOAD_CONFIGURATION_INVALID);
96+
}
97+
}
98+
99+
private void validateRequest(PresignedUrlRequest request) {
100+
if (request == null) {
101+
throw new CommonException(ErrorCode.INVALID_REQUEST_FORMAT);
102+
}
103+
if (isBlank(request.type())) {
104+
throw new CommonException(ErrorCode.VALIDATION_FAILED, Map.of("type", "업로드 타입은 필수입니다."));
105+
}
106+
if (isBlank(request.filename())) {
107+
throw new CommonException(ErrorCode.VALIDATION_FAILED, Map.of("filename", "파일명은 필수입니다."));
108+
}
109+
if (isBlank(request.contentType())) {
110+
throw new CommonException(ErrorCode.VALIDATION_FAILED, Map.of("contentType", "Content-Type은 필수입니다."));
111+
}
112+
}
113+
114+
private String extractExtension(String filename) {
115+
String normalizedFilename = extractFilename(filename);
116+
String extension = StringUtils.getFilenameExtension(normalizedFilename);
117+
118+
if (!StringUtils.hasText(extension)) {
119+
throw new CommonException(ErrorCode.VALIDATION_FAILED, Map.of("filename", "파일명에 확장자가 필요합니다."));
120+
}
121+
122+
return extension;
123+
}
124+
125+
private String extractFilename(String filename) {
126+
String normalized = filename.trim().replace("\\", "/");
127+
int lastSlashIndex = normalized.lastIndexOf('/');
128+
129+
if (lastSlashIndex >= 0) {
130+
normalized = normalized.substring(lastSlashIndex + 1);
131+
}
132+
133+
if (!StringUtils.hasText(normalized)) {
134+
throw new CommonException(ErrorCode.VALIDATION_FAILED, Map.of("filename", "파일명은 필수입니다."));
135+
}
136+
137+
return normalized;
138+
}
139+
140+
private boolean isBlank(String value) {
141+
return value == null || value.isBlank();
142+
}
143+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.example.muneoserver.global.config.s3;
2+
3+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.regions.Region;
7+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
8+
9+
@Configuration
10+
@EnableConfigurationProperties(S3Properties.class)
11+
public class S3Config {
12+
13+
@Bean(destroyMethod = "close")
14+
public S3Presigner s3Presigner(S3Properties s3Properties) {
15+
return S3Presigner.builder()
16+
.region(Region.of(s3Properties.region()))
17+
.build();
18+
}
19+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.example.muneoserver.global.config.s3;
2+
3+
import java.time.Duration;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
6+
@ConfigurationProperties(prefix = "app.s3")
7+
public record S3Properties(
8+
String bucket,
9+
String region,
10+
Duration presignedUrlExpiration,
11+
String estimateDirectory,
12+
String postDirectory
13+
) {
14+
15+
public S3Properties {
16+
bucket = normalize(bucket);
17+
region = normalize(region);
18+
estimateDirectory = normalize(estimateDirectory);
19+
postDirectory = normalize(postDirectory);
20+
}
21+
22+
private static String normalize(String value) {
23+
return value == null ? null : value.trim();
24+
}
25+
}

0 commit comments

Comments
 (0)