← 목록으로
ARCHITECTURE2026.09.11 12:10

파일 업로드 아키텍처 — multipart와 스트리밍 방식 비교

웹 서비스의 파일 업로드는 multipart/form-data로 구현하는 것이 관례지만, 대용량 파일에서는 스트리밍 방식이 메모리와 응답 시간을 근본적으로 바꿉니다. 두 방식의 동작 원리와 선택 기준을 정리합니다.

목차

  1. 개요
  2. multipart/form-data의 동작 원리
  3. 스트리밍 업로드의 동작 원리
  4. 자원 사용량과 성능 비교
  5. 프레임워크별 구현 비교
  6. 대용량 파일과 재개 가능 업로드
  7. 운영 환경에서의 고려사항
  8. 맺음말

개요

문제 배경

웹 서비스에서 파일 업로드를 구현할 때 대부분의 개발자는 별다른 고민 없이 multipart/form-data를 선택합니다. HTML <form enctype="multipart/form-data">가 표준이고, 거의 모든 웹 프레임워크가 MultipartFile이나 req.files 같은 추상화를 기본 제공하기 때문입니다. 프로필 이미지 몇 백 KB를 받는 수준에서는 이 선택이 아무런 문제를 일으키지 않습니다.

문제는 파일 크기가 커지는 순간 시작됩니다. 수백 MB짜리 동영상이나 수 GB짜리 백업 파일을 같은 방식으로 받으면, 단 몇 명의 동시 업로드만으로도 서버 메모리가 고갈되거나 디스크가 가득 차고, 업로드가 끝날 때까지 요청 스레드가 통째로 묶입니다. 이 시점에 대안으로 떠오르는 것이 스트리밍 업로드입니다.

이 글에서는 두 방식이 HTTP 레벨과 서버 내부에서 각각 어떻게 동작하는지, 메모리와 레이턴시에 어떤 차이를 만드는지, 그리고 실제 서비스에서 어떤 기준으로 선택해야 하는지를 정리합니다.

먼저 짚고 갈 용어의 혼동

본론에 들어가기 전에 반드시 정리해야 할 오해가 하나 있습니다. multipart와 스트리밍은 같은 층위의 개념이 아닙니다.

  • multipart/form-data인코딩 형식(wire format) 입니다. 하나의 HTTP 본문에 여러 개의 파트를 경계 문자열로 구분해 담는 규칙입니다.
  • 스트리밍은 처리 모델(processing model) 입니다. 본문 전체를 메모리나 디스크에 모으지 않고, 도착하는 청크를 그대로 다음 단계로 흘려보내는 방식입니다.

따라서 이론적으로는 네 가지 조합이 모두 가능합니다. 실제로 multipart/form-data를 스트리밍으로 파싱하는 것도 완전히 가능하며, Node.js의 busboy나 Apache Commons FileUpload의 FileItemInputIterator가 바로 그 방식입니다.

조합 설명 현실에서의 위치
multipart + 버퍼링 파트 전체를 메모리/임시파일에 모은 뒤 핸들러 호출 프레임워크 기본값. 가장 흔함
multipart + 스트리밍 경계를 파싱하며 파트 본문을 청크 단위로 소비 대용량 처리 시의 실전 해법
raw body + 스트리밍 PUT /files/{id}처럼 본문 전체가 곧 파일 S3 호환 API, 내부 서비스에서 선호
raw body + 버퍼링 본문을 통째로 읽음 안티패턴에 가까움

현업에서 "multipart 방식 vs 스트리밍 방식"이라는 대비가 통용되는 이유는, 프레임워크의 기본 multipart 처리가 거의 항상 버퍼링 방식이기 때문입니다. 이 글도 그 통용되는 대비를 따르되, 각 절에서 "multipart를 스트리밍으로 쓰는 길"을 함께 다룹니다.


multipart/form-data의 동작 원리

인코딩 구조

multipart/form-data는 RFC 7578에 정의되어 있으며, MIME 멀티파트(RFC 2046)를 HTML 폼 제출에 맞게 구체화한 형식입니다. 요청 헤더에 경계 문자열을 선언하고, 본문에서 그 경계로 각 파트를 구분합니다.

POST /api/uploads HTTP/1.1
Host: api.example.com
Content-Type: multipart/form-data; boundary=----Boundary7MA4YWxk
Content-Length: 1048998

------Boundary7MA4YWxk
Content-Disposition: form-data; name="title"

2026년 3분기 보고서
------Boundary7MA4YWxk
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf

%PDF-1.7
...바이너리 데이터...
------Boundary7MA4YWxk--

