본문 바로가기
프로젝트

서버 이미지 저장소 변경하기 AWS S3 -> MINIO

by son_i 2026. 2. 9.
728x90

현재 상황

aws ec2에 petory 프로젝트를 배포했었는데 프리티어 만료로 인해 지인의 미니 pc 서버로 옮겨가게 되었다.

다만 프로젝트의 이미지 업로드에 대해서 어떻게 할 지 고민이 되었다.

프리티어 만료로 어쨌든 AWS 기능을 쓰려면 돈을 내야하는 지라 옮긴 서버에 이미지를 저장하기로 하였다.

 

대안들

1. 가장 간단하게로는 배포된 서버의 로컬스토리지에 그냥 냅다 저장한다.

2. 새로운 Object 저장소를 이용한다.

 

대안 비교

1. 로컬스토리지

1차원적으로만 생각해도 폴더에 그냥 파일이 저장되어있는 방식이라 관리가 번거롭고 

또한 사용자가 URL을 통해 이미지를 조회하게 될텐데 Public URL을 생성하고 해당 url로 서버 내부로 접근하려면 Nginx를 이용해 연결해줘야할 것이다.

 

또한 생각해본 단점들은 다음과 같다.

 

  • 관리 번거로움: 이미지 파일을 일일이 수동으로 백업하고 경로를 관리해줘야하며 폴더가 나눠질 경우 각각 url도 재생성 및 연결해줘야한다.
  • 접근성 문제: 외부에서 이미지를 보려면 매번 수동으로 Nginx매핑 설정을 해줘야함.
  • 확장성 부족: 서버가 2개가 될 경우에 각기 다른 서버에서는 이미지 조회 불가.

 

2. Object 스토리지

위와 같은 문제들 때문에 S3같은 저장소 서칭을 해보면서 Minio라는 것을 알게되었다.

 

오픈 소스로 설치해서 사용가능하고 S3 API와 100% 호환 가능해서 설정만 조금 바꿔주면 기존의 메소드를 수정하지 않고도 갈아낄 수 있어서 빠른 변경이 가능했다.

S3처럼 버킷별로 접근권한이나 용량 제한 설정을 손쉽게 할 수 있다.

 

또한 웹 UI도 제공하고 있어서 단순 터미널로 관리해야하는 로컬스토리지 저장방식과 달리 편리하다.

 

 

적용 과정

1. infra-compose에 Minio 추가

  minio:
    image: minio/minio
    container_name: petory-minio
    restart: always
    environment:
      MINIO_ROOT_USER: #사용할 USER 입력
      MINIO_ROOT_PASSWORD: #사용할 PW 입력
    volumes:
      - minio-data:/data
    networks:
      - petory-network
      - npm-network
    command: server /data --console-address ":9001"
    ports:
      - "9000:9000"
      - "9001:9001"

networks:
  petory-network:
    external: true
  npm-network:
    external: true

volumes:
  es-data:
  minio-data:

 

ports

여기서는 포트를 2개 사용하는데 9000 포트는 API에서 이미지 업로드를 할 때 사용할 API용 포트이고 

9001번 포트는 관리자 화면을 사용할 포트이다.

 

networks

API로 접근을 해야하니 petory-network와 연결해주고 외부에서 사용자가 접근해야하니 npm-network도 연결해주었다.

 

volumes

Named volume인 (앞부분)도커 엔진이 관리하는 minio-data볼륨을 생성하여 (뒷부분) /data 컨테이너 내부의 경로와 연결했다.

-> 이렇게하면 MinIO에서는 컨테이너 내부의 /data에 저장하지만 도커가 가로채서 minio-data 볼륨에 저장한다.

 

여기서 docker compose에 대해 헷갈렸던 개념을 다시한 번 정리하고 넘어갔다.

 

docker compose에 대하여 ..

 

docker compose에 대하여 ..

계속 프로젝트를 하면서 깊이 있게 알아가는 것들이 많은데 한 번에 정리해보려고 한다. [volumes의 두 가지 용도]1. Named Volume 데이터 영속성volumes에 지금처럼 - minio-data:/data 라고 적어준 것은 named

soni-developer.tistory.com

 

2. 외부에서 접속할 수 있는 환경 설정 (NPM & Duck DNS)

minio에 올라간 이미지는 서버 내부에 컨테이너에 있기 때문에 외부에서 접근할 수 없다.

따라서 통로를 만들고, npm으로 연결해줘야한다.

 

 DuckDNS로 생성한 petory-image.auguetzero.duckdns.org를 NPM에 등록 및 프록시 설정

 

이렇게하면 외부에서 petory-image.auguetzero.duckdns.org로 들어오는 요청을 도커 내부의 http://petory-minio:9000으로 전달한다.

 

3. 버킷 생성 및 권한 제어

petory-image라는 버킷을 만들고 익명 접근 권한을 허용했다.

접근을 허용하지 않으면 다음과 같이 Access Denied가 뜬다!

