티스토리 뷰
업로드 끝났다고 끝이 아니다 — 파일 메타데이터를 DB에 어떻게 저장하고, 교체·삭제는 어떻게 다룰까? FastAPI · Spring Boot · Node.js
octo54 2026. 5. 22. 11:59업로드 끝났다고 끝이 아니다 — 파일 메타데이터를 DB에 어떻게 저장하고, 교체·삭제는 어떻게 다룰까? FastAPI · Spring Boot · Node.js
파일 업로드 기능을 붙이고 나면, 처음엔 좀 뿌듯합니다.
이미지도 올라가고, URL도 나오고, 화면에도 잘 보이거든요.
근데 여기서 진짜 중요한 질문이 남아요.
“그래서 이 파일을 DB에는 어떻게 기록할 건데?”
“프로필 이미지를 새로 올리면 예전 파일은?”
“삭제는 DB에서만 지울까, S3에서도 바로 지울까?”
“URL을 저장해야 하나, object key를 저장해야 하나?”
이쯤부터 업로드 기능이 그냥 ‘파일 받기’가 아니라
도메인 데이터 관리가 됩니다.
저는 이 구간이 꽤 중요하다고 생각해요.
업로드 자체보다 오히려 메타데이터를 어떻게 남기고, 교체와 삭제를 어떻게 설계하느냐가 나중 운영을 크게 바꾸거든요.
예전에 저도 초반엔 그냥 image_url 컬럼 하나 두고 끝낸 적이 많았어요.
근데 조금만 기능이 늘어나면 바로 문제가 생기더라고요.
- 원본 파일명이 안 남아서 관리자 추적이 어려움
- S3 URL 구조를 바꾸고 싶어도 DB가 다 깨짐
- 교체 시 예전 파일이 계속 남아 스토리지 비용이 늘어남
- DB에는 삭제됐는데 실제 파일은 남아 있음
- 반대로 파일은 지웠는데 DB는 살아 있어서 깨진 링크가 생김
그래서 이번 글에서는 그걸 정리해보겠습니다.
- 파일 테이블은 어떤 컬럼을 가지면 좋은지
- object_key, URL, 원본 파일명 중 뭘 저장해야 하는지
- 소프트 삭제와 실제 스토리지 삭제를 어떻게 나눌지
- 프로필 이미지 교체 시 예전 파일을 언제 지울지
- FastAPI · Spring Boot · Node.js에서 서비스/스토리지/메타데이터 계층을 어떻게 끊을지
Amazon S3는 객체를 key로 식별하고, 버전 관리가 켜져 있으면 덮어쓰기 시 새 버전이 생기며 삭제 시 delete marker가 만들어질 수 있다고 설명합니다. 그래서 “파일 저장소의 실제 식별자”로는 URL보다 object key가 훨씬 안정적입니다. (AWS Documentation)
먼저 아주 짧게 결론부터 말하면
제가 보통 추천하는 기본 원칙은 이렇습니다.
- DB에는 public URL보다 object key를 중심으로 저장
- 원본 파일명은 따로 저장
- 현재 사용 중인 파일과 삭제 예정 파일을 구분
- 교체는 DB 업데이트와 저장소 정리를 한 흐름으로 설계
- 삭제는 소프트 삭제와 실제 스토리지 삭제를 분리해서 생각
이걸 조금 더 풀면,
- 화면 렌더링용 URL은 계산 가능하게 두고
- 스토리지의 진짜 식별자는 object_key
- 운영 추적용으로 original_filename
- 파일 상태 추적용으로 status, deleted_at 같은 컬럼
이 정도만 잡아도 많이 안 흔들립니다.
왜 URL만 저장하면 나중에 불편해질까
처음엔 URL이 제일 편해 보여요.
https://my-bucket.s3.ap-northeast-2.amazonaws.com/profile-images/u1/abc.png
그런데 이 문자열 하나만 DB에 저장해두면 나중에 꽤 불편해집니다.
예를 들면 이런 상황이 생겨요.
- 버킷 이름을 바꿈
- CDN을 붙임
- 공개 URL 구조를 바꿈
- presigned download로 전환함
- private bucket으로 바꿈
이때 DB에 URL이 하드코딩돼 있으면 수정 범위가 커집니다.
반면 S3는 객체를 bucket + key로 다루고, 버전 관리나 삭제도 결국 object key 기준으로 일어납니다. (AWS Documentation)
그래서 저는 보통 이렇게 봐요.
- DB에 저장할 핵심 식별자 = object key
- URL = 필요 시 계산하거나 별도 캐시
- 원본 파일명 = 운영/관리용 메타데이터
이게 훨씬 오래 갑니다.
파일 메타데이터 테이블은 어떤 컬럼이 있으면 좋을까
초반에는 너무 복잡하게 갈 필요는 없어요.
그래도 이 정도는 꽤 실전적입니다.
최소 추천 컬럼
- id
- owner_id 또는 user_id
- category (PROFILE_IMAGE, POST_IMAGE 등)
- storage_provider (LOCAL, S3)
- object_key
- original_filename
- content_type
- size_bytes
- status
- created_at
- updated_at
- deleted_at (선택)
있으면 좋은 컬럼
- checksum 또는 etag
- version
- is_active
- replaced_by_file_id
- metadata_json
Prisma 문서도 모델과 인덱스, 고유 제약을 스키마 차원에서 설계할 수 있다고 설명합니다. 즉 파일 테이블도 일반 도메인 모델처럼 명시적으로 다루는 게 맞습니다. (Prisma)
저는 보통 status를 두는 편입니다
이건 꽤 실무적인 팁이에요.
파일은 업로드 요청이 들어왔다고 해서 곧바로 “정상 사용 가능” 상태가 아닐 수도 있습니다.
예를 들면,
- presigned URL만 발급된 상태
- 실제 업로드는 아직 안 끝남
- 업로드는 끝났지만 DB 반영 전
- 교체되어 더는 활성 파일이 아님
- 삭제 예약 상태
- 삭제 완료
그래서 저는 보통 이런 상태를 둡니다.
- PENDING
- ACTIVE
- REPLACED
- DELETED
이렇게 해두면 교체·삭제 흐름이 훨씬 덜 꼬입니다.
프로필 이미지 교체는 어떻게 생각해야 할까
이게 진짜 많이 나오는 요구사항이죠.
“새 프로필 이미지를 올리면 예전 건 어떻게 하지?”
처음엔 단순히 이렇게 처리하기 쉽습니다.
- 새 파일 업로드
- DB의 profile_image_url 업데이트
- 끝
근데 이러면 예전 파일이 스토리지에 계속 남습니다.
이게 쌓이면 비용도 문제고, 관리도 지저분해져요.
그래서 보통은 두 가지 전략 중 하나로 갑니다.
전략 1. 즉시 교체 + 이전 파일 즉시 삭제
장점:
- 단순함
- 스토리지 정리가 빠름
단점:
- DB 업데이트는 성공했는데 스토리지 삭제 실패 시 예외 처리 필요
- 롤백 설계가 조금 까다로움
전략 2. 새 파일 활성화 + 이전 파일 비활성화 + 나중에 정리
장점:
- 더 안전함
- 교체 실패 시 복구 여지가 있음
- 배치/비동기 삭제로 분리 가능
단점:
- 구조가 조금 더 복잡함
저는 초반엔 전략 2를 꽤 좋아합니다.
파일 삭제는 생각보다 실패 가능성이 있어서, 업로드/교체 성공과 실제 삭제를 살짝 분리하는 편이 운영상 덜 무섭더라고요.
소프트 삭제와 실제 스토리지 삭제는 왜 나눠서 생각해야 할까
이것도 진짜 중요합니다.
DB에서 파일 레코드를 지운다고 해서
실제 S3 객체가 자동으로 없어지는 건 아니에요.
반대로 S3에서 객체를 지웠다고 해서 DB 레코드가 자동으로 없어지는 것도 아니고요.
Amazon S3는 버전 관리가 켜져 있으면 단순 DELETE가 실제 영구 삭제가 아니라 delete marker를 만들 수 있다고 설명합니다. 즉 “파일 삭제”도 저장소 설정에 따라 의미가 달라질 수 있습니다. (AWS Documentation)
그래서 저는 보통 이 둘을 분리해서 봅니다.
소프트 삭제
DB에서 deleted_at, status=DELETED 처리
장점:
- 추적 가능
- 복구 여지 있음
- 운영 감사에 유리
실제 스토리지 삭제
S3/로컬 파일 삭제
장점:
- 비용 정리
- 불필요 파일 제거
실무에선 종종 이렇게 합니다.
- 먼저 DB에서 삭제 상태 표시
- 실제 파일 삭제 작업은 별도 처리
- 성공하면 완전 정리
이 구조가 조금 귀찮아 보여도 나중에 훨씬 안전해요.
object key, original filename, public URL 중 뭘 저장해야 할까
제 기준으로는 이렇게 갑니다.
반드시 저장 추천
- object_key
- original_filename
거의 항상 저장 추천
- content_type
- size_bytes
상황에 따라
- public_url
- etag
- checksum
왜냐면:
- object_key는 스토리지 식별자
- original_filename은 사람이 보는 정보
- public_url은 나중에 계산 가능할 때가 많음
S3는 객체를 key 기반으로 다루기 때문에 삭제, 교체, 버전 관리 모두 object key가 중심입니다. (AWS Documentation)
그래서 저는 보통 DB에 URL 하나만 박는 구조는 오래 안 간다고 느껴요.
이번 글에서 맞출 공통 예제
이번에는 세 스택 모두 이런 흐름으로 갑니다.
- files 메타데이터 저장소가 있음
- 프로필 이미지 업로드 완료 후 파일 메타데이터 저장
- 기존 활성 프로필 이미지가 있으면 REPLACED 처리
- 새 파일을 ACTIVE로 저장
- 실제 스토리지 삭제는 지금 글에서는 바로 안 하고 “후속 정리 대상”으로 표시
즉 오늘의 핵심은 이겁니다.
파일 업로드 다음 단계의 진짜 일은, 파일 바이트가 아니라 파일 상태를 관리하는 것이다.
1) FastAPI에서 파일 메타데이터 저장 구조 만들기
FastAPI 쪽은 시리즈 흐름상 서비스 계층과 저장소 계층을 분리해서 가는 게 제일 보기 좋습니다.
그리고 Python 진영에서 ORM/트랜잭션 쪽은 SQLAlchemy 세션을 기본 감각으로 가져가면 좋습니다. SQLAlchemy 문서는 Session이 하나의 트랜잭션을 대표하는 상태 객체라고 설명합니다. (SQLAlchemy Documentation)
이번 예제는 구조 이해를 위해 메모리 저장소로 갑니다.
추천 구조
fastapi-backend/
├── app/
│ ├── api/
│ │ └── file.py
│ ├── repositories/
│ │ └── file_repository.py
│ ├── schemas/
│ │ └── file.py
│ ├── services/
│ │ └── file_service.py
│ └── main.py
app/schemas/file.py
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class FileRecord(BaseModel):
id: int
owner_id: str
category: str
storage_provider: str
object_key: str
original_filename: str
content_type: str
size_bytes: int
status: str
created_at: datetime
deleted_at: Optional[datetime] = None
class CompleteProfileImageRequest(BaseModel):
owner_id: str
object_key: str
original_filename: str
content_type: str
size_bytes: int
app/repositories/file_repository.py
from datetime import datetime
from typing import Optional
from app.schemas.file import FileRecord
class FileRepository:
def __init__(self) -> None:
self._files: list[FileRecord] = []
self._next_id = 1
def find_active_profile_image(self, owner_id: str) -> Optional[FileRecord]:
return next(
(
f for f in self._files
if f.owner_id == owner_id
and f.category == "PROFILE_IMAGE"
and f.status == "ACTIVE"
),
None,
)
def save(self, file: FileRecord) -> FileRecord:
self._files.append(file)
return file
def create(
self,
owner_id: str,
category: str,
storage_provider: str,
object_key: str,
original_filename: str,
content_type: str,
size_bytes: int,
status: str,
) -> FileRecord:
file = FileRecord(
id=self._next_id,
owner_id=owner_id,
category=category,
storage_provider=storage_provider,
object_key=object_key,
original_filename=original_filename,
content_type=content_type,
size_bytes=size_bytes,
status=status,
created_at=datetime.utcnow(),
)
self._next_id += 1
return self.save(file)
def mark_replaced(self, file_id: int) -> None:
for file in self._files:
if file.id == file_id:
file.status = "REPLACED"
return
def list_all(self) -> list[FileRecord]:
return self._files
app/services/file_service.py
from app.repositories.file_repository import FileRepository
from app.schemas.file import CompleteProfileImageRequest, FileRecord
class FileService:
def __init__(self, file_repository: FileRepository) -> None:
self.file_repository = file_repository
def complete_profile_image_upload(
self,
request: CompleteProfileImageRequest,
) -> FileRecord:
current = self.file_repository.find_active_profile_image(request.owner_id)
if current is not None:
self.file_repository.mark_replaced(current.id)
return self.file_repository.create(
owner_id=request.owner_id,
category="PROFILE_IMAGE",
storage_provider="S3",
object_key=request.object_key,
original_filename=request.original_filename,
content_type=request.content_type,
size_bytes=request.size_bytes,
status="ACTIVE",
)
app/api/file.py
from fastapi import APIRouter
from app.repositories.file_repository import FileRepository
from app.schemas.file import CompleteProfileImageRequest, FileRecord
from app.services.file_service import FileService
router = APIRouter(prefix="/api/files", tags=["files"])
file_repository = FileRepository()
file_service = FileService(file_repository)
@router.post("/profile-image/complete", response_model=FileRecord)
def complete_profile_image_upload(
request: CompleteProfileImageRequest,
) -> FileRecord:
return file_service.complete_profile_image_upload(request)
@router.get("", response_model=list[FileRecord])
def list_files() -> list[FileRecord]:
return file_repository.list_all()
app/main.py
from fastapi import FastAPI
from app.api.file import router as file_router
app = FastAPI()
app.include_router(file_router)
FastAPI에서 핵심 감각
여기서 중요한 건 업로드 완료 API가
“파일을 받는” 게 아니라 “파일 메타데이터를 확정하는” API라는 점입니다.
즉 presigned URL 업로드 이후, 이 API는 이렇게 생각하면 됩니다.
- 클라이언트가 업로드 끝났다고 알려줌
- 백엔드가 현재 활성 파일 확인
- 기존 파일은 REPLACED
- 새 파일은 ACTIVE
이렇게 가면 나중에 스토리지 정리 작업도 훨씬 쉬워집니다.
2) Spring Boot에서 File 엔티티 느낌으로 메타데이터 다루기
Spring 쪽은 이런 메타데이터 모델링이 특히 자연스럽습니다.
Spring Data JPA는 repository 기반 모델 저장을 쉽게 해주고, save()를 통해 persist/merge가 이뤄진다고 문서가 설명합니다. (Home)
이번 글에서는 JPA 없이도 감각이 보이도록 단순화하되, 구조는 JPA스럽게 가겠습니다.
추천 구조
springboot-backend/
├── src/main/java/com/example/backend/
│ ├── controller/
│ │ └── FileController.java
│ ├── dto/
│ │ └── CompleteProfileImageRequest.java
│ ├── model/
│ │ └── FileRecord.java
│ ├── repository/
│ │ └── FileRepository.java
│ └── service/
│ └── FileService.java
dto/CompleteProfileImageRequest.java
package com.example.backend.dto;
public record CompleteProfileImageRequest(
String ownerId,
String objectKey,
String originalFilename,
String contentType,
long sizeBytes
) {
}
model/FileRecord.java
package com.example.backend.model;
import java.time.Instant;
public record FileRecord(
Long id,
String ownerId,
String category,
String storageProvider,
String objectKey,
String originalFilename,
String contentType,
long sizeBytes,
String status,
Instant createdAt,
Instant deletedAt
) {
}
repository/FileRepository.java
package com.example.backend.repository;
import com.example.backend.model.FileRecord;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Repository
public class FileRepository {
private final List<FileRecord> files = new ArrayList<>();
private long nextId = 1L;
public Optional<FileRecord> findActiveProfileImage(String ownerId) {
return files.stream()
.filter(file ->
file.ownerId().equals(ownerId)
&& file.category().equals("PROFILE_IMAGE")
&& file.status().equals("ACTIVE"))
.findFirst();
}
public FileRecord save(FileRecord file) {
files.add(file);
return file;
}
public FileRecord create(
String ownerId,
String category,
String storageProvider,
String objectKey,
String originalFilename,
String contentType,
long sizeBytes,
String status
) {
FileRecord file = new FileRecord(
nextId++,
ownerId,
category,
storageProvider,
objectKey,
originalFilename,
contentType,
sizeBytes,
status,
Instant.now(),
null
);
return save(file);
}
public void markReplaced(Long fileId) {
for (int i = 0; i < files.size(); i++) {
FileRecord file = files.get(i);
if (file.id().equals(fileId)) {
files.set(i, new FileRecord(
file.id(),
file.ownerId(),
file.category(),
file.storageProvider(),
file.objectKey(),
file.originalFilename(),
file.contentType(),
file.sizeBytes(),
"REPLACED",
file.createdAt(),
file.deletedAt()
));
return;
}
}
}
public List<FileRecord> findAll() {
return files;
}
}
service/FileService.java
package com.example.backend.service;
import com.example.backend.dto.CompleteProfileImageRequest;
import com.example.backend.model.FileRecord;
import com.example.backend.repository.FileRepository;
import org.springframework.stereotype.Service;
@Service
public class FileService {
private final FileRepository fileRepository;
public FileService(FileRepository fileRepository) {
this.fileRepository = fileRepository;
}
public FileRecord completeProfileImageUpload(CompleteProfileImageRequest request) {
fileRepository.findActiveProfileImage(request.ownerId())
.ifPresent(current -> fileRepository.markReplaced(current.id()));
return fileRepository.create(
request.ownerId(),
"PROFILE_IMAGE",
"S3",
request.objectKey(),
request.originalFilename(),
request.contentType(),
request.sizeBytes(),
"ACTIVE"
);
}
}
controller/FileController.java
package com.example.backend.controller;
import com.example.backend.dto.CompleteProfileImageRequest;
import com.example.backend.model.FileRecord;
import com.example.backend.repository.FileRepository;
import com.example.backend.service.FileService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileService fileService;
private final FileRepository fileRepository;
public FileController(FileService fileService, FileRepository fileRepository) {
this.fileService = fileService;
this.fileRepository = fileRepository;
}
@PostMapping("/profile-image/complete")
public FileRecord completeProfileImageUpload(
@RequestBody CompleteProfileImageRequest request
) {
return fileService.completeProfileImageUpload(request);
}
@GetMapping
public List<FileRecord> listFiles() {
return fileRepository.findAll();
}
}
Spring Boot에서 핵심 감각
Spring에서는 이 구조를 나중에 거의 그대로 JPA 엔티티 + JpaRepository로 옮기면 됩니다.
즉 지금은 메모리 저장소지만, 나중엔 이런 식으로 발전하겠죠.
- FileEntity
- FileRepository extends JpaRepository<FileEntity, Long>
- findByOwnerIdAndCategoryAndStatus(...)
Spring Data JPA가 repository 중심으로 저장/조회 패턴을 제공하기 때문에, 파일 메타데이터도 일반 도메인처럼 다루기 좋습니다. (Home)
3) Node.js에서 파일 메타데이터 모델을 분리하기
Node.js는 이 부분을 프레임워크가 대신 잡아주지 않아서,
오히려 더 의식적으로 나눠야 합니다.
즉 업로드 API와 메타데이터 저장을 분리하지 않으면
라우터에 금방 모든 게 섞이기 쉬워요.
Prisma 문서도 모델과 인덱스, 관계, 삭제 시 referential action 등을 스키마 차원에서 설계한다고 설명합니다. 즉 Node.js에서도 파일 메타데이터는 별도 모델로 보는 게 맞습니다. (Prisma)
추천 구조
node-backend/
├── src/
│ ├── repositories/
│ │ └── file.repository.js
│ ├── routes/
│ │ └── file.route.js
│ └── services/
│ └── file.service.js
src/repositories/file.repository.js
export class FileRepository {
constructor() {
this.files = [];
this.nextId = 1;
}
findActiveProfileImage(ownerId) {
return this.files.find(
(file) =>
file.ownerId === ownerId &&
file.category === "PROFILE_IMAGE" &&
file.status === "ACTIVE"
) ?? null;
}
create({
ownerId,
category,
storageProvider,
objectKey,
originalFilename,
contentType,
sizeBytes,
status,
}) {
const file = {
id: this.nextId++,
ownerId,
category,
storageProvider,
objectKey,
originalFilename,
contentType,
sizeBytes,
status,
createdAt: new Date().toISOString(),
deletedAt: null,
};
this.files.push(file);
return file;
}
markReplaced(fileId) {
const file = this.files.find((f) => f.id === fileId);
if (!file) return;
file.status = "REPLACED";
}
findAll() {
return this.files;
}
}
src/services/file.service.js
export class FileService {
constructor(fileRepository) {
this.fileRepository = fileRepository;
}
completeProfileImageUpload({
ownerId,
objectKey,
originalFilename,
contentType,
sizeBytes,
}) {
const current = this.fileRepository.findActiveProfileImage(ownerId);
if (current) {
this.fileRepository.markReplaced(current.id);
}
return this.fileRepository.create({
ownerId,
category: "PROFILE_IMAGE",
storageProvider: "S3",
objectKey,
originalFilename,
contentType,
sizeBytes,
status: "ACTIVE",
});
}
}
src/routes/file.route.js
import { Router } from "express";
import { FileRepository } from "../repositories/file.repository.js";
import { FileService } from "../services/file.service.js";
const router = Router();
const fileRepository = new FileRepository();
const fileService = new FileService(fileRepository);
router.post("/profile-image/complete", (req, res) => {
const { ownerId, objectKey, originalFilename, contentType, sizeBytes } = req.body;
if (!ownerId || !objectKey || !originalFilename || !contentType || !sizeBytes) {
return res.status(400).json({ message: "필수값이 누락되었습니다." });
}
const result = fileService.completeProfileImageUpload({
ownerId,
objectKey,
originalFilename,
contentType,
sizeBytes,
});
return res.status(201).json(result);
});
router.get("/", (req, res) => {
return res.json(fileRepository.findAll());
});
export default router;
src/server.js
import express from "express";
import fileRouter from "./routes/file.route.js";
const app = express();
const PORT = 3000;
app.use(express.json());
app.use("/api/files", fileRouter);
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Node.js에서 핵심 감각
Node.js는 특히
“파일 업로드 완료 처리”와 “스토리지 접근”과 “DB 메타데이터 저장”을
각각 나눠야 나중에 덜 망가집니다.
초반엔 귀찮아 보여도,
나중에 Prisma든 TypeORM이든 붙일 때 이 구조가 훨씬 편해요.
실제 삭제는 언제 해야 할까
이건 진짜 운영적인 질문입니다.
저는 보통 이렇게 구분합니다.
프로필 이미지 같은 교체형 파일
- 새 파일 ACTIVE
- 이전 파일 REPLACED
- 실제 삭제는 배치나 후속 작업으로
첨부파일처럼 즉시 삭제가 필요한 파일
- DB soft delete
- 스토리지 delete 요청
- 성공 시 최종 정리
왜 이렇게 보냐면,
스토리지 삭제는 실패할 수 있기 때문이에요.
S3도 버전 관리가 켜져 있으면 delete marker 개념이 들어오고, 실제 삭제 의미가 달라질 수 있습니다. (AWS Documentation)
그래서 “DB 삭제와 스토리지 삭제를 한 문장으로 처리한다”는 감각은 조금 위험할 때가 있습니다.
S3 버전 관리까지 켜져 있으면 생각이 더 달라진다
이건 중급 이후에 꽤 중요해져요.
S3 버전 관리가 켜진 버킷에서는:
- 덮어쓰면 새 버전이 생기고
- 단순 DELETE는 delete marker가 생길 수 있고
- 이전 버전을 복구할 수도 있습니다
AWS 문서가 이 동작을 분명히 설명합니다. (AWS Documentation)
즉 이 경우엔 “파일 교체”를 더 안전하게 다룰 수 있지만,
반대로 진짜 영구 삭제가 더 단순하지는 않아요.
그래서 운영 정책을 먼저 정하는 게 중요합니다.
- 실수 복구가 중요하면 버전 관리
- 비용과 단순성이 더 중요하면 일반 삭제
- 법적 보존이 필요하면 Object Lock까지 검토
AWS는 Object Lock이 WORM(Write Once Read Many) 방식으로 삭제/덮어쓰기를 막는 기능이라고 설명합니다. (AWS Documentation)
제가 추천하는 아주 현실적인 시작점
초반 서비스라면 이 정도면 충분히 좋습니다.
- files 테이블 둔다
- object_key, original_filename, content_type, size_bytes, status 저장
- 현재 사용 중 파일은 ACTIVE
- 교체된 파일은 REPLACED
- 실제 스토리지 삭제는 바로 하지 말고 후속 정리 대상으로 둔다
- URL은 필요 시 계산하거나 응답에서 생성
이 구조가 생각보다 오래 갑니다.
실무에서 자주 하는 실수
첫 번째는 파일 메타데이터를 DB에 안 남기는 것입니다.
그럼 운영에서 추적이 거의 안 됩니다.
두 번째는 URL만 저장하는 것입니다.
나중에 CDN, 버킷, 공개 정책이 바뀌면 고생합니다.
세 번째는 교체 시 이전 파일을 그냥 덮어쓰기만 하는 것입니다.
이건 특히 복구와 추적이 어려워져요.
네 번째는 DB 삭제와 스토리지 삭제를 완전히 동기 처리로만 묶는 것입니다.
실패 복구 전략이 없으면 나중에 깨진 상태가 생깁니다.
다섯 번째는 파일도 일반 도메인 데이터처럼 모델링해야 한다는 걸 늦게 깨닫는 것입니다.
파일도 결국 엔티티예요. 그냥 문자열 하나가 아닙니다.
이번 글 핵심 정리
이번 글의 핵심은 이겁니다.
업로드 기능의 진짜 운영 안정성은 파일 바이트 저장보다 파일 메타데이터와 상태를 어떻게 관리하느냐에서 나온다.
정리하면 이렇게 볼 수 있어요.
- DB에는 URL보다 object key 중심으로 저장하자
- 원본 파일명은 따로 남기자
- 파일 상태(ACTIVE, REPLACED, DELETED)를 추적하자
- 교체와 실제 삭제를 한 덩어리로 보지 말자
- 스토리지 삭제는 실패 가능성을 고려해 설계하자
스택별 감각은 이렇습니다.
- FastAPI: presigned 업로드 이후 메타데이터 확정 API를 따로 두는 구조가 깔끔하다. (SQLAlchemy Documentation)
- Spring Boot: 파일 메타데이터도 일반 도메인 모델처럼 repository 중심으로 가져가면 오래 간다. (Home)
- Node.js: ORM이 없더라도 file model을 별도 계층으로 분리해야 나중에 덜 꼬인다. (Prisma)
다음 글 예고
다음 글에서는 이어서
백엔드에서 외부 API 호출을 어떻게 구조화할지를 다뤄보겠습니다.
즉,
- 결제, 메일, 문자, AI API, 지도 API 같은 외부 서비스 호출을
- 컨트롤러/서비스/클라이언트 계층으로 어떻게 나눌지
- 재시도, 타임아웃, 실패 처리, idempotency는 어디서 볼지
- FastAPI · Spring Boot · Node.js에서 HTTP client 구조를 어떻게 잡을지
이 흐름으로 이어가겠습니다.
출처
- Amazon S3 공식 문서 — S3 Versioning, overwrite 시 새 버전 생성, delete marker 동작. (AWS Documentation)
- Amazon S3 공식 문서 — Object Lock, WORM 저장 모델. (AWS Documentation)
- Spring Data JPA 공식 문서 / API — save()와 repository 중심 persistence, CRUD repository 동작. (Home)
- SQLAlchemy 공식 문서 — Session은 하나의 트랜잭션을 대표하는 상태 객체. (SQLAlchemy Documentation)
- Prisma 공식 문서 — 모델/인덱스/참조 관계를 스키마로 설계. (Prisma)
백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 파일메타데이터, S3ObjectKey, 업로드설계, 파일삭제전략, 백엔드시리즈
'study > 백엔드' 카테고리의 다른 글
- Total
- Today
- Yesterday
- DevOps
- 쿠버네티스
- PostgreSQL
- fastapi
- LangChain
- 웹개발
- Prisma
- Python
- Next.js
- seo 최적화 10개
- 딥러닝
- rag
- nodejs
- SEO최적화
- llm
- NestJS
- CI/CD
- JAX
- JWT
- 개발블로그
- 생성형AI
- ai철학
- flax
- kotlin
- Express
- nextJS
- 백엔드개발
- REACT
- 주니어개발자
- node.js
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |

