Notice
Recent Posts
250x250
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
관리 메뉴

일상 코딩

[윈도우 개발 환경 설정] 9편: MinIO 컨테이너 설정 본문

Windows 개발환경 세팅

[윈도우 개발 환경 설정] 9편: MinIO 컨테이너 설정

polarcompass 2026. 3. 31. 22:28
728x90

9편: MinIO 컨테이너 설정

시리즈: 윈도우 네이티브 개발 환경 구축 A to Z — 새 PC부터 클라우드 배포까지
이전 편: 8편에서 Docker Compose로 PostgreSQL을 구동하고, DBeaver로 접속을 확인한 뒤, Go(Gin)와 Python(FastAPI)에서 연결 테스트까지 마쳤습니다.


들어가며

웹 서비스를 운영하다 보면 사용자가 업로드한 이미지, 문서, 동영상 같은 비정형 파일을 저장할 곳이 필요합니다. 이런 파일을 데이터베이스에 넣는 것은 비효율적입니다. 관계형 데이터베이스는 구조화된 데이터를 다루는 데 최적화되어 있지, 수십 MB짜리 이미지 파일을 저장하고 꺼내는 데는 적합하지 않습니다.

이때 사용하는 것이 오브젝트 스토리지입니다. AWS S3가 대표적인데, 로컬 개발 환경에서 AWS에 연결하면 비용도 발생하고 네트워크 지연도 있습니다. MinIO는 S3와 동일한 API를 제공하는 오픈소스 오브젝트 스토리지로, Docker 컨테이너 하나로 로컬에 S3 호환 저장소를 띄울 수 있습니다. 개발 중에는 MinIO를 쓰고, 프로덕션에서는 같은 코드로 AWS S3나 Cloudflare R2 같은 서비스에 연결하면 됩니다.

이번 편에서는 8편에서 만든 docker-compose.yml에 MinIO 서비스를 추가하고, 웹 콘솔에서 버킷과 Access Key를 생성한 뒤, Go·Python·JavaScript 세 가지 언어에서 파일을 업로드하고 다운로드하는 것까지 다룹니다.


1. Docker Compose에 MinIO 추가

1-1. docker-compose.yml 수정

8편에서 만든 ~/dev-infra/docker-compose.yml을 열고 MinIO 서비스를 추가합니다.

# ~/dev-infra/docker-compose.yml

services:
  postgres:
    image: postgres:17
    container_name: dev-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: devuser
      POSTGRES_PASSWORD: devpass123
      POSTGRES_DB: devdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d

  minio:
    image: minio/minio:latest
    container_name: dev-minio
    restart: unless-stopped
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin123
    volumes:
      - minio_data:/data
    command: server /data --console-address ":9001"

volumes:
  postgres_data:
  minio_data:

MinIO 서비스의 각 설정을 설명하겠습니다.

image: minio/minio:latest는 MinIO 공식 이미지를 사용합니다. MinIO는 릴리스 주기가 빠르고 하위 호환을 잘 유지하므로 latest를 사용해도 무방합니다. 안정성이 중요하면 특정 날짜 태그(예: RELEASE.2025-01-20T14-49-07Z)를 지정할 수도 있습니다.

ports에서 9000번은 S3 호환 API 포트입니다. 애플리케이션 코드에서 파일을 업로드하거나 다운로드할 때 이 포트를 사용합니다. 9001번은 웹 관리 콘솔 포트입니다. 브라우저에서 버킷을 만들고, 파일을 확인하고, Access Key를 관리할 때 사용합니다.

MINIO_ROOT_USERMINIO_ROOT_PASSWORD는 루트 관리자 계정입니다. 이 계정으로 콘솔에 로그인하고, 초기 설정을 진행합니다.

minio_data:/data는 업로드된 파일을 Docker Named Volume에 저장합니다. 컨테이너를 삭제해도 파일이 유지됩니다.

command: server /data --console-address ":9001"은 MinIO 서버를 시작하면서 콘솔 포트를 9001로 고정합니다. 이 옵션을 지정하지 않으면 콘솔 포트가 랜덤으로 배정되어 매번 달라질 수 있습니다.

1-2. 컨테이너 실행

cd ~/dev-infra
docker compose up -d

PostgreSQL은 이미 실행 중이므로 변경 사항이 없다면 그대로 유지되고, MinIO 컨테이너만 새로 생성됩니다.

[+] Running 3/3
 ✔ Volume "dev-infra_minio_data"  Created
 ✔ Container dev-postgres         Running
 ✔ Container dev-minio            Started

로그를 확인합니다.

docker logs dev-minio

아래와 비슷한 메시지가 나타나면 정상입니다.

