초기 웹은 일반 텍스트로 작동했습니다. 양식에 사용자 이름이나 댓글을 입력하고 제출하면, 짧은 문자열이 HTTP를 통해 서버로 전달되었습니다. 그 단순한 요청-응답 사이클이 인프라를 정의했습니다. 사람들이 사진, 문서, 비디오를 공유하고 싶어 하기 시작했을 때, 엔지니어들은 읽을 수 있는 텍스트만을 위해 구축된 시스템을 통해 어떻게 가공되지 않은 바이너리 데이터를 이동시킬지 고민해야 했습니다.

JSON 객체에 JPEG를 넣으려고 하면 근본적인 벽에 부딪힙니다. JSON은 텍스트 프로토콜입니다. 유효한 유니코드 문자, 따옴표, 중괄호, 그리고 적절하게 이스케이프된 문자열을 기대합니다. 바이너리 파일은 단순히 긴 바이트 시퀀스일 뿐이며, 그중 상당수는 출력 가능한 표현 방식이 없습니다. 이 바이트들을 JSON 문자열에 억지로 밀어 넣으면 파서가 깨지고, 이스케이프 시퀀스가 페이로드를 손상시키며, 반대편에서는 전체 메시지를 읽을 수 없게 됩니다.

Base64 인코딩이 명백한 해결책으로 등장했습니다. 이는 바이너리 데이터를 64개의 제한된 출력 가능한 ASCII 문자로 재매핑합니다. 바이너리 3바이트마다 4개의 텍스트 문자가 됩니다. 이제 페이로드는 유효한 JSON이 되므로, 어떤 표준 API를 거치더라도 안전하게 전달됩니다. 하지만 대가는 즉각적입니다. 이러한 재인코딩은 파일 크기를 약 33% 부풀립니다. 3MB 이미지는 네트워크 전송 시 4MB가 됩니다. 클라이언트와 서버 모두 데이터를 다시 변환하는 데 추가적인 CPU 사이클을 소모합니다. 더 중요한 점은, 많은 JSON 서버 프레임워크가 파싱하기 전에 전체 본문을 메모리로 읽어 들인다는 것입니다. 대용량 업로드가 동시에 몇 건만 발생해도 서버가 마비될 수 있는데, 이는 각 파일이 디스크에 저장되기도 전에 과도한 텍스트 문자열로서 RAM을 점유하기 때문입니다. Base64는 임시방편으로는 작동하지만, 프로덕션 규모에서 무거운 파일을 운반하도록 설계되지는 않았습니다.

더 나은 답은 multipart/form-data입니다. 이 형식은 단일 HTTP 요청을 고유한 경계(boundary) 문자열로 구분된 별개의 파트들의 집합으로 취급합니다. 한 파트는 일반 텍스트 필드를 가질 수 있고, 다음 파트는 자체적인 Content-TypeContent-Disposition 헤더가 지정된 가공되지 않은 바이너리 이미지를 가질 수 있습니다. 서버는 들어오는 스트림을 순차적으로 읽으며 경계 마커를 감시하고, 전체 페이로드를 하나의 텍스트 블록으로 처리할 필요 없이 각 섹션을 적절한 핸들러로 넘겨줍니다.

Node.js에서 이러한 차이는 특히 중요합니다. express.json() 미들웨어는 JSON 본문을 파싱하는 방법은 알지만, 파일 스트림은 처리하지 못합니다. multipart 업로드를 처리하려면 MulterBusboy와 같은 스트리밍 파서가 필요합니다. 이러한 도구들은 원시 요청 스트림에 연결되어 데이터를 청크(chunk) 단위로 읽습니다. 예를 들어, Multer를 사용하면 들어오는 파일을 디스크의 임시 폴더에 쓸지, 아니면 작은 파일은 메모리에 유지할지 선택할 수 있습니다. 이 설정 결정은 매우 중요합니다. 모든 것을 메모리에 유지하다가 앱에 갑자기 대용량 파일이 여러 개 들어오면, 프로세스의 힙(heap) 공간이 부족해져 크래시가 발생할 수 있습니다. 디스크에 쓰는 것은 안정성을 위해 I/O를 희생하는 것이지만, 정리(cleanup) 및 경로 보안에 관한 자체적인 문제를 야기합니다.

