비동기를 비동기답게..!? (feat. mutateAsync와 Promise.all)

xoxristine·2024년 5월 28일
4
post-thumbnail

🧐 문제 상황

구현해야 하는 기능:
1. 이미지 등록
2. 서버에 이미지 업로드 후 url 받아옴 (POST API 사용)
3. url 정보와 함께 다음 페이지로 라우팅
4. 새로운 페이지에서 url 정보 담아 POST API 호출

이미지 url을 가지고 POST API 호출 할 때 자꾸 서버에 저장된 이미지 url이 아닌 Blob 객체 url이 저장되어서 확인해보니 오류가 있었다.

// 문제의 코드
imageFiles.forEach((image) => {
      const formData = new FormData();
      formData.append('image', image);
      mutate(formData);	//서버에 저장된 이미지 url 요청
	});
}

사용자가 선택한 이미지를 저장하고 싶을 때 서버에 저장된 이미지 url을 받아오기 위해 mutate를 호출하기만 하고 POST 바디에 담지 않고 API 호출을 보냈기 때문에 발생한 문제였다.

따라서 먼저 해결방안 [1]을 적용하여 누락된 부분 로직을 추가했다.

해결방안 [1] - 라우팅 미루기

  useEffect(() => {
    console.log(imageFiles, imgURLs); // 콘솔 출력은 아래 사진 참고
    if (imageFiles.length === 0 || imageFiles.length !== imgURLs.length) return;
    router.push(
      {
        pathname: '/upload/input-text',
        query: {
          link,
          imgURLs,
          memo,
          source,
          folderNameList: data?.map((folder) => folder.folderName),
        },
      },
      '/upload/input-text',
    );
  }, [imageFiles, imgURLs]);

  const handleClickNext = () => {
    // 생략
    if (imageFiles.length > 0) {
      for (const image of imageFiles) {
        const formData = new FormData();
        formData.append('image', image);
        mutate(formData, {
          onSuccess: (data) => {
            setImgURLs([...imgURLs, data]);
          },
        });
      }
    }
  };

처음에는 mutate 호출 -> 서버에 저장된 이미지 url을 받아 routing 할 때 router query에 함께 넘겨주는 방식을 선택했지만...

mutate가 비동기이기 때문에 데이터를 받기도 전에 routing 되어버려서 router query에 의도한 이미지 url이 다 담기지 않았다.

따라서 아래와 같은 방식을 사용했다.

  1. mutate 호출한 뒤 데이터를 받아오기 위해 onSuccess를 사용해서 imgURLs state에 새로운 데이터를 추가하도록 했다.

  2. 사용자가 등록한 이미지 File 타입 배열인 imageFiles와 서버에 저장된 이미지 url을 담고 있는 imgURLs 길이가 같을 때만 라우팅되도록 코드를 작성했다.

하지만 의도한대로 동작하지 않아 useEffect 안에서 console.log(imageFiles, imgURLs); state를 확인해보았다.

mutate가 비동기이고, for문 안에서 여러번 호출된 setState는 batching 되어 여러개의 이미지를 첨부해도 맨 마지막 하나의 url만 등록되는 현상이 일어났다.

해결방안 [2] - mutateAsync

const [imgURLs, setImgURLs] = useState<string[]>([]);

useEffect(() => {
  
    console.log(imageFiles, imgURLs);
  
    if (imageFiles.length === 0 || imageFiles.length !== imgURLs.length) return;
  
    router.push(
      {
        pathname: '/upload/input-text',
        query: {
          link,
          imgURLs,
          memo,
          source,
          folderNameList: data?.map((folder) => folder.folderName),
        },
      },
      '/upload/input-text',
    );
  }, [imageFiles, imgURLs]);
            
const handleClickNext = async () => {
    // 입력 링크 유효성 검사
    if (!link) {
      alert('링크를 입력해주세요.');
      return;
    }

    if (imageFiles.length > 0) {
      for (const image of imageFiles) {
        const formData = new FormData();
        formData.append('image', image);
        const data = await mutateAsync(formData);
        setImgURLs([...imgURLs, data]);
      }
    }
  };

mutation reponse에 대한 접근이 필요하고 await을 사용하여 mutation이 완료 될 때까지 기다리기 위해 TanStack Query의 mutateAsync를 사용했다.

하지만 setState batching으로 인해 아래 사진과 같이 맨 마지막 이미지 url만 state에 반영된 것을 볼 수 있었다.