핵심 특징은 세 가지입니다.

첫째, 메타데이터와 파일을 한 요청에 함께 보낼 수 있습니다. 위 예시에서 title 필드와 file 필드가 같은 요청에 담깁니다. 별도 API 호출 없이 "파일 + 부가 정보"를 원자적으로 전달할 수 있다는 점이 이 형식의 가장 큰 실용적 장점입니다.

둘째, 파트마다 파일명과 콘텐츠 타입을 명시할 수 있습니다. filenameContent-Type이 파트 헤더에 들어가므로, 서버는 본문을 해석하기 전에 해당 파트가 무엇인지 알 수 있습니다.

셋째, 경계 문자열 탐색이 필요합니다. 파서는 바이트 스트림을 읽으면서 경계 패턴을 찾아야 하고, 경계가 청크 사이에 걸쳐 있을 수 있으므로 일정 길이의 되돌아보기(lookback) 버퍼를 유지해야 합니다. 이 오버헤드는 작지만 0은 아닙니다.

서버의 기본 처리 흐름

Spring MVC를 예로 들면, StandardServletMultipartResolver는 서블릿 컨테이너(Tomcat)의 HttpServletRequest.getParts()에 파싱을 위임합니다. Tomcat은 다음과 같이 동작합니다.

  1. 요청 본문을 읽으면서 파트를 분리한다.
  2. 각 파트가 fileSizeThreshold보다 작으면 메모리 버퍼에 유지한다.
  3. 임계값을 넘으면 임시 디렉터리(location)에 파일로 기록한다.
  4. 모든 파트를 다 읽은 뒤에야 컨트롤러 메서드를 호출한다.

4번이 결정적입니다. 컨트롤러가 첫 줄을 실행하는 시점에는 이미 업로드 전체가 서버 로컬에 저장되어 있습니다. 개발자가 다루는 MultipartFile은 네트워크 스트림이 아니라 이미 완료된 로컬 자원에 대한 핸들입니다.

@PostMapping("/api/uploads")
public ResponseEntity<UploadResponse> upload(
        @RequestParam("title") String title,
        @RequestParam("file") MultipartFile file) {

    // 이 지점에 도달했다는 것은 업로드가 100% 완료되었다는 뜻이다.
    // file.getSize()가 정확한 값을 반환하는 것도 그 때문이다.
    if (file.getSize() > MAX_ALLOWED) {
        return ResponseEntity.badRequest().build();     // 이미 다 받은 뒤의 거절
    }

    String key = storage.put(file.getInputStream(), file.getSize());
    return ResponseEntity.ok(new UploadResponse(key, title));
}

버퍼링 방식이 주는 이점

이 방식이 널리 쓰이는 데는 분명한 이유가 있습니다.

검증이 쉽습니다. 파일 전체가 손에 있으므로 크기, 매직 넘버, 해시, 바이러스 스캔을 자유롭게 수행할 수 있습니다. 스트리밍에서는 "다 읽어봐야 아는" 검증을 하려면 별도 설계가 필요합니다.

재시도가 자연스럽습니다. 임시 파일을 여러 번 다시 읽을 수 있으므로, 저장소 업로드가 실패하면 같은 데이터로 재시도할 수 있습니다. 스트림은 한 번 소비하면 되감을 수 없습니다.

트랜잭션 경계를 잡기 쉽습니다. "파일 저장 성공 → DB 레코드 생성"이라는 순서를 지키기 쉽고, 중간 실패 시 정리할 대상이 명확합니다.

디버깅이 단순합니다. 요청 처리 중 예외가 발생하면 스택 트레이스가 한 지점을 가리키고, 임시 파일을 직접 열어볼 수도 있습니다.

버퍼링 방식의 한계

반대로 대용량에서 드러나는 문제도 명확합니다.

메모리·디스크 증폭. 동시 업로드 N건에 평균 파일 크기 S를 곱한 만큼의 임시 저장 공간이 필요합니다. 500MB 파일 20개가 동시에 올라오면 임시 디렉터리에 10GB가 쌓입니다. 컨테이너 환경에서 임시 볼륨 크기를 넉넉하게 잡지 않았다면 여기서 바로 장애가 납니다.

응답 시간 증가. 전체를 받은 뒤 저장소로 다시 보내므로, 클라이언트 체감 시간은 최소한 업로드 시간 + 저장소 전송 시간이 됩니다. 스트리밍이라면 두 구간이 겹칩니다.