소규모 애플리케이션의 경우, 파일을 ./uploads와 같은 로컬 폴더에 저장하는 것이 자연스럽고 빠릅니다. 파일은 코드가 실행되는 동일한 머신에 저장되며, 이를 다시 서비스하는 것은 단순히 올바른 경로를 가리키는 문제일 뿐입니다. 이는 문제가 발생하기 전까지는 잘 작동합니다.

두 번째 애플리케이션 서버 앞에 로드 밸런서를 배치하는 순간, 로컬 스토리지는 버그가 됩니다. 사용자가 프로필 사진을 업로드합니다. 로드 밸런서는 요청을 서버 A로 라우팅하고, 파일은 서버 A의 디스크에 기록됩니다. 나중에 사용자가 이미지를 보려고 요청하면, 로드 밸런서는 요청을 서버 B로 보냅니다. 서버 B는 자신의 파일 시스템을 확인하지만 아무것도 찾지 못합니다. 파일이 사실상 사라진 것입니다. 사용자를 동일한 머신에 고정하기 위해 스티키 세션(sticky sessions)을 구현할 수도 있지만, 이는 취약한 해결책입니다. 서버 A가 재시작되거나, 재배포되거나, 오토스케일링 인스턴스로 교체되면 데이터는 사라집니다. 컨테이너 환경에서 로컬 디스크는 훨씬 더 일시적입니다. Docker 컨테이너의 파일 시스템은 폐기 가능한 용도로 설계되었습니다. 이를 영구 저장소로 취급하는 것은 사용자 데이터를 잃어버리는 확실한 방법입니다.

표준 솔루션은 컴퓨팅과 스토리지를 분리하는 것입니다. 애플리케이션 서버를 상태가 없는(stateless) 상태로 유지하고, 업로드된 파일은 AWS S3나 Google Cloud Storage와 같은 전용 오브젝트 스토리지로 보냅니다. 이러한 서비스는 내구성, 지리적 분산 및 대규모 동시성을 위해 구축되었습니다. 애플리케이션 서버는 요청을 처리하고 메타데이터를 검증한 다음, 파일을 저장하기 위해 특별히 설계된 인프라로 바이트를 넘겨줍니다.

Yet even this pattern creates a bottleneck if you implement it carelessly. Many teams start by having the browser upload the file to the backend, and then having the backend forward every single byte to object storage. If a user uploads a five-hundred megabyte video, your server becomes a middleman. It consumes bandwidth pulling the file in, then consumes more bandwidth pushing it out to S3. The connection stays open for the entire duration of the transfer. Slow uploads from users with poor network conditions can tie up server connections for minutes. Memory usage stays elevated if the server buffers the stream, and if you are running on metered hosting, you are paying twice for the same data transfer. Horizontal scaling does not solve this, because every additional server you add still gets stuck ferrying bytes it does not need to see.

Modern systems solve the problem by removing the backend from the data path entirely. Instead of accepting the file, the backend only accepts a request for permission to upload. The flow looks like this:

  • The browser asks the backend to initiate an upload, usually sending only the filename, file type, and intended purpose.
  • The backend authenticates the user, validates the request against business rules, and uses an SDK to generate a temporary presigned URL from the object storage provider.
  • The backend returns that URL to the browser. The URL is scoped to a specific bucket and key, valid for a short window such as five or fifteen minutes, and signed with a token that grants only the precise permissions needed.
  • The browser uploads the file directly to S3 or GCS using a standard PUT or POST. The bytes travel straight from the user’s device to