안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

자바스크립트 (JavaScript)

[JavaScript] 비동기 프로그래밍 (async, await) + 동작 원리

피그브라더 2021. 5. 15. 19:36

ES8(= ES2017) 이전 버전의 JavaScript에서는 비동기 프로그래밍을 위해 콜백 함수Promise 문법을 사용하곤 했다. 특히 ES6(= ES2015) 버전의 JavaScript에서 등장한 Promise 문법은 기존에 사용하던 콜백 함수의 한계점들을 많이 해결하였다. 그러나 콜백 함수 기반의 비동기 프로그래밍에 익숙하지 않은 사람들에게 Promise 문법은 여전히 낯설 수밖에 없었다. 결국 Promise 문법도 콜백 함수 기반이었기 때문이다. 그러다가 ES8(= ES2017) 버전의 JavaScript가 등장하면서부터 비동기 프로그래밍을 위한 새로운 문법이 등장했다. 바로 async, await 문법이다. 이 문법은 기존에 사용하던 콜백 함수나 Promise 문법에 비해 동기 프로그래밍과 거의 흡사하게 비동기 프로그래밍을 할 수 있도록 하였다. 이로 인해 비동기 프로그래밍에 익숙하지 않은 사람들도 비동기 프로그래밍에 조금 더 쉽게 다가갈 수 있게 되었다. 그렇다면 async, await 문법에 대해 한 번 본격적으로 알아보도록 하자.

 

※ 필요한 선행 지식 (클릭 시 해당 포스팅으로 이동)
1. [JavaScript] 비동기 작업의 원리

2. [JavaScript] 콜백 함수 및 Promise 문법 기반의 비동기 프로그래밍

 

1. async, await 기본 문법

함수 선언 시 function 앞에 async 키워드를 붙이면 async 함수가 된다.

// Sync function
function syncFunction() {
    console.log('syncFunction');
}

// Async function
async function asyncFunction() {
    console.log('asyncFunction');
}

 

async 함수를 호출한 결과에 해당하는 반환 값은 반드시 Promise 객체이다. 만약 async 함수가 Promise 객체가 아닌 값을 반환하면 그 값으로 이행되는 Promise 객체를 생성하여 대체한다. 즉, 다음 예시에서 foo와 bar는 완전히 동일하다.

// This function is
async function foo() {
    return 1;
}

// the same as this function.
function bar() {
    return Promise.resolve(1);
}

 

그리고 async 함수 내에서 캐치되지 않은 예외가 발생하면 그 예외로 거부되는 Promise 객체를 생성하여 반환한다. (async와 await를 사용할 때의 예외 처리에 관해서는 뒷부분에서 자세히 다룬다.) 즉, 다음 예시에서 foo와 bar는 완전히 동일하다.

// This function is
async function foo() {
    throw new Error('error');
}

// the same as this function.
function bar() {
    return Promise.reject(new Error('error'));
}

 

그런데 지금까지는 await 키워드를 사용하지 않는 async 함수에 대해서만 다루었다. 그러니 이번에는 await 키워드를 사용하는 async 함수도 한 번 알아보자. 그러려면 먼저 await 키워드에 대한 기본 지식이 있어야 한다. 하나씩 알아보도록 하자.

 

먼저, await 키워드는 async 함수에서만 사용할 수 있다. 즉, sync 함수에서는 await 키워드를 사용할 수 없다. 다음으로, await 키워드의 뒤에 오는 값은 반드시 Promise 객체이다. 만약 Promise 객체가 아닌 값이 await 키워드의 뒤에 온다면 그 값으로 이행되는 Promise 객체를 생성하여 대체한다. 즉, 다음 예시에서 foo와 bar는 완전히 동일하다.

// This function is
async function foo() {
    await 1;
}

// the same as this function.
async function bar() {
    await Promise.resolve(1);
}

 

