GraphQL에서 multi-part 형식의 Mutation 사용법

ddoachi·2025년 7월 9일

TekaPicker

목록 보기
29/30

백엔드

graphql-upload 설치관련

설치하기

yarn add graphql-upload
yarn add -D @types/graphql-upload
  • 내가 설치한 버전은 17.0.0 임.
  • gpt는 호환성 이슈때문에 12버전을 설치하라는데 이것도 개소리

tsconfig.json 에 다음 추가

 "allowJs": true,
 "maxNodeModuleJsDepth": 10,
  • .mjs 파일을 직접 import 해야함. (index.js가 없음)
    import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
    공홈 README.md 보면 이렇게 나와있음

    The npm package graphql-upload features optimal JavaScript module design. It doesn’t have a main index module, so use deep imports from the ECMAScript modules that are exported via the package.json field exports:

graphql-upload 사용 관련 이슈

GraphQLModule 설정 관련

  • gpt는 다음과 같이 하라고 하는데,
// app.module.ts 또는 graphql.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  autoSchemaFile: true,
  uploads: {
    maxFileSize: 10_000_000, // 10MB
    maxFiles: 1,
  },
}),

이렇게하면 안됨. 아마 uploads 라는 속성이 ApolloDirverConfig 안에 없다고 에러날 것임.

대신 main.ts에 이렇게 해줘야함.

app.use(graphqlUploadExpress({ maxFileSize: 10_000_000, maxFiles: 1 }));

graphql-upload 로부터 import 하기

  • gpt는 다음과 같이 하라고 할텐데, 이렇게 하면 안됨.
import { FileUpload, GraphQLUpload } from 'graphql-upload';
// ...