늦은 거절. 위 코드에서 보듯 크기 제한 위반을 알아차리는 시점이 이미 전체를 받은 뒤입니다. spring.servlet.multipart.max-file-size로 컨테이너 레벨에서 막으면 더 빨리 끊을 수 있지만, 비즈니스 규칙 기반 거절(예: 사용자 등급별 용량 제한)은 여전히 늦습니다.

스레드 점유. 서블릿 모델에서는 업로드가 진행되는 내내 워커 스레드가 묶여 있습니다. 느린 모바일 회선에서 5분간 업로드하면 그 스레드는 5분간 다른 요청을 처리하지 못합니다.


스트리밍 업로드의 동작 원리

기본 발상

스트리밍 업로드는 요청 본문을 소비하는 즉시 목적지로 흘려보냅니다. 서버는 전체 데이터를 보관하지 않고, 고정 크기 버퍼(보통 8KB~1MB)만 유지한 채 파이프 역할을 합니다.

[ 버퍼링 방식 ]
클라이언트 ──> 서버 임시파일 ─────(전송 완료 후)────> 오브젝트 스토리지
            └─ 전체 크기만큼 점유 ─┘

[ 스트리밍 방식 ]
클라이언트 ──> 서버(고정 버퍼) ──> 오브젝트 스토리지
            └─ 수십 KB만 점유 ──┘   (동시 진행)

가장 단순한 형태는 파일 자체를 요청 본문으로 보내는 것입니다.

PUT /api/files/9f2c1a HTTP/1.1
Host: api.example.com
Content-Type: video/mp4
Content-Length: 2147483648
X-File-Name: keynote.mp4

...바이너리 데이터 그대로...

메타데이터는 헤더나 URL 경로, 혹은 선행 요청으로 분리합니다. 경계 문자열 파싱이 없으므로 처리 경로가 짧고, 본문 전체가 곧 파일이므로 파서 계층 자체가 사라집니다.

백프레셔가 핵심이다

스트리밍의 실질적인 난이도는 대부분 백프레셔(backpressure) 에 있습니다. 클라이언트가 초당 100MB를 보내는데 저장소가 초당 40MB만 받는다면, 그 차이는 어딘가에 쌓입니다. 백프레셔를 처리하지 않으면 중간 버퍼가 무한히 커지고, 결국 메모리를 아끼려던 스트리밍이 버퍼링보다 더 큰 메모리를 쓰는 역설이 발생합니다.

Node.js에서 pipe()(또는 pipeline())를 권장하는 이유가 여기 있습니다. 이들은 하위 스트림의 write()false를 반환하면 상위 스트림을 자동으로 일시 정지하고, drain 이벤트에 맞춰 재개합니다.

import { pipeline } from 'node:stream/promises';

app.put('/api/files/:id', async (req, res) => {
  const upload = storage.createWriteStream(req.params.id);

  try {
    // pipeline이 백프레셔와 에러 전파, 스트림 정리를 모두 처리한다.
    await pipeline(req, upload);
    res.status(201).json({ key: req.params.id });
  } catch (err) {
    await storage.abort(req.params.id);   // 중단된 업로드의 잔여물 정리
    res.status(500).json({ error: 'upload_failed' });
  }
});

수동으로 data 이벤트를 받아 write()하는 코드는 백프레셔를 놓치기 쉽습니다. 직접 이벤트를 다루어야 한다면 write()의 반환값을 반드시 확인하고 pause()/resume()을 짝지어야 합니다.

스트리밍의 이점

메모리 사용량이 파일 크기와 무관해집니다. 1GB를 받든 10GB를 받든 연결당 점유 메모리는 버퍼 크기 수준으로 고정됩니다. 이것이 스트리밍을 선택하는 가장 큰 이유입니다.

전송 구간이 겹쳐 체감 시간이 줄어듭니다. 클라이언트→서버와 서버→저장소가 동시에 진행되므로, 이상적인 경우 총 소요 시간이 두 구간 중 느린 쪽으로 수렴합니다.

조기 거절이 가능합니다. 헤더만 보고, 혹은 첫 몇 KB의 매직 넘버만 확인하고 연결을 끊을 수 있습니다. 잘못된 업로드에 대역폭과 시간을 낭비하지 않습니다.

변환을 파이프라인에 끼워 넣을 수 있습니다. 압축, 암호화, 해시 계산을 통과 스트림(transform stream)으로 추가하면 추가 저장 공간 없이 처리됩니다.

스트리밍의 대가

검증 시점이 애매합니다. "파일 전체의 해시가 기대값과 일치하는가"를 확인하려면 이미 저장소에 절반을 보낸 뒤입니다. 불일치를 발견하면 이미 쓴 데이터를 되돌리는 보상 로직이 필요합니다.