await 키워드를 사용하면 await 키워드의 뒤에 오는 Promise 객체가 이행되거나 거부될 때까지 기다린다(코드의 실행이 중단된다). 만약 해당 Promise 객체가 이행된다면 그 이행 결괏값이 await 키워드 부분을 대체함과 동시에 코드의 실행이 재개된다. 하지만 해당 Promise 객체가 거부된다면 예외가 발생하게 된다. (async와 await를 사용할 때의 예외 처리에 관해서는 뒷부분에서 자세히 다룬다.) 예시는 다음과 같다.

async function foo(url) {
    let result;
    
    try {
        // downloadData(url) returns a (resolved or rejected) Promise object.
        result = await downloadData(url);
    }
    // If the Promise object was rejected
    catch (e) {
        // Here, `result` is `undefined`.
        alert(e);
        result = {};
    }
    
    // If the Promise object was resolved, `result` is the downloaded data object.
    // Otherwise, `result` is an empty object.
    return processResult(result);
}

 

사실상 이 정도가 async, await 기본 문법의 전부이다. 콜백 함수와 Promise에 대한 이해만 전제되어 있다면 추가로 공부할 게 그리 많지 않은 것이다. 그렇다면 이제 async, await를 활용하는 실제 예시들을 한 번 살펴보자.

 

2. async, await 활용 예시

먼저, 다음과 같이 두 개의 함수를 정의하자. 하나는 2초가 지난 후에 이행되는 Promise 객체를 반환하는 resolveAfter2Seconds 함수이고, 다른 하나는 1초가 지난 후에 이행되는 Promise 객체를 반환하는 resolveAfter1Second 함수이다.

// This function returns a Promise object
// that will be resolved after 2 seconds.
function resolveAfter2Seconds() {
    console.log('start slow promise.');
    return new Promise((resolve) => {
        setTimeout(function () {
            resolve(20);
            console.log('slow promise is done.');
        }, 2000);
    });
};

// This function returns a Promise object
// that will be resolved after 1 second.
function resolveAfter1Second() {
    console.log('start fast promise.');
    return new Promise((resolve) => {
        setTimeout(function () {
            resolve(10);
            console.log('fast promise is done.');
        }, 1000);
    });
};

 

그리고 위 두 함수를 적절히 호출하는 네 종류의 함수를 다음과 같이 정의하자. 우리는 이 네 종류의 함수를 통해 async, await가 어떤 흐름으로 코드를 실행시키는지 알아볼 것이다. 그러니 각 함수의 호출 결과를 아래에서 보기 전에 먼저 한 번 예측해보기 바란다.

// (1)
async function sequentialStart() {
    const slow = await resolveAfter2Seconds();
    console.log(slow);

    const fast = await resolveAfter1Second();
    console.log(fast);
}

// (2)
async function concurrentStart() {
    const slow = resolveAfter2Seconds();
    const fast = resolveAfter1Second();

    console.log(await slow);
    console.log(await fast);
}

// (3)
function stillConcurrent() {
    Promise.all([resolveAfter2Seconds(), resolveAfter1Second()]).then((results) => {
        console.log(results[0]);  // slow
        console.log(results[1]);  // fast
    });
}

// (4)
function parallel() {
    resolveAfter2Seconds().then((result) => console.log(result));
    resolveAfter1Second().then((result) => console.log(result));
}

 

각 함수의 호출 결과는 다음과 같다.

sequentialStart();
// start slow promise.
// ▶ Promise {<pending>}
// (2초 경과)
// slow promise is done.
// 20
// start fast promise.
// (1초 경과)
// fast promise is done.
// 10
concurrentStart();
// start slow promise.
// start fast promise.
// ▶ Promise {<pending>}
// (1초 경과)
// fast promise is done.
// (1초 경과)
// slow promise is done.
// 20
// 10
stillConcurrent();
// start slow promise.
// start fast promise.
// undefined
// (1초 경과)
// fast promise is done.
// (1초 경과)
// slow promise is done.
// 20
// 10
parallel();
// start slow promise.
// start fast promise.
// undefined
// (1초 경과)
// fast promise is done.
// 10
// (1초 경과)
// slow promise is done.
// 20

 

