blegram (6) - 다중 이미지 업로드 및 케루셀
포스트
취소

blegram (6) - 다중 이미지 업로드 및 케루셀

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

AWS-S3를 이용한 게시글의 여러 이미지를 업로드하는 로직에 대한 포스터입니다.
자세한 부분은 이미지 업로드 - (4)를 참고해주세요!

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

이미지 업로드의 전체적인 로직은 이전 포스트인 단일 이미지 업로드의 흐름을 그대로 사용하고 병렬적으로 처리하는 것 이외에는 크게 다른 부분은 없습니다.

📤 FE

  • 다중 이미지 처리 흐름
    1. <input type="file" accept="image/*" multiple />로 여러 이미지를 입력받음
    2. <input />onChange()로 입력된 이미지들의 프리뷰 생성 ( window.URL.createObjectURL() )
    3. 프리뷰들을 이용해서 Carousel 생성
    4. 게시글 생성 클릭 시 presignedURL을 이용해서 AWS-S3에 이미지 업로드
    5. 생성된 이미지의 URL 서버에 전달 후 DB에 등록 ( |를 구분자로 해서 문자열을 합쳐서 넣음 )

0️⃣ Preview

Carousel에 대한 내용은 여기를 참고해주세요.

<input type="file" accept="image/*" multiple />을 통해 여러 이미지를 입력받고 onChangewindow.URL.createObjectURL()를 이용해서 이미지 미리보기용 URL을 생성합니다.
추가적으로 상위에서 내려준 setPhotos()를 이용해서 이미지들(FileList)을 state에 등록하고 이미지 업로드를 다른 컴포넌트에서 처리합니다.

그리고 react-slick을 이용해서 프리뷰로 이미지 캐루셀을 렌더링합니다.
해당 부분은 <Carousel />이라는 컴포넌트를 만들어서 내부에서 react-slick을 사용하고 있습니다.

일단은 브라우저 사이즈에 맞게 반응하기 위해서 widthheight를 받아서 고정적으로 사이즈를 지정하는 형태로 만들었습니다.
( 상위에서는 브라우저 사이즈를 기반으로 width, height를 결정합니다. )
( 이 부분에서 쓸모없는 이벤트가 너무 많이 실행돼서 성능에 문제가 있을까 걱정이 되긴 합니다… 🥲 )

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
import { useCallback, useEffect, useRef, useState } from "react";

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

// style
import StyledMultiPhoto from "./style";

// type
interface Props {
  setPhotos: React.Dispatch<React.SetStateAction<FileList | null>>;
}

/** 2023/04/08 - 다중 이미지 입력받는 인풋 컴포넌트 - by 1-blue */
const MultiPhotoInput: React.FC<Props> = ({ setPhotos }) => {
  /** 2023/04/08 - 이미지 ref - by 1-blue */
  const photoRef = useRef<null | HTMLInputElement>(null);
  /** 2023/04/08 - 이미지 미리보기 - by 1-blue */
  const [previewPhotos, setPreviewPhotos] = useState<string[]>([]);

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

        // 이미 프리뷰가 있다면 제거 ( GC에게 명령 )
        if (previewPhotos.length !== 0) {
          previewPhotos.map((previewPhoto) =>
            URL.revokeObjectURL(previewPhoto)
          );
        }

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

    photoRef.current.onload = () => {
      previewPhotos.map((previewPhoto) => URL.revokeObjectURL(previewPhoto));
    };
  }, [previewPhotos]);

  return (
    <StyledMultiPhoto>
      {/* 이미지 입력받는 인풋 */}
      <input
        type="file"
        accept="image/*"
        hidden
        ref={photoRef}
        onChange={onUploadPreview}
        multiple
      />

      {/* preview || 이미지 */}
      <figure>
        <Carousel photos={previewPhotos} />

        {previewPhotos.length === 0 && <Icon shape={"photo"} size="xl" fill />}

        {/* 마우스 hover 시 보여줄 fg */}
        {previewPhotos.length === 0 && (
          <button type="button" onClick={() => photoRef.current?.click()}>
            <Icon shape={"photo"} size="2xl" fill />
          </button>
        )}
      </figure>
    </StyledMultiPhoto>
  );
};

