구현해야 하는 기능:
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]
을 적용하여 누락된 부분 로직을 추가했다.
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이 다 담기지 않았다.
따라서 아래와 같은 방식을 사용했다.
mutate 호출한 뒤 데이터를 받아오기 위해 onSuccess를 사용해서 imgURLs state에 새로운 데이터를 추가하도록 했다.
사용자가 등록한 이미지 File 타입 배열인 imageFiles
와 서버에 저장된 이미지 url을 담고 있는 imgURLs
길이가 같을 때만 라우팅되도록 코드를 작성했다.
하지만 의도한대로 동작하지 않아 useEffect 안에서 console.log(imageFiles, imgURLs);
state를 확인해보았다.
mutate가 비동기이고, for문 안에서 여러번 호출된 setState는 batching 되어 여러개의 이미지를 첨부해도 맨 마지막 하나의 url만 등록되는 현상이 일어났다.
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를 가져와서 반영하는 식으로 변경했지만 아래 사진과 같이 리렌더링이 자주 일어나 좋은 방법이 아니라고 판단했다.
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가 병렬적으로 실행되는 것을 확인할 수 있었다!!
그렇다면 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();
}
// 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%
빨라지게 되었다!
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이 있는 경우 필요
멋져요.