Skip to content

Commit 3f814d1

Browse files
committed
feat: MinIO 이미지 Presigned URL API 구현
- MinIO 의존성 및 설정 추가 - 도메인별 버킷 분리 (reviews, mountains, restaurants) - Presigned URL 발급 API (GET /api/images/presigned-url) - 앱 시작 시 버킷 자동 생성 - K8s MinIO deployment/service 매니페스트 추가
1 parent df7a662 commit 3f814d1

10 files changed

Lines changed: 267 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ application-local.yaml
4444
### K8s secrets (contains real credentials)
4545
k8s/app/secret.yaml
4646
k8s/postgres/secret.yaml
47+
k8s/minio/secret.yaml
4748

4849
# OMC
4950
.omc

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ dependencies {
3535
// Swagger
3636
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6")
3737

38+
// MinIO
39+
implementation 'io.minio:minio:8.5.17'
40+
3841
// JWT 라이브러리 (jjwt)
3942
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
4043
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'

k8s/minio/deployment.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: minio
5+
namespace: semosan
6+
spec:
7+
replicas: 1
8+
selector:
9+
matchLabels:
10+
app: minio
11+
template:
12+
metadata:
13+
labels:
14+
app: minio
15+
spec:
16+
containers:
17+
- name: minio
18+
image: minio/minio:latest
19+
args:
20+
- server
21+
- /data
22+
- --console-address
23+
- ":9001"
24+
ports:
25+
- containerPort: 9000
26+
- containerPort: 9001
27+
envFrom:
28+
- secretRef:
29+
name: minio-secret
30+
volumeMounts:
31+
- name: minio-data
32+
mountPath: /data
33+
nodeSelector:
34+
kubernetes.io/hostname: k8s-master
35+
volumes:
36+
- name: minio-data
37+
hostPath:
38+
path: /data/minio
39+
type: DirectoryOrCreate

k8s/minio/service.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
apiVersion: v1
2+
kind: Service
3+
metadata:
4+
name: minio
5+
namespace: semosan
6+
spec:
7+
selector:
8+
app: minio
9+
ports:
10+
- name: api
11+
port: 9000
12+
targetPort: 9000
13+
- name: console
14+
port: 9001
15+
targetPort: 9001
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.semosan.api.common.config;
2+
3+
import io.minio.BucketExistsArgs;
4+
import io.minio.MakeBucketArgs;
5+
import io.minio.MinioClient;
6+
import jakarta.annotation.PostConstruct;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.beans.factory.annotation.Value;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
12+
import java.util.List;
13+
14+
@Slf4j
15+
@Configuration
16+
public class MinioConfig {
17+
18+
private static final List<String> REQUIRED_BUCKETS = List.of("reviews", "mountains", "restaurants");
19+
20+
@Value("${minio.endpoint}")
21+
private String endpoint;
22+
23+
@Value("${minio.access-key}")
24+
private String accessKey;
25+
26+
@Value("${minio.secret-key}")
27+
private String secretKey;
28+
29+
@Bean
30+
public MinioClient minioClient() {
31+
return MinioClient.builder()
32+
.endpoint(endpoint)
33+
.credentials(accessKey, secretKey)
34+
.build();
35+
}
36+
37+
@PostConstruct
38+
public void initBuckets() {
39+
MinioClient client = minioClient();
40+
for (String bucket : REQUIRED_BUCKETS) {
41+
try {
42+
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
43+
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
44+
log.info("MinIO 버킷 생성: {}", bucket);
45+
}
46+
} catch (Exception e) {
47+
log.warn("MinIO 버킷 초기화 실패: {} - {}", bucket, e.getMessage());
48+
}
49+
}
50+
}
51+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.semosan.api.domain.image.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.image.controller.docs.ImageControllerDocs;
6+
import com.semosan.api.domain.image.dto.response.PresignedUrlResponse;
7+
import com.semosan.api.domain.image.service.ImageService;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RequestParam;
13+
import org.springframework.web.bind.annotation.RestController;
14+
15+
@RestController
16+
@RequestMapping("/api/images")
17+
@RequiredArgsConstructor
18+
public class ImageController implements ImageControllerDocs {
19+
20+
private final ImageService imageService;
21+
22+
@GetMapping("/presigned-url")
23+
@Override
24+
public ResponseEntity<ApiResponse<PresignedUrlResponse>> getPresignedUrl(
25+
@RequestParam String bucket,
26+
@RequestParam String filename
27+
) {
28+
PresignedUrlResponse response = imageService.generatePresignedUrl(bucket, filename);
29+
return ApiResponse.success(SuccessStatus.PRESIGNED_URL_SUCCESS, response);
30+
}
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.semosan.api.domain.image.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.domain.image.dto.response.PresignedUrlResponse;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.Parameter;
7+
import io.swagger.v3.oas.annotations.media.Content;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.RequestParam;
13+
14+
@Tag(name = "Image", description = "이미지 업로드 관련 API")
15+
public interface ImageControllerDocs {
16+
17+
@Operation(
18+
summary = "Presigned URL 발급",
19+
description = "이미지 업로드를 위한 Presigned URL을 발급합니다. 발급된 URL로 PUT 요청하여 직접 업로드합니다."
20+
)
21+
@ApiResponses({
22+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
23+
responseCode = "200",
24+
description = "Presigned URL 발급 성공"
25+
),
26+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
27+
responseCode = "502",
28+
description = "이미지 업로드 URL 생성 실패",
29+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
30+
)
31+
})
32+
ResponseEntity<ApiResponse<PresignedUrlResponse>> getPresignedUrl(
33+
@Parameter(description = "버킷명 (reviews, mountains, restaurants)", required = true)
34+
@RequestParam String bucket,
35+
@Parameter(description = "원본 파일명 (확장자 추출용)", required = true)
36+
@RequestParam String filename
37+
);
38+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.semosan.api.domain.image.dto.response;
2+
3+
public record PresignedUrlResponse(
4+
String uploadUrl,
5+
String imageUrl
6+
) {
7+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.semosan.api.domain.image.service;
2+
3+
import com.semosan.api.common.exception.GeneralException;
4+
import com.semosan.api.common.status.ErrorStatus;
5+
import com.semosan.api.domain.image.dto.response.PresignedUrlResponse;
6+
import io.minio.GetPresignedObjectUrlArgs;
7+
import io.minio.MinioClient;
8+
import io.minio.http.Method;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.beans.factory.annotation.Value;
11+
import org.springframework.stereotype.Service;
12+
13+
import java.util.Set;
14+
import java.util.UUID;
15+
import java.util.concurrent.TimeUnit;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class ImageService {
20+
21+
private static final Set<String> ALLOWED_BUCKETS = Set.of("reviews", "mountains", "restaurants");
22+
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".jpg", ".jpeg", ".png", ".webp");
23+
24+
private final MinioClient minioClient;
25+
26+
@Value("${minio.endpoint}")
27+
private String endpoint;
28+
29+
@Value("${minio.public-url}")
30+
private String publicUrl;
31+
32+
public PresignedUrlResponse generatePresignedUrl(String bucket, String filename) {
33+
validateBucket(bucket);
34+
String extension = extractExtension(filename);
35+
validateExtension(extension);
36+
37+
String key = UUID.randomUUID() + extension;
38+
39+
try {
40+
String uploadUrl = minioClient.getPresignedObjectUrl(
41+
GetPresignedObjectUrlArgs.builder()
42+
.method(Method.PUT)
43+
.bucket(bucket)
44+
.object(key)
45+
.expiry(10, TimeUnit.MINUTES)
46+
.build()
47+
);
48+
49+
uploadUrl = uploadUrl.replace(endpoint, publicUrl);
50+
String imageUrl = publicUrl + "/" + bucket + "/" + key;
51+
52+
return new PresignedUrlResponse(uploadUrl, imageUrl);
53+
} catch (Exception e) {
54+
throw new GeneralException(ErrorStatus.IMAGE_UPLOAD_FAILED);
55+
}
56+
}
57+
58+
private void validateBucket(String bucket) {
59+
if (bucket == null || !ALLOWED_BUCKETS.contains(bucket)) {
60+
throw new GeneralException(ErrorStatus.BAD_REQUEST);
61+
}
62+
}
63+
64+
private void validateExtension(String extension) {
65+
if (extension.isEmpty() || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
66+
throw new GeneralException(ErrorStatus.BAD_REQUEST);
67+
}
68+
}
69+
70+
private String extractExtension(String filename) {
71+
if (filename == null || !filename.contains(".")) {
72+
return "";
73+
}
74+
return filename.substring(filename.lastIndexOf("."));
75+
}
76+
}

src/main/resources/application-prod.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ spring:
1212
hibernate:
1313
dialect: org.hibernate.dialect.PostgreSQLDialect
1414

15+
minio:
16+
endpoint: ${MINIO_ENDPOINT}
17+
access-key: ${MINIO_ACCESS_KEY}
18+
secret-key: ${MINIO_SECRET_KEY}
19+
public-url: ${MINIO_PUBLIC_URL}
20+
1521
jwt:
1622
secret: ${JWT_SECRET}
1723
access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION}

0 commit comments

Comments
 (0)