PROJECT/code-forest

사용자 불편 최소화를 이미지 업로드 및 렌더링 방식 개선

승큐니 2024. 3. 11. 22:11

📌 문제인식

회원가입 과정에서 사용자가 등록할 프로필 이미지를 선택하면 이미지를 등록하는 과정에서 화면 깜빡임이 발생한다.

사용자가 이미지를 선택하자마자 이미지를 Firebase Storage에 저장 후 URL을 받아오면 로딩시간이 생기기 때문에, 이미지의 URL을 state로 관리하고 클라이언트에서 임시 URL을 사용하고 storage에 저장이 완료되면 storage로 부터 받은 URL을 사용하여 Optimistic Update처럼 구현하려던 계획이었다.

하지만 storage에 업로드 후 받아온 URL로 대체하고 이미지를 읽어오는 과정에서도 딜레이가 발생해서 화면 깜빡임이 발생하는 것이었다.

위에서 말로 설명한 아바타 이미지가 등록되는 로직을 코드와 함께 조금 자세히 살펴보면 아래와 같이 진행된다.

  1. 사용자가 아바타로 사용할 이미지 선택
  2. 클라이언트에서 임시 url을 생성하여 아바타 이미지의 소스로 사용
  • URL.createObjectURL : Blob 또는 File 객체를 메모리에서 URL로 변환해줌
  const onHandleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files ? e.target.files[0] : null;
    if (file) {
      const tempURL = URL.createObjectURL(file);
      setImagePreviewURL(tempURL);
      setSelectedFile(file);
    }
  };
  1. 이미지 파일을 관리하는 state인 selectedFile의 변화를 감지하여 useEffect에서 이미지를 storage에 업로드
  useEffect(() => {
    const handleUpload = async () => {
        ...이미지 최적화를 위한 resize하는 로직 생략

        const imageRefComment = ref(storage, `userImage/${userUid}/profileImage-size-comment`);
        const imageRefCard = ref(storage, `userImage/${userUid}/profileImage-size-card`);
        const imageRefProfile = ref(storage, `userImage/${userUid}/profileImage-size-profile`);
        await uploadBytes(imageRefComment, resizedImageFileComment);
        await uploadBytes(imageRefCard, resizedImageFileCard);
        await uploadBytes(imageRefProfile, resizedImageFileProfile);
        const downloadURLComment = await getDownloadURL(imageRefComment);
        const downloadURLCard = await getDownloadURL(imageRefCard);
        const downloadURLProfile = await getDownloadURL(imageRefProfile);
        setImageURL(downloadURLProfile); // 아바타 이미지의 소스가 될 url set
        setSaveProfileImages({ card: downloadURLCard, comment: downloadURLComment, profile: downloadURLProfile });
          // 유저 DB에 사이즈별로 저장할 객체 state
      }
    };
    handleUpload();
  }, [selectedFile]);

✅ 개선 1. 이미지 업로드의 순서 변경

처음에는 이미지가 선택 후 파일을 storage에 업로드 해야만 한다고 생각해서 위에 작성된 코드가 아니더라도 정말 많은 시도를 해봤다. 하지만 생각해보면 만약 선택한 이미지를 바꾼다면 불필요한 이미지가 여러건 저장이 되는 문제도 발생할 것이다.

그래서 결국 사용자에게는 클라이언트에서 생성한 URL로 이미지를 보여주고, 최종적으로 회원가입을 완료할 때 storage에 업로드하는 방식으로 개선했다.

로직은 아래와 같다.

  1. 사용자가 아바타로 사용할 이미지 선택
  2. 클라이언트에서 임시 url을 생성하여 아바타 이미지의 소스로 사용
  3. 회원가입을 완료 할 때 이미지를 storage에 저장
