개발을 하다 보면 ‘파일 업로드’ 같은 기능들은 자주 접하게 된다. 나 역시 많은 업로드 기능을 만들어왔지만, 이번에 대용량 업로드 파일을 다루게 되면서 블로그에 정리를 해보려고 한다.

단순히 form에 파일을 담아 보내는 것이 아닌, 대용량이라는 한계를 넘기 위해 브라우저의 내부 구조를 뜯어보고 네트워크 자원을 어떻게 요리해야 할지 고민해야 했다. “브라우저는 한 번에 몇 개의 요청을 보낼 수 있을까?”, “청크 조각을 많이 나누면 무조건 빠를까?” 같은 근본적인 질문들에 답을 찾아가며 구현했던 과정들을 기록해 보려 한다.


브라우저의 네트워크 Connection Limit

우리가 사용하는 브라우저는 생각보다 보수적이다. HTTP/1.1 기준으로 동일한 도메인에 대해 동시에 보낼 수 있는 연결(Connection)은 딱 6개로 제한되어 있다.

만약 파일을 1,000개의 청크 조각으로 나누어서 한꺼번에 던지면 브라우저는 어떻게 반응할까? 6개만 먼저 실행시키고, 나머지 994개는 대기열(Queueing)에 묶여버린다. 이 대기가 길어질수록 네트워크 자원은 비효율적으로 쓰이고, 화면이 멈춰있는 것처럼 느껴진다.

실제로 겪은 문제: ERR_NETWORK_CHANGED

처음에는 “많이 보내고 대기열에서 실행되면 빠르겠지"라는 생각으로 동시 워커를 16개로 설정했다. 3GB 파일을 업로드하는데 6분 10초가 걸렸다. 그리고 DevTools Network 탭에 많은 ERR_NETWORK_CHANGED 에러가 발생했다.

POST .../minio-presigned-url net::ERR_NETWORK_CHANGED
PUT .../chunk_70 net::ERR_NETWORK_CHANGED
PUT .../chunk_71 net::ERR_NETWORK_CHANGED
...

브라우저는 6개 연결 제한을 넘어서는 요청들을 처리하지 못하고, 특히 localhost 환경에서는 네트워크 스택이 과부하되어 연결을 끊어버린 것이다.

HTTP/1.1 vs HTTP/2

프로토콜도메인당 동시 연결 수특징
HTTP/1.16개각 요청마다 별도 TCP 연결
HTTP/2100개 이상멀티플렉싱으로 하나의 연결에서 여러 요청 처리

Chrome DevTools에서 확인하는 방법:

Network 탭 → Protocol 컬럼 확인
- h2 = HTTP/2
- http/1.1 = HTTP/1.1

내 경우 MinIO presigned URL이 http://localhost:3000으로 프록시되어 HTTP/1.1을 사용하고 있었다. 즉, 6개가 한계였다.


청크 크기와 메모리

대용량 파일을 업로드하려면 파일을 작은 청크 조각으로 나눠야 한다. 이 조각의 크기를 정하는 게 생각보다 고려해야 될 부분이 많았다.

너무 작게 나누면 3GB 파일 기준으로 1MB 청크로 나누면 3,072개의 조각이 생긴다. 각 청크마다 presigned URL을 서버에서 받아야 하고, 각 청크마다 HTTP 헤더를 붙여서 보내야 한다. 브라우저는 6개씩만 처리하니까 512번의 대기 사이클이 생긴다. 이 오버헤드가 만만치 않다.

반대로 너무 크게 나누면? 100MB 청크를 6개 동시에 업로드하면 메모리에 600MB를 올려야 한다. 네트워크가 끊겼을 때 100MB를 통째로 재전송해야 하고, 사용자는 100MB가 업로드될 때까지 진행률이 멈춰있는 것처럼 느낀다.

