티스토리 뷰

 프로젝트에서 이미지 파일을 다뤄야 하는 경우가 많았다. 기본적으로 유저 프로필뿐만 아니라, 포스트나 리뷰 등에서도 사진을 업로드하고 수정하는 기능을 제공하기 때문이다.

 백에서 이미지 업로드 용 api 를 따로 제공해주셨다. 이미지 파일을 동반하는 모든 요청은 우선적으로 이미지업로드api로 formData 형식의 이미지 파일을 보내고, 이 응답에 담겨오는 이미지 string 값을 본 요청에 포함하여 보낸다. 

 너무 큰 용량의 이미지를 그대로 받아들일 경우 서버 비용이 많이 든다... 그래서 초반에는 특정 용량을 초과하는 이미지 파일의 경우 입력을 할 수 없게 막아두었다. 하지만 보통의 유저들은 용량까지 생각하면서 이미지 파일을 관리하고 업로드 하지 않기 때문에... 나도 다른 서비스를 이용하다 이런 경우를 마주했을 때 굉장히 짜증스러웠던 기억이 있다.  그래서 이미지 압축 로직을 추가하였다. 이때 browser-image-compression 패키지를 사용했다. 

 

 각 로직은 모두 비동기 작업이기 때문에 사용자가 버튼을 한 번 클릭하면 세 번의 비동기 작업이 연쇄적으로 일어난다. (이미지압축-이미지업로드api-본요청api)

 

 

📌  이미지 데이터 

프론트에서 다루어야 할 이미지 데이터는 크게 세 가지였다. 

 

1. 이미지 파일 : 사용자가 업로드한 이미지 파일 그 자체로 추후에 formData 형식으로 백서버로 보내질 파일

2. 프리뷰 이미지 : 사용자가 이미지를 업로드 한 즉시 화면에 보여질 이미지 데이터로 클라이언트에만 존재한다. 그래서 저장하지 않고 페이지 이탈 시 사라진다. 

3. 저장된 이미지 : 서버에 저장된 이미지로 데이터 조회 시 서버로부터 받은 데이터이다. 그래서 수정 요청을 보낼 때, 새로 업로드된 이미지 파일이 없다면(이미지를 제외한 다른 필드만 수정되었다면) 해당 데이터를 함께 보낸다. 

 

📌  로직

데이터 추가가 가능하면 수정 또한 가능하다. 그리고 이미지만 추가/수정하는 경우보다는 다른 필드와 함께 요청 보내지는 경우가 많고, 이미지는 수정하지 않고 필드만 수정하는 경우도 생각해야 한다. 더불어 이미지 압축 로직까지 추가되어 꽤 복잡해졌다. 이때 주된 로직의 흐름을 정리해보면 다음과 같다. 

 

1. 사용자가 이미지 파일과 모든 필드를 입력한 후 저장 버튼을 누른다.

1-1. 필드 중 undefined가 있다면 모두 입력해달라는 alert를 보여주며 return. 

1-2. 단, 입력된 이미지가 없지만 프리뷰 이미지가 있을 시에는 이미지는 수정 않고 필드만 수정한 것으로 볼 수 있으므로 continue.

2. 이미지를 압축한다. 단, 입력된 이미지가 없다면(프리뷰 이미지만 있다면) 이 과정을 생략하고 바로 4번으로. 

3. 압축된 이미지를 formData 형식으로 이미지업로드API 요청을 보낸다. 

4. 이미지업로드API 요청 결과 데이터(혹은 기 저장된 이미지)를 본요청과 함께 보낸다. 

 

여기서 2,3 번 과정이 순수하게 이미지를 저장하는 로직이기 때문에 여기만 쏙 빼내어 훅으로 만들어 볼 것이다. 

 

 

