JavaScript 비동기 처리하기

예제 기준 · JavaScript : 표준 Promise·async/await와 브라우저 setTimeout() (별도 라이브러리 없음)

서버 응답이나 타이머를 기다리는 동안에도 화면은 입력에 반응해야 합니다. 비동기 처리는 기다리는 작업의 결과를 나중에 받아 후속 작업을 이어가는 방법입니다. Promise와 async/await로 성공·실패를 처리하고, Promise.all()과 Promise.allSettled()로 여러 결과를 모아 보겠습니다.

Promise 기본 사용

Promise는 작업의 완료 또는 실패 상태를 표현합니다. 아직 결과가 정해지지 않은 상태는 pending, 성공은 fulfilled, 실패는 rejected입니다. fulfilled 또는 rejected가 되면 상태는 다시 바뀌지 않습니다.

다음 공통 함수는 실행 예제의 타이머 작업을 콘솔용으로 정리한 코드입니다. 이후 코드와 함께 사용하며 DOM 요소는 필요하지 않습니다. 실행 화면에서는 로그를 pre#result에 누적하고, 안내를 #statusMessage에 표시합니다. 본문에서는 버튼 잠금과 초기화 관리를 생략했습니다.

function task(name, delay, fail) {
    return new Promise(function (resolve, reject) {
        setTimeout(function () {
            if (fail) {
                console.log(name + ' reject (' + delay + ' ms)');
                reject(new Error(name + ' 실패'));
            } else {
                console.log(name + ' resolve (' + delay + ' ms)');
                resolve(name + ' 성공');
            }
        }, delay);
    });
}

new Promise()에 전달한 함수는 생성 시 바로 실행되어 타이머를 예약합니다. 이 예제에서 resolve는 성공 문자열을, reject는 실패 이유인 Error를 전달합니다. resolve에 다른 Promise를 전달하면 그 결과를 따르므로 resolve 호출이 언제나 즉시 fulfilled를 뜻하는 것은 아닙니다.

다음 코드는 A 작업을 시작하고 성공·실패·후처리를 연결합니다. fail을 false로 두면 성공하고 true로 바꾸면 실패합니다. 700ms는 타이머에 지정한 지연이며 실제 완료 시간을 측정한 값은 아닙니다.

function runChain(fail) {
    const work = task('A', 700, fail);
    const handled = work.then(function (result) {
        console.log('결과: ' + JSON.stringify(result, null, 2));
    });
    const recovered = handled.catch(function (error) {
        console.log('catch: ' + error.message);
    });
    return recovered.finally(function () {
        console.log('finally 실행');
    });
}

runChain(false);

then의 성공 콜백은 결과를 처리하고, catch는 앞 단계에서 전달된 오류를 처리합니다. finally는 성공·실패와 관계없는 후처리에 사용합니다. 각 메서드는 새 Promise를 반환하므로 위 코드도 단계별 반환값을 연결합니다. finally에서 오류를 던지면 반환된 Promise는 실패할 수 있습니다.

실패 포함 출력 순서
체크 안 함 A resolve (700 ms) → 결과: "A 성공" → finally 실행
체크함 A reject (700 ms) → catch: A 실패 → finally 실행

‘실패 포함’을 선택한 뒤 ‘실행’을 누르면 해당 조건으로 작업을 시작합니다. 실행 버튼은 작업 중 비활성화됩니다. ‘초기화’는 예약된 타이머를 clearTimeout으로 중단하고 로그와 체크 상태를 되돌립니다. 이는 예제에서 별도로 구현한 처리이며 Promise의 취소 기능이 아닙니다. 초기화로 중단한 이전 작업의 finally 실행도 보장하지 않습니다.

async/await 사용

async 함수는 항상 Promise를 반환합니다. 일반 값을 return하면 그 값을 성공 결과로 갖고, 처리하지 않은 오류가 발생하면 실패합니다. await는 Promise가 처리될 때까지 해당 async 함수의 다음 처리를 미루며 브라우저 전체를 멈추지는 않습니다.

