AWS - S3의 preSignedUrl 사용

박상은·2022년 8월 12일
0

🧺 bleshop 🧺

목록 보기
2/10

next.js, prisma, tailwindcss를 사용하는 프로젝트입니다.

😥 이미지 처리 로직의 한계

이전에 AWS-S3를 이용해서 이미지를 저장하는 경우에는 다음과 같은 순서로 처리했습니다.
1. 브라우저에서 서버로 이미지 전송
2. 서버에서 받은 이미지와 key 들을 이용해서 S3에 이미지 업로드 요청
3. S3에서 이미지 업로드 후 결과 반환
4. 서버에서 받은 결과를 브라우저에 전달
5. 브라우저에서 받은 결과로 업로드된 이미지를 화면에 렌더링

위와 같은 순서로 처리할 때 항상 존재한 문제들은 두 가지 있습니다.
1. next.jsapi1MB 이상의 이미지를 처리하지 못함
2. 전송하는데 많은 리소스가 낭비되는 이미지를 두 번 연속으로 처리하므로 많은 낭비가 발생

브라우저에서 바로 S3로 이미지를 전송하면 문제가 해결되지 않냐고 생각할 수 있지만 S3에 접근하기 위해서는 몇 가지 key가 필요합니다. 그것을 브라우저에 저장하면 key가 누구에게나 노출되는 문제가 발생하기 때문에 가능하지만 사용하지 않았습니다.

😮 preSignedUrl

위의 한계를 극복할 방법을 찾다가 알게 된 방법이 preSignedUrl입니다.
미리 서명된 URL으로 처리 로직은 아래와 같습니다.
1. 브라우저에서 이미지 업로드 요청을 서버에게 보냄 ( 이미지는 보내지 않고 요청만 보냄 )
2. 서버에서 S3에 요청해서 일시적으로 이미지 업로드가 가능한 URL을 받음 ( 해당 URLpreSignedUrl라고 부릅니다. )
3. 서버에서 브라우저로 preSignedUrl을 전송함
4. 브라우저에서 받은 preSignedUrl에 이미지를 첨부해서 보냄
5. 문제없이 결과가 오면 이미지 업로드 완료이므로 이미지를 화면에 렌더링

preSignedUrl로 얻은 이점은 이전 방식의 두 가지 문제점을 모두 해결해줍니다.

🧐 preSingedUrl 사용 방법

S3의 버킷 생성과 권한 부여 등은 생략하겠습니다.

  • 설치
npm i aws-sdk
  • .env ( 환경변수 )
# 백엔드에서만 사용하기 때문에 "NEXT_PUBLIC" 붙일 필요 없음

BLESHOP_AWS_REGION=ap-northeast-2 ( 본인이 설정한 지역으로 입력 )
BLESHOP_AWS_ACCESS_KEY=<직접 입력>
BLESHOP_AWS_SECRET_KEY=<직접 입력>
import AWS from "aws-sdk";

AWS.config.update({
  region: process.env.BLESHOP_AWS_REGION,
  accessKeyId: process.env.BLESHOP_AWS_ACCESS_KEY,
  secretAccessKey: process.env.BLESHOP_AWS_SECRET_KEY,
});

// 버킷 정책에서 생성된 버전 날짜 그대로 가져와서 사용함
const S3 = new AWS.S3({ apiVersion: "2012-10-17", signatureVersion: "v4" });

/**
 * "이미지.확장자"를 받아서 "경로/이미지_시간.확장자"으로 변경해주는 함수
 * @param name "이미지.확장자" 형태로 전송
 * @returns "경로/이미지_시간.확장자" 형태로 반환
 */
const getPhotoPath = (name: string) => {
  const [filename, ext] = name.split(".");

  return `photos/${process.env.NODE_ENV}/${filename}_${Date.now()}.${ext}`;
};

/**
 * "preSignedURL"을 생성하는 함수
 * @param name "이미지.확장자" 형태로 전송
 * @returns "preSignedURL"와 "photoURL"을 반환 ( "photoURL"은 정상적으로 완료 시 이미지 url )
 */
export const getSignedURL = (name: string) => {
  const photoURL = getPhotoPath(name);

  const preSignedURL = S3.getSignedUrl("putObject", {
    // 생성한 버킷이름 작성
    Bucket: "bleshop",
    // 생성할 위치 및 파일명 작성 ( 현재는 "photos/development/파일명_시간.확장자" 형태임 )
    Key: photoURL,
    // URL 유효기간 ( 20초 )
    Expires: 20,
  });

  // preSignedURL과 미래에 생성될 이미지의 URL 반환
  return { preSignedURL, photoURL };
};
  • 사용 예시 ( 코드를 모두 분리해서 필요한 부분만 합쳐서 새로 만들었습니다. )
import { useCallback, useState } from "react";
import axios, { AxiosError } from "axios";
import Image from "next/image";

// type
import type { ChangeEvent } from "react";

type ApiGetUrlRespinse = { preSignedURL: string; photoURL: string };

/**
 * 현재 웹페이지의 이미지의 경로를 얻는 헬퍼 함수 ( aws-s3 )
 * @param path 후반부 이미지 경로
 * @returns 전체 이미지 경로
 */
declare function combinePhotoUrl(path: string): string;

