written by yechoi

[GraphQL] 파일 업로드 with NestJS, React 본문

Born 2 Code/Node Modules

[GraphQL] 파일 업로드 with NestJS, React

yechoi 2021. 8. 5. 20:07
반응형

GraphQL 서버를 이용해 파일을 업로드하는 방법을 알아본다.

클라이언트 사이드에서는 프레임워크로는 React, 상태관리 라이브러리로 Apollo Client를 사용하고 있다.

서버 사이드에서는 프레임워크로는 NestJS, 서버는 NestJS 내장 서버를 사용한다.

 

클라이언트 사이드

1. 파일 업로드 버튼 만들기

우선 파일 업로드 버튼을 만들어보자. 기본적으로 input 태그를 사용하면 브라우저별로 아래와 같은 버튼이 만들어진다.

버튼을 예쁘게 만들고 싶어서, label 태그의 for 속성을 활용해 카메라 버튼이 눌리면 input 태그가 눌리도록 변경했다.

      <div className="upload-button-container">
        <label htmlFor="input-file">
          <span role="img" aria-label="camera">
            📷
          </span>
        </label>
        <input type="file" id="input-file" onChange={fileUpload} />
      </div>

 

2. Apollo-client 업로드 링크 설정

파일을 올리기 위해서는 Apollo Link 설정을 해줘야 한다. Apollo Client 가 기본적으로 업로드 기능을 지원하는 게 아니다.

Apollo Link는 Apollo Client와 GraphQL 서버 사이의 데이터 흐름을 커스텀할 수 있도록 하는 라이브러리다.

const httpLink = createUploadLink({
  uri: GRAPHQL_URL,
  headers: {
    authorization: bearerAuthorization(getCookies('access_token')),
    'keep-alive': 'true',
  },
});

const wsLink = new WebSocketLink({
  // subscription 을 위해 만들어놓은 링크로 이번 글의 설명과는 무관함
});

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
  },
  wsLink,
  httpLink,
);

export const createClient = new ApolloClient({
  link: splitLink,
  // 이하 생략
});

위 코드처럼 createUploadLink로 업로드 링크를 생성하고, ApolloClient에 link 부분에 물려주면 된다.

링크가 여럿일 경우 위처럼 split으로 쿼리에 따라 쪼개줄 수 있다.

createUploadLink는 기존의 HttpLink를 대체할 수 있다. 즉 기존에 생성해둔 HttpLink가 있다면, 이를 createUploadLink로 바꾸면 된다.

 

서버 사이드

이 부분에서 deprecated 된 부분이나 에러가 있는데도 유지보수 안되는 부분들이 있어서 애를 많이 먹었다.

깃헙 이슈에서 이 코멘트를 보고 해결했다.

핵심은

  • apollo-server-core에 내장된 graphql-upload은 오래된 버전을 탑재
  • 옛 버전의 graphql-upload는 최근의 Node.js 및 기타 패키지와 충돌함
  • apollo-server는 graphql-upload를 지원하지 않을 것이라고 함
  • 내장된 것을 쓰지 말고 직접 graphql-upload 패키지를 받아서 사용하라

일부 블로그들이 apollo-server-core에서 GraphqlUpload를 import 하라고 하는데, 이는 최신 Node랑 충돌하므로 절대 ❌

 

1. resolution 없애기

이 충돌을 해결하려고 fs-capacitor 와 graphql-upload 를 package.json resolution에 넣으라는 얘기도 있는데,

만약 resolution에 fs-capacitor와 graphql-upload를 넣어뒀다면 지워주자.

 

2. 내장 upload 비활성화하고 graphqlUploadExpress middleware 사용하기

import { graphqlUploadExpress } from "graphql-upload"
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"

@Module({
  imports: [
    GraphQLModule.forRoot({
      uploads: false, // 내장 upload handling 비활성화
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(graphqlUploadExpress()).forRoutes("graphql")
  }
}

 

3. resolver 설정하기

GraphQLUpload무조건❗️ graphql-upload에서 import 한다.

받아온 file로 원하는 작업을 해주면 된다. 아래 코드는 ./images/${filename} 에 받아온 파일을 저장하고, 저장한 파일명을 반환하는 코드다.

import { GraphQLUpload, FileUpload } from 'graphql-upload';

@Mutation(() => String)
  async uploadFile(
    @Args('file', { type: () => GraphQLUpload })
    file: FileUpload,
  ): Promise<string> {
    const { createReadStream, filename } = await file;
    const stream = createReadStream();
    return new Promise(async (resolve, reject) =>
      stream
        .pipe(createWriteStream(`./images/${filename}`))
        .on('finish', () => resolve(`/${filename}`))
        .on('error', () => reject('')),
    );
  }

 

4. 클라이언트 사이드에서 resolver 사용하기

다시 클라이언트 사이드로 돌아가서, 이 mutation을 적용해보자.

export const UPLOAD_FILE = gql`
  mutation uploadFile($file: Upload!) {
    uploadFile(file: $file)
  }
`;
export const FileUploadButton = () => {
  const [uploadFile] = useMutation(UPLOAD_FILE);

  const fileUpload: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
    const files = e.target.files;
    if (files && files.length === 1) {
      const file = files[0];
      const saveLocation = await uploadFile({
        variables: {
          file: file,
        },
      }).catch(() => {
        console.log('upload file error');
        return;
      });
    }
  };
  return (
    <>
      <div className="upload-button-container">
        <label htmlFor="input-file">
          <span role="img" aria-label="camera">
            📷
          </span>
        </label>
        <input type="file" id="input-file" onChange={fileUpload} />
      </div>
    </>
  );
};

끝!

반응형