티스토리 뷰

반응형

업로드 파일, 로컬 디스크에 둘까 S3 같은 외부 스토리지로 뺄까? 저장소 선택과 구조 분리의 기준 — FastAPI · Spring Boot · Node.js

파일 업로드 API까지 만들고 나면, 이제 진짜 현실적인 고민이 시작됩니다.

“일단 서버 폴더에 저장하면 되나?”
“아니면 처음부터 S3로 가야 하나?”
“나중에 바꾸기 어렵지 않을까?”

이 질문, 생각보다 중요해요.
왜냐하면 업로드 API는 그냥 받는 걸로 끝나지 않고, 결국 어디에 저장하고, 어떻게 꺼내주고, 서버가 늘어나면 어떻게 버틸지까지 이어지거든요.

저도 초반엔 로컬 저장이 훨씬 편해서 자주 그렇게 시작했어요.
근데 서비스가 조금만 커져도 바로 부딪히는 문제가 있더라고요.

  • 서버를 두 대로 늘리면 파일이 한쪽에만 있음
  • 컨테이너 재배포하면 파일이 날아가거나 경로가 바뀜
  • 백업/복구가 생각보다 귀찮음
  • CDN 붙이거나 공개 URL 만들 때 점점 복잡해짐

반대로 S3 같은 오브젝트 스토리지는 처음부터 모든 걸 해결해주는 것 같지만,
막상 초반 MVP에는 좀 과한 경우도 있습니다.

이번 글에서는 그 경계를 정리해보겠습니다.

  • 언제 로컬 저장이 괜찮은지
  • 언제 오브젝트 스토리지가 필요한지
  • 업로드 API와 저장소 계층을 어떻게 분리해야 나중에 안 무너지는지
  • FastAPI · Spring Boot · Node.js에서 바로 가져다 쓸 수 있는 최소 구조

Amazon S3는 객체 스토리지 서비스로, 업계 최고 수준의 확장성, 가용성, 보안, 성능을 제공한다고 AWS가 설명합니다. 또한 기본적으로 99.999999999%의 내구성(11 9s)과 99.99% 가용성을 목표로 설계되었다고 안내합니다. (Amazon Web Services, Inc.)


먼저 아주 짧게 결론부터 말하면

로컬 디스크 저장이 괜찮은 경우

  • 혼자 개발 중
  • 단일 서버
  • 내부 테스트/어드민 툴
  • 파일이 아주 중요 자산은 아님
  • 빨리 MVP를 만들어야 함

S3 같은 오브젝트 스토리지가 더 맞는 경우

  • 서버가 여러 대
  • 컨테이너/오토스케일링
  • 업로드 파일이 서비스 핵심 데이터
  • CDN, 백업, 공유 URL이 필요
  • 장기 운영 예정

핵심은 이거예요.

저장 위치를 지금 당장 완벽하게 정하는 것보다,
저장 방식을 바꿔도 서비스 코드가 크게 안 흔들리게 만드는 게 더 중요하다.


로컬 저장의 장점과 한계

솔직히 로컬 저장은 정말 편합니다.

  • 구현이 빠름
  • 디버깅 쉬움
  • 추가 인프라 없음
  • 파일 경로를 바로 확인 가능

FastAPI는 StaticFiles로 특정 디렉터리를 정적 파일 경로에 마운트할 수 있고, Express도 express.static으로 정적 파일 서빙을 바로 할 수 있습니다. Spring도 정적 리소스 서빙 구조를 기본 지원합니다. (FastAPI)

즉 로컬 저장 + 정적 파일 서빙은 초반엔 굉장히 빠른 선택입니다.

그런데 문제는 운영으로 가면 금방 드러나요.

로컬 저장의 대표 한계

  • 서버 인스턴스에 파일이 묶임
  • 서버 증설 시 파일 동기화 필요
  • 배포/재기동/컨테이너 교체에 취약
  • 백업과 이관이 귀찮음
  • 파일 URL 구조가 서버 구조와 강하게 결합됨

