티스토리 뷰

반응형

큰 파일 업로드를 왜 서버가 직접 받으면 버거워질까? Presigned URL로 서버를 거치지 않는 직접 업로드 구조 만들기 — FastAPI · Spring Boot · Node.js

지난 글에서 로컬 저장이냐 S3 같은 오브젝트 스토리지냐를 정리했잖아요.
그다음 단계에서 거의 꼭 나오는 얘기가 있습니다.

“근데 파일 업로드를 꼭 우리 백엔드 서버가 다 거쳐야 해?”

이 질문, 처음엔 되게 사소해 보여요.
어차피 프론트가 서버로 보내고, 서버가 S3에 다시 올리면 되는 거 아닌가… 싶죠.

근데 파일이 조금만 커지거나, 업로드 트래픽이 늘기 시작하면 이 구조가 갑자기 무거워집니다.
제가 이걸 체감한 건 이미지 몇 장 수준이 아니라 음성 파일, 동영상 파일, 원본 이미지 업로드 같은 걸 붙이기 시작하면서였어요.
그때부터는 진짜 “서버가 파일을 통과만 시켜주는 역할” 자체가 아까워집니다.

그래서 많이 쓰는 구조가 바로 Presigned URL입니다.

AWS 문서 기준으로 presigned URL은 AWS 자격 증명을 직접 주지 않아도, 시간 제한이 있는 URL로 특정 S3 객체 업로드나 다운로드를 허용하는 방식입니다. 업로드용 presigned URL을 받은 클라이언트는 그 URL로 직접 S3에 업로드할 수 있습니다. (AWS Documentation)

즉 구조가 이렇게 바뀝니다.

  • 예전: 프론트 → 백엔드 → S3
  • 바뀐 후: 프론트 → 백엔드(사인된 URL만 받음) → S3에 직접 업로드

이 차이가 생각보다 큽니다.


Presigned URL이 뭐냐고 물으면 저는 이렇게 설명합니다

“백엔드가 잠깐만 유효한 업로드 권한이 담긴 주소를 만들어 주고, 실제 파일 전송은 클라이언트가 스토리지로 직접 보내는 방식”

이게 핵심이에요.

AWS 문서도 presigned URL은 업로드나 다운로드를 위한 time-limited access를 주는 방식이라고 설명합니다. 그리고 presigned URL은 그 URL을 만든 IAM principal의 권한 범위 안에서만 동작합니다. (AWS Documentation)

즉 백엔드는 이렇게만 하면 됩니다.

  1. 어떤 파일을 올릴 건지 검증
  2. 어떤 object key로 저장할지 결정
  3. presigned URL 생성
  4. 그 URL과 저장 key를 프론트에 반환

그다음 실제 파일 바이트는 프론트가 바로 S3에 보냅니다.


왜 이 구조가 좋아질까

솔직히 제일 큰 이유는 이거예요.

백엔드 서버가 파일 바이트를 안 들고 있어도 된다.

이게 의미하는 게 꽤 많습니다.

  • 서버 CPU/메모리 부담 감소
  • 업로드 중계 대역폭 부담 감소
  • 백엔드 응답 시간 안정화
  • 대용량 업로드에서 서버 병목 감소
  • 업로드 서버와 API 서버 역할 분리

그리고 S3는 객체 스토리지로 확장성과 내구성을 전제로 설계돼 있어서, 업로드 파일이 커질수록 직접 업로드 구조가 더 자연스러워집니다. AWS는 S3가 사실상 무제한 확장성과 높은 내구성, 99.99% 가용성을 목표로 한다고 설명합니다. (AWS Documentation)

제가 실무 감각으로 보면,
이미지 몇 장 수준에선 서버 경유 업로드도 충분히 괜찮아요.
근데 음성, 영상, 고해상도 원본, 다중 업로드, 모바일 업로드까지 붙기 시작하면 presigned URL 구조가 훨씬 편해집니다.


그럼 백엔드는 이제 아무 일도 안 하나?

아니요. 오히려 역할이 더 명확해집니다.