🫥  이전 코드

 처음에 작성할 땐 이미지 수정 없이 필드만 수정되는 경우나 이미지 압축을 고려하지 않았다. 그 후에 필요해져서 추가하다보니.... 덕지덕지 스파게티 코드가 됐다..... 그리고 이런 코드가 반복적으로 사용되었는데 필요할 때마다  새로 작성했다... 에러 하나 발생하면 다른 곳에서도 똑같이 수정해주고...의 반복

 이전 코드에서 필요한 부분만 가져왔다. 내가 작성한 코드이지만 잠시만 뒤돌았다 다시 보면 나조차도 한참 들여다봐야 하는 코드이기 때문에.... 볼 가치는 솔직히 없ㄷ..ㅏ...🫠  (그래서 더보기에 숨겨놓음..

더보기
const EditProfile = () => {
  const baseUrl = process.env.REACT_APP_BASE_URL;
  const [userImage, setUserImage] = useState<File>(); //File 자체
  const [previewImage, setPreviewImage] = useState<string>(''); //프리뷰 이미지용 blob
  const [savedImagefile, setSavedImagefile] = useState<string>(''); // 저장된 이미지 
  const [username, setUsername] = useState('');
  
  const { data: userData } = useGetUserInfoQuery();
  const [editUserInfo] = useEditUserInfoMutation();
  const [addNewImage] = useAddImageMutation();

  useEffect(() => {
    if (userData) {
      setUsername(userData.username);
      setSavedImagefile(userData.userProfile.slice(8));
      setPreviewImage(baseUrl + userData.userProfile);
    }
  }, [userData]);

  const onChangeUserImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      const imgFile = e.target.files[0];
      setUserImage(imgFile);
      setPreviewImage(URL.createObjectURL(imgFile));
    }
  };

  const onClickSubmitBtn = async () => {
    //추가된 이미지가 있을 경우
    let compressedFile;
    if (userImage) {
      try {
        compressedFile = await imageCompression(userImage, {
          maxSizeMB: 0.2,
          maxIteration: 30,
        });
      } catch (error) {
        console.error(error);
        setIsRequesting(false);
        return;
      }

      const formData = new FormData();
      compressedFile && formData.append('file', compressedFile);
      try {
        const response = await addNewImage({
          imageFile: formData,
        });
        if ('error' in response) {
          alert('잠시 후 다시 시도해주세요.');
          return;
        }
        const filename = response.data.filename;
        sendEditUser(filename);
      } catch (error) {
        console.error(error);
      }
      return;
    }
    //기존 이미지 그대로 저장
    if (savedImagefile) {
      sendEditUser(savedImagefile);
      setIsRequesting(false);
    }
  };

  const sendEditUser = async (filename: string) => {
    if (username === undefined) {
      alert('닉네임은 필수값입니다.');
      return;
    }
    
    const userInfo = {
      username,
      image: filename,
    };
    
    try {
  	editUserInfo(userInfo);
    } catch (error) {
      console.error(error);
    }
  };

 // omit 
 
  return (
	<>
	//omit
	</>
};

export default EditProfile;

 

 

🐝  커스텀 훅

import imageCompression from 'browser-image-compression';

import { useAddImageMutation } from '../features/images/imageApiSlice';

const useImageProcessing = (): {
  uploadImageToServer: (image: File) => Promise<string | undefined>;
} => {
  const [addNewImage] = useAddImageMutation();

  const compressAndUploadImage = async (
    image: File
  ): Promise<string | undefined> => {
    let filename: string | undefined;

    try {
      const compressedImage = await imageCompression(image, {
        maxSizeMB: 0.2,
        maxIteration: 30,
      });

      const formData = new FormData();
      formData.append('file', compressedImage);

      const response = await addNewImage({
        imageFile: formData,
      });

      if ('error' in response) {
        alert('잠시 후 다시 시도해주세요.');
        return;
      }
      filename = response.data.filename;
    } catch (error) {
      console.error(error);
    }

    return filename;
  };

  return { uploadImageToServer: compressAndUploadImage };
};

export default useImageProcessing;

 

 이미지 압축과 이미지 업로드 로직을 useImageProcessing 커스텀훅으로 만들었다. 처음에 구상할 땐 compress와 upload를 각각 함수로 만들려고 했다. 하지만 서버에 업로드할 목적이 아니라면 이미지 압축을 진행할 일이 없다. 더군다나 이미지 압축을 진행하는 이유가 서버의 비용을 아끼기 위해서이기 때문에, 매 요청마다 수행된다고 보면 된다. 오히려 따로 함수를 만들어 사용하면 개발할 때 번거로울 거라 생각했다. 따라서 압축과 업로드는 항상 같이 일어나는 작업이라 보고 하나의 함수 uploadImageToServer로 return하였다.  

 

 

🌼  적용

