03 — Multipart File Upload
질문: GraphQL로 파일을 업로드하려면 어떻게 해야 하나?
Uploadscalar는 어디서 온 표준인가? 한 줄 답: GraphQL spec에는 파일 업로드 정의가 없다. 한 사람(Jayden Seric)이 2017년에 만든graphql-multipart-request-spec이 de facto 표준이 됐다 —multipart/form-databody에operations(JSON) +map(파일↔변수 매핑) + 실제 파일 파트를 함께 묶어 보낸다.Uploadscalar는 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-spec은 multipart/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 이름 | 역할 |
|---|---|
operations | GraphQL 요청 그대로. 파일이 들어갈 자리는 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 Client | apollo-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 v3 | graphql-upload (내장) |
| Apollo Server v4 | graphql-upload 수동 설치 (v4부터 분리) |
| GraphQL Yoga | 내장 (Yoga v3+) |
| Mercurius (Fastify) | mercurius-upload |
| Nexus·Pothos·TypeGraphQL | Upload 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에서 분리된 이유는:
- 보안 사고 —
graphql-upload의 자동 multipart 파싱이 임의의 multipart 요청을 받아주면서 CVE가 여러 번 났다. - HTTP framework agnostic — v4는 Express·Fastify·Lambda 다 지원하려고 framework integration을 분리했는데, multipart는 framework마다 다르게 처리되어야 했다.
- Yoga가 더 잘 함 — GraphQL Yoga는 multipart parser를 자체 내장하면서 더 견고하게 만들었다. Apollo가 Yoga에 부분 양보한 영역.
이게 왜 2026년 Apollo Server에서 업로드가 갑자기 빠진 것처럼 보이는지에 대한 답이다.
요약 (Pyramid Top 재정렬)
- GraphQL spec은 파일 업로드를 정의하지 않는다.
- jaydenseric/graphql-multipart-request-spec이 de facto 표준. multipart body =
operations+map+ 파일들.Uploadscalar는 custom scalar다 — multipart parser와 짝을 이루어야 동작.- 큰 파일은 애초에 GraphQL을 거치지 말고 — pre-signed URL → S3 직접 업로드 후 ID만 GraphQL로 — 도 좋은 패턴.
다음: 04 — GraphQL over WS — 응답이 N번 와야 한다면. WebSocket 위의 GraphQL.