재시도가 어렵습니다. 요청 본문 스트림은 한 번만 읽을 수 있습니다. 저장소 전송이 중간에 실패하면 클라이언트에게 처음부터 다시 보내달라고 요청할 수밖에 없습니다.

부분 실패의 정리 책임이 커집니다. 중단된 업로드가 저장소에 불완전한 객체나 미완료 멀티파트로 남을 수 있습니다. 이를 청소하는 배치나 수명 주기 정책이 반드시 필요합니다.

메타데이터 전달이 번거롭습니다. 한 요청에 파일과 필드를 함께 담는 multipart의 편의를 포기하게 되므로, 헤더 규약이나 2단계 API(메타데이터 등록 → 파일 업로드)를 설계해야 합니다.

스트리밍은 메모리를 절약하는 대신, "되돌리기"의 복잡도를 애플리케이션이 떠안는 거래입니다.


자원 사용량과 성능 비교

메모리·디스크 모델

동시 업로드 수를 N, 평균 파일 크기를 S, 스트리밍 버퍼 크기를 B라고 할 때 대략적인 자원 소요는 다음과 같습니다.

항목 multipart 버퍼링 스트리밍
힙 메모리 N × min(S, 임계값) N × B
임시 디스크 N × S (임계값 초과분) 0
연결당 상한 파일 크기에 비례 파일 크기와 무관
동시성 한계 결정 요인 디스크 용량 / IOPS 대역폭 / 연결 수

N=50, S=500MB, B=64KB를 대입하면 버퍼링 방식은 임시 디스크 약 25GB가 필요한 반면, 스트리밍은 힙 메모리 약 3.2MB로 끝납니다. 이 차이가 대용량 업로드에서 스트리밍을 선택하게 만드는 결정적 근거입니다.

레이턴시 모델

클라이언트가 응답을 받기까지의 시간은 다음과 같이 갈립니다. T_in은 클라이언트→서버 전송 시간, T_out은 서버→저장소 전송 시간입니다.

방식 총 소요 시간(근사) 비고
버퍼링 T_in + T_out + 디스크 I/O 두 구간이 순차
스트리밍 max(T_in, T_out) + 마무리 비용 두 구간이 중첩

일반적인 서비스에서 클라이언트 회선이 서버-저장소 간 대역폭보다 느리므로 T_in > T_out인 경우가 많고, 이때 스트리밍의 총 시간은 사실상 T_in에 수렴합니다. 즉 "저장소에 올리는 시간"이 체감에서 사라집니다.

다만 소용량 파일에서는 이 이점이 거의 없습니다. 수백 KB 파일에서는 T_out이 수십 ms에 불과해, 스트리밍 구현의 복잡도를 정당화하지 못합니다.

CPU와 파싱 비용

multipart 파싱은 경계 문자열 탐색을 위해 본문 전체를 스캔합니다. 최적화된 파서는 Boyer-Moore 계열 알고리즘을 쓰므로 비용이 크지 않지만, 기가바이트 단위에서는 무시할 수 없는 CPU 사이클이 됩니다. raw body 스트리밍은 이 스캔이 아예 없습니다.

반대로 스트리밍은 청크 단위 콜백이 빈번하게 발생해, 구현에 따라 컨텍스트 전환이나 이벤트 루프 부하가 커질 수 있습니다. 버퍼 크기를 너무 작게 잡으면(예: 4KB) 시스템 콜 횟수가 늘어 오히려 처리량이 떨어지므로, 64KB~1MB 범위에서 실측해 조정하는 것이 일반적입니다.

정리 비교표

기준 multipart (버퍼링) 스트리밍
메모리 사용 파일 크기 비례 상수
메타데이터 동봉 기본 지원 별도 설계 필요
전체 검증(해시, 백신) 쉬움 어려움
조기 거절 제한적 용이
서버 측 재시도 가능 사실상 불가
구현 난이도 낮음 중간~높음
브라우저 호환성 폼으로 즉시 가능 Fetch/XHR 필요
적합 규모 ~수십 MB 수백 MB 이상

프레임워크별 구현 비교

Spring: 버퍼링에서 스트리밍으로

Spring Boot의 기본 설정은 버퍼링입니다. 임계값과 상한은 프로퍼티로 조정합니다.

spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 100MB          # 파트 하나의 상한
      max-request-size: 200MB       # 요청 전체의 상한
      file-size-threshold: 2MB      # 이 크기를 넘으면 임시 파일로
      location: /var/tmp/uploads    # 임시 디렉터리(명시 권장)