MinIO Object Storage Server
...
API: http://0.0.0.0:9000
WebUI: http://0.0.0.0:9001

2. 웹 콘솔 접속

브라우저에서 http://localhost:9001을 엽니다. 로그인 화면이 나타나면 아래 계정으로 접속합니다.

Username: minioadmin
Password: minioadmin123

로그인에 성공하면 MinIO 관리 대시보드가 표시됩니다.


3. 버킷 생성

오브젝트 스토리지에서 버킷(Bucket)은 파일을 담는 최상위 폴더입니다. S3에서도 같은 개념을 사용합니다. 용도별로 버킷을 분리하는 것이 일반적입니다. 예를 들어 사용자 업로드 이미지는 uploads 버킷에, 앱에서 생성하는 리포트 파일은 reports 버킷에 저장하는 식입니다.

3-1. 콘솔에서 생성

왼쪽 사이드바에서 Buckets를 클릭한 뒤 우측 상단의 Create Bucket 버튼을 누릅니다. Bucket Name에 uploads를 입력하고 Create Bucket을 클릭합니다. 같은 방법으로 reports 버킷도 하나 더 만들어 두겠습니다.

3-2. CLI로 생성하는 방법

콘솔 대신 커맨드라인으로도 버킷을 만들 수 있습니다. MinIO 컨테이너 안에 mc(MinIO Client)가 포함되어 있으므로 docker exec로 실행합니다.

# MinIO 서버를 mc에 등록
docker exec dev-minio mc alias set local http://localhost:9000 minioadmin minioadmin123

# 버킷 생성
docker exec dev-minio mc mb local/uploads
docker exec dev-minio mc mb local/reports
Bucket created successfully `local/uploads`.
Bucket created successfully `local/reports`.

이미 콘솔에서 만들었다면 "Bucket already exists" 메시지가 나옵니다. 무시해도 됩니다.


4. Access Key 생성

루트 계정(minioadmin)을 애플리케이션 코드에서 직접 사용하는 것은 보안상 좋지 않습니다. 권한을 제한한 별도의 Access Key를 만들어서 사용하는 것이 권장됩니다.

4-1. 콘솔에서 생성

왼쪽 사이드바에서 Access Keys를 클릭하고 Create access key 버튼을 누릅니다. Access Key와 Secret Key가 자동으로 생성됩니다. 기본값을 그대로 사용해도 되고, 원하는 값을 직접 입력해도 됩니다. 이 글에서는 아래 값을 사용하겠습니다.

Access Key: dev-access-key
Secret Key: dev-secret-key-123

Create를 클릭합니다. Secret Key는 이 화면을 벗어나면 다시 확인할 수 없으므로 반드시 기록해 두세요.


5. Go(Gin)에서 MinIO 연결 테스트

Go에서 MinIO에 접속할 때는 MinIO 공식 Go SDK를 사용합니다. 이 SDK는 AWS S3 API와 호환되므로, 나중에 S3로 전환할 때도 엔드포인트만 바꾸면 됩니다.

5-1. 의존성 추가

cd ~/go-api
go get github.com/minio/minio-go/v7

5-2. 연결 테스트 코드

main.go에 MinIO 관련 코드를 추가합니다. 8편에서 작성한 PostgreSQL 코드는 그대로 유지하고, MinIO 업로드·다운로드 엔드포인트를 추가하는 형태입니다. 여기서는 MinIO 관련 부분만 보여 드리겠습니다.

// main.go (MinIO 관련 부분만 발췌 — 기존 코드에 추가)
package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/minio/minio-go/v7"
    "github.com/minio/minio-go/v7/pkg/credentials"
)

// ... (기존 User 구조체, db 변수, getUsers 함수는 그대로 유지)

var minioClient *minio.Client

func main() {
    // --- PostgreSQL 연결 (기존 코드 유지) ---
    dsn := os.Getenv("DATABASE_URL")
    if dsn == "" {
        dsn = "postgres://devuser:devpass123@localhost:5432/devdb?sslmode=disable"
    }
    var err error
    db, err = pgxpool.New(context.Background(), dsn)
    if err != nil {
        log.Fatalf("Unable to connect to database: %v\n", err)
    }
    defer db.Close()
    if err := db.Ping(context.Background()); err != nil {
        log.Fatalf("Unable to ping database: %v\n", err)
    }
    log.Println("Connected to PostgreSQL!")

    // --- MinIO 연결 ---
    minioClient, err = minio.New("localhost:9000", &minio.Options{
        Creds:  credentials.NewStaticV4("dev-access-key", "dev-secret-key-123", ""),
        Secure: false,
    })
    if err != nil {
        log.Fatalf("Unable to connect to MinIO: %v\n", err)
    }
    log.Println("Connected to MinIO!")

    // --- Gin 라우터 ---
    r := gin.Default()

    r.GET("/", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"message": "Hello, World!"})
    })
    r.GET("/users", getUsers)
    r.POST("/upload", uploadFile)
    r.GET("/files/:filename", downloadFile)

    r.Run(":8080")
}

