Promise.all()을 사용하여 갯수가 유동적인 비동기 작업 처리하기

Jessie·2024년 8월 24일

회사 프로젝트의 게시판 부분을 작업하면서 이미지를 여러장 받아 업로드를 해야하는 작업과 마주쳤다. 이미지는 다음과 같은 과정을 거쳐 AWS S3 서버에 저장된다.

  1. 백엔드 서버에 S3 접근 권한인 Presigned Url을 요청한다.
  2. 백엔드 서버가 Presigned Url을 받아와 다른 필요한 정보(리소스 id 등)와 함께 리턴해준다.
  3. Presigned Url을 통해 S3에 이미지를 업로드한다.
  4. 이미지 업로드가 성공적으로 완료되면 백엔드 서버에 업로드된 이미지의 리소스 id를 보내 성공적으로 이미지가 업로드되었음을 알린다.

*이미지 업로드가 메인인 글이 아니기 때문에 간단하게 적었다. 사진과 설명의 숫자는 다르지만 순서는 같다.

이러한 과정에서 프론트가 서버와 클라우드에 보내는 3번의 요청이 모두 비동기 작업이어서 처음에는 복잡한 작업이 될 것이라고 생각했었다. 하지만 단순하게 이전 작업이 성공하면 다음 작업을 진행하면 되는거라 then()catch()를 이용해 쉽게 해결했다.

오히려 어려웠던 부분은 이미지를 여러장 보내려고 할 때 이 다음으로 진행되는 작업이었다. 이미지를 한장만 업로드하는 경우에는 업로드 작업을 한번만 진행하고 끝내면 되는데, 여러장을 업로드하려면 이미지마다 S3 url을 받아와 업로드 해야하기 때문에 업로드를 하고자하는 이미지의 수만큼 작업을 반복해주어야 했다. 이 부분에서는 각 이미지의 업로드 성공 여부가 다음 업로드에 영향을 미치지 않기 때문에 then()catch()로는 해결할 수 없었다. 처음에는 각각의 작업이 Promise니까 그냥 반복문으로 돌려주면 되겠거니 하며 코드를 작성했다.

async function uploadmage(images: File[]) {
    // 업로드가 성공한 이미지의 정보를 담을 배열
    const imageArr: UploadedImageType[] = []
		
    // 여러장의 이미지가 담긴 배열을 돌며 한장씩 업로드 시도
    for (const image of images) {
        fetchImageUpload({
            type: UploadImageType.BOARD,
            file: image,
        }).then((res) => imageArr.push(res))  
        // 업로드가 성공하면 응답으로 이미지가 저장된 url과 resource id가 돌아온다. 프론트에서 사용하기 위해 배열에 담아준다.
    }
		
    return imageArr
}

업로드 할 이미지의 배열을 받아 반복문을 돌며 업로드를 하고, 업로드가 성공한 이미지의 url과 resource id를 클라이언트에서 사용할 수 있게 배열에 담아주는 함수이다. 이 작업의 결과는 빈 배열이었다. 함수가 비동기 작업의 결과가 돌아올때까지 기다리지 않고 imageArr를 반환했기 때문이다. 지금 생각해보면 비동기 작업에 await이나 다른 어떤 처리도 안했으니 당연한 결과인데, 그때는 이미지 업로드 작업을 하면서 Promise를 여러번 쓰다보니 될거라고 헷갈렸던 것 같다.

빈 배열을 보면서 비동기 작업이 처리되지 않았다는 것은 대충 예측했는데, 이상하게도 한참을 삽질했다. Promise.all()을 한번도 써보지 않아서 이걸 쓸 생각을 못했다. 몇시간을 날리고 나서야 Promise.all()을 떠올렸고, 적용을 해봤다.

이미지 업로드 작업들이 이미 정의가 되어있는 비동기 함수들이면 간단하게 Promise.all()의 인자로 함수들을 담은 배열을 넣어주기만 하면 되겠지만, 이거는 작업마다 몇번씩 반복이 될지 정해지지 않았기 때문에 그렇게 할 수는 없었다. 그래서 일단 반복문을 사용해서 각 이미지를 업로드 하는 작업을 Promise 객체로 만들어주었다.

// 이미지를 받아 해당 이미지를 업로드하는 과정을 Promise 객체로 만들어주는 함수
const promise = (image: File, uploadArr: UploadedImageType[]) =>
    new Promise<void>((resolve, reject) => {
        fetchImageUpload({
            type: UploadImageType.BOARD,
            file: image,
        })
        .then(() => {
            uploadArr.push(res)
            resolve()
        })
        .catch(() => reject())
    })

// 업로드하고자 하는 이미지의 수만큼 배열을 돌며 각 이미지를 업로드하는 Promise 객체를 생성
const promiseArr = []
const imageArr: UploadedImageType[] = []
for (const image of images) {
    const p = promise(image, imageArr)
    promiseArr.push(p)
}

참고로 여기서 Promise 객체를 생성 할 때 then()에 들어가는 콜백 함수의 마지막에는 resolve() 함수를, catch()에 들어하는 콜백 함수의 마지막에는 reject() 함수를 넣어주어야 한다. 넣어주지 않으면 Promise가 성공/실패 여부를 판단하지 못해 콜백함수 내부에 작성한 작업이 진행되지 않는다. 나같은 경우에는 api 요청이 성공했음에도 imageArr가 빈 배열로 돌아와서 또 삽질을 했다.

이제 Promise 객체들이 담긴 배열을 Promise.all()로 실행해준다.

async function uploadRequestImage(images: File[]) {
    const promise = (image: File, uploadArr: UploadedImageType[]) =>
        new Promise<void>((resolve, reject) => {
            fetchImageUpload({
                type: UploadImageType.BOARD,
                file: image,
            })
                .then((res) => {
                    uploadArr.push(res)
                    resolve()
                })
                .catch(() => reject())
        })
        
        const promiseArr = []
        const imageArr: UploadedImageType[] = []
        
        for (const image of images) {
            const p = promise(image, imageArr)
            promiseArr.push(p)
        }
        
        await Promise.all(promiseArr)
        return imageArr
}

Promise.all()에도 잊지말고 await을 붙여 작업이 완료될때까지 함수가 기다릴 수 있도록 해준다. 그러면 정상적으로 값이 다 들어있는 배열을 받을 수 있게 된다.


취업을 하고 실무를 하며 Promise의 동작은 어느정도 이해했다고 생각했는데, 이번 작업은 그게 나의 오만이었음을 다시 한번 깨닫게 해주었다. 텍스트로 복잡한 작업의 순서를 매기려다보니 조금만 정신에 힘을 빼도 이 흐름을 따라가기가 쉽지 않다. 블로그 글을 적으면서 코드를 다시보니 정말 바보같은 실수로 한참을 삽질했다고 생각되어 조금 슬프지만, 그래도 Promise.all()을 더 잘 이해할 수 계기가 되어서 좋았다!

profile
주니어 프론트엔드 개발자입니다 😎

0개의 댓글