
들어가며
이번 글에서는 앞서 구축한 MinIO 오브젝트 스토리지를 실제 Spring Boot 애플리케이션과 완전히 연동하는 방법을 알아보겠습니다. 설정부터 파일 업로드, 이미지 관리까지 실무에서 바로 사용할 수 있는 핵심 구현 방법을 제시합니다.
dependencies {
// MinIO 클라이언트
implementation 'io.minio:minio:8.5.2'
}
환경별로 다른 MinIO 접근 방식을 설정합니다:
# 로컬 개발 환경
spring:
config:
activate:
on-profile: local
minio:
endpoint: http://localhost:9100
external-endpoint: http://localhost:9100
accessKey: minioadmin
secretKey: minioadmin
bucket:
name: health-images
---
# Kubernetes 환경
spring:
config:
activate:
on-profile: k8s
minio:
endpoint: http://minio:9100 # 클러스터 내부 접근
external-endpoint: http://<EC2-IP>:31100 # 외부 접근
accessKey: minioadmin
secretKey: minioadmin
bucket:
name: health-images
핵심 포인트
SSL 검증을 비활성화한 MinIO 클라이언트 설정입니다:
@Configuration
public class MinioConfig {
@Value("${spring.minio.endpoint}")
private String endpoint;
@Value("${spring.minio.accessKey}")
private String accessKey;
@Value("${spring.minio.secretKey}")
private String secretKey;
@Bean
public MinioClient minioClient() {
// SSL 검증 비활성화를 위한 OkHttpClient 설정
OkHttpClient httpClient = new OkHttpClient().newBuilder()
.hostnameVerifier((hostname, session) -> true)
.sslSocketFactory(createTrustAllSSLSocketFactory(), new TrustAllCerts())
.build();
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.httpClient(httpClient)
.build();
}
// SSL 관련 헬퍼 메서드들...
}
주의사항: SSL 검증 비활성화는 개발 환경에서만 사용하고, 프로덕션에서는 적절한 SSL 인증서를 사용해야 합니다.
실제 파일 업로드와 URL 생성을 담당하는 서비스입니다:
@Service
public class MealImageService {
@Autowired
private MinioClient minioClient;
@Value("${spring.minio.external-endpoint:#{null}}")
private String externalEndpoint;
@Value("${spring.minio.bucket.name}")
private String bucketName;
// 파일 업로드 핵심 로직
public String uploadMealImage(MultipartFile file) {
checkBucket();
try {
// 유니크한 파일명 생성 (충돌 방지)
String originalFilename = file.getOriginalFilename();
String extension = originalFilename != null && originalFilename.contains(".") ?
originalFilename.substring(originalFilename.lastIndexOf(".")) : ".jpg";
String objectName = "meal/" + UUID.randomUUID() + extension;
// 파일 확장자에 따른 MIME 타입 결정
String contentType = determineContentType(extension, file.getContentType());
// MinIO에 파일 업로드
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(contentType)
.build());
return objectName;
} catch (Exception e) {
throw new RuntimeException("이미지 업로드 중 오류가 발생했습니다: " + e.getMessage(), e);
}
}
// 클라이언트 접근용 URL 생성
public String getMealImageUrl(String objectName) {
if (objectName == null || objectName.isEmpty()) {
return null;
}
// 외부 엔드포인트 사용 (클라이언트 직접 접근용)
String baseUrl = externalEndpoint != null && !externalEndpoint.isEmpty()
? externalEndpoint : endpoint;
return String.format("%s/%s/%s", baseUrl, bucketName, objectName);
}
}
핵심 구현 포인트
데이터베이스에는 객체명만 저장하고, 응답 시 완전한 URL로 변환하는 전략입니다:
@Entity
public class MealFood {
// 기본 필드들...
@Column(length = 250)
private String foodImageUrl; // MinIO 객체명만 저장
public void updateFoodImageUrl(String foodImageUrl) {
this.foodImageUrl = foodImageUrl;
}
}
응답 DTO에서 완전한 URL로 변환:
@Getter
@Setter
@Builder
public class MealFoodResponse {
private String foodImageUrl; // 완전한 URL로 변환되어 반환
// 영양 정보 및 기타 필드들...
public static MealFoodResponse toDto(MealFood mealFood) {
return MealFoodResponse.builder()
.foodImageUrl(mealFood.getFoodImageUrl()) // 객체명만 저장
// 기타 필드 매핑...
.build();
}
}
@RestController
@RequestMapping("/api/meals/images")
public class MealImageController {
// 단순 이미지 업로드
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ResponseDto<Map<String, String>>> uploadMealImage(
@RequestParam("image") MultipartFile file,
@RequestHeader("X-USER-ID") Integer userId
) {
String objectName = mealImageService.uploadMealImage(file);
String imageUrl = mealImageService.getMealImageUrl(objectName);
Map<String, String> result = new HashMap<>();
result.put("objectName", objectName);
result.put("imageUrl", imageUrl);
return ResponseEntity.ok(ResponseDto.success(
HttpStatus.CREATED, "식단 이미지 업로드 성공", result));
}
// 음식별 이미지 업로드 (업로드 + 연결 한번에)
@PostMapping(value = "/meal-food/{mealFoodId}/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ResponseDto<Map<String, String>>> uploadMealFoodImage(
@PathVariable Integer mealFoodId,
@RequestParam("image") MultipartFile file,
@RequestHeader("X-USER-ID") Integer userId
) {
Map<String, String> result = mealService.uploadMealFoodImage(
mealFoodId, file, userId);
return ResponseEntity.ok(ResponseDto.success(
HttpStatus.CREATED, "음식 이미지 업로드 성공", result));
}
}
@RestController
@RequestMapping("/api/meals")
public class MealController {
// 식단 데이터와 이미지를 함께 처리
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ResponseDto<MealDetailResponse>> createOrUpdateMealWithImages(
@RequestPart("mealData") MealCreateRequest request,
@RequestPart(value = "images", required = false) List<MultipartFile> images,
@RequestHeader("X-USER-ID") Integer userId
) {
MealDetailResponse response = mealService.createOrUpdateMealWithImages(
request, images, userId);
return ResponseEntity.ok(ResponseDto.success(
HttpStatus.CREATED, "식단 및 이미지 등록/수정 성공", response));
}
}
조회 시점에 객체명을 완전한 URL로 변환하는 로직입니다:
@Service
public class MealService {
// 조회 시 이미지 URL 변환
public MealDetailResponse getMealDetail(Integer mealId, Integer userId) {
// 기본 조회 로직...
MealDetailResponse response = // ... 기본 응답 생성
// 이미지 URL 처리
for (MealTimeResponse mealTimeResponse : response.getMealTimes()) {
// 시간대 이미지 URL 변환
String mealTimeImageUrl = mealTimeResponse.getMealTimeImageUrl();
if (mealTimeImageUrl != null && !mealTimeImageUrl.isEmpty()) {
mealTimeResponse.setMealTimeImageUrl(
mealImageService.getMealImageUrl(mealTimeImageUrl));
}
// 음식별 이미지 URL 변환
for (MealFoodResponse foodResponse : mealTimeResponse.getFoods()) {
String imageUrl = foodResponse.getFoodImageUrl();
if (imageUrl != null && !imageUrl.isEmpty()) {
foodResponse.setFoodImageUrl(
mealImageService.getMealImageUrl(imageUrl));
}
}
}
return response;
}
}
http://minio:9100 (Kubernetes 서비스명)http://<EC2-IP>:31100 (NodePort)meal/uuid.jpg)마치며
MinIO와 Spring Boot의 연동을 통해 안정적이고 확장 가능한 파일 관리 시스템을 구축할 수 있습니다. 특히 내부/외부 URL 분리와 객체명 기반 저장 전략을 통해 유연하고 효율적인 이미지 관리가 가능합니다.
실제 운영 환경에서는 SSL 인증서 적용, 접근 권한 관리, 이미지 리사이징 등의 추가 기능을 고려해야 하지만, 이번 구현을 기반으로 점진적으로 확장해나갈 수 있습니다.