blegram (4) - 이미지 업로드 ( AWS-S3, presignedURL )
포스트
취소

blegram (4) - 이미지 업로드 ( AWS-S3, presignedURL )

해당 프로젝트는 Next.js + TypeScript를 기반으로 만드는 인스타그램 클론 개인 프로젝트입니다.

AWS-S3를 이용한 이미지 업로드 로직에 대한 정리 포스트입니다. ( + presignedURL )

타입과 코드들을 각각 모두 파일을 분리해서 작성했기 때문에 일부분만 가져온 간소화된 코드입니다.

🫗 이미지 업로드 흐름

전체적인 로직을 구현하는 코드들이 여러 파일로 흩어져 있어서 단순하게 코드만 첨부하면 읽기도 어렵고 이해하기도 쉽지 않아서 해당 포스트에서는 전체적인 흐름과 중요한 코드만 작성하고 다른 부분은 깃헙 링크로 대체하겠습니다.

  • 흐름
    1. <input type="file" />로 부터 입력받은 이미지를 Blob으로 생성
    2. Blob으로 미리 보여주다가 사용자가 업로드 요청 대기
    3. presignedURL을 서버로부터 받아오기 ( FEBEAWSBEFE )
    4. 가져온 presignedURL을 통해서 이미지 업로드하기 ( FEAWS )
    5. 이미지를 정상적으로 업로드했다면 서버에 업로드된 URL 등록 ( FEBE )

📤 FE

0️⃣ 단일 이미지 업로드 컴포넌트

디자인을 위해서 <input />을 숨기고 ref를 이용해서 이미지를 클릭하는 경우 이미지를 업로드할 수 있는 파일 탐색기를 열도록 만들었습니다.

window.URL.createObjectURL()을 이용해서 이미지를 업로드한 브라우저에서만 이미지를 볼 수 있는 Blob URL을 만들어서 사용했습니다.
( 사용자가 이미지를 업로드하고 확인하는 과정에서 취소할 수 있기 때문에 일단 업로드하고 미리보기로 보여주기 위함입니다. )

사용자가 이미지를 업로드하면 모달창이 나와서 이미지를 업로드할지 취소할지를 결정할 수 있습니다.
모달창에서 확인을 누르면 처음에 설명했던 전체적인 로직이 실행됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";

// util
import { blurDataURL, combinePhotoURL } from "@src/utils";

// component
import Icon from "@src/components/common/Icon";

// style
import StyledSinglePhotoInput, { StyledConfirmModal } from "./style";

// type
interface Props {
  src: string | null;
  alt: string;
  onUploadPhoto: (filelist: null | FileList) => Promise<void>;
}