const EditProfile = () => {
// omit
  const { uploadImageToServer } = useImageProcessing();

  const onClickSaveBtnHandler = async () => {
    try {
      setIsRequesting(true);
      if (!username) {
        alert('닉네임을 입력해주세요.');
        throw new Error('Invalid username');
      }

      if (!userImage && !savedImagefile) {
        alert('프로필 사진을 업로드 해주세요.');
        throw new Error('Invalid profile picture');
      }

      const filename = await processImage();

      filename && onSaveUserInfoHandler(filename);
    } catch (error) {
      console.error(error);
    }
  };

  const processImage = async () => {
    if (userImage) {
      const uploadedImage = await uploadImageToServer(userImage);
      return uploadedImage;
    } else if (savedImageFile) {
      return savedImageFile;
    }
  };

  const onSaveUserInfoHandler = async (filename: string) => {
    const userInfo = {
      username,
      image: filename,
    };

    try {
      const res = await editUserInfo(userInfo);

      if ('data' in res) {
        alert('정상적으로 수정되었습니다');
      } else if ('error' in res) {
        handleErrorResponse(res.error);
      }
    } catch (error) {
      console.error(error);
    }
  };


  return (
  	<>
	  //omit
	</>
   );
 };

export default EditProfile;

 

- onClickSaveBtnHandler : 사용자가 저장 버튼을 눌렀을 때 실행되는 함수. 필드의 유효성을 확인하는 작업을 가장 먼저 한다. 그후 processImage 함수를 호출하고, 이 리턴값을 onSaveUserInfoHandler에게 넘겨준다. 

- processImage : 본 요청에 보낼 이미지 데이터를 리턴하는 함수이다. 새롭게 업로드된 이미지가 있다면 이를 uploadImageToServer 함수로 전달하여 압축 및 업로드를 진행한다. 새롭게 업로드된 이미지 없이 기 저장된 이미지만 있다면 해당 이미지를 그대로 리턴한다. 

- onSaveUserInfoHandler :  본 요청을 수행하는 함수이다. 이 경우에는 processImage로부터 리턴받은 이미지 데이터를 유저네임과 함께 서버로 보내어 유저의 정보를 수정하는 요청을 보낸다. 

 

 

 


+ 추가 ) 🌞

생각해보니 좀 더 캡슐화 할 수 있을 거 같아 추가로 리팩토링을 진행하였다.

import imageCompression from 'browser-image-compression';

import { useAddImageMutation } from '../features/images/imageApiSlice';

interface processImageProps {
  newImage: File | undefined;
  savedImage: string | undefined;
}

const useImageProcessing = (): {
  ImageProcessing: (props: processImageProps) => Promise<string | undefined>;
} => {
  const [addNewImage] = useAddImageMutation();

  const compressAndUploadImage = async (
    image: File
  ): Promise<string | undefined> => {
    let filename: string | undefined;

    try {
      const compressedImage = await imageCompression(image, {
        maxSizeMB: 0.2,
        maxIteration: 30,
      });

      const formData = new FormData();
      formData.append('file', compressedImage);

      const response = await addNewImage({
        imageFile: formData,
      });

      if ('error' in response) {
        alert('잠시 후 다시 시도해주세요.');
        return;
      }
      filename = response.data.filename;
    } catch (error) {
      console.error(error);
    }

    return filename;
  };

  const ImageProcessing = async ({
    newImage,
    savedImage,
  }: processImageProps) => {
    if (newImage) {
      const uploadImage = await compressAndUploadImage(newImage);
      return uploadImage;
    } else if (savedImage) {
      return savedImage;
    }
  };

  return { ImageProcessing };
};

export default useImageProcessing;

 

 우선 processImage 함수도 여러 컴포넌트에서 사용되는데 그때마다 반복해서 새롭게 정의해야하는 게 불만이었다. 더 나아가 생각해보면, 압축 및 업로드가 되는 과정이 밖으로 드러날 필요가 없다. 본 요청에 어떤 파일을 함께 보낼 건지가 중요한 것이지 그 내부에서 어떤 동작을 하는지는 중요하지 않다.  그래서 useImageProcessing 훅 내부에 ImageProcessing 함수를 정의하고, 이 함수 내부에서 compressAndUpload 를 호출하여 일련의 과정을 수행하거나 기존 이미지를 return 하도록 했다. 

 

      const filename = await ImageProcessing({
        newImage: userImage,
        savedImage: savedImageFile,
      });

 

이제는 ImageProcessing만을 호출하여 인자 두 개만 전달해주면, 본 요청에 보내야할 file을 return해준다. 훨씬 깔끔해졌다👍

 


 

이렇게 만든 커스텀 훅은 세 군데 이상 사용되었다. 중복 로직을 줄일 수 있는 것뿐만 아니라 컴포넌트 내부의 코드도 한결 깔끔해져 한눈에 알아보기 좋아졌다. 유지보수에 좋은 건 두말할 필요도 없다. 😸

댓글