location을 명시하지 않으면 컨테이너 기본 임시 디렉터리가 쓰이는데, 컨테이너 환경에서는 이 경로가 작은 오버레이 파일시스템일 수 있습니다. 대용량을 다룬다면 반드시 충분한 용량의 볼륨을 지정해야 합니다.

multipart를 유지하면서 스트리밍으로 처리하려면, Spring의 자동 파싱을 끄고 Apache Commons FileUpload의 반복자 API를 직접 사용합니다.

// spring.servlet.multipart.enabled=false 로 두어야 요청이 미리 소비되지 않는다.
@PostMapping("/api/uploads/stream")
public ResponseEntity<UploadResponse> streamUpload(HttpServletRequest request)
        throws IOException {

    if (!JakartaServletFileUpload.isMultipartContent(request)) {
        return ResponseEntity.badRequest().build();
    }

    var upload = new JakartaServletFileUpload();
    var iterator = upload.getItemIterator(request);

    String title = null;
    String key = null;

    while (iterator.hasNext()) {
        FileItemInput item = iterator.next();

        // 여기서 열리는 스트림은 네트워크 스트림 그 자체다.
        try (InputStream in = item.getInputStream()) {
            if (item.isFormField()) {
                title = new String(in.readAllBytes(), StandardCharsets.UTF_8);
            } else {
                // 저장소로 바로 흘려보낸다. 임시 파일을 만들지 않는다.
                key = storage.putStream(item.getName(), in);
            }
        }
    }
    return ResponseEntity.ok(new UploadResponse(key, title));
}

이 방식에는 중요한 제약이 하나 있습니다. 파트는 전송된 순서대로만 소비할 수 있고, 이전 파트로 되돌아갈 수 없습니다. 따라서 "파일을 저장하기 전에 메타데이터 필드를 먼저 봐야 한다"면, 클라이언트가 메타데이터 파트를 파일 파트보다 앞에 보내도록 규약을 정해야 합니다. 이 순서 의존성은 문서화하지 않으면 반드시 사고로 이어집니다.

WebFlux에서는 Flux<DataBuffer> 기반으로 더 자연스럽게 표현됩니다.

@PostMapping("/api/uploads/reactive")
public Mono<UploadResponse> reactiveUpload(
        @RequestPart("file") FilePart filePart) {

    // DataBuffer 흐름을 그대로 저장소 싱크로 전달한다.
    return storage.write(filePart.filename(), filePart.content())
                  .map(UploadResponse::new);
}

DataBuffer를 직접 다룰 때는 반드시 해제해야 합니다. 풀링된 버퍼를 놓치면 조용한 메모리 누수가 되며, 개발 단계에서는 spring.reactor.debug-agent 또는 Netty의 누수 탐지기를 켜서 확인하는 것이 좋습니다.

Node.js: busboy와 raw stream

Node.js 생태계에서 multer는 기본적으로 버퍼링(메모리 또는 디스크)입니다. 스트리밍이 필요하면 busboy를 직접 사용합니다.

import Busboy from 'busboy';

app.post('/api/uploads/stream', (req, res) => {
  const bb = Busboy({
    headers: req.headers,
    limits: { fileSize: 5 * 1024 * 1024 * 1024, files: 1 },
  });

  const fields = {};
  let pending = null;

  bb.on('field', (name, value) => { fields[name] = value; });

  bb.on('file', (name, stream, info) => {
    // stream은 파트 본문에 대한 읽기 스트림이다.
    pending = storage.upload(info.filename, stream);

    stream.on('limit', () => {
      // 상한 초과 시점에 즉시 알 수 있다. 버퍼링에는 없는 이점이다.
      res.status(413).json({ error: 'file_too_large' });
      req.unpipe(bb);
    });
  });

  bb.on('close', async () => {
    try {
      const key = await pending;
      res.status(201).json({ key, title: fields.title });
    } catch {
      res.status(500).json({ error: 'upload_failed' });
    }
  });

  req.pipe(bb);
});

busboy를 쓸 때 가장 흔한 버그는 파일 스트림을 소비하지 않는 것입니다. file 이벤트에서 스트림을 읽지 않고 무시하면 파서가 다음 파트로 진행하지 못해 요청이 영원히 멈춥니다. 사용하지 않을 파트라도 stream.resume()으로 명시적으로 비워야 합니다.

raw body 방식은 앞서 본 것처럼 훨씬 단순합니다. 프레임워크의 바디 파서 미들웨어가 해당 경로에 적용되지 않도록 제외하는 것만 주의하면 됩니다. express.json()이 경로 전역에 걸려 있으면 본문이 이미 소비되어 스트림이 비어 있게 됩니다.


대용량 파일과 재개 가능 업로드