이거를 MinIO 웹 URI에서 설정해줄 수 있다고했는데 아무리 해도 설정해줄 수 있는 뭐가 없는 거임 ?!?!?! 

버킷 생성, 삭제, 그안에 데이터 업로드, 삭제 정도만 가능하고 설정 관련된 부분이 아예 안 됨 !

따라서 다음과 같이 터미널에 입력해줬다.

// local 이름으로 내 MinIO를 mc라는 minio client 도구에 등록
docker exec petory-minio mc alias set local http://localhost:9000 {설정한 USER} {설정한 PW}
                                          -> local을 치면 이 주소, user, pw로 접속하라는 설정 (자동 로그인 느낌)

// local 별칭을 사용해 버킷 권한을 download로 변경
docker exec petory-minio mc anonymous set download local/petory-image
                                         -> 익명 유저 (anonymous)를 petory-image 버킷의 파일을 다운로드(보기) 할 수 있게함.


UI의 Access 부분이 CUSTOM으로 바뀐 것을 확인했다 ! 

 

 

 

4. Spring Boot 어플리케이션 설정

엔드포인트 분리를 통한 네트워크 최적화

하나의 주소로 업로드와 조회를 처리하지 않고 내부망과 외부망으로 엔드포인트를 분리하여 인프라 효율을 높였다.

 

내부망 주소(http://petory-minio:9000)를 endpoint 로 설정하여 업로드를 수행하고,

DB 저장용으로는 외부 도메인 주소(duckdns.org)를 사용하도록 yml에 환경 변수 추가 및 코드 변경.

 

업로드(서버 -> minio) : http://petory-minio:9000 내부망 사용

- 데이터가 서버 밖으로 나가지 않고 Docker Bridge Network를 통해 컨테이너 간 직접 통신이 이루어진다.

- 불필요한 네트워크 홉이 제거되어 대용랑 이미지도 병목형상 없이 빠른 업로드가 가능하다.

 

조회(클라이언트 -> minio) : https://petory-image.augustzero.duckdns.org 외부망 사용 

- 사용자의 브라우저에서 사진을 조회할 수 있도록 DB에는 외부 접근이 가능한 public url을 저장

- NPM을 거쳐 보안과 도메인 관리 효율적으로 수행 가능

 

내부망 주소를 업로드 주소로 할 경우 데이터가 외부로 유출되지 않고 컨테이너간 직접 통신이 이루어져 빠르게 업로드가 가능하다.

 

application-prod.yml

cloud:
  aws:
    s3:
      endpoint: http://petory-minio:9000
      public-url: https://petory-image.augustzero.duckdns.org
      bucketName: petory-image
    credentials:
      accessKey: ${MINIO_ACCESS_KEY}
      secretKey: ${MINIO_SECRET_KEY}
    region:
      static: ap-northeast-2
    stack:
      auto: false

 

여기의 accessKey와 secretKey는 jenkins credentials에 등록해주고 파이프라인내에서 .env파일로 내보내서 사용한다.

 

s3 호환성 설정

S3Config에 withPathStyleccessEnabled(true)를 활성화하여 MinIO 경로 방식을 따르도록 함

@Configuration
public class S3Config {

    @Value("${cloud.aws.credentials.accessKey}")
    private String accessKey;

    @Value("${cloud.aws.credentials.secretKey}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Value("${cloud.aws.s3.endpoint}")
    private String endpoint;

    @Bean
    public AmazonS3 amazonS3() {
        AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);

        return AmazonS3ClientBuilder.standard()
                .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint, region))
                .withPathStyleAccessEnabled(true)
                .withCredentials(new AWSStaticCredentialsProvider(credentials))
                .build();
    }

withPathStyleccessEnabled(true) 

  AWS S3에는 버킷명.s3.주소 형태로 쓰이는데 MinIO는 주소/버킷명 형태를 사용해서 해당 옵션이 있어야 에러가 나지 않는다.

 

s3 주소 형태 : https://petory-image.s3.ap-northeast-2.amazonaws.com/dog.jpg

                                  [버킷 이름].s3.[리전].amazonaws.com/[파일명]   Virtual-Hosted style

 

MinIO 주소 형태 : http://petory-minio:9000/petory-image/dog.jpg

                                http://[엔드포인트]/[버킷명]/[파일명]         Path style

 

Spring Boot의 AWS SDK (S3 클라이언트)는 기본값이 AWS 방식이다.

따라서  withPathStyleccessEnabled(true) 옵션을 통해 버킷명을 도메인 뒤에 경로로 붙여 보내도록 해야한다.

 

withEndpointConfiguration를 사용해 실제 AWS 옵션 대신 우리가 설정한 endpoint(petory-minio:9000)을 적용함.

  설정해주지 않으면 무조건 AWS s3 서버로 요청을 보낸다. 

 

5. 서비스 로직 수정

Public URL 생성 로직 구현 (getPublicUrl 메소드)

기존에는 amazeonS3service.getURL()을 통해서 제공해주는 URL을 사용했지만 지금은 외부로 노출된 도메인으로 접속하는 url이 있어야한다.

 

