이미지 미리보기 및 S3 업로드 커스텀 훅으로 구현하기 (URL.createObjectURL, S3)

D uuu·2024년 7월 1일
0

Next.js14 프로젝트

목록 보기
9/11
post-custom-banner

이미지 미리보기

input 태그로 파일을 업로드 하고 양식을 제출하기 전에 사용자가 올린 이미지를 미리볼 수 있도록 작업한 내용을 기록한다.


기본 사용법

우선 기본적인 사용법은 아래와 같다.
여러장의 파일을 선택하고 싶다면 multiple 속성을 넣어준다.
이미지를 선택하여 추가하는 작업은 onClick 이 아닌 onChange 이벤트에 속한다. 따라서 onChange 이벤트 함수를 등록해준다.

const [files, setFiles] = useState<File[]>([]);

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
	if(e.target.files) {
    	const selectedFiles = Array.from(e.target.files);
        setFiles(prev => [...prev, ...selectedFiles])
     }
  }

<input 
	type="file" 
    multiple
    accept="image/*"
    onChange={handleFileChange}
/>

e.target.files 타입

파일을 선택하면 onChange 이벤트가 발생하게 되고, e.target.files 는 FileList 객체를 반환한다.
FileList 객체는 선택된 파일들의 리스트를 나타내며, 각 항목은 File 객체로 이루어져있다.
각각의 File 요소를 살펴보면, name, size, type 등 파일의 정보를 가지고 있다.

이제 이 File 객체를 사용해 이미지 미리보기를 구현하려면 각각의 요소를 <img> 태그의 src 속성으로 설정해야 한다. 이러한 작업을 도와주는 FileReader 와 URL.createObjectURL API 가 존재하는데 어떤 차이점이 있을까?

FileReader 와 URL.createObjectURL

구현 방법에 자세히 대해서는 다루지 않고 특징만 나열해보면 아래와 같다.

FileReader

  • FileReader 객체는 파일을 읽는 작업이 비동기적으로 실행된다.

  • onload, onerror 등의 이벤트 핸들러를 사용하여 읽기 작업의 성공 또는 실패를 처리한다.

  • 파일의 내용을 다양한 형식으로 읽어올 수 있다. (readAsText, readAsArrayBuffer, readAsDataURL)

  • readAsDataURL 메서드를 사용할 경우 base64 로 인코딩 된 data URL 을 읽어와서 img 태그에 직접 사용할 수 있다 (이미지 미리보기 등에 유용)

URL.createObjectURL

  • URL.createObjectURL 메서드는 File 또는 Blob 객체를 브라우저에서 사용할 수 있는 임시 URL로 변환해준다.

  • 해당 URL 은 브라우저 세션 동안만 유효하며 페이지를 새로고침하거나 종료하면 URL 은 더이상 유효하지 않다.

  • 생성된 URL 은 브라우저 내에서만 사용할 수 있기 때문에 웹 서버에 전송이 불가하다.

  • 그러나 브라우저가 닫히기 전까지는 같은 객체를 사용하더라도 createObjectURL()을 매번 호출할 때마다 새로운 객체 URL 을 생성하기 때문에 각각의 URL 을 더이상 사용하지 않을땐 URL.revokeObjectURL() 메서드를 사용해 하나씩 해제해줘야 메모리 누수가 발생하지 않는다.

비교 및 선택

FileReader 는 readAsDataURL 을 사용해서 base64 를 읽어와 img 태그의 src 속성에 넣어주면 미리보기를 구현할 수 있지만, base64 인코딩을 사용하면 원본보다 용량이 커져서 (약 33%) 큰 파일의 경우 성능이 저하될 수 있다.

URL.createObjectURL 로 생성한 임시 URL 을 img 태그에 src 속성에 넣어주면 미리보기를 구현할 수 있지만, 사용하지 않을때 일일이 revokeObjectURL 로 메모리를 해제해줘야 하는 번거로움이 동반된다.

각각의 특징을 고려했을때, 현재 서비스에선 여러장의 이미지를 다뤄야 하고 사용자가 파일을 선택하면 빠르게 반영되도록 구현하기 위해 URL.createObjectURL 을 사용하기로했다!


onChange 함수를 작성해보자

사용자가 파일을 선택하면 실행 될 onChange 함수를 작성해보자.

선택한 파일들을 Array.from 메서드로 File 타입의 배열로 만들어준다.
해당 배열을 map 을 돌면서 URL.createObjectURL() 를 사용해 임시 url 을 생성해준 다음 setImageUrls 사용해 상태를 저장한다.

const [imageUrls, setImageUrls] = useState<string[]>([]);
    
const handleFileInputChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
     if (e.target.files) {
         const selectedFiles = Array.from(e.target.files);
              
         if (maxSize === 1) {
             const urls = selectedFiles.map((file) => URL.createObjectURL(file));
             setImageUrls(urls); // 임시 url 로 저장
         } else {
             if (maxSize && imageUrls.length + selectedFiles.length > maxSize) {
                 isValid.current = false;
                 setAlertMessage(`최대 ${maxSize}개의 이미지만 업로드할 수 있습니다.`);
                 return;
             }
            
			 const newBlobUrls = selectedFiles.map((file) => URL.createObjectURL(file));
             setImageUrls((prev) => [...prev, ...newBlobUrls]);
         }
     }
 },
 [maxSize, imageUrls.length]);

