본문 바로가기

송송 DEV

송송은 뚠뚠 오늘도 뚠뚠 열심히 ~ 코딩 하네 뚠뚠

S3에 파일을 업로드하는 세 가지 방법

AWS SDK for Javascript v3을 이용해 next.js에서 S3에 파일 업로드를 하는 다양한 방법을 알아보자!

개요

기본 참고 자료 링크

링크로 소개한 영상에서는 express를 이용했는데, 더보기란에 첨부한 예제에 nextjs 소스도 있다.
aws 설정 및 세팅 부분은 위 영상 참고하는 게 더 자세하다.

조건

  • DB 없음, 업로드 소스만 따로 떼어서 작성
  • 추가 패키지 최소 사용
  • AWS 및 nextjs 기본 사용법 숙지한 상태라 가정

백엔드 api 통신을 많이 해 본 편이 아니라 개념이나 사용하는 패키지 등이 익숙하지가 않아 이리저리 시험해본 결과를 정리하였다. 원리에 대한 깊은 이해나 자세한 설명보다는 실제로 구현했던 코드 정리 위주의 포스팅이고 DB 없이 S3만 가지고 진행하기 때문에 업로드를 제외한 파일 읽어오기나 삭제는 아예 다루지 않는다.

설치 패키지

"dependencies": {
    "@aws-sdk/client-s3": "^3.201.0",
    "@aws-sdk/s3-request-presigner": "^3.201.0",
    "eslint": "8.26.0",
    "eslint-config-next": "13.0.1",
    "formidable": "^2.0.1",
    "fs": "^0.0.1-security",
    "multer": "^1.4.5-lts.1",
    "next": "13.0.1",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }

AWS 설정

S3 생성 및 설정

  • 별다른 셋팅 없이 리전만 잘 골라서 만들면 된다.

IAM 설정

아래 권한 추가해주고 대상 버킷 지정해준다. 아래 json 참고

  • S3
    • GetObject(객체 가져오기)
    • PutObject(객체 업로드)
    • DeleteObject(객체 삭제)
  • ARN 버킷 이름 및 리소스 경로 지정
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::버킷이름/*",
            ]
        }
    ]
}

아래와 같이 지정하면 지정한 버킷 이름 내 모든 오브젝트에 접근하여 읽기, 업로드, 삭제 가능

S3 CORS 설정

📌 Amazon S3 → 버킷 → 해당 버킷 → 권한 → CORS

  • 클라이언트에서 바로 올리려면 CORS 설정 필요함
  • 설정하지 않고 presigned url로 업로드 하면 CORS 에러 발생
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT",
            "POST",
            "DELETE"
        ],
        "AllowedOrigins": [
            "http://localhost:3000"
        ],
        "ExposeHeaders": []
    }
]

“AllowedOrigins”에 작업중인 URL 입력

.env 파일 설정

#aws iam 작성 시 나오는 액세스 키와 시크릿 키
MY_AWS_ACCESS_KEY = "[aws iam 키]"
MY_AWS_SECRET_KEY = "[aws iam 시크릿 키]"

#버킷 이름
MY_AWS_S3_BUCKET = "[버킷이름]"

#버킷 리전
MY_AWS_S3_BUCKET_REGION = "ap-northeast-2"

vercel에 디플로이 할 생각이라면 AWS_ACCESS_KEY, AWS_SECRET_KEY등은 이미 예약되어 있으므로 위 예시와 같이 피해서 사용할 것


화면 작업

사용자가 인풋에 이미지를 업로드하면
→ 상단에 첨부한 이미지가 나오고
→ 하단에 업로드 버튼 렌더링
→ 여기서 버튼을 누르면 각자 다른 방법으로 AWS S3에 업로드

스타일 작업은 안 했다😋

import { useState } from "react";
export default function ImageUploadPage() {
  // file 데이터
  const [image, setImage] = useState(null);
  // 화면 표시를 위한 임시 url
  const [createObjectURL, setCreateObjectURL] = useState(null);

  // 화면 상단에 이미지 표시
  const uploadToClient = (e) => {
    // ...중략
  };

  // 클라이언트에서 업로드 (aws-sdk getsignedurl 이용)
  const uploadImgClient = async () => {
    // ...중략
    };

  // fs 모듈을 이용해 업로드
  const uploadToFs = async () => {
    // ...중략
  };

  // multer 이용해서 업로드
  const uploadImgMulter = async () => {
    // ...중략
  };

  return (
    <>
      <div>
        <img src={createObjectURL} alt="" />
        <h4>Select Image</h4>
        <input name="myImage" type="file" onChange={uploadToClient} />
        {image && (
          <>
            <button type="submit" onClick={uploadImgClient}>
              클라이언트에서 바로 업로드
            </button>
            <button type="submit" onClick={uploadToFs}>
              form, fs로 업로드
            </button>
            <button type="submit" onClick={uploadImgMulter}>
              미들웨어, multer로 업로드
            </button>
          </>
        )}
      </div>
    </>
  );
}

화면에 업로드 한 이미지 표시

사용자가 인풋의 파일 첨부를 이용해 추가한 파일을 보여준다. (실제로 S3에 업로드 한 이미지가 아니다)

인풋이 변경될 때마다 인풋에서 파일 객체를 가져와 createObjectURL로 임시 URL을 생성하고 그걸 브라우저에 렌더링 한다.

// 화면 상단에 이미지 표시
const uploadToClient = (e) => {
  // 기존에 첨부한 이미지가 있을 경우 createObjectUrl 해제
  if (createObjectURL) {
    URL.revokeObjectUrl(createObjectURL);
  }
  /*
  input에 이미지 파일을 첨부하게 되면 e.target.files 배열에 이미지가 추가된다.
  단일 파일을 추가할 것이므로 0번 인덱스만 이용

  e.target.files[0]은 File객체로 Blob의 일종이다 자세한 것은 mdn 참고
  https://developer.mozilla.org/ko/docs/Web/API/File
  */

  if (e.target.files && e.target.files[0]) {
    const i = e.target.files[0];
    setImage(i);

    /*
    화면 상단에 현재 input에 추가한 파일 표시
    실제 S3 업로드X, 클라이언트에서만 처리하는 것으로
    URL.createObjectURL : file객체를 이용하여 임시 url 생성하여 이미지 표시한다
    mdn : https://developer.mozilla.org/ko/docs/Web/API/URL/createObjectURL
    */
    setCreateObjectURL(URL.createObjectURL(i));
  }
};