export default MultiPhotoInput;

1️⃣ 다중 이미지 업로드

모든 로직을 다 적기에는 너무 많기 때문에 상세한 타입과 메서드들은 깃헙에서 확인해주세요!

아래의 setPhotos()를 하위 컴포넌트에 내려주고 이미지를 업로드하고 생성 버튼을 누르면 정해진 로직을 순서대로 실행합니다.

  1. presignedURL들 가져오기
  2. AWS-S3에 이미지들 업로드
  3. 백엔드에 업로드한 이미지의 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
const PostUploadPage = () => {
  /** 2023/04/08 - 이미지 input value - by 1-blue */
  const [photos, setPhotos] = useState<null | FileList>(null);

  /** 2023/04/08 - 게시글 업로드 핸들러 - by 1-blue */
  const onUploadPost: React.FormEventHandler<HTMLFormElement> = handleSubmit(
    useCallback(
      async ({ contents }) => {
        if (!photos?.length) return toast.warning("이미지를 등록해주세요!");

        try {
          setIsUploadPost(true);

          // "presignedURL"들 생성 및 가져오기
          const { presignedURLs } =
            await apiServicePhotos.apiFetchPresignedURLs({
              names: [...photos].map((photo) => photo.name),
            });

          // "AWS-S3"에 이미지들 등록
          await Promise.all(
            presignedURLs.map((presignedURL, i) =>
              apiServicePhoto.apiUploadPhoto({ presignedURL, file: photos[i] })
            )
          );

          // 완성될 이미지 경로들 얻기
          const photoPaths = presignedURLs.map((presignedURL) =>
            presignedURL
              .slice(0, presignedURL.indexOf("?"))
              .slice(presignedURL.indexOf(process.env.NODE_ENV))
          );

          // 게시글 업로드
          uploadPostMudate({ contents, photoPaths });
        } catch (error) {
          console.error("게시글 업로드 에러 >> ", error);
        } finally {
          setIsUploadPost(false);
        }
      },
      [photos, uploadPostMudate]
    )
  );

  return (
    <>
      <div>
        <span>이미지 등록</span>
        <FormToolkit.MultiPhotoInput
          width={width}
          height={width * 0.8}
          alt="게시글에 등록할 이미지"
          setPhotos={setPhotos}
        />
      </div>
    </>
  )
}

export default PostUploadPage;

📥 BE

이미지들을 DB에 등록하는 로직은 그냥 prisma로 게시글 생성할 때 URL을 넣는 단순한 부분밖에 없어서 제외하겠습니다.

0️⃣ presignedURL들 생성

프론트로부터 받은 이미지의 name들을 이용해서 presignedURL들을 생성하고 그 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
// aws
import { getPresignedURL } from "@src/aws";

// lib
import withAuthMiddleware from "@src/lib/middleware";

// type
import type { NextApiHandler } from "next";
import type {
  ApiFetchPresignedURLsRequest,
  ApiFetchPresignedURLsResponse,
  ApiResponse,
} from "@src/types/api";

interface MyResponseType
  extends Partial<ApiFetchPresignedURLsResponse>,
    ApiResponse {}

/** 2023/04/08 - "AWS-S3"에 이미지들 생성 요청 관련 엔드포인트 - by 1-blue */
const handler: NextApiHandler<MyResponseType> = async (req, res) => {
  try {
    // "presignedURL"들 요청
    if (req.method === "POST") {
      const { names } = req.body as ApiFetchPresignedURLsRequest;

      const results = await Promise.all(
        names.map((name) => getPresignedURL({ name }))
      );

      const presignedURLs = results.map((v) => v.presignedURL);

      return res
        .status(200)
        .json({ message: "presignedURL들을 가져왔습니다.", presignedURLs });
    }
  } catch (error) {
    console.error("/api/photo error >> ", error);

    return res
      .status(500)
      .json({ message: "서버측 문제입니다.\n잠시후에 다시 시도해주세요!" });
  }
};

export default withAuthMiddleware({
  methods: ["POST"],
  handler,
  isAuth: true,
});

📮 레퍼런스

  1. window.URL.createObjectURL()
  2. 1-blue - Carousel

  3. 1-blue - 이미지 업로드 - (4)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.