스프링 부트 애플리케이션에 MinIO 객체 스토리지 연동하기

MinIO 환경 설정 및 설치

MinIO는 고성능, 분산형 객체 스토리지 서버로, 아마존 S3와 호환되는 API를 제공합니다. 스프링 부트 애플리케이션에서 MinIO를 활용하기 위한 기본적인 환경 설정과 설치 방법을 알아봅니다.

MinIO 데이터 볼륨 생성

MinIO 인스턴스가 사용할 데이터를 저장할 디렉토리를 호스트 시스템에 생성합니다. 이는 MinIO 설정과 객체 데이터를 영구적으로 보존하기 위함입니다.

# MinIO 설정 파일 및 데이터 저장을 위한 디렉토리 생성
mkdir -p /mnt/minio/config
mkdir -p /mnt/minio/data

Docker를 이용한 MinIO 실행

Docker 컨테이너를 사용하여 MinIO 서버를 쉽게 배포할 수 있습니다. 다음 명령어를 통해 MinIO를 실행합니다. 여기서 `MINIO_ACCESS_KEY`와 `MINIO_SECRET_KEY`는 MinIO 콘솔 및 API 접근에 사용될 인증 정보입니다.

docker run -p 9000:9000 -p 9001:9001 \
  --name minio-server \
  -d --restart=always \
  -e "MINIO_ROOT_USER=minioadmin" \
  -e "MINIO_ROOT_PASSWORD=minioadmin" \
  -v /mnt/minio/data:/data \
  -v /mnt/minio/config:/root/.minio \
  minio/minio server /data --console-address ":9001" -address ":9000"
  • -p 9000:9000: MinIO API 접근 포트 (애플리케이션 연동)
  • -p 9001:9001: MinIO 웹 콘솔 접근 포트
  • --name minio-server: 컨테이너 이름 지정
  • -d --restart=always: 백그라운드 실행 및 Docker 재시작 시 자동 실행
  • -e "MINIO_ROOT_USER", -e "MINIO_ROOT_PASSWORD": 관리자 계정 정보
  • -v /mnt/minio/data:/data: MinIO 객체 데이터 저장 볼륨 매핑
  • -v /mnt/minio/config:/root/.minio: MinIO 설정 파일 저장 볼륨 매핑
  • minio/minio server /data --console-address ":9001" -address ":9000": MinIO 서버 실행 명령 및 포트 설정

컨테이너가 정상적으로 실행되는지 확인하고, 웹 브라우저에서 http://[호스트_IP]:9001로 접속하여 MinIO 콘솔에 로그인할 수 있습니다.

MinIO 콘솔 관리

MinIO 웹 콘솔에서는 버킷 생성, 사용자 관리, 객체 접근 권한 설정 등을 수행할 수 있습니다.

  1. 접속 주소: http://[호스트_IP]:9001
  2. 로그인: Docker 명령어에서 설정한 MINIO_ROOT_USERMINIO_ROOT_PASSWORD로 로그인합니다.
  3. 버킷 생성: 'Buckets' 메뉴에서 새로운 버킷을 생성합니다. 버킷 이름은 소문자로 지정하는 것이 권장됩니다.
  4. 버킷 정책 설정: 생성된 버킷의 'Manage' -> 'Access Policy'에서 외부 접근을 허용하도록 정책을 설정합니다. 예를 들어, 모든 사용자가 버킷의 객체를 읽을 수 있도록 'Public' 권한을 부여할 수 있습니다. 이 설정을 통해 http://[호스트_IP]:9000/[버킷명]/[객체명]으로 직접 접근이 가능해집니다.

스프링 부트와 MinIO 연동

이제 스프링 부트 애플리케이션에서 MinIO를 사용하여 파일을 업로드하고 다운로드하는 기능을 구현해봅니다.

의존성 추가 (pom.xml)

새로운 스프링 부트 프로젝트를 생성하거나 기존 프로젝트에 다음 Maven 의존성을 추가합니다. 특히 MinIO 클라이언트 라이브러리가 중요합니다.