백엔드는 여전히 중요한 걸 합니다.

  • 로그인 사용자 인증
  • 업로드 권한 확인
  • 허용 확장자/용량 정책 확인
  • 저장할 key 생성
  • presigned URL 발급
  • 업로드 후 메타데이터 저장
  • 필요하면 업로드 완료 처리 API 제공

파일 전송 자체는 안 하지만,
업로드 정책과 권한 통제는 여전히 백엔드 책임입니다.

이게 중요해요.
presigned URL은 “보안을 프론트에 넘긴다”가 아니라,
“전송 경로만 최적화한다”에 가깝습니다. AWS도 presigned URL이 만든 사람의 권한 범위 안에서만 동작한다고 설명합니다. (AWS Documentation)


이번 글에서 맞출 공통 구조

이번에는 세 스택 모두 이 흐름으로 갑니다.

  1. 클라이언트가 업로드 준비 API 호출
  2. 백엔드가 파일명/확장자 검증
  3. 백엔드가 object key 생성
  4. 백엔드가 presigned PUT URL 발급
  5. 클라이언트가 그 URL로 S3에 직접 업로드
  6. 백엔드는 업로드 key와 public URL 또는 object key를 응답

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

업로드 API의 본질은 파일을 받는 게 아니라, 업로드를 안전하게 허가하는 것이다.


1) FastAPI에서 Presigned URL 발급 API 만들기

FastAPI는 JSON 응답 API를 만들기 쉬워서, presigned URL 발급용 엔드포인트를 붙이기 좋습니다. FastAPI 문서도 기본적으로 JSON 응답과 response model 구조를 잘 지원합니다. (FastAPI)

그리고 AWS 쪽은 boto3가 presigned URL 생성을 공식적으로 지원합니다. boto3 문서와 S3 문서는 presigned URL로 PUT 업로드를 허용할 수 있다고 설명합니다. (AWS Documentation)

추천 구조

fastapi-backend/
├── app/
│   ├── api/
│   │   └── upload.py
│   ├── schemas/
│   │   └── upload.py
│   ├── services/
│   │   └── upload_service.py
│   ├── storage/
│   │   └── s3_signer.py
│   └── main.py
└── requirements.txt

requirements.txt

fastapi
uvicorn[standard]
pydantic
boto3

app/schemas/upload.py

from pydantic import BaseModel, Field


class CreateUploadUrlRequest(BaseModel):
    filename: str = Field(min_length=1, max_length=255)
    content_type: str = Field(min_length=1, max_length=100)


class CreateUploadUrlResponse(BaseModel):
    object_key: str
    upload_url: str
    public_url: str
    method: str = "PUT"

app/storage/s3_signer.py

반응형
from __future__ import annotations

import os
from dataclasses import dataclass

import boto3


@dataclass
class S3SignerConfig:
    bucket_name: str
    region_name: str


class S3PresignedUrlSigner:
    def __init__(self, config: S3SignerConfig) -> None:
        self.config = config
        self.client = boto3.client("s3", region_name=config.region_name)

    def create_put_url(
        self,
        object_key: str,
        content_type: str,
        expires_seconds: int = 300,
    ) -> str:
        return self.client.generate_presigned_url(
            ClientMethod="put_object",
            Params={
                "Bucket": self.config.bucket_name,
                "Key": object_key,
                "ContentType": content_type,
            },
            ExpiresIn=expires_seconds,
        )

    def build_public_url(self, object_key: str) -> str:
        return (
            f"https://{self.config.bucket_name}.s3."
            f"{self.config.region_name}.amazonaws.com/{object_key}"
        )


def load_signer_from_env() -> S3PresignedUrlSigner:
    bucket_name = os.environ["AWS_S3_BUCKET"]
    region_name = os.environ.get("AWS_REGION", "ap-northeast-2")

    return S3PresignedUrlSigner(
        S3SignerConfig(bucket_name=bucket_name, region_name=region_name)
    )

app/services/upload_service.py

from __future__ import annotations