세 번째 선택지: 클라이언트 직접 업로드

수 GB 규모를 다루기 시작하면, 사실 "서버를 거치는가"부터 다시 물어야 합니다. 애플리케이션 서버를 데이터 경로에서 완전히 빼는 사전 서명 URL(presigned URL) 방식이 대안입니다.

1. 클라이언트 ──> API 서버 : "report.pdf, 2GB 올리고 싶다"
2. API 서버   ──> 클라이언트 : 유효기간 15분짜리 업로드 URL + 업로드 ID
3. 클라이언트 ──> 오브젝트 스토리지 : 파일 직접 전송 (서버 경유 없음)
4. 오브젝트 스토리지 ──> API 서버 : 완료 이벤트 통지
5. API 서버 : DB 레코드 확정

이 구조에서 애플리케이션 서버는 권한 검사와 메타데이터 관리만 담당합니다. 대역폭 비용과 서버 자원 소모가 모두 사라지고, 확장성은 스토리지 서비스의 것을 그대로 물려받습니다. 대용량 업로드가 서비스의 핵심 기능이라면 가장 먼저 검토해야 할 선택지입니다.

대가는 관측 가능성의 저하입니다. 업로드 진행 상황이 서버 로그에 남지 않고, 업로드 중 실시간 변환이나 검사를 끼워 넣을 수 없으며, 완료 통지가 유실될 경우를 대비한 정합성 보정 로직이 필요합니다.

S3 멀티파트 업로드: 이름은 같지만 다른 개념

여기서 용어의 두 번째 혼동을 정리해야 합니다. S3의 "멀티파트 업로드"는 multipart/form-data와 아무 관련이 없습니다. 전자는 하나의 큰 객체를 여러 개의 독립적인 HTTP 요청으로 나누어 올리는 스토리지 API 프로토콜이고, 후자는 단일 HTTP 요청 본문의 인코딩 형식입니다.

S3 멀티파트 업로드의 주요 제약은 다음과 같습니다.

항목 제한
파트 크기 5MB 이상 5GB 이하 (마지막 파트는 하한 없음)
파트 개수 최대 10,000개
객체 최대 크기 5TB
미완료 업로드 명시적으로 중단하지 않으면 과금 대상으로 잔존

마지막 항목이 실무에서 자주 사고를 냅니다. 중단된 멀티파트 업로드는 목록 조회에 나타나지 않으면서 스토리지 비용은 계속 발생시킵니다. 반드시 AbortIncompleteMultipartUpload 수명 주기 규칙(예: 7일)을 버킷에 설정해 두어야 합니다.

파트를 나누어 올리는 구조는 재개 가능성과 병렬 전송이라는 두 가지 이점을 함께 제공합니다. 네트워크가 끊기면 실패한 파트만 다시 보내면 되고, 여러 파트를 동시에 전송해 단일 연결의 대역폭 한계를 넘을 수 있습니다.

재개 가능 업로드 프로토콜

모바일처럼 연결이 불안정한 환경에서는 재개(resumable) 기능이 사실상 필수입니다. 표준화된 접근으로 tus 프로토콜이 있고, IETF에서도 재개 가능 업로드에 대한 HTTP 확장이 논의되고 있습니다.

기본 발상은 단순합니다. 업로드 세션을 만들고, 각 청크를 오프셋과 함께 보내며, 중단 시 서버에 "지금까지 몇 바이트를 받았는지" 물어 그 지점부터 이어 보냅니다.

# 1) 세션 생성
POST /files HTTP/1.1
Upload-Length: 2147483648

HTTP/1.1 201 Created
Location: /files/9f2c1a

# 2) 청크 전송
PATCH /files/9f2c1a HTTP/1.1
Content-Type: application/offset+octet-stream
Upload-Offset: 0

...데이터...

# 3) 중단 후 재개 지점 확인
HEAD /files/9f2c1a HTTP/1.1

HTTP/1.1 200 OK
Upload-Offset: 1073741824      # 여기서부터 이어서 보내면 된다

이 모델은 본질적으로 스트리밍 계열이지만, 세션 상태를 서버가 관리한다는 점에서 순수 스트리밍과 다릅니다. 상태 저장이 필요하므로 다중 인스턴스 환경에서는 세션 정보를 공유 저장소에 두거나, 업로드 세션을 특정 인스턴스에 고정해야 합니다.

규모별 권장 선택