aws S3 관련 함수 만들기

AWS SDK for JavaScript v3 버전 이용하기 위한 함수들 미리 정의

// s3 접근하기 위해 불러옴
import {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  DeleteObjectCommand,
} from "@aws-sdk/client-s3";
// presigned url 이용하기 위해 불러옴
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// .env에서 aws 정보 읽어오기
const awsAccessKey = process.env.MY_AWS_ACCESS_KEY;
const awsSecretKey = process.env.MY_AWS_SECRET_KEY;
const awsS3Bucket = process.env.MY_AWS_S3_BUCKET;
const awsS3BucketRegion = process.env.MY_AWS_S3_BUCKET_REGION;

// s3 클라이언트 연결
const s3 = new S3Client({
  credentials: {
    accessKeyId: awsAccessKey,
    secretAccessKey: awsSecretKey,
  },
  region: awsS3BucketRegion,
});

// file signedUrl 가져오기
export async function getSignedFileUrl(data) {
  const params = {
    Bucket: awsS3Bucket,
    Key: data.name,
  };
  const command = new PutObjectCommand(params);
  const url = await getSignedUrl(s3, command, {
    expiresIn: 3600,
  });
  return url;
}

// 파일 업로드
export async function uploadFile(fileBuffer, fileName, mimetype) {
  const uploadParams = {
    Bucket: awsS3Bucket,
    Key: fileName,
    Body: fileBuffer,
    ContentType: mimetype,
  };

  const res = await s3.send(new PutObjectCommand(uploadParams));
  return res.$metadata.httpStatusCode;
}

Presigned URL로 업로드

Presigned URL API 요청 도식

  1. 클라이언트에서 이미지 업로드를 진행한다.
  2. 먼저 파일 이름과 타입으로 업로드를 위한 인증된 url을 생성한다.
  3. 생성된 url에 이미지 데이터를 첨부하여 요청을 보내면 해당 S3에 업로드된다.

프론트 : 단계별 요청

업로드를 위한 api url 요청 → 해당 url로 업로드 요청