<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>io.minio</groupId>
        <artifactId>minio</artifactId>
        <version>7.0.2</version> <!-- 최신 버전으로 업데이트 가능 -->
    </dependency>
    <!-- 기타 유틸리티 라이브러리 (필요에 따라 추가) -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.6.5</version>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
        <version>3.12.0</version> <!-- 최신 버전으로 업데이트 가능 -->
    </dependency>
</dependencies>

MinIO 설정 (application.yml)

src/main/resources/application.yml 파일에 MinIO 서버 접속 정보를 설정합니다. 여기서 minio.endpoint는 MinIO API 포트(9000번)를 가리킵니다.


server:
  port: 8080 # 스프링 부트 애플리케이션 포트
spring:
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
minio:
  endpoint: http://127.0.0.1:9000
  access-key: minioadmin # MinIO 관리자 사용자 이름
  secret-key: minioadmin # MinIO 관리자 비밀번호
  bucket-name: myfiles # 기본으로 사용할 버킷 이름
  url-prefix: ${minio.endpoint}/${minio.bucket-name}/ # 파일 접근을 위한 URL 접두사

MinIO 서비스 클래스 구현

MinIO 클라이언트와의 상호작용을 담당하는 서비스 클래스를 생성합니다. 이 클래스는 파일을 업로드하고 다운로드하며, 버킷을 관리하는 등의 기능을 제공합니다.


import io.minio.*;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.apache.commons.io.IOUtils; // commons-io 의존성 추가 필요

@Service
public class MinioStorageService implements InitializingBean {

    private static final Logger log = LoggerFactory.getLogger(MinioStorageService.class);

    @Value("${minio.endpoint}")
    private String minioEndpoint;

    @Value("${minio.access-key}")
    private String minioAccessKey;

    @Value("${minio.secret-key}")
    private String minioSecretKey;

    @Value("${minio.bucket-name}")
    private String defaultBucketName;

    @Value("${minio.url-prefix}")
    private String publicUrlPrefix;

    private MinioClient minioClient;

    @Override
    public void afterPropertiesSet() throws Exception {
        // 필수 설정값이 비어있는지 확인
        Objects.requireNonNull(minioEndpoint, "MinIO endpoint is null");
        Objects.requireNonNull(minioAccessKey, "MinIO access key is null");
        Objects.requireNonNull(minioSecretKey, "MinIO secret key is null");

        this.minioClient = MinioClient.builder()
                .endpoint(minioEndpoint)
                .credentials(minioAccessKey, minioSecretKey)
                .build();

        // 기본 버킷이 없으면 생성
        if (!bucketExists(defaultBucketName)) {
            createBucket(defaultBucketName);
            log.info("Default MinIO bucket '{}' created successfully.", defaultBucketName);
        }
    }