파일 크기 권장 방식 근거
~10MB multipart 버퍼링 구현 단순성이 모든 것을 압도
10MB~100MB multipart 버퍼링 + 상한 설정 임시 디스크 용량만 관리하면 충분
100MB~1GB multipart 스트리밍 또는 presigned URL 메모리·디스크 압박이 현실화
1GB 이상 presigned URL + 스토리지 멀티파트 서버를 데이터 경로에서 제외
불안정한 회선 재개 가능 프로토콜 재전송 비용이 지배적

운영 환경에서의 고려사항

프록시와 게이트웨이 설정

애플리케이션 코드를 아무리 잘 짜도 앞단 프록시가 버퍼링하면 스트리밍의 이점이 사라집니다. nginx는 기본적으로 요청 본문을 전부 받은 뒤에 업스트림으로 전달합니다.

location /api/uploads {
    client_max_body_size 5g;          # 기본 1m. 넘으면 413
    proxy_request_buffering off;      # 스트리밍의 핵심 설정
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
    proxy_pass http://app_backend;
}

proxy_request_buffering off가 없으면 nginx가 디스크에 통째로 받아버리므로, 백엔드에서 아무리 스트리밍을 구현해도 조기 거절도 시간 중첩도 얻지 못합니다. 반대로 이 옵션을 켜면 느린 클라이언트가 백엔드 연결을 오래 점유하게 되므로, 백엔드의 동시 연결 수용량을 함께 검토해야 합니다.

관리형 로드밸런서나 API 게이트웨이를 쓴다면 페이로드 크기 상한을 확인해야 합니다. 서버리스 게이트웨이 계열은 수 MB 수준의 상한이 흔하고, 이 한계는 애플리케이션 설정으로 우회할 수 없습니다. 이런 환경이라면 presigned URL이 선택이 아니라 필수가 됩니다.

조기 거절과 연결 정리

요청을 중간에 거절할 때는 남은 본문을 어떻게 처리할지가 중요합니다. 서버가 413을 보내고 즉시 연결을 닫으면, 클라이언트는 아직 전송 중이던 데이터에 대해 연결 초기화(RST)를 받고 응답 본문을 읽지 못한 채 "네트워크 오류"로 처리하는 경우가 많습니다.

Tomcat에는 이를 위한 maxSwallowSize 설정이 있습니다. 오류 응답 후 삼켜 줄 본문의 최대 바이트를 지정하며, 기본값은 2MB입니다. 이 값을 넘는 데이터가 남아 있으면 연결을 끊습니다. 대용량 업로드를 다룬다면 클라이언트가 오류 메시지를 제대로 받을 수 있도록 이 값과 클라이언트 동작을 함께 검증해야 합니다.

가장 깔끔한 해법은 본문 전송 전에 거절하는 것입니다. HTTP의 Expect: 100-continue 메커니즘을 쓰면 클라이언트가 헤더만 먼저 보내고 서버의 승인을 기다립니다.

PUT /api/files/9f2c1a HTTP/1.1
Content-Length: 2147483648
Expect: 100-continue

# 서버가 권한과 용량을 검사한 뒤
HTTP/1.1 100 Continue        # 또는 HTTP/1.1 413 Payload Too Large

대부분의 서버 스택이 자동으로 100을 응답하기 때문에 이 이점을 활용하려면 명시적인 설정이 필요하지만, 2GB를 다 받은 뒤 거절하는 것과 비교하면 투자할 가치가 충분합니다.

타임아웃 설계

업로드 경로의 타임아웃은 일반 API와 다른 기준이 필요합니다. 3G 회선에서 500MB를 올리면 30분이 넘게 걸릴 수 있습니다.

계층 확인할 설정 유의점
로드밸런서 유휴 타임아웃 전송 중에는 유휴가 아니지만, 정체 구간에서 걸릴 수 있음
리버스 프록시 proxy_read/send_timeout 업로드 경로만 별도 location으로 분리
애플리케이션 요청 처리 타임아웃 업로드 엔드포인트는 예외 처리
저장소 SDK 소켓/요청 타임아웃 파트 단위 타임아웃으로 설정

전체 요청에 하나의 큰 타임아웃을 거는 것보다, 진행이 멈춘 것을 감지하는 방식이 안전합니다. "60초 동안 단 1바이트도 수신되지 않으면 중단"이라는 규칙은 느린 회선의 정상 업로드를 죽이지 않으면서 죽은 연결만 정리합니다.

보안 점검 항목

파일 업로드는 전형적인 공격 표면입니다. 두 방식 모두에 공통으로 적용해야 할 항목이 있습니다.

파일명은 절대 신뢰하지 않습니다. Content-Dispositionfilename에는 경로 순회 문자열(../../etc/passwd)이나 널 바이트가 들어올 수 있습니다. 서버 저장 키는 UUID 등으로 새로 생성하고, 원본 파일명은 메타데이터로만 보관하는 것이 안전합니다.