앞 절의 runChain을 다음처럼 바꿀 수 있습니다. 동일한 task 함수를 사용하고, reject는 await 지점에서 오류가 되어 catch로 이동합니다.

async function runAwait(fail) {
    try {
        const result = await task('A', 700, fail);
        console.log('결과: ' + JSON.stringify(result, null, 2));
    } catch (error) {
        console.log('catch: ' + error.message);
    } finally {
        console.log('finally 실행');
    }
}

runAwait(false);

‘then/catch로 실행’과 ‘async/await로 실행’은 같은 A 작업을 사용합니다. ‘실패 포함’을 체크하면 어느 버튼을 눌러도 A 실패를 처리하고 finally를 출력합니다. 두 실행 버튼은 작업 중 함께 비활성화됩니다.

위 코드는 catch에서 오류를 출력하고 다시 던지지 않으므로 작업 A가 실패해도 runAwait가 반환하는 Promise는 정상적으로 끝납니다. 호출자도 실패를 알아야 한다면 catch에서 오류를 다시 던지고 호출 쪽에서도 처리해야 합니다.

이 실행 예제는 type="module"이 없는 일반 script이며 await를 async 함수 안에서만 사용합니다. 일반 함수 내부나 일반 script 최상위에서 await를 바로 쓸 수는 없습니다. 최상위 await는 이를 지원하는 JavaScript module 환경에서 사용할 수 있습니다.

여러 비동기 작업을 동시에 처리하기

Promise.all()은 여러 Promise가 모두 성공하면 결과 배열을 반환하고, 하나라도 실패하면 반환한 Promise가 reject됩니다. 결과 배열은 완료 순서가 아니라 전달한 순서를 따릅니다.

실행 예제는 A·B·C 작업의 지연을 각각 500·900·1400ms로 지정합니다. ‘실패 포함’은 B에만 적용됩니다. 다음 코드는 화면의 Promise 체인을 async/await로 옮겼으며, 작업 조건과 로그 내용은 같습니다. 앞서 정의한 task 함수와 함께 사용합니다.

async function runAll(fail) {
    const tasks = [
        task('A', 500, false),
        task('B', 900, fail),
        task('C', 1400, false)
    ];

    try {
        const result = await Promise.all(tasks);
        console.log('결과: ' + JSON.stringify(result, null, 2));
    } catch (error) {
        console.log('catch: ' + error.message);
    } finally {
        console.log('finally 실행');
    }
}

runAll(false);

각 task 호출에서 타이머가 예약되어 대기 구간이 겹칩니다. Promise.all은 이미 시작한 작업들의 결과를 묶어 기다립니다. 이것이 JavaScript 계산을 여러 스레드에서 동시에 실행한다는 뜻은 아닙니다.

모두 성공하면 A·B·C resolve 로그 뒤에 다음 결과와 finally가 출력됩니다.

["A 성공", "B 성공", "C 성공"]

실패를 포함하면 A resolve, B reject 뒤에 catch: B 실패와 finally가 표시되고, 이후 C resolve 로그도 추가됩니다. Promise.all의 실패는 C 작업을 취소하지 않습니다. 화면의 실행 버튼도 남은 작업 C가 끝나야 다시 활성화됩니다. 여기서 finally가 실행됐다는 사실만으로 모든 작업이 끝났다고 판단하면 안 됩니다.

성공과 실패를 모두 확인하기

Promise.allSettled()는 전달한 모든 Promise가 fulfilled 또는 rejected가 될 때까지 기다립니다. 각 작업이 실패해도 그 이유를 결과 배열에 담습니다. 성공 항목에는 status와 value, 실패 항목에는 status와 reason이 있습니다. 배열 순서는 입력 순서입니다.