/** 2023/04/02 - 단일 이미지 입력받는 인풋 컴포넌트 - by 1-blue */
const SinglePhotoInput: React.FC<Props> = ({ src, alt, onUploadPhoto }) => {
  /** 2023/04/02 - 이미지 input value - by 1-blue */
  const [photo, setPhoto] = useState<null | FileList>(null);
  /** 2023/04/02 - 이미지 ref - by 1-blue */
  const photoRef = useRef<null | HTMLInputElement>(null);
  /** 2023/04/02 - 이미지 미리보기 - by 1-blue */
  const [previewPhoto, setPreviewPhoto] = useState("");

  /** 2023/04/02 - 이미지 미리보기 등록 - by 1-blue */
  const onUploadPreview: React.ChangeEventHandler<HTMLInputElement> =
    useCallback(
      (e) => {
        setPhoto(e.target.files);

        // 이미 프리뷰가 있다면 제거 ( GC에게 명령 )
        if (previewPhoto) URL.revokeObjectURL(previewPhoto);

        // 썸네일이 입력되면 브라우저에서만 보여줄 수 있도록 blob url 얻기
        if (e.target.files && e.target.files.length > 0) {
          setPreviewPhoto(URL.createObjectURL(e.target.files[0]));
        }
      },
      [previewPhoto]
    );
  /** 2023/04/02 - 이미지 업로드 취소 - by 1-blue */
  const onCancelPhoto = useCallback(() => {
    URL.revokeObjectURL(previewPhoto);
    setPhoto(null);
    setPreviewPhoto("");
  }, [previewPhoto]);
  /** 2023/04/02 - 새로운 이미지 업로드하면 Blob 할당 해제 - by 1-blue */
  useEffect(() => {
    if (!photoRef.current) return;

    photoRef.current.onload = () => URL.revokeObjectURL(previewPhoto);
  }, [previewPhoto]);
  /** 2023/04/02 - 이미지 업로드 - by 1-blue */
  const onUploadPhotoFunc = useCallback(async () => {
    await onUploadPhoto(photo);
    onCancelPhoto();
  }, [onUploadPhoto, photo, onCancelPhoto]);

  /** preview || photo */
  let path = "";

  // 미리보기 이미지가 있는 경우
  if (previewPhoto) path = previewPhoto;
  else if (src) {
    // "AWS-S3"에 업로드된 이미지인 경우
    if (src.includes(process.env.AWS_S3_BUCKET)) path = combinePhotoURL(src);
    // 다른곳에서 제공받은 이미지인 경우
    else path = src;
  }

  return (
    <>
      <StyledSinglePhotoInput
        type="button"
        onClick={() => photoRef.current?.click()}
      >
        {/* 이미지 입력받는 인풋 */}
        <input
          type="file"
          accept="image/*"
          hidden
          ref={photoRef}
          onChange={onUploadPreview}
        />
        {/* preview || 이미지 */}
        <figure>
          {path && (
            <Image
              src={path}
              alt={alt}
              fill
              quality={75}
              placeholder="blur"
              blurDataURL={blurDataURL}
            />
          )}
        </figure>
        {/* 마우스 hover 시 보여줄 fg */}
        <div>
          <Icon shape={previewPhoto ? "arrow-path" : "plus"} size="xl" fill />
        </div>
      </StyledSinglePhotoInput>

      {/* 이미지 업로드 확인 모달 */}
      {previewPhoto && (
        <StyledConfirmModal>
          <button type="button" onClick={onUploadPhotoFunc}>
            이미지 저장
          </button>
          <button type="button" onClick={onCancelPhoto}>
            이미지 취소
          </button>
        </StyledConfirmModal>
      )}
    </>
  );
};

export default SinglePhotoInput;

1️⃣ 이미지 업로드 함수

아래의 코드가 동작하지는 않지만 간소화해서 필요한 부분만 작성한 코드입니다.
( 더 구체적인 코드가 필요하다면 GitHub에서 확인해주세요! )

핵심은 presignedURL을 받아서 S3로 직접 요청을 보내고 최종적으로 본인 서버에 수정 요청을 보내는 흐름입니다.
( 프론트에서 바로 S3로 요청을 보내지 않는 이유는 AWS와 연결하는데 사용하는 key를 숨기기 위함입니다. )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useCallback, useState } from "react";

import useUser from "@src/hooks/query/useUser";

import FormToolkit from "@src/components/common/FormToolkit";

const TestComponent = () => {
  const { user, isFetchingUser } = useUser(nickname);

  /** 2023/04/02 - 이미지 등록중인지 판단할 변수 - by 1-blue */
  const [isUploadAvatar, setIsUploadAvatar] = useState(false);

  /** 2023/04/01 - 이미지 등록 핸들러 - by 1-blue */
  const onUploadAvatar = useCallback(
    async (filelist: null | FileList) => {
      if (!filelist) return;

      try {
        setIsUploadAvatar(true);

        // 1. 서버에서 "presignedURL" 생성 후 가져오기
        const { preSignedURL } = await apiServicePhoto.apiFetchPresignedURL({
          name: filelist[0].name,
        });

        // 완성될 이미지 경로 얻기
        const avatarPath = preSignedURL
          .slice(0, preSignedURL.indexOf("?"))
          .slice(preSignedURL.indexOf(process.env.NODE_ENV));

        // 2. 아바타 이미지 "S3"에 업로드
        await apiServicePhoto.apiUploadPhoto({
          file: filelist[0],
          preSignedURL,
        });

        // 3. 아바타 이미지 경로 서버에 업로드
        updateAvatarMutata({ avatar: avatarPath });
      } catch (error) {
        console.error("아바타 이미지 업로드 에러 >> ", error);
      } finally {
        setIsUploadAvatar(false);
      }
    },
    [updateAvatarMutata]
  );

  return (
    <>
      <FormToolkit.SinglePhotoInput
        width={120}
        height={120}
        src={user.avatar || "/photo/user.png"}
        alt={`${user.nickname}님의 프로필 이미지`}
        onUploadPhoto={onUploadAvatar}
      />
    </>
  );
};