이게 특히 Docker, Kubernetes, ECS, Railway, Render 같은 환경으로 가면 더 크게 느껴집니다.


오브젝트 스토리지의 장점

S3 같은 오브젝트 스토리지의 가장 큰 장점은
파일이 애플리케이션 서버와 분리된다는 점입니다.

즉 서버는 업로드 요청을 처리만 하고,
파일 자체는 외부 저장소가 책임집니다.

이 구조가 좋아지는 이유는 아주 명확해요.

  • 서버를 여러 대 띄워도 파일 저장소는 하나
  • 앱 재배포와 파일 보관이 분리됨
  • 백업/수명주기/권한 제어가 쉬워짐
  • CDN 연결이 쉬워짐
  • 대용량 파일 관리에 유리함

AWS는 S3가 사실상 무제한 확장성과 높은 내구성·가용성을 갖춘 객체 스토리지라고 설명합니다. (Amazon Web Services, Inc.)

그래서 서비스가 업로드 파일에 진심이 되기 시작하면,
결국 오브젝트 스토리지로 가는 경우가 많아요.


그럼 처음부터 무조건 S3로 가야 하나

반응형

그건 또 아닙니다.

저는 이걸 꽤 솔직하게 말하고 싶어요.
초반 MVP에서 파일 몇 개 올리는 기능 때문에 S3, IAM, 버킷 정책, presigned URL, CDN, 권한 분리까지 한 번에 다 넣으면… 생각보다 진도가 안 나갑니다.

그래서 저는 보통 이렇게 권합니다.

아주 초기

  • 로컬 저장으로 시작 가능
  • 단, 저장소 계층은 분리

기능 검증 후

  • S3로 교체
  • 서비스 코드는 거의 그대로
  • storage implementation만 바꾸기

즉 처음부터 “무조건 S3”보다
처음부터 storage abstraction을 두는 것이 더 중요합니다.


저장소 계층을 왜 따로 둬야 할까

이건 repository 얘기랑 비슷해요.

서비스에서 이런 코드를 직접 쓰기 시작하면 나중에 힘들어집니다.

  • 파일 경로 조합
  • 디스크 쓰기
  • S3 SDK 호출
  • public URL 생성
  • 삭제 처리

이런 저장 세부 구현을 서비스에 넣으면,
나중에 로컬 → S3로 바꿀 때 서비스 코드 전체가 흔들립니다.

그래서 저는 파일 업로드도 보통 이렇게 나눕니다.

  • Controller/Route: 요청 받고 파일 전달
  • Service: 업로드 업무 흐름 처리
  • Storage: 실제 저장 구현
  • Metadata Repository: 파일 메타데이터 DB 저장

즉 “파일 업로드”도 사실은
업무 흐름 + 저장 구현 + 메타데이터 관리
세 가지가 섞인 기능이에요.


이번 글에서 맞출 공통 구조

이번에는 세 스택 모두 아래 구조로 갑니다.

  • 업로드 API는 profile-image 하나
  • 저장 인터페이스는 FileStorage
  • 구현은 LocalFileStorage
  • 나중에 S3FileStorage로 바꿀 수 있는 구조

즉 오늘 글의 핵심은 이겁니다.

파일 업로드의 핵심은 저장 위치보다 저장 구현을 분리하는 구조다.


1) FastAPI에서 LocalFileStorage로 시작하기

FastAPI는 UploadFile과 StaticFiles를 함께 쓰면 로컬 저장 MVP를 꽤 빠르게 만들 수 있습니다. 정적 파일 디렉터리는 StaticFiles로 마운트할 수 있다고 문서가 설명합니다. (FastAPI)

추천 구조

fastapi-backend/
├── app/
│   ├── api/
│   │   └── upload.py
│   ├── services/
│   │   └── upload_service.py
│   ├── storage/
│   │   ├── base.py
│   │   └── local_storage.py
│   └── main.py
└── uploads/

app/storage/base.py

from abc import ABC, abstractmethod


class FileStorage(ABC):
    @abstractmethod
    def save(self, filename: str, content: bytes) -> dict:
        raise NotImplementedError