다음은 같은 A·B·C 작업을 사용한 async/await 코드입니다. 화면은 then/catch로 처리하지만 결과를 읽는 분기와 출력은 같습니다. 앞 절의 task 함수를 함께 사용합니다.

async function runAllSettled(fail) {
    const tasks = [
        task('A', 500, false),
        task('B', 900, fail),
        task('C', 1400, false)
    ];

    try {
        const result = await Promise.allSettled(tasks);
        const lines = [];
        for (let i = 0; i < result.length; i++) {
            const item = result[i];
            if (item.status === 'rejected') {
                lines.push('rejected: ' + item.reason.message);
            } else if (item.status === 'fulfilled') {
                lines.push('fulfilled: ' + item.value);
            }
        }
        console.log('결과:\n' + lines.join('\n'));
    } catch (error) {
        console.log('catch: ' + error.message);
    } finally {
        console.log('finally 실행');
    }
}

runAllSettled(true);

‘실패 포함’을 체크하고 ‘allSettled 실행’을 누르면 C까지 로그가 나온 뒤 다음 결과와 finally가 표시됩니다. 체크하지 않으면 세 항목 모두 fulfilled입니다. 예제는 Error로 reject하므로 reason.message를 읽습니다. 다른 코드에서 문자열 등으로 reject했다면 reason의 형태에 맞춰 처리해야 합니다.

결과:
fulfilled: A 성공
rejected: B 실패
fulfilled: C 성공
finally 실행

B 작업의 실패는 rejected 항목으로 수집되므로 위 catch로 이동하는 원인이 아닙니다. catch는 결과를 처리하는 코드 자체에서 발생한 오류 등에 대비합니다. ‘all 실행 (비교)’ 버튼은 같은 작업에 Promise.all을 적용하여, B가 실패하면 catch로 이동하고 C는 계속 실행되는 차이를 보여 줍니다. 모두 성공하면 비교 버튼은 A 성공·B 성공·C 성공을 줄별로 표시합니다.

allSettled를 지원하지 않는 환경에서는 ‘allSettled 실행’ 버튼을 비활성화하고 all 비교만 가능합니다.

순차 실행과 병렬 실행 차이

다음은 앞의 task 함수를 이용한 보충 코드입니다. 실행 화면에 별도의 순차 실행 버튼은 없습니다. 호출과 await를 번갈아 쓰면 A가 성공한 뒤에야 B를 시작하고, B가 성공한 뒤 C를 시작합니다.

async function runSequential() {
    try {
        const first = await task('A', 500, false);
        const second = await task('B', 900, false);
        const third = await task('C', 1400, false);
        console.log([first, second, third]);
    } catch (error) {
        console.log('catch: ' + error.message);
    }
}

runSequential();

앞 작업의 결과가 다음 작업의 입력으로 필요하다면 이런 순서가 필요합니다. 이 코드에서 앞 작업이 실패하면 catch로 이동하고 아직 호출하지 않은 다음 작업은 시작하지 않습니다.

서로 의존하지 않는 작업은 모두 시작한 뒤 Promise.all로 기다릴 수 있습니다. 앞 절의 runAll이 이 방식입니다. 차이는 await의 개수만이 아니라 작업 함수를 언제 호출하는지에 있습니다. 여기서 병렬 처리는 여러 비동기 작업의 대기를 겹치는 의미이며, 실제 성능은 작업과 실행 환경에 따라 달라집니다.

정리

방법 역할 사용 상황
Promise 완료·실패 상태와 결과 표현 then·catch·finally로 후속 처리 연결
async/await Promise를 기다리는 흐름 표현 순서가 있는 처리를 try/catch와 함께 작성
Promise.all() 전체 성공 결과 수집, 하나라도 실패하면 reject 독립 작업의 성공 결과가 모두 필요할 때
Promise.allSettled() 모든 작업의 성공·실패 결과 수집 일부 실패와 관계없이 개별 결과를 확인할 때

참고자료