export default TestComponent;

📥 BE

0️⃣ 세팅

AWS에서 발급받은 key를 이용해서 등록하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import AWS from "aws-sdk";

/** 2023-04-01 - "AWS-S3"에 접근하기 위한 세팅 - by 1-blue */
AWS.config.update({
  region: process.env.AWS_S3_REGION,
  accessKeyId: process.env.AWS_S3_ACCESS_KEY,
  secretAccessKey: process.env.AWS_S3_ACCESS_SECRET_KEY,
});

/** 2023/04/01 - "AWS-S3"의 접근에 사용하는 값 - by 1-blue */
export const S3 = new AWS.S3({
  apiVersion: "2012-10-17",
  signatureVersion: "v4",
});

export * from "./presignedURL";
export * from "./control";

1️⃣ presignedURL 얻는 함수

세팅에서 연결한 S3 객체를 이용해서 presignedURL을 얻는 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { S3 } from ".";

// type
import type { ApiFetchPresignedURLHandler } from "@src/types/api";

/**
 * 2023/04/01 - "이미지.확장자"를 받아서 "경로/이미지_시간.확장자"으로 변경해주는 함수 - by 1-blue
 * 현재 사용하고 있는 s3 폴더 구조는 `"개발모드"/images/이미지파일.확장자` 형태입니다.
 *
 * @param name "이미지.확장자" 형태로 전송
 * @returns "경로/이미지_시간.확장자" 형태로 반환
 */
