파일 업로드에서 multipart form-data가 동작하는 방식

파일 업로드에서 multipart form-data가 동작하는 방식

한눈에 보기

각 part는 boundary로 구분되고 자체 헤더를 가진다. 서버는 전체 파일을 메모리에 올리지 않고 스트림으로 처리할 수 있어야 한다.

목차

왜 이 문제가 생기는가

프로필 이름과 이미지 파일을 한 번에 서버로 보내려 할 때 JSON만으로는 바이너리 파일을 자연스럽게 표현하기 어렵다. Base64 문자열로 바꿀 수는 있지만 크기가 증가하고 인코딩·디코딩 비용도 생긴다. multipart/form-data는 하나의 요청 본문을 여러 part로 나누어 일반 필드와 파일 바이트를 함께 운반한다.

형식 자체보다 더 중요한 문제는 서버가 업로드를 어떻게 제한하고 검증하며 저장하는가다. 사용자가 보낸 파일명과 Content-Type은 모두 신뢰할 수 없는 입력이고, 큰 파일 전체를 메모리에 올리면 몇 번의 동시 요청만으로 프로세스가 불안정해질 수 있다.

이 글의 예제에 관하여

프로필 업로드 endpoint와 파일명은 전송 원리를 설명하기 위한 가상 예제다. 실제 서비스의 저장 경로나 코드를 사용하지 않았다.

multipart 본문은 어떻게 나뉘는가

multipart/form-data 본문은 boundary로 구분된 part의 연속이다. 각 part에는 헤더와 본문이 있다.

POST /profiles/profile-42/avatar HTTP/1.1
Host: api.example.test
Content-Type: multipart/form-data; boundary=----ExampleBoundary7MA4

------ExampleBoundary7MA4
Content-Disposition: form-data; name="displayName"

Example User
------ExampleBoundary7MA4
Content-Disposition: form-data; name="avatar"; filename="portrait.jpg"
Content-Type: image/jpeg

(JPEG binary bytes)
------ExampleBoundary7MA4--

본문에 보이는 구분자는 --와 boundary 값을 결합한 것이다. 마지막 구분자 뒤에는 다시 --가 붙는다. 실제 줄바꿈은 프로토콜 규칙에 맞게 처리되며, boundary 값은 part의 내용 안에 나타나지 않도록 생성되어야 한다.

각 part의 Content-Disposition에는 폼 필드 이름인 name이 들어간다. 파일 part에는 원래 파일명을 나타내는 filename과 해당 part의 Content-Type이 추가될 수 있다. 같은 name으로 여러 파일 part를 보내는 것도 가능하다.

브라우저에서 FormData 보내기

브라우저에서는 FormData가 본문 형식과 boundary 생성을 맡는다.

<form id="profile-form">
  <input name="displayName" autocomplete="name" />
  <input name="avatar" type="file" accept="image/png,image/jpeg" />
  <button type="submit">저장</button>
</form>
const form = document.querySelector("#profile-form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const formData = new FormData(form);
  const response = await fetch("/profiles/profile-42/avatar", {
    method: "POST",
    body: formData,
  });

  if (!response.ok) {
    showUploadError(await response.json());
  }
});

JavaScript에서 값을 직접 추가할 수도 있다.

const formData = new FormData();
formData.append("displayName", "Example User");
formData.append("avatar", selectedFile, selectedFile.name);

accept 속성은 사용자가 파일을 고르는 경험을 도울 뿐 보안 검증이 아니다. 요청 도구나 수정된 클라이언트는 어떤 파일이든 보낼 수 있으므로 서버 검증이 반드시 필요하다.

boundary를 직접 지정하면 안 되는 이유

다음 코드는 흔한 실수다.

// 좋지 않은 예
await fetch("/uploads", {
  method: "POST",
  headers: {
    "Content-Type": "multipart/form-data",
  },
  body: formData,
});

헤더에는 boundary parameter가 없지만 브라우저가 만든 본문에는 브라우저가 선택한 boundary가 들어간다. 서버는 어디서 part가 끝나는지 알 수 없어 파싱에 실패할 수 있다. FormDatafetch body로 전달할 때는 Content-Type을 직접 설정하지 않고 브라우저가 boundary까지 포함해 생성하도록 둔다.

직접 HTTP 클라이언트 라이브러리를 사용하는 서버 코드에서는 해당 라이브러리가 제공하는 multipart 헤더 생성 기능을 사용한다. 고정 문자열을 임의로 조립하지 않는다.

서버는 파일을 스트림으로 처리한다

작은 이미지 하나는 메모리에 들어가지만 업로드 크기와 동시 요청 수가 늘면 합계가 문제가 된다.

요청당 50MB × 동시 업로드 40개 = 파일 버퍼만 최대 2GB

전체 요청을 메모리에 모은 뒤 처리하기보다 parser가 제공하는 스트림을 임시 파일이나 object storage로 전달한다.

async function handleAvatarUpload(request, response) {
  const upload = createMultipartParser({
    maxFiles: 1,
    maxFileBytes: 5 * 1024 * 1024,
    maxFields: 4,
  });

  const result = await upload.parse(request, {
    onFile: async ({ stream, clientFilename, contentType }) => {
      const storageKey = `quarantine/${crypto.randomUUID()}`;

      await objectStorage.writeStream(storageKey, stream);
      return { storageKey, clientFilename, contentType };
    },
  });

  const verified = await verifyUploadedImage(result.file);
  await publishVerifiedFile(verified);

  return response.status(201).json({ fileId: verified.id });
}

위 코드는 특정 라이브러리 문법이 아니라 책임의 흐름을 보여 주는 예시다. 실제 parser가 limit 초과 시 스트림을 어떻게 중단하는지, 이미 기록한 임시 데이터를 누가 삭제하는지 문서를 확인해야 한다.