// 클라이언트에서 업로드 (aws-sdk getsignedurl 이용)
  const uploadImgClient = async () => {
    /*
        aws-sdk-3버전의 getSignedUrl 이용
        1단계 : signed url을 받아온다
        2단계 : 받아온 url에 put으로 요청을 해서 업로드 한다

        특징 : client에서 바로 올리므로 서버에 이미지 데이터가 안넘어간다
        (폼데이터를 api로 넘기는 부분이 어려운데 해당 부분 생략이 가능하다)
        주의 사항 : S3 버킷에 CORS 설정을 해줘야 한다
        */

    // url 가져오는 데 필요한 데이터 정리
    // s3에서 구분할 이미지 이름(경로), 타입만 있으면 됨
    const body = {
      name:
        "client/" + Math.random().toString(36).substring(2, 11) + image.name,
      type: image.type,
    };

    try {
      // 1단계 : signed url 가져오기
      const urlRes = await fetch(`api/media/client`, {
        method: "POST",
        body: JSON.stringify(body),
      });
      const data = await urlRes.json();
      const signedUrl = data.url;

      console.log(signedUrl);

      // 2단계 : 가져온 url로 put 요청 보내기
      // 이미 파일 이름이나 경로 등은 url 받아올 때 지정을 다 해놨으므로,
      // image 파일 객체와 Content-type 정보만 넣어서 보냄
      const uploadRes = await fetch(signedUrl, {
        method: "PUT",
        body: image,
        headers: {
          "Content-type": image.type,
        },
      });

      console.log(uploadRes);
    } catch (err) {
      console.log(err);
    }
  };

백엔드 : presigned URL 생성

import { getSignedFileUrl } from "../../../lib/s3";

export default async function handler(req, res) {
  if (req.method === "POST") {
    try {
      let { name, type } = JSON.parse(req.body);

      const fileParams = {
        name: name,
        type: type,
      };

      const signedUrl = await getSignedFileUrl(fileParams);
      console.log(signedUrl);

      return res.status(201).json({
        message: "make url succeed",
        url: signedUrl,
      });

    } catch (e) {
      return res.status(500).json({
        message: "make url failed",
      });
    }
  }
}

CORS 설정하기

클라이언트 도메인(localhost:3000)과 요청을 받는 도메인(amazonaws.com)이 서로 다르므로 그냥 실행하게 되면 당연히 CORS 에러가 발생한다. 해당 에러가 발생한다면 S3 버킷의 CORS 관련 설정이 완료되었는지 확인해보자.


노드 모듈 File System 이용

클라이언트에서 바로 s3로 업로드하는 것과 달리 여기서는 api로 file 객체를 보내고 그곳에서 s3에 업로드하는 방식으로 구현할 예정이다.

프론트 : form 데이터 첨부

파일 객체를 api 쪽으로 넘기려면 form 형태를 이용해야 한다.

폼 객체를 만들고 해당 폼에 원하는 데이터를 append 하면 된다.

여기서는 파일 하나만 넘기면 되므로, 그대로 바로 body에 넣고 api를 호출했다.

// fs 모듈을 이용해 업로드
const uploadToFs = async () => {
  const body = new FormData();
  body.append("file", image);

  try {
    const res = await fetch("/api/media/fs", {
      method: "POST",
      body: body,
    });
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.log(err);
  }
};

백엔드 : form 파싱 하여 s3 업로드

form데이터를 이용해 파일 정보를 리퀘스트에서 찾아볼 수 있다. 그런데 form 데이터를 여기서는 json처럼 바로 읽어올 수 없다. 때문에 formidable 같은 폼 파싱을 위해 추가 패키지를 사용했다.

파싱 한 데이터를 이용해 노드의 file system 모듈을 이용하여 s3에 업로드할 수 있는 버퍼 데이터를 얻고 해당 데이터를 s3에 업로드했다.

import { uploadFile } from "../../../lib/s3";
import formidable from "formidable";
import fs from "fs";

export default async function handler(req, res) {
  if (req.method === "POST") {
    try {
      // formidable로 폼 파싱
      const fileData = await new Promise((resolve, reject) => {
        const form = new formidable.IncomingForm();
        form.parse(req, (err, fields, files) => {
          if (err) return reject(err);
          return resolve(files);
        });
      });

      /**
       * fs.createReadStream이용해 스트림 전환 후 올리기
       * C:\\Users\\[사용자]\\AppData\\Local\\Temp\\[파일이름]
       * 임시 저장된 주소 읽어와서 버퍼로 변환 -> aws 업로드
       */

      const fileBuffer = fs.createReadStream(fileData.file.filepath);
      fileBuffer.on("error", (err) => console.log(err));
      const fileName =
        "fsupload/" +
        fileData.file.newFilename +
        fileData.file.originalFilename;

      await uploadFile(fileBuffer, fileName, fileData.file.mimetype);

      return res.status(201).json({
        message: "s3 uploading with fs succeeded",
        imgUrl: fileName,
      });
    } catch (err) {
      return res
        .status(500)
        .json({ message: "s3 uploading with fs has failed" });
    }
  }
}