콘텐츠 타입은 매직 넘버로 검증합니다. 클라이언트가 보낸 Content-Type은 선언일 뿐입니다. 스트리밍에서는 첫 청크의 앞 몇 바이트를 검사하면 되므로 오히려 조기 차단에 유리합니다.

압축 폭탄을 방어합니다. 업로드된 압축 파일을 서버에서 푼다면, 압축 해제 크기의 상한과 압축률 임계값을 설정해야 합니다. 이 검사는 압축 해제 스트림에 카운터를 다는 방식으로 구현합니다.

미완료 업로드를 정리합니다. 임시 디렉터리와 스토리지 양쪽에 청소 정책이 필요합니다. 이것이 없으면 디스크 소진이 서서히 진행되다가 어느 날 갑자기 장애로 드러납니다.

관측해야 할 지표

지표 의미 경보 기준
업로드 성공률 완료/시작 비율 급락 시 프록시·저장소 장애 의심
파일 크기 분포 p95/p99 실제 트래픽 특성 예상보다 크면 설정 재검토
임시 디스크 사용률 버퍼링 방식의 병목 70% 초과 시 경보
미완료 멀티파트 수 정리 누락 여부 지속 증가 시 정리 배치 점검
평균 전송 속도 회선 품질 급락 시 타임아웃 재조정 검토

특히 파일 크기 분포는 초기 설계 시의 가정을 검증하는 데 가장 유용합니다. "우리 서비스는 이미지 위주라 10MB면 충분하다"는 가정이 실제로는 p99에서 80MB를 찍고 있는 경우가 드물지 않습니다.


맺음말

핵심 요약

multipart와 스트리밍은 대립하는 두 기술이 아니라, 인코딩 형식과 처리 모델이라는 서로 다른 축입니다. 현업에서 이 둘이 대비되는 이유는 프레임워크의 기본 multipart 처리가 거의 항상 버퍼링이기 때문이며, 이 기본값은 작은 파일에서는 최적이지만 파일이 커지는 순간 메모리·디스크·레이턴시 세 방향에서 동시에 무너집니다.

스트리밍은 연결당 자원 사용량을 파일 크기와 분리하고 전송 구간을 중첩시켜 체감 시간을 줄이지만, 재시도 불가와 부분 실패 정리라는 복잡도를 애플리케이션이 떠안습니다. 그리고 규모가 더 커지면 세 번째 선택지 — 사전 서명 URL로 서버를 데이터 경로에서 완전히 빼는 방식 — 이 두 방식 모두보다 나은 답이 됩니다.

적용 판단 기준

선택을 가르는 질문은 결국 세 가지입니다. 파일 크기 p99가 얼마인가, 동시 업로드 수가 얼마인가, 업로드 중간에 서버가 데이터에 개입할 일이 있는가입니다.

p99가 수십 MB 이하라면 기본 multipart로 충분하고, 상한 설정과 임시 디렉터리 용량 관리만 제대로 하면 됩니다. 여기에 스트리밍을 도입하는 것은 근거 없는 복잡도입니다. 반대로 p99가 수백 MB를 넘고 동시 업로드가 두 자릿수라면 임시 디스크가 먼저 무너지므로 스트리밍이나 presigned URL로 이동해야 합니다. 서버가 업로드 스트림에 개입해야 하는 경우(실시간 변환, 인라인 검사)에만 스트리밍이 presigned URL보다 우위에 있습니다.

어느 쪽을 택하든 애플리케이션 코드만 보아서는 안 됩니다. 프록시의 버퍼링 설정, 게이트웨이의 페이로드 상한, 각 계층의 타임아웃이 실제 동작을 결정하며, 이들이 어긋나 있으면 코드 수준의 최적화는 아무 효과가 없습니다.


출처

  1. RFC 7578, Returning Values from Forms: multipart/form-data — 경계 문자열과 파트 헤더의 규범적 정의.
  2. RFC 9110, HTTP SemanticsExpect: 100-continue와 본문 처리에 대한 표준 규정.
  3. AWS, Uploading and copying objects using multipart upload — 파트 크기·개수 제한과 미완료 업로드 정리 정책.
  4. Node.js, Stream API — Backpressurepipeline()과 백프레셔 처리의 공식 지침.
  5. Spring Framework, Multipart Resolver — 버퍼링 기반 기본 동작과 설정 항목.
  6. tus, Resumable Upload Protocol — 오프셋 기반 재개 업로드의 오픈 프로토콜 사양.