@Mutation(() => GraphqlBaseMenuResponseDto)
async createMenu(
  @GqlUser('id') userId: string,
  @Args('input') input: GraphqlCreateMenuRequestDto,
  @Args({ name: 'file', type: () => GraphQLUpload }) file: FileUpload,
): Promise<GraphqlBaseMenuResponseDto> {
  // ...

  // 파일 저장
  await new Promise((resolve, reject) =>
    createReadStream()
      .pipe(createWriteStream(uploadPath))
      .on('finish', resolve)
      .on('error', reject),
  );

일단 import는 이렇게 해야함.

import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { FileUpload } from 'graphql-upload/processRequest.mjs';

경로도 틀렸고, 그리고 processRequest.mjs 로부터 export 되는 FileUpload를 써야함.

createMenu()의 인자 쓰기

  • imageFileinputGraphqlCreateMenuRequestDto 에는 빼고, 별도의 imageFile 인자로 받아야 함.
    • 왜냐하면 GraphQL은 파일입력을 직접 처리할 수 없어서 graphql-upload를 써야하는 것.
  • { nullable: true } 를 꼭 써야 함. 도대체 에러를 몇개를 만나는건지
async createMenu(
    @GqlUser('id') userId: string,
    @Args('input') input: GraphqlCreateMenuRequestDto,
    @Args({ name: 'imageFile', type: () => GraphQLUpload, nullable: true }) imageFile?: FileUpload,
  ): Promise<GraphqlBaseMenuResponseDto> {...}

프론트엔드

relay 환경에서 multipart/form-data 대응

  • relay 기본 fetchGraphQL()은 JSON POST만 처리 가능
  • 파일 업로드 시 multipart/form-data로 전송 필요
    -> fetchMultipartGraphQL 함수 구현 필요
  1. imageFile이 있을 경우 variables.imageFile = null 대입. 이유는,
  • operations JSON 안에서 variables.imageFile = null 로 선언함: 플레이스홀더 역할

  • map 객체에서 "0": ["variables.imageFile"] 로 매핑

  • 실제 "0" key에 파일을 넣어주면, 서버가 자동으로 null 자리에 파일 스트림을 주입

    이렇게 하지 않고 file 을 그대로 넣으면,

  • JSON.stringify 할 때 오류 나거나,

  • 서버가 스트림을 주입할 자리를 못 찾음

  • 그리고 Apollo가 "Unexpected input type" 같은 에러를 냄

  1. 이 때, formData append 순서 중요!
  • operationsmap → 실제 파일(0) 순으로 append 해야 정상 동작
  • Misordered multipart fields 에러 발생 시 순서 확인 필요
  1. axios.post() 호출할때 url은 ""로 비워두기. 왜냐하면 baseURL/graphql을 이미 넣어뒀으니까.
import axiosInstanceGraphql from "@/common/axiosInstanceGraphql";
// multipart 대응 fetcher
export function fetchMultipartGraphQL(
  operation: any,
  variables: Record<string, any>
) {
  const formData = new FormData();

  console.log("[DEBUG] fetchMultipartGraphQL operation: ", operation);
  console.log("[DEBUG] fetchMultipartGraphQL variables: ", variables);

  const operations = JSON.stringify({
    query: operation.text,
    variables: {
      ...variables,
      // 파일이 있을 경우 null로 치환
      imageFile:
        variables.imageFile instanceof File ? null : variables.imageFile,
    },
  });

  formData.append("operations", operations);

  const map: Record<string, string[]> = {};
  if (variables.imageFile instanceof File) {
    map["0"] = ["variables.imageFile"];
    formData.append("map", JSON.stringify(map));
    formData.append("0", variables.imageFile);
  }


  return axiosInstanceGraphql.post("", formData).then((res) => res.data);
}

4. CSRF 방어 정책으로 인한 차단

  • 서버에서 다음 에러 발생:
    This operation has been blocked as a potential Cross-Site Request Forgery (CSRF)
  • 원인: axiosInstanceGraphql에서 Content-Type 헤더를 강제로 설정하고 있었음
  • 해결:
    • Content-Type을 명시하면 preflight 생략되어 CSRF 차단 대상이 됨
    • multipart 요청 시 Content-Type을 명시하지 않으면 브라우저가 자동 설정
    • 인터셉터에서 아래와 같이 제거 처리:
axiosInstanceGraphql.interceptors.request.use((config) => {
  const isMultipart = config.data instanceof FormData;
  if (isMultipart) {
    delete config.headers["Content-Type"];
  }
  return config;
});

5. query가 누락될 경우 서버가 요청 거부

  • 에러 메시지:
    GraphQL operations must contain a non-empty `query`
  • 해결:
    • relay operation.text가 반드시 operations.query에 포함되어야 함
    • 누락 시 백엔드에서 BAD_REQUEST

6. relay useCreateMenuMutation에 imageFile 추가

  • 기존 mutation 정의:

    mutation useCreateMenuMutation($input: GraphqlCreateMenuRequestDto!) {
      createMenu(input: $input) { ... }
    }
  • 이미지 파일 포함을 위해 아래와 같이 수정:

    mutation useCreateMenuMutation($input: GraphqlCreateMenuRequestDto!, $imageFile: Upload) {
      createMenu(input: $input, imageFile: $imageFile) { id name }
    }
  • useCreateMenu.ts도 다음과 같이 수정:

    commit({
      variables: { input, imageFile },
      onCompleted: (data) => { ... }
    })

7. NestJS 업로드 디렉토리 미존재로 인한 오류

  • 에러: ENOENT: no such file or directory, open 'uploads/...'
  • 해결:
    • 디렉토리 존재 여부 검사 후 자동 생성
if (!existsSync(uploadsDir)) {
  mkdirSync(uploadsDir, { recursive: true });
}

최종 결과

  • 프론트에서 이미지 포함한 mutation 성공 전송
  • 백엔드에서 이미지 저장 성공
  • imagePath로 경로 반환되어 DB에 저장 가능
  • 근데 아직 refetch가 안됨. 하...
profile
내일도 풀스택

0개의 댓글