from pathlib import Path
from uuid import uuid4

from app.storage.s3_signer import S3PresignedUrlSigner

ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png"}


class UploadService:
    def __init__(self, signer: S3PresignedUrlSigner) -> None:
        self.signer = signer

    def create_profile_image_upload_url(
        self,
        filename: str,
        content_type: str,
        user_id: str,
    ) -> dict:
        ext = Path(filename).suffix.lower()
        if ext not in ALLOWED_EXTENSIONS:
            raise ValueError("허용되지 않는 파일 확장자입니다.")

        if content_type not in ALLOWED_CONTENT_TYPES:
            raise ValueError("허용되지 않는 Content-Type입니다.")

        object_key = f"profile-images/{user_id}/{uuid4().hex}{ext}"

        upload_url = self.signer.create_put_url(
            object_key=object_key,
            content_type=content_type,
            expires_seconds=300,
        )

        return {
            "object_key": object_key,
            "upload_url": upload_url,
            "public_url": self.signer.build_public_url(object_key),
            "method": "PUT",
        }

app/api/upload.py

from fastapi import APIRouter, HTTPException, status

from app.schemas.upload import (
    CreateUploadUrlRequest,
    CreateUploadUrlResponse,
)
from app.services.upload_service import UploadService
from app.storage.s3_signer import load_signer_from_env

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

upload_service = UploadService(load_signer_from_env())


@router.post(
    "/profile-image/presigned-url",
    response_model=CreateUploadUrlResponse,
    status_code=status.HTTP_201_CREATED,
)
def create_profile_image_upload_url(
    request: CreateUploadUrlRequest,
) -> CreateUploadUrlResponse:
    try:
        result = upload_service.create_profile_image_upload_url(
            filename=request.filename,
            content_type=request.content_type,
            user_id="demo-user-1",
        )
        return CreateUploadUrlResponse(**result)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

app/main.py

from fastapi import FastAPI

from app.api.upload import router as upload_router

app = FastAPI(title="backend-series-fastapi-presigned-upload")
app.include_router(upload_router)

FastAPI에서 핵심 감각

FastAPI 쪽은 “실제 파일 업로드”가 아니라 “업로드 URL 발급” API가 됩니다.
즉 백엔드는 더 가벼워지고, 프론트는 응답받은 upload_url로 직접 PUT 요청을 보내면 됩니다.

AWS 문서도 presigned URL로 PUT 업로드를 허용할 수 있다고 설명하고, URL이 정해진 만료 시간 동안 유효하다고 안내합니다. (AWS Documentation)


2) Spring Boot에서 Presigned URL 발급 서비스 만들기

Spring Boot는 서비스 계층을 나누기 편해서,
이런 “스토리지 서명 발급” 기능을 서비스로 빼기 좋습니다.

그리고 Spring 쪽도 핵심은 똑같아요.

  • 컨트롤러는 요청 받기
  • 서비스는 정책 검증
  • 스토리지/사이너는 presigned URL 생성

추천 구조

springboot-backend/
├── src/main/java/com/example/backend/
│   ├── controller/
│   │   └── UploadController.java
│   ├── dto/
│   │   ├── CreateUploadUrlRequest.java
│   │   └── CreateUploadUrlResponse.java
│   ├── service/
│   │   ├── UploadService.java
│   │   └── S3PresignedUrlService.java
│   └── BackendApplication.java

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.3'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'software.amazon.awssdk:s3'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

dto/CreateUploadUrlRequest.java

package com.example.backend.dto;

public record CreateUploadUrlRequest(
        String filename,
        String contentType
) {
}

dto/CreateUploadUrlResponse.java

package com.example.backend.dto;

public record CreateUploadUrlResponse(
        String objectKey,
        String uploadUrl,
        String publicUrl,
        String method
) {
}

service/S3PresignedUrlService.java

package com.example.backend.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

import java.net.URL;
import java.time.Duration;

@Service
public class S3PresignedUrlService {

    private final String bucketName;
    private final Region region;