app/storage/local_storage.py

from pathlib import Path
from uuid import uuid4

from app.storage.base import FileStorage


class LocalFileStorage(FileStorage):
    def __init__(self, upload_dir: str = "uploads") -> None:
        self.upload_dir = Path(upload_dir)
        self.upload_dir.mkdir(parents=True, exist_ok=True)

    def save(self, filename: str, content: bytes) -> dict:
        ext = Path(filename).suffix.lower()
        safe_name = f"{uuid4().hex}{ext}"
        save_path = self.upload_dir / safe_name

        with open(save_path, "wb") as f:
            f.write(content)

        return {
            "stored_filename": safe_name,
            "stored_path": str(save_path),
            "public_url": f"/static/{safe_name}",
        }

app/services/upload_service.py

from pathlib import Path

from app.storage.base import FileStorage

ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
MAX_FILE_SIZE = 5 * 1024 * 1024


class UploadService:
    def __init__(self, storage: FileStorage) -> None:
        self.storage = storage

    def upload_profile_image(self, original_filename: str, content: bytes) -> dict:
        ext = Path(original_filename).suffix.lower()

        if ext not in ALLOWED_EXTENSIONS:
            raise ValueError("허용되지 않는 파일 확장자입니다.")

        if len(content) > MAX_FILE_SIZE:
            raise ValueError("파일 크기는 5MB 이하여야 합니다.")

        stored = self.storage.save(original_filename, content)

        return {
            "original_filename": original_filename,
            **stored,
        }

app/api/upload.py

from fastapi import APIRouter, File, HTTPException, UploadFile

from app.services.upload_service import UploadService
from app.storage.local_storage import LocalFileStorage

router = APIRouter(prefix="/api/uploads", tags=["uploads"])

storage = LocalFileStorage()
upload_service = UploadService(storage)


@router.post("/profile-image")
async def upload_profile_image(file: UploadFile = File(...)):
    try:
        content = await file.read()
        return upload_service.upload_profile_image(file.filename or "", content)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

app/main.py

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from app.api.upload import router as upload_router

app = FastAPI()
app.mount("/static", StaticFiles(directory="uploads"), name="static")
app.include_router(upload_router)

FastAPI에서 핵심 감각

여기서 중요한 건 UploadService가 디스크 쓰기를 직접 모른다는 점입니다.
LocalFileStorage가 바뀌면 나중에 S3FileStorage로 교체할 수 있어요.

그리고 StaticFiles는 로컬 개발/단일 서버에선 정말 편합니다.
FastAPI 문서도 디렉터리를 특정 경로에 마운트해서 정적 파일을 자동 서빙할 수 있다고 설명합니다. (FastAPI)

다만 이 방식은 어디까지나 애플리케이션 서버가 정적 파일도 직접 들고 서빙하는 구조라는 점을 잊으면 안 됩니다.


2) Spring Boot에서 Storage Service 분리하기

Spring은 정적 리소스 서빙과 웹 계층이 잘 정리돼 있어서, 로컬 저장으로 시작하기 좋은 편입니다. Spring Framework는 정적 리소스를 특정 location에서 서빙할 수 있는 resource handler 구성을 설명합니다. (Home)

추천 구조

springboot-backend/
├── src/main/java/com/example/backend/
│   ├── controller/
│   │   └── UploadController.java
│   ├── service/
│   │   ├── FileStorage.java
│   │   ├── LocalFileStorage.java
│   │   └── UploadService.java
│   └── config/
│       └── StaticResourceConfig.java
└── uploads/

service/FileStorage.java

package com.example.backend.service;

public interface FileStorage {
    StoredFile save(String originalFilename, byte[] content) throws Exception;

    record StoredFile(
            String storedFilename,
            String storedPath,
            String publicUrl
    ) {}
}

service/LocalFileStorage.java

package com.example.backend.service;

import org.springframework.stereotype.Service;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;

@Service
public class LocalFileStorage implements FileStorage {

