객체 저장소 vs 데이터베이스 블롭: 파일 메타데이터는 Postgres에 두고 바이트는 객체 저장소에 저장해 빠른 전송과 예측 가능한 비용을 유지하세요.

사용자 업로드는 단순해 보입니다: 파일을 받아 저장하고 나중에 보여주면 됩니다. 소수 사용자와 작은 파일에서는 잘 작동하죠. 하지만 사용량이 늘고 파일이 커지면 업로드 버튼과 상관없는 곳에서 문제가 드러납니다.
다운로드가 느려지는 이유는 앱 서버나 데이터베이스가 무거운 작업을 떠맡기 때문입니다. 백업은 거대해지고 느려지며, 복원이 필요할 때 시간이 오래 걸립니다. 저장 비용과 대역폭(egress) 비용도 비효율적인 제공, 중복 저장 또는 삭제되지 않은 파일 때문에 급증할 수 있습니다.
원하는 것은 지루하지만 신뢰할 수 있는 것들입니다: 부하 중에도 빠른 전송, 명확한 접근 규칙, 간단한 운영(백업·복원·정리), 그리고 사용량이 늘어도 예측 가능한 비용.
이를 위해 자주 섞이는 두 가지를 분리하세요.
메타데이터는 파일에 대한 작은 정보입니다: 누가 소유하는지, 이름, 크기, 타입, 업로드 시각, 저장 위치 등입니다. 질의, 필터링, 사용자·프로젝트·권한과의 조인이 필요하므로 데이터베이스(예: Postgres)에 두는 것이 맞습니다.
파일 바이트는 파일의 실제 내용(사진, PDF, 비디오)입니다. 데이터베이스 블롭에 바이트를 넣을 수는 있지만, 그렇게 하면 데이터베이스가 무거워지고 백업이 커지며 성능 예측이 어려워집니다. 바이트를 객체 저장소에 두면 데이터베이스는 본연의 역할에 집중하고, 파일은 그 일을 위해 설계된 시스템에서 빠르고 저렴하게 제공됩니다.
사람들이 "업로드를 데이터베이스에 저장하라"고 말할 때는 보통 데이터베이스 블롭을 의미합니다: 행에 원시 바이트를 저장하는 BYTEA 컬럼이나 Postgres의 "large objects" 기능처럼 큰 값을 별도로 저장하는 경우입니다. 둘 다 가능하지만, 둘 다 파일 바이트를 제공하는 책임을 데이터베이스에 지웁니다.
객체 저장소는 다른 개념입니다: 파일은 버킷의 객체로 존재하고 키(예: uploads/2026/01/file.pdf)로 주소 지정됩니다. 대용량 파일, 저렴한 저장, 스트리밍 다운로드에 최적화되어 있고 많은 동시 읽기를 잘 처리합니다. 데이터베이스 연결을 묶어두지 않습니다.
Postgres는 쿼리, 제약조건, 트랜잭션에 강합니다. 파일의 소유자, 종류, 업로드 시각, 다운로드 가능 여부 같은 메타데이터에 적합합니다. 메타데이터는 작고 인덱싱하기 쉽고 일관성 있게 유지하기도 편합니다.
실용적인 규칙:
간단한 점검: 백업, 복제본, 마이그레이션이 파일 바이트 때문에 고통스러워진다면 바이트는 Postgres 밖에 두세요.
대부분 팀이 택하는 구성은 명확합니다: 바이트는 객체 저장소에 두고, 파일 레코드(누가 소유하는지, 무엇인지, 어디 있는지)는 Postgres에 저장합니다. API는 조정과 권한 부여를 담당하지만 대용량 업로드와 다운로드를 프록시하지 않습니다.
이렇게 하면 세 가지 책임이 명확해집니다:
file_id, 소유자, 크기, 콘텐츠 타입, 객체 포인터 등.그 안정적인 file_id는 첨부 파일을 참조하는 댓글, PDF를 가리키는 송장, 감사 로그, 지원 도구 등 모든 곳의 기본 키가 됩니다. 사용자는 파일 이름을 바꿀 수 있고 버킷 간 이동도 가능하지만 file_id는 그대로 유지됩니다.
가능하면 저장된 객체를 불변(immutable)으로 다루세요. 사용자가 문서를 교체하면 바이트를 덮어쓰는 대신 새 객체(그리고 보통 새 행 또는 버전 행)를 만드세요. 캐싱을 단순화하고 "옛 링크가 새로운 파일을 반환"하는 문제를 피하며 되돌리기 시나리오를 깨끗하게 만듭니다.
프라이버시 정책은 초기에 결정하세요: 기본적으로 비공개, 예외적으로 공개. 좋은 규칙은 데이터베이스가 누가 파일에 접근할 수 있는지의 진실의 원천이고, 객체 저장소는 API가 부여한 짧은 권한을 시행한다는 것입니다.
분리된 구조에서는 Postgres가 파일에 대한 사실을 저장하고 객체 저장소가 바이트를 저장합니다. 데이터베이스가 작아지고 백업이 빨라지며 쿼리가 단순해집니다.
실용적인 uploads 테이블은 누가 소유하는지, 어디에 저장되는지, 다운로드가 안전한지 같은 질문에 답할 수 있는 몇 가지 필드면 충분합니다.
CREATE TABLE uploads (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
bucket text NOT NULL,
object_key text NOT NULL,
size_bytes bigint NOT NULL,
content_type text,
original_filename text,
checksum text,
state text NOT NULL CHECK (state IN ('pending','uploaded','failed','deleted')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX uploads_owner_created_idx ON uploads (owner_id, created_at DESC);
CREATE INDEX uploads_checksum_idx ON uploads (checksum);
나중에 문제를 줄이는 몇 가지 결정:
bucket + object_key를 사용하세요. 업로드된 후에는 불변으로 유지하세요.pending 행을 삽입하세요. 객체가 존재하고 크기(가능하면 체크섬까지)가 일치할 때만 uploaded로 전환하세요.original_filename은 표시용으로만 저장하세요. 보안 판단이나 타입 판단에 신뢰하지 마세요.교체(replace)를 지원하면 upload_versions 같은 별도 테이블을 추가해 upload_id, version, object_key, created_at 등을 기록하세요. 이렇게 하면 이력 관리, 롤백, 오래된 참조 깨짐을 방지할 수 있습니다.
API는 조정만 하고 바이트는 직접 전달하는 방식으로 업로드를 빠르게 유지하세요. 데이터베이스는 응답성을 유지하고 대역폭은 객체 저장소가 처리합니다.
먼저 업로드 레코드를 생성한 뒤 아무것도 전송하지 마세요. API는 upload_id, 파일이 저장될 위치(object_key), 그리고 짧은 기간의 업로드 권한을 반환합니다.
일반적인 흐름:
pending 상태의 행을 생성하고 예상 크기 및 의도된 콘텐츠 타입을 기록합니다.upload_id와 저장소의 응답 필드(예: ETag)를 서버에 호출합니다. 서버는 크기, 체크섬(사용 시), 콘텐츠 타입을 검증한 뒤 행을 uploaded로 표시합니다.failed로 표시하고 객체를 삭제할 수 있습니다.재시도와 중복은 정상입니다. 최종화 호출을 멱등(idempotent)으로 만드세요: 동일한 upload_id가 두 번 최종화되어도 성공을 반환하고 아무것도 변경되지 않게 하세요.
재시도와 재업로드 중 중복을 줄이려면 체크섬을 저장하고 "같은 소유자 + 같은 체크섬 + 같은 크기"를 동일 파일로 취급하세요.
좋은 다운로드 흐름은 파일 바이트가 다른 곳에 있어도 앱에 하나의 안정적인 URL을 두는 것에서 시작합니다. 예: /files/{file_id}. API는 file_id로 Postgres에서 메타데이터를 조회하고 권한을 확인한 후 전달 방법을 결정합니다.
file_id로 안정적인 URL을 요청합니다.uploaded 상태인지 확인합니다.공개 또는 준공개 파일의 경우 리디렉트가 간단하고 빠릅니다. 비공개 파일에는 사전 서명된 GET URL이 저장소를 비공개로 유지하면서 브라우저가 직접 다운로드하도록 합니다.
비디오와 대용량 다운로드의 경우 객체 저장소(및 프록시 계층)가 범위 요청(Range 헤더)을 지원하는지 확인하세요. 이 기능이 있어야 탐색(seek)과 재개가 가능합니다. API를 통해 바이트를 전달하면 범위 지원이 깨지거나 비용이 커집니다.
캐싱은 속도의 핵심입니다. /files/{file_id} 엔드포인트는 보통 캐시 불가로 두고(인증 게이트), 객체 저장소의 응답은 콘텐츠에 따라 캐시할 수 있게 하세요. 파일이 불변이면 긴 캐시 수명을 설정할 수 있습니다. 덮어쓰기를 한다면 캐시 시간을 짧게 유지하거나 버전된 키를 사용하세요.
글로벌 사용자나 큰 파일이 많다면 CDN을 사용하세요. 사용자가 적거나 한 지역에 대부분 집중돼 있다면 객체 저장소만으로도 충분하고 초기 비용이 저렴합니다.
놀라운 청구는 보통 디스크에 있는 원시 바이트 때문이 아니라 다운로드와 변동(churn)에서 옵니다.
비용에 영향을 주는 네 가지 동인을 가격 책정하세요: 저장량, 읽기/쓰기 빈도(요청 수), 제공되는 데이터 양(egress), 그리고 반복된 원본 다운로드를 줄여주는 CDN 사용 여부. 아무도 보지 않는 큰 파일보다 작은 파일이 10,000번 다운로드되는 것이 더 비용이 클 수 있습니다.
비용을 안정화하는 제어책:
수명 규칙은 가장 쉬운 승리입니다. 예: 원본 사진은 30일간 '핫'으로 두고 이후 저렴한 스토리지 클래스로 이동, 송장은 7년 보관, 실패한 업로드 파트는 7일 후 삭제 등. 기본적인 보존 정책만으로도 저장소 증가를 막을 수 있습니다.
중복 제거는 간단할 수 있습니다: 파일 메타데이터 테이블에 콘텐츠 해시(예: SHA-256)를 저장하고 소유자별로 중복성을 강제하세요. 사용자가 같은 PDF를 두 번 업로드하면 기존 객체를 재사용하고 메타데이터 행만 새로 만들 수 있습니다.
마지막으로 사용량을 이미 기록하는 곳인 Postgres에 저장하세요. bytes_uploaded, bytes_downloaded, object_count, last_activity_at 같은 값을 사용자 또는 워크스페이스별로 기록하면 UI에 한도를 표시하고 청구 전에 알림을 트리거하기 쉽습니다.
업로드 보안은 두 가지로 귀결됩니다: 누가 파일에 접근할 수 있는가, 그리고 문제가 생겼을 때 나중에 무엇을 증명할 수 있는가.
명확한 접근 모델로 시작하고 이를 서비스 전반에 흩어진 일회성 규칙 대신 Postgres 메타데이터에 인코딩하세요.
대부분의 앱을 커버하는 간단한 모델:
비공개 파일의 경우 원시 객체 키를 노출하지 마세요. 시간 제한과 범위 제한된 사전 서명된 업로드·다운로드 URL을 발급하고 이를 자주 교체하세요.
전송 중 및 저장 중 암호화를 확인하세요. 전송 중 암호화는 업로드를 포함한 종단 간 HTTPS를 의미합니다. 저장 중 암호화는 저장 제공자의 서버사이드 암호화와 백업·복제본의 암호화도 포함합니다.
안전성과 데이터 품질을 위한 체크포인트를 추가하세요: 업로드 URL을 발급하기 전에 콘텐츠 타입과 크기를 검증하고, 업로드 후에는 실제 저장된 바이트를 기반으로 다시 검증하세요(파일명만 신뢰하지 마세요). 위험 수준이 높다면 비동기 악성코드 검사(malware scanning)를 실행하고 통과할 때까지 파일을 격리(quarantine)하세요.
사건 조사를 위해 uploaded_by, ip, user_agent, last_accessed_at 같은 감사 필드를 저장하세요.
데이터 거주 요건이 있다면 저장소 리전을 신중히 선택하고 컴퓨트가 실행되는 위치와 일관되게 유지하세요.
대부분의 업로드 문제는 원시 속도 문제가 아닙니다. 초기에는 편리해 보이고 트래픽이 생기고 데이터가 쌓이면서, 그리고 지원 티켓이 생기면서 고통스러워지는 설계 선택에서 옵니다.
invoice.pdf)과 이상한 문자 때문에 에지 케이스가 생깁니다. 파일명은 표시용으로만 두고 저장소 키는 고유하게 생성하세요.구체적 예: 사용자가 프로필 사진을 세 번 교체하면 정리 작업이 없을 때 세 개의 오래된 객체에 대해 비용을 내게 됩니다. 안전한 패턴은 Postgres에서 소프트 삭제를 하고 이후 백그라운드 작업으로 객체를 제거하며 결과를 기록하는 것입니다.
첫 큰 파일이 도착하거나 사용자가 업로드 중 페이지를 새로고침하거나 누군가 계정을 삭제할 때 바이트가 남아 있는 상황에서 대부분 문제가 드러납니다.
Postgres 테이블에 파일 크기, 체크섬(무결성 검증용), 그리고 명확한 상태 경로(예: pending, uploaded, failed, deleted)가 기록되는지 확인하세요.
마지막 점검 목록:
uploaded 행을 만들지 않아야 합니다.하나의 구체적 테스트: 2GB 파일을 업로드하고 30%에서 페이지를 새로고침한 뒤 이어서 재개해 보세요. 그런 다음 느린 연결에서 다운로드하고 중간으로 탐색(seek)해 보세요. 어느 흐름이든 문제가 있으면 출시 전에 고치세요.
단순 SaaS 앱에는 자주 업로드되는 작고 캐시해도 안전한 프로필 사진과 민감해서 비공개로 둬야 하는 PDF 송장이라는 서로 다른 업로드 유형이 공존합니다. 메타데이터는 Postgres에, 바이트는 객체 저장소에 두는 분리가 빛을 발하는 곳입니다.
다음은 하나의 files 테이블에서 동작 방식에 영향을 주는 몇 가지 필드 예시입니다:
| field | profile photo example | invoice PDF example |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (served via signed URL) | private |
cache_control | public, max-age=31536000, immutable | no-store |
object_key | users/42/avatars/2026-01-17T120102Z.webp | orgs/7/invoices/INV-1049.pdf |
status | uploaded | uploaded |
size_bytes | 184233 | 982341 |
사용자가 사진을 교체할 때는 덮어쓰지 말고 새 파일로 처리하세요. 새 행과 새 object_key를 만들고 사용자 프로필은 새 file_id를 가리키게 하세요. 이전 행은 replaced_by=<new_id>(또는 deleted_at)로 표시하고 백그라운드 작업으로 오래된 객체를 삭제하세요. 이렇게 하면 이력이 보존되고 롤백이 쉬워지며 경쟁 상태를 피할 수 있습니다.
지원과 디버깅도 메타데이터가 이야기를 해주기 때문에 쉬워집니다. 사용자가 "내 업로드가 실패했다"고 하면 지원팀은 status, 사람이 읽을 수 있는 last_error, 저장소 로그 추적용 storage_request_id나 etag, 타임스탬프(정체가 있었는가?), owner_id, kind 등을 확인할 수 있습니다.
작게 시작하고 주요 경로를 단순하게 만드세요: 파일은 업로드되고 메타데이터는 저장되며 다운로드는 빠르고 아무 것도 잃지 않습니다.
좋은 첫 마일스톤은 최소한의 Postgres 메타데이터 테이블과 하나의 업로드 흐름, 하나의 다운로드 흐름을 화이트보드에 설명할 수 있게 만드는 것입니다. 엔드투엔드가 작동하면 버전, 쿼터, 수명 규칙을 추가하세요.
파일 타입별로 하나의 명확한 저장 정책을 정하고 문서화하세요. 예: 프로필 사진은 캐시 가능, 송장은 비공개이고 짧은 기간의 다운로드 URL로만 접근 가능. 하나의 버킷 접두사 안에 정책을 섞어놓으면 우발적 노출이 발생하기 쉽습니다.
일찍 계측(instrumentation)을 추가하세요. 첫날부터 보고 싶은 수치는 업로드 최종화 실패율, 고아 비율(메타데이터 없는 객체와 그 반대), 파일 타입별 이그레스 볼륨, P95 다운로드 지연, 평균 객체 크기입니다.
빠른 프로토타이핑을 원하면 Koder.ai (koder.ai)는 채팅으로 전체 앱을 생성하도록 설계되어 있으며 여기서 제시한 일반 스택(React, Go, Postgres)에 맞춰 스키마, 엔드포인트, 정리 작업을 생성해 반복 작업을 줄여줍니다.
그다음에는 한 문장으로 설명할 수 있는 것만 추가하세요: "우리는 30일간 이전 버전을 보관한다" 또는 "워크스페이스당 10GB를 부여한다" 같은 명확한 규칙을 유지하세요. 실사용이 증가할 때까지 단순하게 유지하는 것이 좋습니다.
Postgres에는 쿼리와 보안(소유자, 권한, 상태, 체크섬, 포인터)에 필요한 메타데이터를 저장하고, 실제 바이트는 객체 저장소에 두세요. 이렇게 하면 다운로드와 대용량 전송이 데이터베이스 연결을 차지하거나 백업을 부풀리지 않습니다.
데이터베이스가 파일 서버 역할까지 하게 됩니다. 이로 인해 테이블 크기가 커지고 백업·복원이 느려지며, 복제 부담이 늘고 많은 사용자가 동시에 다운로드할 때 성능 예측이 어려워집니다.
하나의 안정적인 file_id를 앱에 두고 메타데이터는 Postgres에, 바이트는 버킷과 object_key로 주소 지정되는 객체 저장소에 두세요. API는 접근을 허가하고 짧은 기간의 업로드/다운로드 권한을 발급해야 하며 바이트를 프록시하지 마세요.
먼저 pending 행을 만들고 고유한 object_key를 생성한 다음, 클라이언트가 짧은 유효 기간의 권한으로 저장소에 직접 업로드하게 하세요. 업로드 후 클라이언트가 최종화 엔드포인트를 호출하면 서버는 크기와 체크섬을 검증한 뒤 uploaded로 표시합니다.
pending/uploaded/failed/deleted 같은 상태가 필요합니다. 실제 업로드는 실패하고 재시도되기 때문에, 상태 필드는 기대되는 객체가 없거나 완료되었는지, 손상되었는지, 삭제되었는지를 구분해 UI, 정리 작업, 지원 도구가 올바르게 동작하게 합니다.
original_filename은 화면에 보여주기 위한 데이터로만 사용하세요. 충돌(예: 두 사용자가 동일한 invoice.pdf)과 특수 문자 문제를 피하려면 저장소 키는 UUID 기반 경로 같은 고유한 키로 생성하세요.
앱에서 /files/{file_id} 같은 안정적인 URL을 권한 게이트로 두세요. Postgres에서 접근을 확인한 뒤 리디렉트나 짧은 기간의 서명된 다운로드 권한을 반환하면 클라이언트가 객체 저장소에서 직접 다운로드해 API를 과부하시키지 않습니다.
대부분은 원본에서 데이터가 빠져나가는 'egress'와 반복 다운로드에서 비용이 발생합니다. 파일 크기 제한과 쿼터를 설정하고, 보관/수명 규칙을 사용하고, 체크섬으로 중복을 제거하며, 사용량 카운터를 기록해 청구 전 알림을 받으세요.
메타데이터를 Postgres에 소스 오브 트루스로 저장하고 저장소는 기본적으로 비공개로 유지하세요. 업로드 전후로 타입과 크기를 검증하고, HTTPS로 전송 암호화, 저장소의 서버사이드 암호화(그리고 백업 암호화)를 확인하세요. 사건 조사용으로 uploaded_by, ip, user_agent, last_accessed_at 같은 감사 필드를 남기세요.
하나의 메타데이터 테이블, 저장소로 직접 업로드하는 흐름, 그리고 다운로드 게이트 엔드포인트로 시작하세요. 이후 고아 객체 정리와 소프트 삭제를 위한 백그라운드 작업을 추가하세요. React/Go/Postgres 스택에서 빠른 프로토타입을 원하면 Koder.ai (koder.ai)가 채팅으로 엔드포인트, 스키마, 백그라운드 작업을 생성해 반복을 줄여줄 수 있습니다.