const convertS3ImagePath = (name: string) => {
  const [filename, ext] = name.split(".");

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

/**
 * 2023/04/01 - S3의 "preSignedURL"을 생성하는 함수 - by 1-blue
 * @param name 이미지 이름  ("이미지.확장자" 형태 )
 * @returns "preSignedURL" 반환
 */
export const getPresignedURL: ApiFetchPresignedURLHandler = async ({
  name,
}) => {
  const photoURL = convertS3ImagePath(name);

  // 20초동안 이미지를 업로드할 수 있는 미리 서명된 URL 생성
  const preSignedURL = S3.getSignedUrl("putObject", {
    // 버킷을 생성할 때 지정한 유니크한 이름
    Bucket: process.env.AWS_S3_BUCKET,
    // 경로 + 파일명
    // ( "image"라는 폴더에 "cat1.jpg"로 저장하고 싶다면 -> "images/cat1.jpg" )
    Key: photoURL,
    // presigendURL 유지 시간 ( 초 )
    Expires: 20,
  });

  // 서명된 URL 반환
  return { preSignedURL };
};

2️⃣ 이미지 복사/삭제/이동 함수

세팅에서 연결한 S3 객체를 이용해서 이미지 복사/삭제를 쉽게 해주는 함수와 복사/삭제를 순차적으로 실행해서 이동을 시키는 함수입니다.
사용자가 이미지를 제거한다고 해서 S3에서 제거하는 것이 아닌 /remove 폴더에 제거된 이미지를 넣어둡니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { S3 } from ".";

// type
import type {
  CopyPhotoHandler,
  DeletePhotoHandler,
  MovePhotoHandler,
} from "@src/types";

/**
 * 2023/04/01 - S3 이미지 제거 - by 1-blue
 * @param photo 이미지 파일 이름
 * @returns
 */
const deletePhoto: DeletePhotoHandler = async (name) => {
  await S3.deleteObject(
    {
      Bucket: process.env.AWS_S3_BUCKET,
      Key: name,
    },
    (error, data) => {
      if (error) console.error("S3 이미지 제거 error >> ", error);
    }
  ).promise();
}
  
/**
 * 2023/04/01 - S3 이미지 복사 ( 이미지 제거에 사용 ) - by 1-blue
 * @param originalSource 이미지 파일 이름
 * @returns void
 */
const copyPhoto: CopyPhotoHandler = async (originalSource) => {
  const firstSlashIndex = originalSource.indexOf("/");
  const secondSlashIndex = originalSource.indexOf("/", firstSlashIndex + 1);

  // 이미지가 복사될 위치
  const key =
    originalSource.slice(0, secondSlashIndex) +
    "/remove" +
    originalSource.slice(secondSlashIndex);

  await S3.copyObject(
    {
      Bucket: process.env.AWS_S3_BUCKET,
      CopySource: process.env.AWS_S3_BUCKET + "/" + originalSource,
      Key: key,
    },
    (error, data) => {
      if (error) console.error("S3 이미지 이동 error >> ", error);
    }
  ).promise();
};

/**
 * 2023/04/01 - S3 이미지 이동 ( 복사 후 제거 ) - by 1-blue
 * @param photo 이미지 파일 이름
 * @returns void
 */
export const movePhoto: MovePhotoHandler = (name: string) => {
  // AWS-S3의 이미지가 아닌 경우 ( "OAuth" 이미지를 사용하는 경우 )
  if (name.includes("http")) return;

  try {
    // "/remove" 폴더로 복사
    await copyPhoto(name);
    // 기존 위치의 이미지 제거
    await deletePhoto(name);
  } catch (error) {
    console.error("S3 이미지 이동 >> ", error);
  }
};

🃏 알쓸개잡

0️⃣ S3 이미지 복사

S3의 이미지를 복사하는 경우 아래와 같이 CopySourceKey를 작성해야 합니다.
CopySource"버킷명/"를 붙여주지 않으면 Access Denied 오류가 발생합니다.
아직도 정확한 이유는 모르겠지만, 권한 설정을 잘못한건지 한참 삽질하다가 해결했습니다… 🥲

1
2
3
4
5
6
7
8
9
10
S3.copyObject(
  {
    Bucket: process.env.AWS_S3_BUCKET,
    CopySource: process.env.AWS_S3_BUCKET + "/" + originalSource,
    Key: key,
  },
  (error, data) => {
    if (error) console.error("S3 이미지 이동 error >> ", error);
  }
).promise();

1️⃣ TypeScript에게 거짓말하지 말기

처음에는 S3 이미지 복사/제거/이동부분에서 작성한 함수의 반환 타입void를 적었습니다.
async 함수인데도 void를 적어도 타입 문제가 발생하지 않아서 뭐가 잘못된건지 몰랐고 이대로 로직을 작성했습니다.

이후에 동작은 제대로하는데 자꾸 movePhoto()copyPhoto()error부분에서 에러를 출력해서 순서의 문제라고 추측했습니다.
복사를 완전하게 처리하고 제거를 해야하는데 복사와 제거를 동시에 수행해서 발생하는 문제라고 생각해서 await을 붙여줬는데 await 사용이 식에 영향을 끼치지 않는다고 해서 테스트하지 않고 넘어갔습니다.

한참 뒤에 함수의 반환 타입에 Promise를 적용하지 않은 것을 찾았고, 적용하지 않았기 때문에 실제로는 Promise를 반환하지만, TypeScript는 제가 잘못 작성한 타입을 보고 영향을 주지 않는다고 알려준 것이었습니다.
이렇게 애초에 개발자가 타입정의 자체를 잘못 적어버리면 TypeScript도 잘못된 방향으로 추론하고 런타임에 문제가 된다는 것을 알게 되었습니다.

📮 레퍼런스

  1. 1-blue - AWS-S3-presignedURL
  2. GitHub - blegram
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.