const TestComponent = () => {
  const [photoUrl, setPhotoUrl] = useState<string | null>(null);

  const onUploadPhoto = useCallback(
    async (e: ChangeEvent<HTMLInputElement>) => {
      try {
        if (e.target.files?.length) {
          const photo = e.target.files[0];

          // 해당 api에서는 이전에 "preSignedURL()"를 이용해서 값을 얻고 반환
          const {
            data: { preSignedURL, photoURL },
          } = await axios.get<ApiGetUrlRespinse>(
            `/api/photo?name=${photo.name}`
          );

          // S3로 이미지 생성 요청
          await axios.put(preSignedURL, photo, {
            headers: { "Content-Type": photo.type },
          });

          // catch구문으로 넘어가지 않았으니 정상 작동
          setPhotoUrl(photoURL);
        }
      } catch (error) {
        console.error(error);

        if (error instanceof AxiosError) {
          // 예측 가능한 에러 ex) 이미지 용량 초과, 전송 시간 초과 등
        }
      }
    },
    [setPhotoUrl]
  );

  return (
    <>
      <input type="file" accept="image/*" onChange={onUploadPhoto} />
      
      // Next.js의 "<Image>"를 사용하기 위해서는 도메인을 등록이 필수
      {photoUrl && (
        <figure className="w-80 h-80 relative bg-black rounded-md">
          <Image
            layout="fill"
            priority
            src={combinePhotoUrl(photoUrl)}
            className="object-contain"
            alt="업로드한 이미지"
          />
        </figure>
      )}
    </>
  );
};

export default TestComponent;

AWS-S3 파일 이동 함수

현재 이미지 저장 방식은 다음과 같습니다.
1. 기본 이미지 저장 형태: photos/모드/사용방식/이미지명.확장자
2. 이미지 확정 전 방식: /temporary
3. 이미지 확정 후 방식: 이미지를 사용하는 형태에 따라 다름 ( /user, /product 등 )

예를 들어 회원가입하는 경우 회원가입 버튼을 누르기 전에는 /temporary에 이미지를 저장하고 회원가입 버튼을 누르고 유효성 검사 후 유저 생성이 확정되면 /user로 이미지를 옮깁니다.

이렇게 하면 임시 저장 이미지와 실제 사용하는 이미지를 구분할 수 있고, 임시 저장 이미지도 보관할 수 있습니다.
아래 코드는 위 로직을 쉽게 적용할 수 있도록 이미지 이동을 도와주는 헬퍼 함수입니다.

/**
 * 2022/08/14 - S3 이미지 제거 - by 1-blue
 * @param photo 이미지 파일 이름
 * @returns
 */
export const deletePhoto = (photo: string) =>
  S3.deleteObject(
    {
      Bucket: "bleshop",
      Key: photo,
    },
    (error, data) => {
      if (error) console.error("S3 이미지 제거 error >> ", error);
    }
  ).promise();

/**
 * 2022/08/14 - S3 이미지 복사 - by 1-blue
 * @param originalSource: 이미지 파일 이름, location: 이미지 복사 위치
 * @returns
 */
export const copyPhoto = (originalSource: string, location: PhotoKinds) => {
  // 이미지 저장 형태 : photos/모드/사용방식/이미지명.확장자
  // 모드: production or development
  // 사용 방식: temporary or remove or product or review 등
  // 따라서 두 번째 "/"를 찾아서 다음 "/"까지 내용을 바꾸면 됨 ( temporary -> product )
  
  let Key: unknown = null;
  const firstSlashIndex = originalSource.indexOf("/");
  const secondSlashIndex = originalSource.indexOf("/", firstSlashIndex + 1);

  switch (location) {
    // 이미지 제거
    case "remove":
      Key =
        originalSource.slice(0, secondSlashIndex) +
        "/" +
        location +
        originalSource.slice(secondSlashIndex);
      break;

    // 이미지 사용 확정으로 인한 이미지 이동
    default:
      Key = originalSource.replace("/temporary", "");
      break;
  }

  if (typeof Key !== "string")
    return console.error("이미지 저장 위치가 올바르지 않습니다.");

  return S3.copyObject(
    {
      Bucket: "bleshop",
      CopySource: "bleshop/" + originalSource,
      Key,
    },
    (error, data) => {
      /**
       * >>> 여기가 가끔씩 두 번 실행됨, 요청은 한 번으로 확인했고, callback이 두 번 실행되면서 에러가 발생함
       * 하지만 첫 번째 실행에 정상작동해서 이미지 복사는 정상적으로 실행되므로 상관은 없지만 에러 로그가 남는 문제가 발생
       */

      if (error) console.error("S3 이미지 이동 error >> ", error);
    }
  ).promise();
};

/**
 * 2022/08/14 - S3 이미지 이동 ( 복사 후 제거 ) - by 1-blue
 * @param photo: 이미지 파일 이름, location: 이미지 복사 위치
 * @returns
 */
export const movePhoto = async (photo: string, location: PhotoKinds) => {
  // OAuth의 이미지를 사용하는 경우
  if (photo.includes("http")) return;

  try {
    await copyPhoto(photo, location);
    await deletePhoto(photo);
  } catch (error) {
    console.error("movePhoto >> ", error);
  }
};

😩 겪은 문제

preSignedURLPUT 요청을 할 때는 File 객체 자체를 그대로 넘겨줘야 하는데 처음에는 FormData를 이용해서 넘겨줬었습니다. FormData를 이용하면 아무 문제 없이 정상 작동을 하지만 이미지가 깨지는 건지는 모르겠는데 이미지가 불러오는 부분에서 문제가 발생합니다.
처음에 이 부분을 몰라서 문제를 찾느라 4시간 정도 삽질하면서 결국 해결했습니다.

0개의 댓글