const handleUpload = async () => {
  if (selectedFile) {
    ...이미지 최적화를 위한 resize하는 로직 생략

    const imageRefComment = ref(storage, `userImage/${userUid}/profileImage-size-comment`);
    const imageRefCard = ref(storage, `userImage/${userUid}/profileImage-size-card`);
    const imageRefProfile = ref(storage, `userImage/${userUid}/profileImage-size-profile`);
    await uploadBytes(imageRefComment, resizedImageFileComment);
    await uploadBytes(imageRefCard, resizedImageFileCard);
    await uploadBytes(imageRefProfile, resizedImageFileProfile);
    const downloadURLComment = await getDownloadURL(imageRefComment);
    const downloadURLCard = await getDownloadURL(imageRefCard);
    const downloadURLProfile = await getDownloadURL(imageRefProfile);

    return { cardImage: downloadURLCard, commentImage: downloadURLComment, profileImage: downloadURLProfile }; // 유저 DB에 저장할 url들 객체
  }
};

    // 최종 회원가입 완료버튼 클릭시 진행되는 로직
    // 이미지를 storage에 저장하고
    // 해당 페이지에서 입력받은 닉네임과 소개말과 함께 DB에 저장
  const signUpHandler = async () => {
  try {
    const responseUrls = await handleUpload(); // url들을 객체 형태로 반환
    const userProfileImageURLs = {
      card: responseUrls?.cardImage,
      comment: responseUrls?.commentImage,
      profile: responseUrls?.profileImage,
    };
    const newData = {
      nickName: nickName,
      introduction: introduction,
      profileImage: userProfileImageURLs,
      updatedAt: serverTimestamp(),
    };
    if (userUid) {
      const docRef = doc(db, 'users', userUid);
      await updateDoc(docRef, newData).then(() => {
        console.log(`유저 정보가 업데이트 되었습니다.`);
        navigate('/');
      });
    }
  } catch (error) {
    console.log(error);
  }
};

이제 더이상 화면 깜빡임은 없어....

✅ 개선 2. 비동기 함수의 병렬적 수행

코드를 개선하면서 이미지를 저장하고 URL을 받아오는 로직이 완료되는 것을 보장해야하는 것은 당연하지만, 순차적으로 실행되어야 하는가? 하는 고민이 생겼다.

서로의 기능에 관여하지 않고 독립적으로 수행되어도 된다면 병렬적으로 수행하는게 맞다고 판단했고, Promise.all을 사용하여 비동기 작업이 병렬적으로 수행되도록 변경했다.

  const handleUpload = async () => {
    try {
      if (selectedFile) {
        const optionComment = {
          maxWidthOrHeight: 100,
        };
        const optionCard = {
          maxWidthOrHeight: 150,
        };
        const optionProfile = {
          maxWidthOrHeight: 300,
        };
        // 이미지 리사이즈를 병렬적으로 수행
        const [resizedImageFileComment, resizedImageFileCard, resizedImageFileProfile] = await Promise.all([
          imageCompression(selectedFile, optionComment),
          imageCompression(selectedFile, optionCard),
          imageCompression(selectedFile, optionProfile),
        ]);

        const imageRefComment = ref(storage, `userImage/${userUid}/profileImage-size-comment`);
        const imageRefCard = ref(storage, `userImage/${userUid}/profileImage-size-card`);
        const imageRefProfile = ref(storage, `userImage/${userUid}/profileImage-size-profile`);
        // 이미지 업로드 및 url 다운로드도 병렬적으로 수행
        await Promise.all([
          uploadBytes(imageRefComment, resizedImageFileComment),
          uploadBytes(imageRefCard, resizedImageFileCard),
          uploadBytes(imageRefProfile, resizedImageFileProfile),
        ]);
        const [downloadURLComment, downloadURLCard, downloadURLProfile] = await Promise.all([
          getDownloadURL(imageRefComment),
          getDownloadURL(imageRefCard),
          getDownloadURL(imageRefProfile),
        ]);

        return { cardImage: downloadURLCard, commentImage: downloadURLComment, profileImage: downloadURLProfile };
      }
    } catch (error) {
      console.log(error);
    }
  };

Promise.all 을 사용하면 에러 처리나 병목 현상이 발생할 수도 있지만, 무거운 작업이 아니기도 하고 작업 순서를 보장해야하는 케이스가 아니기 때문에 Promise.all을

Promise.all 을 사용하면 하나의 작업이 실패하면 그 시점에서 다른 모든 작업이 중단될 수 있고, 모든 작업이 동시에 수행되기 때문에 네트워크나 리소스에 부하가 발생할 수도 있다.

하지만 이번 경우에는

  • 각 Promise.all 이 수행하는 작업이 동일한 작업이기 때문에 오류가 발생할 경우 각 Promise.all에 같은 이유로 모든 작업이 수행되지 않을 가능성이 높고,
  • 이미지 압축, 저장, URL 다운 등 리소스가 크지 않은 작업이기도 하고,
  • 작업 순서를 보장할 필요가 없기도 하다.

비슷한 형태의 작업이라고 해서 반드시 Promise.all을 사용할 수는 없겠지만, 위의 로직에서는 문제될 것이 없다고 판단해서 사용하게 되었다.