다음으로, 한 학생의 정보를 가져오고 그 학생의 학급 정보를 다시 가져오는 코드를 생각해보자. 이를 위해 먼저 다음과 같은 두 종류의 함수를 정의하자. 하나는 특정 학생의 정보를 가져오는 fetchStudent 함수, 다른 하나는 특정 학급의 정보를 가져오는 fetchClass 함수이다. 이 함수들을 호출하면 각각 학생의 정보와 학급의 정보를 가져오도록 하는 Promise 객체가 반환된다.

function fetchStudent(id) {
    var url = `https://xyz.com/students/${id}`;
    return fetch(url).then(function (response) {
        return response.json();
    });
}

function fetchClass(id) {
    var url = `https://xyz.com/classes/${id}`;
    return fetch(url).then(function (response) {
        return response.json();
    });
}

 

그러면 이제 위 함수들을 이용하여 한 학생의 정보를 가져오고 그 학생의 학급 정보를 다시 가져오는 코드를 작성해보면 다음과 같다. 

async function fetchStudentAndClass(studentId) {
    const studentInfo = await fetchStudent(studentId);
    const classInfo = await fetchClass(student.class_id);
    return {
        studentInfo: studentInfo,
        classInfo: classInfo
    };
}

눈치가 빠르다면, 동기 프로그래밍을 할 때와 거의 유사한 방식으로 코드를 작성하였음을 알 수 있을 것이다. 이것이 바로 async, await의 강점이다. 코드가 작성된 순서대로 실행이 되는 것이 인간의 뇌에는 가장 직관적이다. 만약 Promise 기반으로 위 코드를 작성하고자 했다면 콜백 함수가 두 번 중첩되는 형태가 될 것이기 때문에 코드를 읽기가 어려워질 것이다.

 

3. async, await 예외 처리 방식 (vs Promise)

그렇다면 async, await를 사용할 때는 예외 처리를 어떤 식으로 하면 될까? 만약 Promise 기반으로 비동기 코드를 작성했다면, 예외가 발생하는 경우 거부된 Promise 객체에 대해 catch 메소드를 호출하면 예외 처리가 가능하였다. 다음 예시처럼 말이다.

async function foo(url) {
    // downloadData(url) returns a (resolved or rejected) Promise object.
    return downloadData(url)
        // If the Promise object was rejected
        .catch(e => {
            alert(e);
            return {};
        })
        // If the Promise object was resolved, `result` is the downloaded data object.
        // Otherwise, `result` is an empty object.
        .then(result => {
            return processResult(result);
        });
}

 

위의 코드를 async, await를 사용하여 다시 작성해보면 다음과 같다. 앞서 보여준 예시 코드와 동일한 것이다.

async function bar(url) {
    let result;
    
    try {
        // downloadData(url) returns a (resolved or rejected) Promise object.
        result = await downloadData(url);
    }
    // If the Promise object was rejected
    catch (e) {
        // Here, `result` is `undefined`.
        alert(e);
        result = {};
    }
    
    // If the Promise object was resolved, `result` is the downloaded data object.
    // Otherwise, `result` is an empty object.
    return processResult(result);
}

await 키워드의 뒤에는 Promise 객체가 오는데, 그 Promise 객체가 이행이 된다면 예외가 발생하지 않고 코드가 정상적으로 실행될 것이다. 그러나 그 Promise 객체가 거부된다면 그것은 곧 예외의 발생으로 이어진다. 따라서 동기 코드를 작성할 때와 마찬가지로 예외가 발생할 수 있는 부분을 try catch 블록으로 감싸면 예외 처리가 가능해진다.

 

4. 참고 : async, await 실행 흐름 (동작 원리)