    private static final Path UPLOAD_DIR = Path.of("uploads");

    @Override
    public StoredFile save(String originalFilename, byte[] content) throws Exception {
        Files.createDirectories(UPLOAD_DIR);

        String ext = getExtension(originalFilename);
        String storedFilename = UUID.randomUUID().toString().replace("-", "") + ext;
        Path savePath = UPLOAD_DIR.resolve(storedFilename);

        Files.write(savePath, content);

        return new StoredFile(
                storedFilename,
                savePath.toString(),
                "/static/" + storedFilename
        );
    }

    private String getExtension(String filename) {
        int lastDot = filename.lastIndexOf(".");
        if (lastDot == -1) return "";
        return filename.substring(lastDot).toLowerCase();
    }
}

service/UploadService.java

package com.example.backend.service;

import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;
import java.util.Set;

@Service
public class UploadService {

    private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".jpg", ".jpeg", ".png");
    private static final long MAX_FILE_SIZE = 5L * 1024L * 1024L;

    private final FileStorage fileStorage;

    public UploadService(FileStorage fileStorage) {
        this.fileStorage = fileStorage;
    }

    public Map<String, Object> uploadProfileImage(MultipartFile file) throws Exception {
        String originalFilename = file.getOriginalFilename();
        if (originalFilename == null || originalFilename.isBlank()) {
            throw new IllegalArgumentException("파일명이 올바르지 않습니다.");
        }

        String ext = getExtension(originalFilename);
        if (!ALLOWED_EXTENSIONS.contains(ext)) {
            throw new IllegalArgumentException("허용되지 않는 파일 확장자입니다.");
        }

        if (file.getSize() > MAX_FILE_SIZE) {
            throw new IllegalArgumentException("파일 크기는 5MB 이하여야 합니다.");
        }

        FileStorage.StoredFile stored = fileStorage.save(originalFilename, file.getBytes());

        return Map.of(
                "originalFilename", originalFilename,
                "storedFilename", stored.storedFilename(),
                "storedPath", stored.storedPath(),
                "publicUrl", stored.publicUrl()
        );
    }

    private String getExtension(String filename) {
        int lastDot = filename.lastIndexOf(".");
        if (lastDot == -1) return "";
        return filename.substring(lastDot).toLowerCase();
    }
}

controller/UploadController.java

package com.example.backend.controller;

import com.example.backend.service.UploadService;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

@RestController
@RequestMapping("/api/uploads")
public class UploadController {

    private final UploadService uploadService;

    public UploadController(UploadService uploadService) {
        this.uploadService = uploadService;
    }

    @PostMapping("/profile-image")
    public Map<String, Object> uploadProfileImage(@RequestParam("file") MultipartFile file) throws Exception {
        return uploadService.uploadProfileImage(file);
    }
}

config/StaticResourceConfig.java

package com.example.backend.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;

@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry
                .addResourceHandler("/static/**")
                .addResourceLocations("file:uploads/");
    }
}

Spring Boot에서 핵심 감각

여기서 중요한 건 Spring이 기본적으로 정적 리소스를 잘 서빙해주지만,
업로드 디렉터리처럼 애플리케이션 외부 파일 시스템 경로를 직접 서빙하려면 ResourceHandler를 명시적으로 잡아주는 편이 깔끔하다는 점입니다. Spring Framework 문서도 resource handler로 특정 location을 정적 리소스 source로 등록하는 방식을 설명합니다. (Home)

그리고 FileStorage 인터페이스를 둬버리면,
나중에 S3FileStorage를 추가해도 컨트롤러와 서비스는 거의 안 바뀝니다.


3) Node.js에서 Local Storage 추상화로 시작하기

Express는 express.static으로 정적 파일을 바로 서빙할 수 있습니다. 공식 문서도 정적 파일 서빙은 express.static(root)를 쓰라고 설명합니다. (Express.js)

추천 구조

node-backend/
├── src/
│   ├── routes/
│   │   └── upload.route.js
│   ├── services/
│   │   └── upload.service.js
│   ├── storage/
│   │   ├── base.js
│   │   └── local-storage.js
│   └── server.js
└── uploads/