func uploadFile(c *gin.Context) {
    file, header, err := c.Request.FormFile("file")
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
        return
    }
    defer file.Close()

    bucketName := "uploads"
    objectName := fmt.Sprintf("%d_%s", time.Now().UnixMilli(), header.Filename)

    info, err := minioClient.PutObject(
        context.Background(),
        bucketName,
        objectName,
        file,
        header.Size,
        minio.PutObjectOptions{ContentType: header.Header.Get("Content-Type")},
    )
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "message":  "uploaded successfully",
        "bucket":   info.Bucket,
        "object":   objectName,
        "size":     info.Size,
    })
}

func downloadFile(c *gin.Context) {
    filename := c.Param("filename")

    object, err := minioClient.GetObject(
        context.Background(),
        "uploads",
        filename,
        minio.GetObjectOptions{},
    )
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    defer object.Close()

    stat, err := object.Stat()
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
        return
    }

    c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
    c.Header("Content-Type", stat.ContentType)
    c.Header("Content-Length", fmt.Sprintf("%d", stat.Size))
    io.Copy(c.Writer, object)
}

코드를 설명하겠습니다. minio.New로 MinIO 클라이언트를 생성합니다. Secure: false는 로컬 환경에서 HTTP(비암호화)를 사용한다는 뜻입니다. 프로덕션에서는 HTTPS를 사용해야 합니다.

POST /upload 엔드포인트는 multipart/form-data로 전달된 파일을 받아서 uploads 버킷에 저장합니다. 파일명 앞에 타임스탬프를 붙여서 이름 충돌을 방지합니다.

GET /files/:filename 엔드포인트는 uploads 버킷에서 파일을 가져와서 다운로드 응답으로 보냅니다.

5-3. 실행 & 확인

cd ~/go-api
go run .
... Connected to PostgreSQL!
... Connected to MinIO!
... Listening and serving HTTP on :8080

새 PowerShell 탭에서 파일을 업로드해 봅니다. 테스트용 텍스트 파일을 하나 만들겠습니다.

"Hello MinIO from Go!" | Out-File -Encoding utf8 test.txt
curl -X POST -F "file=@test.txt" http://localhost:8080/upload
{
  "message": "uploaded successfully",
  "bucket": "uploads",
  "object": "1743400800000_test.txt",
  "size": 23
}

응답에서 object 값(타임스탬프가 붙은 파일명)을 복사해서 다운로드를 테스트합니다.

curl -O http://localhost:8080/files/1743400800000_test.txt

다운로드된 파일의 내용을 확인합니다.

cat 1743400800000_test.txt
Hello MinIO from Go!

MinIO 웹 콘솔(http://localhost:9001)에서도 Object Browser → uploads 버킷으로 들어가면 업로드된 파일이 보입니다. 확인이 끝났으면 Ctrl+C로 Go 서버를 종료합니다.


6. Python(FastAPI)에서 MinIO 연결 테스트

Python에서도 MinIO 공식 SDK인 minio 패키지를 사용합니다.

6-1. 의존성 추가

cd ~/fastapi-app
.\.venv\Scripts\Activate.ps1
pip install minio

6-2. 연결 테스트 코드

main.py에 MinIO 관련 코드를 추가합니다. 8편에서 작성한 PostgreSQL 코드는 그대로 유지합니다.

# main.py (전체)
import os
import time
from contextlib import asynccontextmanager
from datetime import datetime

import psycopg
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from minio import Minio
from pydantic import BaseModel


DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql://devuser:devpass123@localhost:5432/devdb",
)

MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "dev-access-key")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "dev-secret-key-123")
MINIO_BUCKET = "uploads"


class User(BaseModel):
    id: int
    username: str
    email: str
    created_at: datetime


def get_connection():
    return psycopg.connect(DATABASE_URL)


minio_client = Minio(
    MINIO_ENDPOINT,
    access_key=MINIO_ACCESS_KEY,
    secret_key=MINIO_SECRET_KEY,
    secure=False,
)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # PostgreSQL 연결 확인
    with get_connection() as conn:
        print("Connected to PostgreSQL!")

    # MinIO 연결 확인
    if minio_client.bucket_exists(MINIO_BUCKET):
        print(f"Connected to MinIO! Bucket '{MINIO_BUCKET}' exists.")
    else:
        print(f"Connected to MinIO! Bucket '{MINIO_BUCKET}' not found.")

    yield