따라서 state 변경 부분을 setImgURLs((prevImgURLs) => [...prevImgURLs, data]);로 이전 state를 가져와서 반영하는 식으로 변경했지만 아래 사진과 같이 리렌더링이 자주 일어나 좋은 방법이 아니라고 판단했다.

해결방안 [3] - state 제거하기

const handleClickNext = async () => {
    // 입력 링크 유효성 검사
    if (!link) {
      alert('링크를 입력해주세요.');
      return;
    }
  
    const imgURLs = [];
    if (imageFiles.length > 0) {
      for (const image of imageFiles) {
        const formData = new FormData();
        formData.append('image', image);
        const data = await mutateAsync(formData);
        imgURLs.push(data);
      }
    }
  
    router.push(
      {
        pathname: '/upload/input-text',
        query: {
          link,
          imgURLs,
          memo,
          source,
          folderNameList: data?.map((folder) => folder.folderName),
        },
      },
      '/upload/input-text',
    );
  };

그래서 state를 사용하지 않고 간단하게 const 변수를 사용하여 배열에 모든 url을 담고, routing 하는 방식을 택했더니 의도한 대로 동작하게 되었다!

🐠 속도 개선 방향

기능 구현은 완료되었지만 await mutateAsync 사용에 따라 위 사진과 같이 병목현상이 일어났다.

이를 해결하기 위한 방법은 아래와 같다.

속도 개선 방안

const handleClickNext = async () => {
    // 생략
    let imgURLs: string[] = [];
    if (imageFiles.length > 0) {
      imgURLs = await Promise.all(
        imageFiles.map((value) => {
          const formData = new FormData();
          formData.append('image', value);
          return mutateAsync(formData);
        }),
      );
    }
  
    router.push(
      {
        pathname: '/upload/input-text',
        query: {
          link,
          imgURLs,
          memo,
          source,
          folderNameList: data?.map((folder) => folder.folderName),
        },
      },
      '/upload/input-text',
    );
  };

택한 방식은 Promise.all()을 사용하여 mutateAsync 비동기 호출을 병렬적으로 모두 실행하고, 가장 마지막 task의 response를 기다릴 수 있도록 await를 사용하는 것이었다!

코드 수정 결과 위 사진처럼 task가 병렬적으로 실행되는 것을 확인할 수 있었다!!

Test - before

그렇다면 task를 병렬적으로 호출하는 것이 과연 속도에도 영향을 미칠지 궁금해서 Promise.all() 사용 전/후 속도를 비교할 수 있도록 console.time()을 사용했다.

// Promise.all() 사용 전 코드
if (imageFiles.length > 0) {
	console.time();
	for (const image of imageFiles) {
		const formData = new FormData();
		formData.append('image', image);
        const data = await mutateAsync(formData);
        imgURLs.push(data);
	}
	console.timeEnd();
}

Test - after

// Promise.all() 사용 후 코드
if (imageFiles.length > 0) {
	console.time();
	imgURLs = await Promise.all(
		imageFiles.map((value) => {
		const formData = new FormData();
		formData.append('image', value);
		return mutateAsync(formData);
		}),
	);
	console.timeEnd();
}

그 결과 등록한 모든 이미지의 서버 이미지 url을 받아오는데 속도가 31% 빨라지게 되었다!

Test - 3G

PWA 환경의 프로젝트이기 때문에 3G를 사용하는 모바일 유저의 환경도 고려해야 했다. 따라서 네트워크 속도를 빠른 3G로 제한하여 측정해보았다.

  • before Promise.all()

  • after Promise.all()

측정 결과 5.2초가 빨라진 것을 확인할 수 있었다. 얏호

🍀 결론

state update batching: 여러 상태 업데이트를 하나의 렌더링으로 묶어 호출
mutateAsync: Promise 반환 -> async/await 구문을 사용하여 뮤테이션이 완료될 때까지 기다릴 수 있음
Promise.all(): 여러 개의 Promise들을 병렬적으로 실행

  • mutate
    - 콜백을 통해 데이터나 오류에 액세스 가능
    - 오류 처리 걱정 X (내부적으로 오류 캐치 / 무시)

  • mutateAsync
    - Promise 반환
    - 오류를 직접 처리해야함
    - 여러 mutations를 동시에 시작하며, 모든 mutations 이 끝나기를 기다리는 작업에 필요
    - 콜백 지옥에 빠질 수 있는 의존적 mutations이 있는 경우 필요

profile
🔥🦊

0개의 댓글