    public S3PresignedUrlService(
            @Value("${aws.s3.bucket}") String bucketName,
            @Value("${aws.region}") String region
    ) {
        this.bucketName = bucketName;
        this.region = Region.of(region);
    }

    public String createPutUrl(String objectKey, String contentType) {
        try (S3Presigner presigner = S3Presigner.builder()
                .region(region)
                .credentialsProvider(DefaultCredentialsProvider.create())
                .build()) {

            PutObjectRequest objectRequest = PutObjectRequest.builder()
                    .bucket(bucketName)
                    .key(objectKey)
                    .contentType(contentType)
                    .build();

            PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
                    .signatureDuration(Duration.ofMinutes(5))
                    .putObjectRequest(objectRequest)
                    .build();

            URL url = presigner.presignPutObject(presignRequest).url();
            return url.toString();
        }
    }

    public String buildPublicUrl(String objectKey) {
        return "https://" + bucketName + ".s3." + region.id() + ".amazonaws.com/" + objectKey;
    }
}

service/UploadService.java

package com.example.backend.service;

import com.example.backend.dto.CreateUploadUrlResponse;
import org.springframework.stereotype.Service;

import java.util.Set;
import java.util.UUID;

@Service
public class UploadService {

    private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".jpg", ".jpeg", ".png");
    private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpeg", "image/png");

    private final S3PresignedUrlService s3PresignedUrlService;

    public UploadService(S3PresignedUrlService s3PresignedUrlService) {
        this.s3PresignedUrlService = s3PresignedUrlService;
    }

    public CreateUploadUrlResponse createProfileImageUploadUrl(
            String filename,
            String contentType,
            String userId
    ) {
        String ext = getExtension(filename);

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

        if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
            throw new IllegalArgumentException("허용되지 않는 Content-Type입니다.");
        }

        String objectKey = "profile-images/" + userId + "/" + UUID.randomUUID().toString() + ext;
        String uploadUrl = s3PresignedUrlService.createPutUrl(objectKey, contentType);

        return new CreateUploadUrlResponse(
                objectKey,
                uploadUrl,
                s3PresignedUrlService.buildPublicUrl(objectKey),
                "PUT"
        );
    }

    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.dto.CreateUploadUrlRequest;
import com.example.backend.dto.CreateUploadUrlResponse;
import com.example.backend.service.UploadService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

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

    private final UploadService uploadService;

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

    @PostMapping("/profile-image/presigned-url")
    @ResponseStatus(HttpStatus.CREATED)
    public CreateUploadUrlResponse createProfileImageUploadUrl(
            @RequestBody CreateUploadUrlRequest request
    ) {
        return uploadService.createProfileImageUploadUrl(
                request.filename(),
                request.contentType(),
                "demo-user-1"
        );
    }
}

Spring Boot에서 핵심 감각

여기서 중요한 건 Spring Boot가 업로드 파일 바이트를 직접 안 받는다는 점입니다.
컨트롤러는 JSON 요청만 받고, presigned URL만 내려줍니다.

즉 업로드 API가 훨씬 가벼워지고, 나중에 S3가 아니라 다른 객체 스토리지로 바뀌어도 S3PresignedUrlService 같은 구현만 교체하면 됩니다.


3) Node.js에서 Presigned URL 발급 API 만들기

Node.js도 방향은 똑같습니다.
Express는 얇기 때문에 오히려 presigned URL 발급용 API를 만들기 쉬워요.

AWS 공식 문서는 presigned URL을 통해 PUT 업로드를 허용할 수 있다고 설명하고, AWS SDK들도 이 생성을 지원합니다. (AWS Documentation)

추천 구조

node-backend/
├── src/
│   ├── routes/
│   │   └── upload.route.js
│   ├── services/
│   │   └── upload.service.js
│   ├── storage/
│   │   └── s3-signer.js
│   └── server.js

package.json

{
  "name": "backend-series-node-presigned-upload",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node src/server.js"
  },
  "dependencies": {
    "express": "^5.1.0",
    "@aws-sdk/client-s3": "^3.0.0",
    "@aws-sdk/s3-request-presigner": "^3.0.0"
  }
}