src/storage/base.js

export class FileStorage {
  async save(originalFilename, buffer) {
    throw new Error("Not implemented");
  }
}

src/storage/local-storage.js

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { FileStorage } from "./base.js";

export class LocalFileStorage extends FileStorage {
  constructor(uploadDir = "uploads") {
    super();
    this.uploadDir = uploadDir;

    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir, { recursive: true });
    }
  }

  async save(originalFilename, buffer) {
    const ext = path.extname(originalFilename).toLowerCase();
    const storedFilename = `${crypto.randomUUID()}${ext}`;
    const storedPath = path.join(this.uploadDir, storedFilename);

    fs.writeFileSync(storedPath, buffer);

    return {
      storedFilename,
      storedPath,
      publicUrl: `/static/${storedFilename}`,
    };
  }
}

src/services/upload.service.js

import path from "node:path";

const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
const MAX_FILE_SIZE = 5 * 1024 * 1024;

export class UploadService {
  constructor(fileStorage) {
    this.fileStorage = fileStorage;
  }

  async uploadProfileImage(file) {
    if (!file) {
      throw new Error("파일이 없습니다.");
    }

    const ext = path.extname(file.originalname).toLowerCase();
    if (!ALLOWED_EXTENSIONS.has(ext)) {
      throw new Error("허용되지 않는 파일 확장자입니다.");
    }

    if (file.size > MAX_FILE_SIZE) {
      throw new Error("파일 크기는 5MB 이하여야 합니다.");
    }

    const stored = await this.fileStorage.save(file.originalname, file.buffer);

    return {
      originalFilename: file.originalname,
      ...stored,
    };
  }
}

src/routes/upload.route.js

import { Router } from "express";
import multer from "multer";
import { UploadService } from "../services/upload.service.js";
import { LocalFileStorage } from "../storage/local-storage.js";

const router = Router();
const upload = multer({ storage: multer.memoryStorage() });

const storage = new LocalFileStorage();
const uploadService = new UploadService(storage);

router.post("/profile-image", upload.single("file"), async (req, res) => {
  try {
    const result = await uploadService.uploadProfileImage(req.file);
    return res.status(201).json(result);
  } catch (error) {
    return res.status(400).json({ message: error.message });
  }
});

export default router;

src/server.js

import express from "express";
import uploadRouter from "./routes/upload.route.js";

const app = express();
const PORT = 3000;

app.use("/static", express.static("uploads"));
app.use("/api/uploads", uploadRouter);

app.listen(PORT, () => {
  console.log(`server running on http://localhost:${PORT}`);
});

Node.js에서 핵심 감각

여기서는 일부러 multer.memoryStorage()를 썼습니다.
왜냐하면 “업로드를 받고 → storage abstraction에 넘긴다”는 구조를 보여주기 좋기 때문이에요.

다만 이 방식은 대용량 파일엔 조심해야 합니다.
파일이 메모리에 올라오니까요. 그래서 이미지 몇 MB 정도에는 시작점으로 괜찮지만, 더 큰 파일로 가면 스트리밍/직접 디스크 저장 쪽으로 다시 설계해야 합니다. multer 문서도 memoryStorage 사용 시 큰 파일이나 많은 업로드는 메모리 부족을 일으킬 수 있다고 경고합니다. (github.com)


나중에 S3로 바꿀 때 진짜 중요한 건

이제 핵심이에요.

지금 예제들은 전부 LocalFileStorage만 구현했습니다.
그런데 구조를 잘 끊어두면 나중엔 이런 클래스만 추가하면 됩니다.

  • S3FileStorage
  • MinioFileStorage
  • NCPObjectStorageFileStorage

그리고 서비스 코드는 그대로 둬도 돼요.

즉 서비스는 이런 것만 알면 됩니다.

  • 원본 파일이 들어옴
  • 저장소에 save 요청
  • 저장 결과로 URL/키를 받음

