해당 프로젝트는
Next.js
+TypeScript
를 기반으로 만드는 인스타그램 클론 개인 프로젝트입니다.
AWS-S3
를 이용한 게시글의 여러 이미지를 업로드하는 로직에 대한 포스터입니다.
자세한 부분은 이미지 업로드 - (4)를 참고해주세요!
타입과 코드들을 각각 모두 파일을 분리해서 작성했기 때문에 일부분만 가져온 간소화된 코드입니다.
이미지 업로드의 전체적인 로직은 이전 포스트인 단일 이미지 업로드의 흐름을 그대로 사용하고 병렬적으로 처리하는 것 이외에는 크게 다른 부분은 없습니다.
📤 FE
- 다중 이미지 처리 흐름
<input type="file" accept="image/*" multiple />
로 여러 이미지를 입력받음<input />
의onChange()
로 입력된 이미지들의 프리뷰 생성 ( window.URL.createObjectURL() )- 프리뷰들을 이용해서
Carousel
생성 - 게시글 생성 클릭 시
presignedURL
을 이용해서AWS-S3
에 이미지 업로드 - 생성된 이미지의
URL
서버에 전달 후DB
에 등록 (|
를 구분자로 해서 문자열을 합쳐서 넣음 )
0️⃣ Preview
Carousel
에 대한 내용은 여기를 참고해주세요.
<input type="file" accept="image/*" multiple />
을 통해 여러 이미지를 입력받고 onChange
와 window.URL.createObjectURL()
를 이용해서 이미지 미리보기용 URL
을 생성합니다.
추가적으로 상위에서 내려준 setPhotos()
를 이용해서 이미지들(FileList
)을 state
에 등록하고 이미지 업로드를 다른 컴포넌트에서 처리합니다.
그리고 react-slick
을 이용해서 프리뷰로 이미지 캐루셀을 렌더링합니다.
해당 부분은 <Carousel />
이라는 컴포넌트를 만들어서 내부에서 react-slick
을 사용하고 있습니다.
일단은 브라우저 사이즈에 맞게 반응하기 위해서 width
랑 height
를 받아서 고정적으로 사이즈를 지정하는 형태로 만들었습니다.
( 상위에서는 브라우저 사이즈를 기반으로 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()
를 하위 컴포넌트에 내려주고 이미지를 업로드하고 생성 버튼을 누르면 정해진 로직을 순서대로 실행합니다.
presignedURL
들 가져오기AWS-S3
에 이미지들 업로드- 백엔드에 업로드한 이미지의
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,
});