여러 시도 끝에 16MB가 가장 균형 잡힌 크기였다. 3GB 파일 기준으로 192개 청크가 생성되고, 워커 6개가 동시에 돌면 메모리는 96MB만 사용한다. 네트워크가 끊겨도 16MB만 재전송하면 되고, 진행률은 약 0.5% 단위로 부드럽게 올라간다.


워커의 병목점 해결

여러 개의 워커가 동시에 청크를 업로드할 때 가장 중요한 건 “중복 없이, 빠짐 없이” 처리하는 것이다. 워커 A가 청크 42번을 처리하고 있으면, 워커 B는 43번으로 넘어가야 한다. 같은 청크를 두 번 올리거나, 어떤 청크를 건너뛰면 안 된다.

이를 위해 각 청크에 상태를 붙였다. pending, uploading, success, error 네 가지다. 워커는 pending 상태인 첫 번째 청크를 찾아서 uploading으로 바꾸고 업로드를 시작한다. 다른 워커가 이미 uploading으로 바꿔놨으면 다음 청크로 넘어간다.

const uploadWorker = async (
  fileIndex: number,
  uploadSessionId: string,
  file: File,
) => {
  while (true) {
    const chunks = chunkStatesRef.current.get(fileIndex);
    if (!chunks) break;

    const pendingIndex = chunks.findIndex((c) => c.status === "pending");
    if (pendingIndex === -1) break;

    const { chunkIndex } = chunks[pendingIndex];

    // 상태 변경: pending → uploading
    chunks[pendingIndex].status = "uploading";

    // 청크 업로드
    const chunkBlob = file.slice(
      chunkIndex * CHUNK_SIZE,
      Math.min((chunkIndex + 1) * CHUNK_SIZE, file.size),
    );

    await uploadChunkMutation({
      uploadSessionId,
      chunkIndex,
      chunk: chunkBlob,
    });

    // 상태 변경: uploading → success
    chunks[pendingIndex].status = "success";
  }
};

이렇게 하면 워커들이 알아서 일을 나눠서 한다. 6개 워커가 동시에 돌아도 같은 청크를 건드리는 일이 없다. findIndex가 첫 번째 pending만 찾아주기 때문이다.

메모리 측면에서도 효율적이다. file.slice()는 원본 파일을 복사하지 않고 참조만 유지한다. 실제 데이터는 업로드 직전에 읽히고, 업로드가 끝나면 자동으로 가비지 컬렉션 대상이 된다. 192개 청크가 있어도 동시에 메모리에 올라가는 건 업로드 중인 6개뿐이다.


일시정지 기능 구현

일시정지 기능을 만들면서 조금 까다로웠던 건 “진행 중인 HTTP 요청을 어떻게 멈추나"였다. fetch는 한 번 시작하면 자연스럽게 멈출 수 없기 때문에 서버가 응답을 보내기 전까지 기다려야 한다.

여기서 AbortController를 지피티에 추천을 받아 사용했다. 각 청크 업로드마다 컨트롤러를 만들어서 fetch에 연결해두면, 일시정지 버튼을 눌렀을 때 모든 진행 중인 요청을 즉시 중단할 수 있다.

const controller = new AbortController();

await fetch(presignedUrl, {
  method: "PUT",
  body: chunkBlob,
  signal: controller.signal, // 취소 가능하도록 연결
});

// 일시정지 시
controller.abort(); // 모든 fetch가 즉시 중단됨

일시정지했다가 재개할 때는 경과 시간을 보존해야 한다. 2분 업로드하고 일시정지한 뒤 나중에 재개하면, 처음부터 다시 세는 게 아니라 2분부터 이어서 세야 한다.

이를 위해 시작 시간을 과거로 조정하는 트릭을 썼다. 재개할 때 “지금 - 경과 시간"을 새로운 시작 시간으로 설정하는 것이다. 예를 들어 2분 경과 후 일시정지했다면, 재개 시점을 시작 시간으로 잡는 게 아니라 2분 전 시점을 시작 시간으로 잡는다. 그러면 이후에 계산되는 경과 시간이 자연스럽게 이어진다.