저장소가 로컬이든 S3든 그건 storage implementation이 해결합니다.

이게 진짜 중요합니다.
처음엔 사소해 보여도, 나중에 저장소 바꾸는 비용 차이가 엄청 납니다.


로컬 저장 vs S3 선택 기준을 더 현실적으로 정리하면

로컬 저장 추천

  • 1인 개발
  • 사이드 프로젝트
  • 단일 서버
  • 빠른 MVP
  • 관리자가 가끔 올리는 이미지 몇 장

S3 추천

  • 사용자 업로드가 핵심 기능
  • 서버 여러 대
  • 컨테이너 중심 배포
  • CDN 붙일 예정
  • 장기 운영
  • 장애/백업/복구 중요

AWS는 S3가 높은 내구성, 가용성, 확장성을 가진 객체 스토리지라고 설명하고 있습니다. 그래서 “파일이 서비스 자산”이 되는 순간 S3 쪽이 확실히 유리해집니다. (Amazon Web Services, Inc.)


실무에서 자주 하는 실수

첫 번째는 업로드 API와 저장 구현을 분리하지 않는 것입니다.
그러면 로컬에서 S3로 바꿀 때 서비스 코드가 다 깨집니다.

두 번째는 정적 파일 서빙 구조를 운영 구조로 착각하는 것입니다.
로컬 StaticFiles, express.static, Spring resource handler는 편하지만, 운영에서 반드시 최종 정답은 아닙니다. (FastAPI)

세 번째는 컨테이너 환경에서 로컬 디스크를 영구 저장소처럼 생각하는 것입니다.
이건 나중에 꼭 문제를 만듭니다.

네 번째는 파일 URL과 물리 경로를 서비스 전역에 박아두는 것입니다.
이것도 저장소 전환을 어렵게 만듭니다.


이번 글 핵심 정리

이번 글의 핵심은 이겁니다.

파일 업로드의 다음 단계는 “어디에 저장할지”보다 “저장 방식을 바꿔도 애플리케이션이 크게 안 흔들리게 만드는 것”이다.

정리하면 이렇게 볼 수 있어요.

  • 로컬 저장은 초반 MVP에 충분히 유효하다
  • 오브젝트 스토리지는 운영 확장성에 강하다
  • 저장 구현은 storage 계층으로 분리하는 게 좋다
  • 서비스는 저장 위치보다 저장 결과만 알게 하자
  • 정적 파일 서빙은 편하지만 운영 구조와 분리해서 생각하자

스택별 감각은 이렇습니다.

  • FastAPI: StaticFiles는 시작점으로 좋지만 storage abstraction을 같이 두는 게 중요하다. (FastAPI)
  • Spring Boot: resource handler와 storage service 분리를 같이 가져가면 전환 비용이 줄어든다. (Home)
  • Node.js: express.static은 간단하지만 storage/service 분리가 없으면 나중에 제일 빨리 꼬인다. (Express.js)

다음 글 예고

다음 글에서는 이어서
Presigned URL과 서버를 거치지 않는 직접 업로드 구조를 다뤄보겠습니다.

즉,

  • 왜 큰 파일은 서버를 경유하지 않는 구조가 유리한지
  • presigned URL은 어떤 원리인지
  • 백엔드는 어디까지 책임지고, 프론트는 어디서 업로드하는지
  • FastAPI · Spring Boot · Node.js에서 presigned URL 발급 API를 어떻게 설계하는지

이 흐름으로 이어가겠습니다.

출처

  • Amazon S3 공식 페이지 — 11 nines durability, 99.99% availability, object storage characteristics. (Amazon Web Services, Inc.)
  • FastAPI 공식 문서 — Static Files. (FastAPI)
  • Spring Framework 공식 문서 — Static Resources / ResourceHandler. (Home)
  • Spring Boot 문서 — Static Content defaults. (Home)
  • Express 공식 문서 — Serving static files / express.static. (Express.js)

백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 파일저장전략, S3, ObjectStorage, StaticFiles, 백엔드시리즈

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
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
31
글 보관함
반응형