MinIO 오브젝트 스토리지 (2) - 백엔드 연동

김슭삵·2025년 5월 26일
post-thumbnail

들어가며

이번 글에서는 앞서 구축한 MinIO 오브젝트 스토리지를 실제 Spring Boot 애플리케이션과 완전히 연동하는 방법을 알아보겠습니다. 설정부터 파일 업로드, 이미지 관리까지 실무에서 바로 사용할 수 있는 핵심 구현 방법을 제시합니다.

1. 프로젝트 설정

의존성 추가

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

핵심 포인트

  • endpoint: 애플리케이션에서 MinIO 서버 접근용
  • external-endpoint: 클라이언트가 이미지 직접 접근용
  • 환경별로 다른 URL 설정으로 유연성 확보

2. MinIO 설정 클래스

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 인증서를 사용해야 합니다.

3. 파일 업로드 서비스

실제 파일 업로드와 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);
    }
}

핵심 구현 포인트

  • UUID를 활용한 파일명 중복 방지
  • MIME 타입 자동 결정
  • 내부/외부 접근 URL 분리

4. 엔티티와 이미지 URL 처리

데이터베이스에는 객체명만 저장하고, 응답 시 완전한 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();
    }
}

5. 컨트롤러 구현

다양한 업로드 방식 지원

@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));
    }
}

6. 이미지 URL 변환 로직

조회 시점에 객체명을 완전한 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;
    }
}

7. 핵심 설계 원칙

1. URL 분리 전략

  • 내부 통신: http://minio:9100 (Kubernetes 서비스명)
  • 외부 접근: http://<EC2-IP>:31100 (NodePort)
  • 클라이언트가 이미지에 직접 접근할 수 있도록 외부 URL 제공

2. 데이터 저장 전략

  • DB에는 객체명만 저장 (경량화)
  • 응답 시점에 완전한 URL로 변환
  • URL 변경 시 유연한 대응 가능

3. 파일명 관리

  • UUID 사용으로 중복 방지
  • 폴더 구조로 체계적 관리 (meal/uuid.jpg)
  • 원본 확장자 보존

4. 에러 처리

  • 업로드 실패, 권한 오류 등 상황별 예외 처리
  • 기존 이미지 삭제 시 실패해도 서비스 중단 방지

마치며

MinIO와 Spring Boot의 연동을 통해 안정적이고 확장 가능한 파일 관리 시스템을 구축할 수 있습니다. 특히 내부/외부 URL 분리와 객체명 기반 저장 전략을 통해 유연하고 효율적인 이미지 관리가 가능합니다.
실제 운영 환경에서는 SSL 인증서 적용, 접근 권한 관리, 이미지 리사이징 등의 추가 기능을 고려해야 하지만, 이번 구현을 기반으로 점진적으로 확장해나갈 수 있습니다.

profile
비전공자의 개발 적응기

0개의 댓글