imageUrls 배열을 map 함수로 순회하면서 각 url 을 <img> 태그의 src 속성으로 넣어주면 선택한 이미지가 화면에 렌더링 된다.

return (
	{imageUrls.map((url, idx) => (
    	<div key={url}>
        	<img src={url} width="160px" height="160px" alt={`image${idx}`} />
        </div>
   ))}

S3 에 이미지 업로드하기

이제는 input 태그로 선택한 이미지들을 S3 에 업로드하기 위한 로직을 작성해보자.
앞서 URL.createObjectURL 로 임시 url 을 생성했는데, 서버에 이미지를 전송할때는 URL.createObjectURL()로 생성된 임시 URL이 아닌, 실제 이미지가 저장된 위치의 고유한 URL을 저장해야 한다.

예를들어, AWS S3와 같은 저장소에 이미지를 업로드하고 해당 이미지의 URL을 받아서 데이터베이스(DB)에 저장해야한다.

onChange 함수 수정하기

기존에 파일을 업로드 할때 동작하는 onChange 함수에는 createObjectURL 로 생성한 임시 url 만 담긴 값을 setImageUrls 상태에 저장했었다.

하지만 선택한 파일들을 서버에 전송하기 위해서는 File 객체가 필요하기 때문에 setFiles 라는 상태값을 하나더 생성해준다.

  • files: 서버에 전송할 File 객체를 저장합니다.
  • imageUrls: 미리보기 이미지를 위한 임시 URL을 저장합니다.

onChange 함수를 수정하여 두가지 상태를 관리하고 각 용도에 맞게 활용할 수 있도록 했다.

// files 상태를 추가해줬다! 
const [files, setFiles] = useState<File[]>([]);
const [imageUrls, setImageUrls] = useState<string[]>([]);
    
const handleFileInputChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
      if (e.target.files) {
           const selectedFiles = Array.from(e.target.files);
           
           if (maxSize === 1) {
               const url = selectedFiles.map((file) => URL.createObjectURL(file));
               setFiles(selectedFiles); // File type 으로 저장
               setImageUrls(url); // 임시 url 로 저장
           } else {
               if (maxSize && imageUrls.length + selectedFiles.length > maxSize) {
                   isValid.current = false;
                   setAlertMessage(`최대 ${maxSize}개의 이미지만 업로드할 수 있습니다.`);
                   return;
               }

               setFiles((prev) => [...prev, ...selectedFiles]); // File type 으로 저장

               const newBlobUrls = selectedFiles.map((file) => URL.createObjectURL(file)); 
               setImageUrls((prev) => [...prev, ...newBlobUrls]); // 임시 url 로 저장
           }
       }
   },
   [maxSize, imageUrls.length]);

AWS SDK 사용해 이미지 업로드하기

AWS S3 에 접근해 이미지를 업로드 하기 위해서는 aws-sdk 를 먼저 설치해줘야 한다.
File 로 이루어진 배열 값인 files 을 map 으로 순회하면서 각 파일을 리사이즈하고, S3 의 Upload 객체를 사용해 업로드 요청을 보낸다.
이때 Promise.allSettled 을 사용해 모든 업로드 작업이 완료될때까지 기다렸다가 성공 및 실패 결과를 처리하도록 했다.

const bucketName = process.env.NEXT_PUBLIC_AWS_BUCKET_NAME,
        
const config = {
    region: 'ap-northeast-2',
    credentials: {
        accessKeyId: process.env.NEXT_PUBLIC_AWS_ACCESS_KEY!,
        secretAccessKey: process.env.NEXT_PUBLIC_AWS_SCREAT_KEY!,
    },
};
const uploadFiles = useCallback(async () => {
     if (files.length === 0) return;

     try {
         const uploadPromises = files.map(async (file) => {
             const resizedFile = await resizeFile(file); // 파일 리사이즈 최적화 작업

             const data = new Upload({
                 client: new S3(config),
                 params: {
                     Key: `upload/${file.name.replace(/\.[^/.]+$/, '')}.webp`,
                     Body: resizedFile,
                     Bucket: bucketName,
                     ContentType: 'image/webp               
                 },
             });

             return data
                 .done()
                 .then((response) => {
                     return { status: 'fulfilled', url: response.Location || '' };
                 })
                 .catch((error) => {
                     return { status: 'rejected', reason: error.message };
                 });
         });

         const results = await Promise.allSettled(uploadPromises);

         const urls = results
             .filter((result) => result.status === 'fulfilled')
             .map((result) => (result as PromiseFulfilledResult<{ url: string }>).value.url);

         // 실패한 작업에 대한 로그 또는 처리
         results
             .filter((result) => result.status === 'rejected')
             .forEach((result) => console.error('업로드 실패:', (result as PromiseRejectedResult).reason));

         return urls;
     } catch (error: any) {
         console.error('이미지 업로드 중 오류 발생:', error);
         throw new Error(error.message);
     }
 }, [files]);