async 함수의 코드는 0개 이상의 await 키워드에 의해 분할된 것으로 생각할 수 있다. 만약 await 키워드가 없다면 모든 코드가 중단 없이 동기적으로 실행될 것이다. 그러나 await 키워드가 두 개라면, 첫 번째 await 키워드까지가 첫 번째 코드 덩어리, 첫 번째 await 키워드부터 두 번째 await 키워드까지가 두 번째 코드 덩어리, 두 번째 await 키워드부터 마지막까지가 세 번째 코드 덩어리가 되는 것이다.

 

그렇다면 await 키워드를 마주칠 때 일어나는 일은 무엇일까? 앞에서는 await 키워드의 뒤에 오는 Promise 객체가 이행되거나 거부될 때까지 코드를 중단시키는 역할이라고만 설명하였다. 그러나 여기서 의문점이 생길 수 있다. "기다리지 않고 다른 작업을 하는 게 비동기라고 하지 않았나? 근데 기다리면 동기와 다를 게 뭐지?" 같은 식으로 말이다. 좋은 의문이다. await 키워드를 마주치는 순간 일어나는 일을 간략하게 설명하자면 다음과 같다.

 

A라는 함수를 실행하던 도중 await promiseB 키워드를 마주치면, 그 함수는 (promiseB와는 다른) Promise 객체(promiseA라고 하자)를 하나 새로 만들어서 즉시 반환하고 A의 스택 프레임이 스택에서 즉시 팝 된다. 즉, 마냥 기다리지 않고 A를 호출한 곳으로 다시 돌아가서 다음 코드를 실행할 수 있게 하는 것이다. 이때 promiseA는 A의 실행 결과(반환 값, 예외 발생 여부)에 따라 이행 또는 거부되는 Promise 객체이다. 이후 시간이 흘러 promiseB 객체가 이행 또는 거부되면, 아까 중단시켰던 코드부터 다시 실행이 되도록 A의 스택 프레임을 다시 스택에 푸시한다. 이는 마치 콜백 함수가 실행되는 메커니즘과 비슷하다고 볼 수 있다. 그렇게 A의 실행을 마치고 나면 그 결과에 따라 promiseA가 이행 또는 거부될 것이고, 만약 promiseA도 await 대상이었다면 마찬가지 메커니즘으로 그 부분부터 다시 코드가 실행될 것이다. 이것이 async 함수 내에서 await 키워드의 역할이다.

 

결국, await 키워드가 중단시키는 코드라는 것은 해당 await 키워드가 존재하는 함수 내의 코드인 것이다(모든 코드가 아니라). 그곳에서의 코드만 실행을 중단시킨 뒤, 해당 함수를 호출한 곳으로 다시 돌아가서 다음 코드를 실행한다. 이러한 원리로, 마냥 기다리지 않고 다른 작업을 수행할 수 있게 되는 것이다. 다음 예시를 참고하자.

// This function returns a Promise object
// that will be resolved after the specified seconds.
function sleep(seconds) {
    return new Promise(resolve => {
        setTimeout(function() {
            resolve(seconds);
        }, seconds * 1000);
    });
};

async function sleepFor1Second() {
    await sleep(1);  // stops here and returns to main().
    console.log('End sleep for 1 seconds.');
}

async function sleepFor2Seconds() {
    await sleep(2);  // stops here and returns to main().
    console.log('End sleep for 2 second.');
}

function main() {
    sleepFor1Second();
    sleepFor2Seconds();
    console.log('Other works ...');  // does other works in the meantime.
}

main();

 

 

 

 

 

 

본 글은 아래 링크의 내용을 참고하여 학습한 내용을 나름대로 정리한 글임을 밝힙니다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/async_function

https://joshua1988.github.io/web-development/javascript/js-async-await/

https://stackoverflow.com/questions/56241258/working-of-call-stack-when-async-await-is-used