app = FastAPI(lifespan=lifespan)


@app.get("/")
def read_root():
    return {"message": "Hello, World!"}


@app.get("/users", response_model=list[User])
def read_users():
    try:
        with get_connection() as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT id, username, email, created_at FROM users")
                rows = cur.fetchall()
                return [
                    User(id=row[0], username=row[1], email=row[2], created_at=row[3])
                    for row in rows
                ]
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
    try:
        timestamp = int(time.time() * 1000)
        object_name = f"{timestamp}_{file.filename}"

        content = await file.read()
        from io import BytesIO
        data = BytesIO(content)

        minio_client.put_object(
            MINIO_BUCKET,
            object_name,
            data,
            length=len(content),
            content_type=file.content_type or "application/octet-stream",
        )

        return {
            "message": "uploaded successfully",
            "bucket": MINIO_BUCKET,
            "object": object_name,
            "size": len(content),
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/files/{filename}")
def download_file(filename: str):
    try:
        response = minio_client.get_object(MINIO_BUCKET, filename)
        return StreamingResponse(
            response,
            media_type="application/octet-stream",
            headers={"Content-Disposition": f"attachment; filename={filename}"},
        )
    except Exception as e:
        raise HTTPException(status_code=404, detail="file not found")

Go 코드와 동일한 패턴입니다. POST /upload는 파일을 받아서 uploads 버킷에 저장하고, GET /files/{filename}은 버킷에서 파일을 가져와서 스트리밍 응답으로 반환합니다.

6-3. 실행 & 확인

uvicorn main:app --reload --port 8000
Connected to PostgreSQL!
Connected to MinIO! Bucket 'uploads' exists.
INFO:     Uvicorn running on http://127.0.0.1:8000

새 PowerShell 탭에서 파일을 업로드합니다.

"Hello MinIO from Python!" | Out-File -Encoding utf8 test_py.txt
curl -X POST -F "file=@test_py.txt" http://localhost:8000/upload
{
  "message": "uploaded successfully",
  "bucket": "uploads",
  "object": "1743400860000_test_py.txt",
  "size": 27
}

다운로드도 확인합니다.

curl -O http://localhost:8000/files/1743400860000_test_py.txt
cat 1743400860000_test_py.txt
Hello MinIO from Python!

확인이 끝났으면 Ctrl+C로 서버를 종료하고 deactivate로 가상 환경을 빠져나옵니다.


7. JavaScript(Node.js)에서 MinIO 연결 테스트

프론트엔드는 React SPA이므로 브라우저에서 직접 MinIO에 접속하지는 않습니다. 파일 업로드는 보통 백엔드 API를 경유합니다. 하지만 Node.js 스크립트로 MinIO를 다루어야 할 경우가 있을 수 있으므로(빌드 스크립트, 마이그레이션 도구 등) 간단히 확인해 두겠습니다.

7-1. 테스트 프로젝트 생성

mkdir ~/minio-js-test
cd ~/minio-js-test
npm init -y
npm install minio

7-2. 연결 테스트 코드

test.mjs 파일을 생성합니다.

// test.mjs
import { Client } from "minio";
import { readFileSync } from "fs";
import { Readable } from "stream";

const minioClient = new Client({
  endPoint: "localhost",
  port: 9000,
  useSSL: false,
  accessKey: "dev-access-key",
  secretKey: "dev-secret-key-123",
});

const BUCKET = "uploads";

async function main() {
  // 버킷 존재 확인
  const exists = await minioClient.bucketExists(BUCKET);
  console.log(`Bucket '${BUCKET}' exists: ${exists}`);

  // 파일 업로드
  const content = Buffer.from("Hello MinIO from JavaScript!");
  const objectName = `${Date.now()}_test_js.txt`;

  await minioClient.putObject(BUCKET, objectName, content, content.length, {
    "Content-Type": "text/plain",
  });
  console.log(`Uploaded: ${objectName}`);

  // 파일 다운로드
  const stream = await minioClient.getObject(BUCKET, objectName);
  const chunks = [];
  for await (const chunk of stream) {
    chunks.push(chunk);
  }
  const downloaded = Buffer.concat(chunks).toString();
  console.log(`Downloaded content: ${downloaded}`);

  // 버킷 내 파일 목록 조회
  console.log(`\nFiles in '${BUCKET}':`);
  const objectsStream = minioClient.listObjects(BUCKET, "", true);
  for await (const obj of objectsStream) {
    console.log(`  - ${obj.name} (${obj.size} bytes)`);
  }
}

main().catch(console.error);

7-3. 실행 & 확인

node test.mjs
Bucket 'uploads' exists: true
Uploaded: 1743400920000_test_js.txt
Downloaded content: Hello MinIO from JavaScript!

Files in 'uploads':
  - 1743400800000_test.txt (23 bytes)
  - 1743400860000_test_py.txt (27 bytes)
  - 1743400920000_test_js.txt (28 bytes)

Go, Python, JavaScript 세 곳에서 업로드한 파일이 모두 같은 uploads 버킷에 들어 있는 것을 확인할 수 있습니다. MinIO 웹 콘솔에서도 Object Browser → uploads를 열면 세 파일이 모두 보입니다.

테스트가 끝났으면 이 폴더는 삭제해도 됩니다.

cd ~
Remove-Item -Recurse -Force ~/minio-js-test

8. MinIO와 S3의 관계 — 프로덕션 전환 팁

MinIO SDK의 클라이언트 설정에서 엔드포인트와 인증 정보만 바꾸면 AWS S3나 Cloudflare R2 같은 S3 호환 서비스에 그대로 연결할 수 있습니다. 코드를 변경할 필요가 없습니다. 예를 들어 Go에서는 이렇게 바뀝니다.

// 로컬 MinIO
minioClient, _ = minio.New("localhost:9000", &minio.Options{
    Creds:  credentials.NewStaticV4("dev-access-key", "dev-secret-key-123", ""),
    Secure: false,
})

// AWS S3
minioClient, _ = minio.New("s3.amazonaws.com", &minio.Options{
    Creds:  credentials.NewStaticV4("AWS_ACCESS_KEY", "AWS_SECRET_KEY", ""),
    Secure: true,
})

이런 식으로 환경 변수에서 엔드포인트와 키를 읽도록 코드를 작성해 두면, 로컬에서는 MinIO, 프로덕션에서는 S3를 사용하는 전환이 설정 파일 수정만으로 가능합니다. 14편에서 통합 Docker Compose를 구성할 때 이 패턴을 다시 다룰 예정입니다.


9. 자주 겪는 문제와 해결

콘솔 접속 시 "Access Denied"

MINIO_ROOT_USERMINIO_ROOT_PASSWORD를 정확히 입력했는지 확인하세요. 이 값을 변경하려면 docker-compose.yml을 수정한 뒤 볼륨을 삭제하고 다시 시작해야 합니다.

cd ~/dev-infra
docker compose down -v
docker compose up -d

다만 볼륨을 삭제하면 기존에 업로드한 파일도 전부 사라지므로 주의하세요.

Access Key로 접속할 때 "Invalid Access Key"

콘솔에서 Access Key를 생성한 뒤 값을 정확히 복사했는지 확인하세요. 특히 앞뒤 공백이 포함되지 않았는지 주의합니다. 어떤 Access Key가 있는지 기억나지 않으면 루트 계정으로 콘솔에 로그인해서 Access Keys 메뉴에서 확인할 수 있습니다.

포트 충돌

9000 포트가 다른 프로그램에서 사용 중이라면 docker-compose.yml에서 "9002:9000" 같이 호스트 포트를 변경하고, 애플리케이션 코드의 엔드포인트도 localhost:9002로 맞춰 주면 됩니다.


최종 확인 체크리스트

아래 항목을 모두 확인했다면 9편은 완료입니다.

✅ docker-compose.yml에 MinIO 서비스가 추가되어 있다
✅ docker compose up -d 로 PostgreSQL과 MinIO가 함께 정상 기동된다
✅ 브라우저에서 http://localhost:9001 로 MinIO 웹 콘솔에 접속할 수 있다
✅ uploads 버킷이 생성되어 있다
✅ Access Key가 생성되어 있다
✅ Go(Gin) 서버에서 파일 업로드·다운로드가 동작한다
✅ Python(FastAPI) 서버에서 파일 업로드·다운로드가 동작한다
✅ JavaScript(Node.js)에서 파일 업로드·다운로드가 동작한다

다음 편 예고

데이터베이스(PostgreSQL)와 파일 저장소(MinIO)가 준비되었습니다. 10편: n8n 워크플로우 자동화 설정에서는 Docker Compose에 n8n을 추가하고, 웹 UI에서 워크플로우를 만들어 봅니다. Webhook으로 외부 요청을 받아 처리하는 트리거를 설정하고, 8편의 PostgreSQL과 9편의 MinIO를 n8n에서 연동하는 것까지 다루겠습니다.

728x90