hook 으로 관리하기

input 을 이용해 파일을 업로드하고, 해당 이미지를 S3 에 저장하거나 삭제하는 로직을 hook 으로 관리했다.
이런식으로 관련 로직을 hook 에서 처리하게 함으로써 컴포넌트 내의 로직을 간결하게 유지하고 재사용성을 높이고자 했다!

export const useS3FileUploader = (options?: Options) => {
    const { maxSize } = options || {};
    const isValid = useRef<boolean>(true);

    const [files, setFiles] = useState<File[]>([]);
    const [imageUrls, setImageUrls] = useState<string[]>([]);
    const [alertMessage, setAlertMessage] = useState<string>('');

    const handleFileInputChange = useCallback(
        async (e: React.ChangeEvent<HTMLInputElement>) => {
            if (e.target.files) {
                const selectedFiles = Array.from(e.target.files);

                if (maxSize === 1) {
                    const url = selectedFiles.map((file) => URL.createObjectURL(file));
                    setFiles(selectedFiles); // File type 으로 저장
                    setImageUrls(url); // 임시 url 로 저장
                } else {
                    if (maxSize && imageUrls.length + selectedFiles.length > maxSize) {
                        isValid.current = false;
                        setAlertMessage(`최대 ${maxSize}개의 이미지만 업로드할 수 있습니다.`);
                        return;
                    }

                    setFiles((prev) => [...prev, ...selectedFiles]);

                    const newBlobUrls = selectedFiles.map((file) => URL.createObjectURL(file));
                    setImageUrls((prev) => [...prev, ...newBlobUrls]);
                }
            }
        },
        [maxSize, imageUrls.length]
    );

    const uploadFiles = useCallback(async () => {
        if (files.length === 0) return;

        try {
            const uploadPromises = files.map(async (file) => {
                const resizedFile = await resizeFile(file); // 파일 리사이즈 최적화 작업

                const data = new Upload({
                    client: new S3(config),
                    params: {
                        Key: `upload/${file.name.replace(/\.[^/.]+$/, '')}.webp`,
                        Body: resizedFile,
                        Bucket: bucketName,
                        ContentType: 'image/webp',
                        // CacheControl: 'no-store',
                    },
                });

                return data
                    .done()
                    .then((response) => {
                        // response.Location이 undefined인 경우 빈 문자열로 대체
                        return { status: 'fulfilled', url: response.Location || '' };
                    })
                    .catch((error) => {
                        return { status: 'rejected', reason: error.message };
                    });
            });

            const results = await Promise.allSettled(uploadPromises);

            const urls = results
                .filter((result) => result.status === 'fulfilled')
                .map((result) => (result as PromiseFulfilledResult<{ url: string }>).value.url);

            // 실패한 작업에 대한 로그 또는 처리
            results
                .filter((result) => result.status === 'rejected')
                .forEach((result) => console.error('업로드 실패:', (result as PromiseRejectedResult).reason));

            return urls;
        } catch (error: any) {
            console.error('이미지 업로드 중 오류 발생:', error);
            throw new Error(error.message);
        }
    }, [files]);

    const deleteFiles = useCallback(async (deleteImages: string[]) => {
        const client = new S3(config);

        try {
            const deletePromises = deleteImages.map(async (imgUrl) => {
                // URL에서 파일 이름 추출 (URL이 아닌 파일 이름이 필요)
                const fileName = imgUrl.split('/').pop()!;

                const command = new DeleteObjectCommand({
                    Bucket: bucketName,
                    Key: `upload/${fileName}`,
                });

                // 파일 삭제
                await client.send(command);

                // 상태 업데이트
                setFiles((prev) => prev.filter((file) => URL.createObjectURL(file) !== imgUrl));
                setImageUrls((prev) => prev.filter((url) => url !== imgUrl));

                // Object URL 해제
                URL.revokeObjectURL(imgUrl);
            });

            // 모든 삭제 작업이 완료될 때까지 기다림
            await Promise.all(deletePromises);
        } catch (err: any) {
            console.error(err);
            throw new Error(err.message);
        }
    }, []);

    useEffect(() => {
        return () => {
            imageUrls.forEach((url) => URL.revokeObjectURL(url));
        };
    }, [imageUrls]);

    return {
        imageUrls,
        setImageUrls,
        handleFileInputChange,
        uploadFiles,
        isValid,
        alertMessage,
        deleteFiles,
        setFiles,
    };
};

최종 및 느낀점

최종적으로 업로드 한 사진이 있는 경우에는 이렇게 preview 자리에 이미지가 띄어지고, 사진을 추가하거나 삭제할 수 있다.

profile
배우고 느낀 걸 기록하는 공간
post-custom-banner

0개의 댓글