🔷 GraphQL4. 전송 계층03 — Multipart File Upload

03 — Multipart File Upload

질문: GraphQL로 파일을 업로드하려면 어떻게 해야 하나? Upload scalar는 어디서 온 표준인가? 한 줄 답: GraphQL spec에는 파일 업로드 정의가 없다. 한 사람(Jayden Seric)이 2017년에 만든 graphql-multipart-request-specde facto 표준이 됐다 — multipart/form-data body에 operations(JSON) + map(파일↔변수 매핑) + 실제 파일 파트를 함께 묶어 보낸다. Upload scalar는 spec 사양이 아니다 — 각 서버 라이브러리가 추가하는 custom scalar.


Why — JSON 1개로는 못 보내는 것

01-graphql-over-http에서 본 표준 요청은 항상 JSON body 하나다.

{ "query": "...", "variables": { "file": ??? } }

variables.file바이너리 파일을 어떻게 넣을까?

시도문제
Base64 encoding크기 +33%, CPU 부하, 메모리 spike. 1MB 이미지 100장이면 수백 MB JSON
URL로 보내고 서버가 fetch클라이언트가 공개 URL을 가진 파일만 가능
별도 REST endpoint로 업로드 후 ID만 GraphQL로트랜잭션 단절 — 업로드 성공 후 GraphQL 실패하면 orphan 파일이 남음

GraphQL 한 요청 안에 파일을 그대로 끼우려면 transport를 바꿔야 한다 — JSON이 아니라 multipart/form-data로.


How — multipart 요청의 정확한 모양

jaydenseric/graphql-multipart-request-specmultipart/form-data body를 3종류의 part로 정의한다:

1) operations   — GraphQL 요청 (JSON), 파일 자리는 null
2) map          — 파일 파트와 variables의 어떤 자리를 연결하는지
3) <N>          — 실제 파일 파트들 (이름은 0, 1, 2...)

예시 — single mutation으로 파일 1개 업로드

스키마:

scalar Upload
 
type Mutation {
  uploadAvatar(file: Upload!): User!
}

실제 HTTP 요청:

POST /graphql HTTP/1.1
Host: api.example.com
Content-Type: multipart/form-data; boundary=----abc
 
------abc
Content-Disposition: form-data; name="operations"
 
{
  "query": "mutation Upload($file: Upload!) { uploadAvatar(file: $file) { id } }",
  "variables": { "file": null }
}
------abc
Content-Disposition: form-data; name="map"
 
{ "0": ["variables.file"] }
------abc
Content-Disposition: form-data; name="0"; filename="me.png"
Content-Type: image/png
 
<binary png bytes here>
------abc--

세 part의 역할:

Part 이름역할
operationsGraphQL 요청 그대로. 파일이 들어갈 자리는 null로 비워둠
map"파일 part 이름": ["variables.X.Y", ...] — 파일 part가 어느 변수 경로로 들어갈지
0, 1, …실제 파일들. 이름은 map의 키와 일치

예시 — multiple 파일 + 중첩 변수

type Mutation {
  uploadGallery(input: GalleryInput!): Gallery!
}
input GalleryInput {
  title: String!
  cover: Upload!
  photos: [Upload!]!
}

요청:

Content-Type: multipart/form-data; boundary=----xyz
 
------xyz
Content-Disposition: form-data; name="operations"
 
{
  "query": "mutation($input: GalleryInput!) { uploadGallery(input: $input) { id } }",
  "variables": {
    "input": {
      "title": "Summer",
      "cover": null,
      "photos": [null, null]
    }
  }
}
------xyz
Content-Disposition: form-data; name="map"
 
{
  "0": ["variables.input.cover"],
  "1": ["variables.input.photos.0"],
  "2": ["variables.input.photos.1"]
}
------xyz
Content-Disposition: form-data; name="0"; filename="cover.jpg"
Content-Type: image/jpeg
 
<cover binary>
------xyz
Content-Disposition: form-data; name="1"; filename="p1.jpg"
Content-Type: image/jpeg
 
<p1 binary>
------xyz
Content-Disposition: form-data; name="2"; filename="p2.jpg"
Content-Type: image/jpeg
 
<p2 binary>
------xyz--

map깊이 있게 중첩된 변수의 어느 자리에 어떤 파일이 들어가는지를 dot path로 표현한다.


How — 클라이언트와 서버 라이브러리

클라이언트

라이브러리패키지
Apollo Clientapollo-upload-client
urql@urql/exchange-multipart-fetch
Relay직접 multipart 빌더 작성 (공식 지원 X)

브라우저 코드 예시 (Apollo):

import { ApolloClient, InMemoryCache } from '@apollo/client';
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
 
const client = new ApolloClient({
  link: createUploadLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
});
 
