Dev/GraphQL

GraphQL - S3에 이미지 업로드(S3 Image File Upload)

sincerely10 2021. 1. 4. 00:21
반응형

안녕하세요. 2021년의 새해가 밝았습니다! 시간이 너무 빠르네요. 올해에도 작년처럼 자주는 아니더라도 꾸준한 포스트를 작성하는게 목표 중 하나입니다.

이번에는 GraphQL에서 이미지를 업로드하고 S3에 저장해 보는 과정을 소개드리도록 하겠습니다. 전에 Python 언어의 Flask를 활용해 S3로의 이미지 업로드를 소개한 적이 있는데, 비슷하면서도 Node.js & GraphQL만의 특징이 느껴졌습니다.

1. GraphQL에서 업로드하기 위한 설정(Apollo Server를 활용하기)

먼저 GraphQL과 같이 활용되는 Apollo Server에는 파일을 업로드 하기 위한 별도의 라이브러리가 내장되어 있습니다. 저의 경우에는 GraphQL Yoga를 활용하기 때문에 Apollo Server가 Default로 있기 때문에 Apollo Server 관련한 설치는 필요 없었습니다.

그리고 GraphQL Yoga를 활용하지 않더라도 GraphQL을 활용하실 때, Apollo Server를 install 해서 사용하실 것이기 때문에 아마 대부분 바로 활용이 가능할 것 같네요.

2. Upload Type 선언하기(GraphQL Schema)

1) Apollo Server를 통해 GraphQL 파일 업로드를 구현하기 위해서는 Upload라는 Scalar Type을 별도로 선언해줘야 활용이 가능합니다.
2) Upload를 불러오고 나서, file 또는 files와 같은 변수로 Mutation의 Arguments로 정의합니다.

이 과정을 코드로 표현하면 아래와 같습니다.

// schema.graphql
scalar Upload

type Mutation {
  createProduct(metadata: metadata!, files: [Upload]!): productList
}

저의 경우에 사진이 반드시 들어가야 하므로 필수 옵션을 붙여주었고, 이미지 파일이 하나가 아닌 여러 개이므로 배열 형태로 들어오게끔 하였습니다. 만약 파일 또는 이미지가 하나만 받는 경우는 file: Upload! 와 같이 표현할 수 있을 겁니다.

3. Upload 객체 받아서 Readstream 가져오기

이제 본격적으로 파일을 받아올 준비가 되었고, 백엔드에서 처리해주는 부분을 구현하면 됩니다.

과정을 간단히 소개하면, 선언한 Upload 객체는 Javascript의 Promise 객체입니다. 동기적으로 처리하는 Python에 익숙해지다 보니 다소 헷갈렸는데요. 간단히 정리하면, 비동기로 입력되는 Promise 객체의 배열을 읽어 들일 수 있는 forEach로 처리하였습니다.

마찬가지로 코드를 보면서 설명해보겠습니다.

// create_product.js Insert item image
await args.files.forEach(async (file, index) => {
  const uniqueId = uniqueUrl.makeId(8);

  const { createReadStream, filename, mimetype } = await file;
  const fileStream = createReadStream();
  s3.uploadS3(fileStream, "generated", itemId, uniqueId);
  const imageInsertQuery = `
    INSERT INTO item_images (
      item_id,
      image_url,
      image_no
    ) VALUES(
      ${itemId},
      "${uniqueId}",
      ${index + 1}
    )
  `;
  const [insertImageResult] = await connection.query(imageInsertQuery);
  if (insertImageResult.affectedRows < 1) throw new Error("QUERY_FAILED");
});

먼저, Upload Type으로 입력되는 args.files를 forEach를 통해 동기적으로 받아옵니다.
각 element를 file이라고 하겠습니다.

반복문에 고유 ID를 생성하는 부분이 있는데, 별도 함수를 만들어서 생성하도록 했습니다.(뒷부분에 간략히 기재할 예정입니다.)

file 변수에서 createReadStream, filename, mimetype 세 변수를 받아올 수 있습니다. 각각을 간략하게 설명하자면,
createReadStream은 파일을 읽어오는 기능을 합니다.(본 과정에서는 이 변수만 사용합니다.)
filename은 읽어온 파일의 이름입니다. 이 변수는 별도의 unique 한 ID를 만들어 대체하므로 사용되지 않습니다.
마지막으로 mimetype은 읽어온 파일의 타입입니다. 받아온 파일을 검증하는 데 사용될 수 있겠네요. 그렇지만 이 과정에서 별도로 활용하지는 않았습니다. 저의 경우에 S3 Upload 할 때, 동일한 이미지 포맷으로 지정했기 때문입니다.

fileStream이라는 변수에 createReadStream의 함수 형태로 선언합니다.

이 과정까지가 이미지를 받아들여 file Stream(파일 스트림)을 읽어오는 과정입니다.
코드에서 바로 아랫줄에 S3.upload라는 별도로 만든 함수를 통해 이미지를 업로드하는 내용부터는 하단에서 소개하겠습니다.

4. S3 Upload 하는 함수 만들기

저의 경우에 S3에 Upload 하는 함수를 모듈화 하여 사용했습니다. 모듈화의 장점은 다 아시겠지만, 절약성과 가독성이 좋아진다는 점이 있기 때문입니다.

먼저 코드부터 보여드리고 설명드리도록 하겠습니다.

// s3Uploader.js
const AWS = require("aws-sdk");
require("dotenv").config();

const s3 = new AWS.S3({
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_ACCESS_KEY_SECRET,
  region: process.env.AWS_S3_REGION,
});