일시정지를 30분 넘게 하면 자동으로 취소되도록 했다. S3의 presigned URL은 기본적으로 10분 유효기간을 가지는데, 30분이면 충분히 만료될 시간이다. 만료된 URL로 업로드하면 403 에러만 받으니, 차라리 미리 정리하는 게 낫다고 판단했다.


동시 워커 수를 찾아가는 과정

브라우저가 6개 연결 제한을 가진다는 걸 알고 나서, 처음에는 단순하게 생각했다. “그럼 워커를 6개로 설정하면 되겠네.” 하지만 구현하다면서 더 복잡했다.

localhost 환경에서 워커 6개로 돌렸을 때 여전히 ERR_NETWORK_CHANGED가 가끔 발생했다. 로컬 네트워크 스택은 프로덕션보다 훨씬 약하다는 걸 깨달았다. 각 워커가 presigned URL을 받고 청크를 업로드하는 과정에서 순간적으로 12개의 연결이 생기기도 했다. 결국 localhost에서는 3~5개가 가장 안정적이었다.

더 복잡한 건 여러 파일을 동시에 업로드할 때였다. 파일 3개를 각각 워커 6개씩 돌리면? 18개의 연결이 생긴다. 브라우저는 6개만 실행하고 나머지는 대기시킨다. 이런 상황에서는 파일당 워커 수를 줄여서 전체 합이 6개를 넘지 않도록 조정해야 했다.

const getConcurrentUploads = (
  fileSize: number,
  totalUploadingFiles: number,
) => {
  const isLocalhost = window.location.hostname === "localhost";

  if (isLocalhost) {
    if (totalUploadingFiles === 1) return fileSize < 500 * 1024 * 1024 ? 4 : 5;
    return totalUploadingFiles === 2 ? 3 : 2;
  }

  if (totalUploadingFiles === 1) return 6;
  if (totalUploadingFiles === 2) return 3;
  return 2;
};

단일 파일은 최대 활용, 여러 파일은 나눠 쓰기. 간단한 원칙이지만 이걸 찾기까지 수십 번의 테스트가 필요했다. 결과적으로 localhost 4분 30초, 프로덕션 4분으로 안정화됐다.


마무리하며

3GB 파일을 6분 10초에서 4분 30초로 줄인 건 의미 있는 결과였지만, 더 중요한 건 이 과정에서 배운 것들이었다.

브라우저의 6개 연결 제한은 성능을 떨어뜨리는 장애물이 아니라, 서버와 네트워크를 보호하는 안전장치였다. 이를 무시하고 무작정 많이 보내려 하면 오히려 성능이 떨어진다는 걸 몸소 체험했다.

무엇보다 “빠르게"보다 “안정적으로"가 더 중요하다는 걸 깨달았다. 에러가 발생하면 재시도 로직이 동작하고, 그 시간이 오히려 더 오래 걸린다. 안정적인 4분 30초가 불안정한 4분보다 낫다.

앞으로는 HTTP/2 마이그레이션을 시도해볼 예정이다. 프로덕션 환경에서 HTTP/2를 쓴다면 동시 연결 제한이 100개 이상으로 늘어나서 워커를 10~15개까지 늘려도 문제없을 것이다. presigned URL을 청크마다 받는 게 아니라 10개씩 묶어서 배치로 받는 API도 서버 팀과 협의 중이다. 이 두 가지가 적용되면 2분대 업로드도 가능할 것으로 예상한다.

대용량 파일 업로드는 단순히 “파일을 서버에 보내는 것"이 아니었다. 브라우저의 네트워크 제약, 메모리 관리, 비동기 처리의 복잡성을 모두 이해하고 조율해야 하는 작업이었다. 이번 경험을 통해 브라우저와 네트워크를 더 깊이 이해하게 됐고, 다음 프로젝트에서도 이런 교훈들이 큰 도움이 될 것 같다.