// 일반 mutation처럼 쓰면 됨 — file이 File/Blob이면 자동 multipart 변환
await client.mutate({
  mutation: UPLOAD_AVATAR,
  variables: { file: fileInputRef.current.files[0] },
});

서버

라이브러리패키지
Apollo Server v3graphql-upload (내장)
Apollo Server v4graphql-upload 수동 설치 (v4부터 분리)
GraphQL Yoga내장 (Yoga v3+)
Mercurius (Fastify)mercurius-upload
Nexus·Pothos·TypeGraphQLUpload scalar import + 미들웨어

서버 리졸버:

async function uploadAvatar(_: unknown, args: { file: Promise<FileUpload> }) {
  const { filename, mimetype, createReadStream } = await args.file;
  const stream = createReadStream();
  // S3·local·anywhere로 흘려보냄
  await s3.upload({ Bucket: 'avatars', Key: filename, Body: stream }).promise();
  return { id: '...', avatarUrl: `https://cdn/${filename}` };
}

Upload scalar는 promise를 반환한다 — 그래야 multipart parser가 stream을 지연 처리할 수 있다.


What — Upload scalar는 왜 spec이 아닌가

GraphQL spec은 transport를 정의하지 않았기 때문에, 파일도 정의하지 않았다. Upload는 multipart spec과 짝을 이루는 해석 약속이지 spec scalar가 아니다.

이건 곧 — client와 server가 같은 multipart spec을 따라야Upload가 통한다는 뜻이다. 만약 클라이언트가 jaydenseric multipart로 보내고 서버가 자기 만의 multipart parser를 쓰면 둘은 안 맞는다.


What-if — multipart spec을 안 따르면

  • Base64로 끼워 보냄 → 작은 파일은 동작, 5MB 넘으면 JSON 파싱이 멈춤. 메모리 폭발.
  • 별도 REST upload endpoint → 동작은 하지만, GraphQL의 단일 endpoint 약속이 깨진다. 인증·rate limit·monitoring을 두 곳에서 해야 함.
  • 클라이언트는 multipart인데 서버는 못 받음 → 서버가 400 Bad Request. 가장 흔한 첫 사고는 Apollo Server v4로 업그레이드했더니 업로드가 사라진 경우 — v4는 graphql-upload분리됐다.
  • CDN/WAF가 multipart body를 가로챔 → Cloudflare 기본 100MB 제한, AWS API Gateway 10MB 제한 등. transport layer 한계가 GraphQL 위로 새어 나온다.

Insight — 사양의 공백이 한 사람의 레포를 표준으로 만들었다

graphql-multipart-request-spec은 GraphQL Foundation이 만들지 않았다. Jayden Seric이라는 한 개인이 2017년에 GitHub repo로 제안서를 올렸고, Apollo·urql·다른 모든 라이브러리가 그걸 받아들이면서 표준이 됐다.

이건 consortium 사양과 다른 de facto 표준화의 좋은 사례다:

  • 장점: 빠르게 합의됐다 — Foundation 절차는 5년이 걸리지만 이건 6개월.
  • 단점: 변경 권한이 한 사람에게 있다. 그가 활동을 멈추면 생태계가 정지한다.
  • 현재 상태 (2026): spec은 2018년 v2 이후 큰 변경 없음. 안정화된 상태. 다만 GraphQL Foundation 산하 working group은 공식 upload spec을 별도로 작업 중이다 — 그게 안정화되면 jaydenseric은 legacy가 된다.

흥미로운 이야기 — Apollo Server v4가 upload를 끊어낸 이유

Apollo Server v3까지는 graphql-upload기본 내장이었다. v4에서 분리된 이유는:

  1. 보안 사고graphql-upload의 자동 multipart 파싱이 임의의 multipart 요청을 받아주면서 CVE가 여러 번 났다.
  2. HTTP framework agnostic — v4는 Express·Fastify·Lambda 다 지원하려고 framework integration을 분리했는데, multipart는 framework마다 다르게 처리되어야 했다.
  3. Yoga가 더 잘 함 — GraphQL Yoga는 multipart parser를 자체 내장하면서 더 견고하게 만들었다. Apollo가 Yoga에 부분 양보한 영역.

이게 왜 2026년 Apollo Server에서 업로드가 갑자기 빠진 것처럼 보이는지에 대한 답이다.


요약 (Pyramid Top 재정렬)

  1. GraphQL spec은 파일 업로드를 정의하지 않는다.
  2. jaydenseric/graphql-multipart-request-specde facto 표준. multipart body = operations + map + 파일들.
  3. Upload scalar는 custom scalar다 — multipart parser와 짝을 이루어야 동작.
  4. 큰 파일은 애초에 GraphQL을 거치지 말고 — pre-signed URL → S3 직접 업로드 후 ID만 GraphQL로 — 도 좋은 패턴.

다음: 04 — GraphQL over WS — 응답이 N번 와야 한다면. WebSocket 위의 GraphQL.