    /**
     * 파일 업로드
     * @param file 업로드할 MultipartFile 객체
     * @return 업로드된 파일의 공개 접근 URL
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public String uploadFile(MultipartFile file) throws Exception {
        String originalFilename = file.getOriginalFilename();
        Objects.requireNonNull(originalFilename, "File name cannot be null.");

        try (InputStream inputStream = file.getInputStream()) {
            PutObjectOptions options = new PutObjectOptions(file.getSize(), -1); // -1 for unknown part size, MinIO handles it
            options.setContentType(file.getContentType());
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(defaultBucketName)
                            .object(originalFilename)
                            .stream(inputStream, file.getSize(), -1)
                            .contentType(file.getContentType())
                            .build()
            );
            return publicUrlPrefix + UriUtils.encode(originalFilename, StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.error("Failed to upload file to MinIO: {}", originalFilename, e);
            throw new RuntimeException("파일 업로드 실패: " + originalFilename, e);
        }
    }

    /**
     * 파일 다운로드
     * @param objectName 다운로드할 파일 객체 이름
     * @param response HTTP 응답 객체
     */
    public void downloadFile(String objectName, HttpServletResponse response) {
        try {
            GetObjectResponse objectResponse = minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(defaultBucketName)
                            .object(objectName)
                            .build()
            );

            response.setContentType(objectResponse.contentType());
            response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(objectName, StandardCharsets.UTF_8.toString()));
            IOUtils.copy(objectResponse, response.getOutputStream());
        } catch (Exception e) {
            log.error("Failed to download file from MinIO: {}", objectName, e);
            throw new RuntimeException("파일 다운로드 실패: " + objectName, e);
        }
    }

    /**
     * 버킷 존재 여부 확인
     * @param bucketName 확인할 버킷 이름
     * @return 존재하면 true, 아니면 false
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public boolean bucketExists(String bucketName) throws Exception {
        return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
    }

    /**
     * 버킷 생성
     * @param bucketName 생성할 버킷 이름
     * @return 생성 성공 여부
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public boolean createBucket(String bucketName) throws Exception {
        if (!bucketExists(bucketName)) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            return true;
        }
        return false;
    }

    /**
     * 버킷 삭제 (버킷이 비어있어야 함)
     * @param bucketName 삭제할 버킷 이름
     * @return 삭제 성공 여부
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public boolean deleteBucket(String bucketName) throws Exception {
        if (bucketExists(bucketName)) {
            // 버킷 내 객체가 있는지 확인
            Iterable objectList = minioClient.listObjects(
                    ListObjectsArgs.builder().bucket(bucketName).recursive(true).build()
            );
            if (objectList.iterator().hasNext()) {
                log.warn("Bucket '{}' is not empty and cannot be deleted.", bucketName);
                return false; // 버킷이 비어있지 않으면 삭제 불가
            }
            minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
            return true;
        }
        return false;
    }

    /**
     * 특정 버킷의 모든 객체 이름 목록 조회
     * @param bucketName 버킷 이름
     * @return 객체 이름 목록
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public List<String> listObjectNames(String bucketName) throws Exception {
        List<String> objectNames = new ArrayList<>();
        if (bucketExists(bucketName)) {
            Iterable objects = minioClient.listObjects(
                    ListObjectsArgs.builder().bucket(bucketName).recursive(true).build()
            );
            for (Result<Item> result : objects) {
                objectNames.add(result.get().objectName());
            }
        }
        return objectNames;
    }

    /**
     * 특정 객체 삭제
     * @param objectName 삭제할 객체 이름
     * @param targetBucket 버킷 이름
     * @return 삭제 성공 여부
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public boolean deleteObject(String objectName, String targetBucket) throws Exception {
        if (bucketExists(targetBucket)) {
            minioClient.removeObject(
                    RemoveObjectArgs.builder().bucket(targetBucket).object(objectName).build()
            );
            return true;
        }
        return false;
    }

    /**
     * 객체의 직접 접근 URL 조회
     * @param objectName 객체 이름
     * @param targetBucket 버킷 이름
     * @return 객체 URL
     * @throws Exception MinIO 작업 중 발생할 수 있는 예외
     */
    public String getObjectPublicUrl(String objectName, String targetBucket) throws Exception {
        if (bucketExists(targetBucket)) {
            // MinIO client 7.x 버전에서는 getObjectUrl이 사용 가능
            // 8.x 버전부터는 getPresignedObjectUrl 사용 권장 (기본적으로 private)
            return minioClient.getObjectUrl(targetBucket, objectName);
        }
        return null;
    }
}

MinIO REST 컨트롤러 구현

MinIO 서비스 계층을 활용하여 클라이언트 요청을 처리하는 REST API 컨트롤러를 작성합니다.


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;

@RestController
@RequestMapping("/api/storage")
@CrossOrigin(origins = "*") // CORS 허용 (개발 환경에서만 사용 권장)
public class StorageController {

    private final MinioStorageService minioStorageService;