업로드 검증은 여러 층으로 한다

하나의 검사만으로 파일 종류와 안전성을 보장할 수 없다.

검사 막으려는 문제 한계
파일 크기·개수 제한 자원 고갈 파일 내용의 안전성은 모름
확장자 allowlist 불필요한 형식 이름은 조작 가능
요청 Content-Type 실수로 잘못 고른 파일 클라이언트가 위조 가능
파일 signature 확인 선언과 실제 형식 차이 이것만으로 악성 내용 전체를 판별하지 못함
실제 parser로 decode 손상되거나 비정상인 형식 parser 취약점 관리 필요
malware/CDR 처리 알려진 악성 문서·활성 콘텐츠 비용과 지연, 오탐 가능

이미지를 받는다면 “확장자가 jpg인가?”에서 끝내지 않고 제한된 이미지 형식만 허용하고 실제 이미지 decoder로 열어 볼 수 있다. 필요하다면 서버가 새 이미지로 다시 인코딩해 불필요한 메타데이터와 예상하지 못한 내용을 제거한다.

압축 파일은 작은 압축 크기만 보고 허용하면 안 된다. 압축 해제 후 크기, 파일 수, 경로, 중첩 깊이를 제한하지 않으면 zip bomb과 path traversal 위험이 생긴다.

MIME과 magic bytes도 단독 판정기가 아니다

Content-Type은 사용자가 보낸 값이고 파일 signature 역시 우회 가능성이 있다. 업무에 필요한 형식만 allowlist로 좁히고 여러 검사를 겹치는 defense in depth가 필요하다.

파일명과 저장 경로를 신뢰하지 않는다

클라이언트가 보낸 filename을 그대로 파일 시스템 경로에 합치면 경로 이동이나 이름 충돌이 생길 수 있다.

// 위험한 예
const destination = `/var/www/uploads/${clientFilename}`;

저장 키는 서버가 생성하고, 원래 이름은 화면 표시가 필요할 때 정제한 metadata로만 보관한다.

const fileRecord = {
  id: crypto.randomUUID(),
  storageKey: `avatars/${crypto.randomUUID()}.jpg`,
  displayName: normalizeDisplayFilename(clientFilename),
  detectedType: "image/jpeg",
  ownerId: authenticatedUser.id,
};

업로드 파일은 가능한 한 애플리케이션 실행 경로와 분리하고, 웹 서버가 실행 파일로 해석하지 않는 object storage나 별도 host에 둔다. 다운로드는 예측 가능한 원본 경로 대신 권한을 확인하는 handler 또는 짧은 수명의 서명 URL을 사용한다.

실패, 중단, 정리까지 설계하기

업로드 중 사용자가 탭을 닫거나 네트워크가 끊기면 임시 object만 남을 수 있다. DB 저장은 실패했지만 파일 저장은 성공하는 반대의 중간 상태도 있다.

stateDiagram-v2
    [*] --> Uploading
    Uploading --> Quarantined: 바이트 저장 완료
    Uploading --> Aborted: 연결 중단 또는 제한 초과
    Quarantined --> Verified: 검사 통과
    Quarantined --> Rejected: 검사 실패
    Verified --> Published: DB 연결 및 공개
    Aborted --> Cleaned
    Rejected --> Cleaned

임시 파일에는 만료 시간을 두고 정리 작업이 삭제한다. 검사 전 파일과 공개 가능한 파일의 저장 prefix 또는 bucket을 분리하면 실수로 미검증 파일을 제공하는 일을 줄일 수 있다. 로그에는 원본 전체 경로나 개인 파일명을 과도하게 남기지 않는다.

큰 파일은 직접 업로드를 고려한다

동영상처럼 큰 파일이 API 서버를 통과하면 서버 네트워크와 연결을 오래 점유한다. Presigned URL을 사용하면 서버가 권한과 저장 키를 결정하고, 클라이언트가 object storage로 직접 업로드하게 할 수 있다.

sequenceDiagram
    participant C as Client
    participant A as API Server
    participant S as Object Storage
    C->>A: 업로드 세션 요청
    A-->>C: 제한된 key와 서명 URL
    C->>S: 파일 직접 업로드
    C->>A: 완료 통지
    A->>S: 크기·metadata 확인
    A-->>C: 파일 레코드 확정

직접 업로드도 검증을 없애는 것은 아니다. 허용 크기와 content type 조건, key 범위, 짧은 만료, 완료 후 서버 검증, 사용되지 않은 object 정리가 필요하다.

운영 점검 목록

파일 업로드 점검

  • 요청 전체 크기, 파일별 크기, 파일 개수, 필드 수 제한이 있는가?
  • proxy와 애플리케이션의 제한 값이 일관적인가?
  • 사용자 제공 filename과 Content-Type을 신뢰하지 않는가?
  • 파일을 메모리 전체가 아니라 스트림으로 처리하는가?
  • 중단된 임시 파일과 미완료 multipart upload를 정리하는가?
  • 업로드·조회·삭제 권한을 각각 검사하는가?
  • 미검증 파일과 공개 파일의 저장 영역이 분리되어 있는가?

결론

multipart/form-data는 boundary로 나뉜 여러 part에 일반 필드와 파일 바이트를 함께 담는 전송 형식이다. 브라우저의 FormData를 사용할 때 boundary 헤더를 직접 지정하지 않고, 서버에서는 크기 제한과 스트리밍을 요청 초기에 적용한다. 파일명·MIME·확장자 어느 하나도 신뢰하지 말고 여러 검증, 격리 저장, 실패 정리까지 하나의 업로드 생명주기로 설계해야 한다.

관련 노트