/**
 * next에서는 기본적으로 req요청이 들어오면 body-parser로 body를 분석해서 객체로 가져온다
 * 그런데 이건 json 기준이라, 지금처럼 이미지를 폼 데이터에 첨부하면 제대로 읽어오지 못함(undefined)
 * body-parser를 사용하지 않도록 next body-parser 옵션 해제하고 raw 데이터로 읽어와야 함
 */
export const config = {
  api: {
    bodyParser: false,
    sizeLimit: "4mb", // 업로드 이미지 용량 제한
  },
};

bodyParser : false 설정

Presigned Url로 업로드하는 것과 달리 여기서는 주의사항이 하나 있다.

bodyParser옵션을 꺼야 한다. bodyParser는 리퀘스트에 body 데이터를 자동으로 파싱 해주는데 json데이터라면 아무 문제가 없지만 raw-data나 스트림이 있다면 오류가 발생한다.


Multer 이용

multer는 파일 업로드를 위해 많이 사용하는 모듈인데, 내부를 뜯어보면 역시 노드 file system을 이용해서 스트림 데이터를 만들고 조작하는 것으로 보인다.

사용법은 보통 미들웨어 형태로 구현한다. 돌아다니는 소스들을 보면 보통 next-connect와 함께 쓰는 경우가 많은데, 이번에는 추가 패키지를 설치하지 않고 직접 함수를 하나 만들어서 구현해보았다.

multer 사용법 링크

프론트 : 이미지 데이터 첨부

// multer 이용해서 업로드
  const uploadImgMulter = async () => {
    const body = new FormData();
    const fileName =
      "multer/" + Math.random().toString(36).substring(2, 11) + image.name;

    // 이미지 첨부 시 키 이름(image)을 api의 multer사용하는 부분이랑 맞춰주어야 함
    body.append("image", image);
    body.append("name", fileName);

    try {
      const res = await fetch("/api/media/multer", {
        method: "POST",
        body: body,
      });
      const data = await res.json();
      console.log(data);
    } catch (err) {
      console.log(err);
    }
  };

백엔드 : 미들웨어 구현

import multer from "multer";
import { uploadFile } from "../../../lib/s3";

// multer는 미들웨어 형태로 구동된다
// 프로미스 객체 생성하여 직접 구현
// 참고 : https://github.com/vercel/next.js/discussions/37886
function runMiddleware(req, res, fn) {
  return new Promise((resolve, reject) => {
    fn(req, res, (result) => {
      if (result instanceof Error) {
        return reject(result);
      }
      return resolve(result);
    });
  });
}

export default async function handler(req, res) {
  if (req.method === "POST") {

    const storage = multer.memoryStorage();
    const upload = multer({ storage: storage });
    try {
      // 파라미터로 들어가는 텍스트(image)는
      // req에서 이미지 파일 첨부한 key와 동일하게 맞춰야한다.
      await runMiddleware(req, res, upload.single("image"));

      const fileBuffer = req.file.buffer;
      const fileName = req.body.name;
      const fileType = req.file.mimetype;

      await uploadFile(fileBuffer, fileName, fileType);

      return res.status(201).json({
        message: "s3 uploading with multer succeeded",
        imgurl: fileName,
      });
    } catch (error) {
      return res.status(500).json({
        message: "s3 uploading with multer has failed",
      });
    }
  }
}

export const config = {
  api: {
    bodyParser: false,
    sizeLimit: "4mb", // 업로드 이미지 용량 제한
  },
};

후기

사실 상단에 먼저 소개했던 강의 영상 소스를 이해만 할 수 있었다면 이런 삽질은 하지 않았을 텐데…

api 구현에 대한 개념 없이 패키지를 사용하려니 사용법이 이해가 안 되어 손을 댈 엄두가 나질 않았는데, 쌩으로 코딩하면서 기본적인 개념이 어느 정도 눈에 익게 되었고 왜 사람들이 패키지를 많이 쓰는지 아주 절실하게 깨달았다 😂