기존 S3 사용시 - S3 서비스 자체가 전세계 어디서든 접근 가능한 Public DNS를 제공.

public String upload(MultipartFile image) {
    // S3가 주소 제공"https://bucket.s3.region.amazonaws.com/uuid.jpg"
    return amazonS3.getUrl(bucketName, fileName).toString(); 
}

 

현재 MinIO + NPM 사용시 - 서버 내부망 주소(petory-minio)와 외부 접속 주소(petory-image ~~.duckdns.org) 가 다르므로 직접 세팅이 필요함.

private String getPublicUrl(String fileName) {
        return String.format("%s/%s/%s", publicUrl, bucketName, fileName);
    }
private String uploadImage(MultipartFile image) {
        String randomFilename = generateRandomFilename(image.getOriginalFilename());
        //업로드 로직
        return getPublicUrl(randomFilename);
    }

getPublicUrl의 리턴값으로 생성된  URL은 다음과 같은 형식이 되어 DB에 저장될 것이다.

petory-image.augustzero.duckdns.org/petory-image/밥버니.jpg

 

삭제로직 수정

기존 URL에서 순수 파일명만 추출하는 extrackKeyFromUrl을 수정하여 이미지 삭제 시 에러 없이 동작하도록 함.

 

delete 메소드는 다음과 같다.

public void delete(String imageUrl) {
    try {
        String key = extractKeyFromUrl(imageUrl);
        amazonS3.deleteObject(bucketName, key);
        log.info("deleted success : {}", key);
    } catch (Exception e) {
        log.error("Failed to delete file from S3", e);
        throw new S3Exception(ErrorCode.IMAGE_DELETE_FAIL); // 필요 시 새 에러코드 정의
    }
}

imageURL이 들어오면 Url에서 Key를 추출해 s3에 버킷명, key 값을 보내 삭제한다.

 

기존 메소드 - 단순 substring 방식

private String extractKeyFromUrl(String imageUrl) {
    URL url = new URL(imageUrl);
    return url.getPath().substring(1); 
}

aws s3의 주소 체계가 [버킷 이름].s3.[리전].amazonaws.com/[파일명]  이었기 떄문에 getPath()를 하면 /dog.jpg 만 남게 되고 substring(1)을 통해 / 를 제거한 순수 파일명만 남기기가 가능했던 거고 지금은 주소 체계가 다르기 때문에 수정이 필요하다.

 

현재 - lastIndexOf 방식

private String extractKeyFromUrl(String imageUrl) throws MalformedURLException {
    URL url = new URL(imageUrl);
    String path = url.getPath();

    // 경로에서 버킷 이름을 제외한 순수 파일명먄 남김
    return path.substring(path.lastIndexOf("/") + 1);
}

MinIO를 이용할 때의 주소 체계는 이런 형식이라 http://petory-minio:9000/petory-image/dog.jpg 

 getPath() 후 남는 결과 : /petory-image/dog.jpg

 

따라서 path.lastIndexOf("/")  로 맨 마지막 슬래시 위치를 얻고

 +1을 하여 슬래시 뒷부분 파일명 doc.jpg만 남긴다.

 

 

모든 과정을 해주고 펫 이미지를 등록해보면

MinIO에도 잘 올라간 것을 확인할 수 있고 퍼블릭 URL로 DB에 저장해주었기 때문에 아래처럼 조회가 가능하다.

짜잔

아주 바보같은 얼굴의 강아지가 잘 등록되었다 !

 


적용 과정에서 발생한 문제 해결

403 Forbidden & Access Denied (권한 문제)

  • 현상: MinIO에 이미지는 올라갔는데, 웹 브라우저나 앱에서 이미지를 불러오려고 하면 권한 거부 에러가 발생함.
  • 원인: MinIO 버킷의 기본 보안 정책이 Private으로 설정되어 있어 익명 사용자의 접근을 막았기 때문임.
  • 해결: mc 도구를 이용해 버킷 정책을 download로 변경하여 외부에서 URL만으로 사진을 볼 수 있게 설정함.

// local 이름으로 내 MinIO를 mc라는 minio client 도구에 등록
docker exec petory-minio mc alias set local http://localhost:9000 {설정한 USER} {설정한 PW}
                                          -> local을 치면 이 주소, user, pw로 접속하라는 설정 (자동 로그인 느낌)

// local 별칭을 사용해 버킷 권한을 download로 변경
docker exec petory-minio mc anonymous set download local/petory-image
                                         -> 익명 유저 (anonymous)를 petory-image 버킷의 파일을 다운로드(보기) 할 수 있게함.

 

내부 vs 외부 URL 혼선

  • 현상: DB에 http://petory-minio:9000/... 주소로 db에 저장되어 사용자가 조회 불가
  • 원인: 서버 내부망 주소가 외부주소와 달라서 생긴 문제임.
  • 해결: getPublicUrl 메소드를 만들어 DB에는 DuckDNS 주소가 저장되도록 로직을 분리함.

 

728x90