    @Autowired
    public StorageController(MinioStorageService minioStorageService) {
        this.minioStorageService = minioStorageService;
    }

    /**
     * 파일 업로드 API
     * @param file 업로드할 파일
     * @return 업로드된 파일의 URL
     * @throws Exception
     */
    @PostMapping("/upload")
    public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) throws Exception {
        String fileUrl = minioStorageService.uploadFile(file);
        return ResponseEntity.ok(fileUrl);
    }

    /**
     * 파일 다운로드 API
     * @param filename 다운로드할 파일 이름
     * @param response
     */
    @GetMapping("/download")
    public void downloadFile(@RequestParam("filename") String filename, HttpServletResponse response) {
        minioStorageService.downloadFile(filename, response);
    }

    /**
     * 버킷 생성 API
     * @param bucketName 생성할 버킷 이름
     * @return 생성 성공 여부
     * @throws Exception
     */
    @PostMapping("/bucket/{bucketName}")
    public ResponseEntity<Boolean> createMinioBucket(@PathVariable String bucketName) throws Exception {
        boolean success = minioStorageService.createBucket(bucketName);
        return ResponseEntity.ok(success);
    }

    /**
     * 버킷 삭제 API
     * @param bucketName 삭제할 버킷 이름
     * @return 삭제 성공 여부
     * @throws Exception
     */
    @DeleteMapping("/bucket/{bucketName}")
    public ResponseEntity<Boolean> deleteMinioBucket(@PathVariable String bucketName) throws Exception {
        boolean success = minioStorageService.deleteBucket(bucketName);
        return ResponseEntity.ok(success);
    }

    /**
     * 특정 버킷의 객체 목록 조회 API
     * @param bucketName 버킷 이름
     * @return 객체 이름 목록
     * @throws Exception
     */
    @GetMapping("/objects/{bucketName}")
    public ResponseEntity<List<String>> listBucketObjects(@PathVariable String bucketName) throws Exception {
        List<String> objectNames = minioStorageService.listObjectNames(bucketName);
        return ResponseEntity.ok(objectNames);
    }

    /**
     * 객체 삭제 API
     * @param bucketName 버킷 이름
     * @param objectName 객체 이름
     * @return 삭제 성공 여부
     * @throws Exception
     */
    @DeleteMapping("/object/{bucketName}/{objectName}")
    public ResponseEntity<Boolean> deleteMinioObject(@PathVariable String bucketName, @PathVariable String objectName) throws Exception {
        boolean success = minioStorageService.deleteObject(objectName, bucketName);
        return ResponseEntity.ok(success);
    }

    /**
     * 객체 공개 접근 URL 조회 API
     * @param bucketName 버킷 이름
     * @param objectName 객체 이름
     * @return 객체 URL
     * @throws Exception
     */
    @GetMapping("/object/url/{bucketName}/{objectName}")
    public ResponseEntity<String> getObjectUrl(@PathVariable String bucketName, @PathVariable String objectName) throws Exception {
        String url = minioStorageService.getObjectPublicUrl(objectName, bucketName);
        return ResponseEntity.ok(url);
    }
}

테스트 및 확인

스프링 부트 애플리케이션을 실행한 후, Postman 또는 웹 브라우저를 통해 MinIO 기능을 테스트할 수 있습니다.

  • 파일 업로드: POST /api/storage/upload 엔드포인트에 multipart/form-data 형식으로 파일을 전송합니다. 성공 시 파일에 접근 가능한 URL이 반환됩니다.
  • 파일 확인: 반환된 URL (예: http://[호스트_IP]:9000/myfiles/test.txt)로 웹 브라우저에서 직접 접근하여 업로드된 파일이 정상적으로 표시되는지 확인합니다. MinIO 웹 콘솔에서도 myfiles 버킷에 해당 파일이 존재하는 것을 확인할 수 있습니다.

태그: MinIO SpringBoot ObjectStorage docker java

6월 2일 16:53에 게시됨