export const uploadS3 = async (fileStream, imageKey, itemId, url) => {
  const params = {
    Bucket: process.env.AWS_S3_BUCKET,
    Key: `${imageKey}/${itemId}/${url}`,
    Body: fileStream,
    ACL: "public-read",
    ContentType: "image/jpg",
  };

  try {
    const response = await s3.upload(params).promise();
    console.log(response);
  } catch (err) {
    console.log(err);
  }
};

1) aws-sdk 설치하기

과정을 진행하기 위해서 aws-sdk를 설치해줘야 합니다. 저는 yarn을 활용해 설치했습니다.

$ yarn add aws-sdk

2) s3 객체 선언하기

조금 전에 설치한 aws-sdk를 이용해 S3 객체를  만들어 줍니다. access key와 secret access key id는 S3 권한을 설정할 때, csv 파일로 받으실 수 있습니다. 관련 내용을 다 설명하기에는 길어서 추후에 업로드해보겠습니다.

저의 경우에 .env 파일로 설정했는데 다른 방법을 사용하셔도 무관합니다.

region의 경우는 S3에서 영향은 없으나, 링크에서 사용되기 때문에 넣어줘야 합니다.

3) S3 upload 함수 만들기

함수의 역할은 정말 간단합니다.

내용물인 fileStream, url을 조합해서 만드는데 prefix 역할을 하는 imageKey, 상품 ID별 prefix 역할을 하는 itemId 그리고 고유한 값을 만드는 url이라는 변수가 사용됩니다.

먼저 params를 통해 S3 업로드에 필요한 parameter들을 정의합니다.
조금 전의 .env를 활용한 것처럼 target Bucket을 정의해줍니다.
그리고 이미지 URL의 구분자로 경로 역할을 Key를 정의합니다. 저의 경우 이미지의 종류는 imageKey라고 정의했습니다. 그리고 이미지 종류에 따라 item의 ID를 별도 구분했습니다. 마지막으로 고유한 이미지 ID 값을 가지게끔 하였습니다.(url)

Body는 위에서 설명드렸던 fileStream입니다.

ACL은 권한의 역할을 합니다. public-read로 하지 않으면 제3자가 사진에 접근할 수 없습니다. 서비스하는 사진의 경우에는 꼭 'public-read'로 설정하셔야 합니다.

마지막으로 ContentType은 파일의 종류를 지정하는 것입니다. 같은 이미지라도 다양한 파일 타입이 있을 수 있는데 저의 경우에 저장할 때는 하나로 통일시켰습니다.

이 설정을 통해 S3 Upload를 동기적으로 작동하게 수행해줍니다.

정상적으로 수행되면 S3 저장 경로와 key, Bucket 등이 보입니다.

5. Unique ID 만드는 함수

이제 언급했던 것과 같이 고유 ID를 만드는 함수에 대해서 소개드리도록 하겠습니다. 별 다른 것은 없고 원하는 길이에서 '알파벳 대문자'와 '숫자'를 무작위로 선정해 만드는 것입니다. UUID도 있지만, 너무 길기도 하고 이미지 경로에서 상품 ID와 이미지 종류로 구분했기 때문에 더 복잡한 과정은 필요 없을 것 같습니다.

코드는 아래와 같습니다.

// makeId.js
export function makeId(length) {
  let result = "";
  const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  let charactersLength = characters.length;
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
}

말씀드린 것과 같이 비교적 단순한 형태의 함수입니다.

추가 설명은 생략하도록 하겠습니다.

6. 종합

다시 위에서 언급한 image Upload 구현 내용을 다시 가져와 보겠습니다. 이번에는 선언부도 포함했습니다.

// create_product.js Insert item image
const s3 = require("../s3Uploader");
const uniqueUrl = require("../makeId");

await args.files.forEach(async (file, index) => {
  const uniqueId = uniqueUrl.makeId(8);

  const { createReadStream, filename, mimetype } = await file;
  const fileStream = createReadStream();
  s3.uploadS3(fileStream, "generated", itemId, uniqueId);
  const imageInsertQuery = `
    INSERT INTO item_images (
      item_id,
      image_url,
      image_no
    ) VALUES(
      ${itemId},
      "${uniqueId}",
      ${index + 1}
    )
  `;
  const [insertImageResult] = await connection.query(imageInsertQuery);
  if (insertImageResult.affectedRows < 1) throw new Error("QUERY_FAILED");
});

S3 업로드 한 이후의 부분은 간략하게 이미지 업로드를 하고 이 이미지가 어떤 상품의 id인지와 image의 번호를 부여해 썸네일(thumbnail)등을 가리기 위한 과정입니다. 이런 과정을 통해서야 추후에 상품의 이미지를 정상적으로 불러오고 렌더링 할 수 있기 때문이죠.

쿼리 부분은 각자 모델링 설계에 따라 다르기 때문에 참조만 해주시면 되겠습니다. 저의 경우에 SQL을 다룰 때는 mysql2를 사용했습니다.

확실히 javascript에서의 이미지 처리 등의 방식은 새로웠습니다. 기존에 form-data를 통해서만 이미지를 업로드한 것 과는 다르게 자체적으로 파일 업로드를 제공하기 때문에 수월하게 처리한 부분도 있습니다.

반면 비동기적으로 고려해야 하는 부분은 다소 새롭고 어렵게 느껴졌습니다. 그렇지만 이 비동기를 잘 활용한다면, 더 효율적인 로직이 구현될 수 있을 거라는 생각도 했습니다. ES5/6의 비동기 부분은 지속적으로 학습할 필요성을 느낍니다.

이상으로 2021년의 첫 포스팅을 마치도록 하겠습니다!

감사합니다.

반응형

'Dev > GraphQL' 카테고리의 다른 글

GraphQL 특징 & 입문하기  (0) 2020.10.07