src/storage/s3-signer.js

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export class S3Signer {
  constructor({ bucketName, region }) {
    this.bucketName = bucketName;
    this.region = region;
    this.client = new S3Client({ region });
  }

  async createPutUrl(objectKey, contentType, expiresIn = 300) {
    const command = new PutObjectCommand({
      Bucket: this.bucketName,
      Key: objectKey,
      ContentType: contentType,
    });

    return getSignedUrl(this.client, command, { expiresIn });
  }

  buildPublicUrl(objectKey) {
    return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${objectKey}`;
  }
}

src/services/upload.service.js

import path from "node:path";
import crypto from "node:crypto";

const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
const ALLOWED_CONTENT_TYPES = new Set(["image/jpeg", "image/png"]);

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

  async createProfileImageUploadUrl({ filename, contentType, userId }) {
    const ext = path.extname(filename).toLowerCase();

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

    if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
      throw new Error("허용되지 않는 Content-Type입니다.");
    }

    const objectKey = `profile-images/${userId}/${crypto.randomUUID()}${ext}`;
    const uploadUrl = await this.s3Signer.createPutUrl(objectKey, contentType, 300);

    return {
      objectKey,
      uploadUrl,
      publicUrl: this.s3Signer.buildPublicUrl(objectKey),
      method: "PUT",
    };
  }
}

src/routes/upload.route.js

import { Router } from "express";
import { UploadService } from "../services/upload.service.js";
import { S3Signer } from "../storage/s3-signer.js";

const router = Router();

const s3Signer = new S3Signer({
  bucketName: process.env.AWS_S3_BUCKET,
  region: process.env.AWS_REGION || "ap-northeast-2",
});

const uploadService = new UploadService(s3Signer);

router.post("/profile-image/presigned-url", async (req, res) => {
  const { filename, contentType } = req.body;

  if (!filename || !contentType) {
    return res.status(400).json({ message: "filename과 contentType이 필요합니다." });
  }

  try {
    const result = await uploadService.createProfileImageUploadUrl({
      filename,
      contentType,
      userId: "demo-user-1",
    });

    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(express.json());
app.use("/api/uploads", uploadRouter);

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

Node.js에서 핵심 감각

Node.js 쪽은 이 구조가 특히 잘 맞아요.

  • Express route는 얇게
  • UploadService가 정책 검증
  • S3Signer가 presigned URL 생성

즉 multer조차 필요 없습니다.
왜냐하면 서버가 파일 자체를 안 받으니까요.

이게 진짜 presigned URL 구조의 맛입니다.


프론트는 그다음에 뭘 해야 할까

흐름은 보통 이렇습니다.

  1. 프론트가 백엔드에 filename, contentType 전송
  2. 백엔드가 uploadUrl, objectKey, publicUrl 반환
  3. 프론트가 fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } })
  4. 업로드 성공 후 백엔드에 메타데이터 저장 API를 추가로 호출하거나, 곧바로 public URL 사용

AWS 문서도 presigned URL을 받은 쪽이 그 URL로 직접 업로드할 수 있다고 설명합니다. (AWS Documentation)

여기서 중요한 건
프론트가 업로드 요청을 보낼 때 Content-Type을 presign 때 쓴 값과 맞추는 것입니다.
이 부분은 실제 동작에서 자주 놓칩니다.


Presigned URL 구조에서 꼭 같이 챙길 것

1. object key는 서버가 정하세요

프론트가 "uploads/myfile.png" 같은 key를 마음대로 정하게 두면 나중에 위험해집니다.
서버가 prefix, 사용자 식별자, UUID 조합으로 생성하는 편이 훨씬 낫습니다.

2. 만료 시간은 짧게

AWS 문서도 presigned URL은 time-limited access라고 설명합니다. 업로드용 URL이면 보통 몇 분 수준으로 짧게 가는 편이 안전합니다. (AWS Documentation)

3. 허용 파일 정책은 여전히 백엔드가 가져가야 합니다

파일 바이트를 직접 안 받는다고 해서 검증 책임이 사라지는 건 아닙니다.
확장자, MIME 타입, 저장 위치 prefix, 사용자별 경로 규칙은 여전히 백엔드 정책입니다.

4. 업로드 완료 후 메타데이터 저장 흐름을 분리해서 생각하세요

실무에선 보통 이렇게 나뉩니다.

  • 업로드 URL 발급
  • 클라이언트 직접 업로드
  • 업로드 완료 후 DB에 파일 메타데이터 저장

이걸 한 API로 다 뭉개면 나중에 상태 관리가 복잡해집니다.


실무에서 자주 하는 실수

첫 번째는 presigned URL만 발급하고 key 정책이 없는 것입니다.
그러면 파일 경로가 금방 지저분해집니다.

두 번째는 업로드 URL 만료 시간을 너무 길게 잡는 것입니다.
presigned URL은 임시 권한이라 짧을수록 좋습니다. AWS도 time-limited access라고 설명합니다. (AWS Documentation)

세 번째는 public URL을 너무 일찍 고정해버리는 것입니다.
나중에 CDN, private bucket, download presigned URL 구조로 바뀔 수 있어서,
초반에는 objectKey를 중심으로 생각하는 편이 더 오래 갑니다.

네 번째는 프론트가 업로드 후 성공/실패 상태를 백엔드에 안 알려주는 것입니다.
그러면 DB에는 업로드된 줄 아는데 실제론 없거나, 반대로 S3엔 있는데 DB엔 없는 상태가 생길 수 있어요.


이번 글 핵심 정리

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

Presigned URL 구조는 백엔드가 파일을 직접 받지 않으면서도, 업로드 권한과 저장 정책은 계속 통제할 수 있게 해주는 구조다.

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

  • 큰 파일이나 업로드 트래픽이 늘면 직접 업로드 구조가 유리하다
  • 백엔드는 presigned URL만 발급한다
  • 실제 파일 전송은 클라이언트가 스토리지로 직접 한다
  • object key는 서버가 생성한다
  • 저장 구현은 storage 계층으로 분리한다
  • 나중에 로컬 저장 → S3 전환보다, storage abstraction이 더 중요하다

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

  • FastAPI: presigned URL 발급 API는 가볍고, 응답 모델과 잘 맞는다. (FastAPI)
  • Spring Boot: 서비스 계층과 스토리지 사이너를 분리하면 장기 유지보수에 좋다. (Home)
  • Node.js: Express는 파일 업로드 미들웨어 없이도 presigned URL API를 아주 단순하게 만들 수 있다. (expressjs.com)

다음 글 예고

다음 글에서는 이 흐름을 이어서
업로드 완료 후 파일 메타데이터를 DB에 어떻게 저장하고, 삭제/교체는 어떻게 다룰지를 다뤄보겠습니다.

즉,

  • 파일 테이블은 어떤 컬럼을 가져야 하는지
  • object key, URL, 원본 파일명 중 뭘 저장해야 하는지
  • 소프트 삭제가 좋은지, 실제 스토리지 삭제가 좋은지
  • 프로필 이미지 교체 시 예전 파일은 언제 지울지

이걸 FastAPI · Spring Boot · Node.js 기준으로 이어가겠습니다.

출처

  • Amazon S3 공식 문서 — presigned URL upload/download, time-limited access, creator permissions, overwrite behavior. (AWS Documentation)
  • Amazon S3 공식 페이지 — object storage durability, availability, scalability. (AWS Documentation)
  • FastAPI 공식 문서 — JSON response/response model, static files. (FastAPI)
  • Spring Framework 공식 문서 — static resources and resource handlers. (Home)
  • Express 공식 문서 — express.static, Express framework overview. (expressjs.com)

백엔드개발, FastAPI, SpringBoot, Nodejs, Express, PresignedURL, S3업로드, 직접업로드, ObjectStorage, 백엔드시리즈

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