October 24 October 28, 2016

김동우 edited this page Oct 31, 2016 · 3 revisions
Clone this wiki locally

Weekly Magazine

Weekly Pick!

원문 : Tips for using async functions (ES2017)

Async 함수 사용을 위한 팁 (ES2017)

이 블로그 포스트는 async 함수 사용을 위한 팁을 제공한다. 만약 async 함수에 대해 잘 모른다면, "Exploring ES2016 and ES2017"의 Async 함수 챕터를 읽어보기 바란다.

1. 프라미스를 잘 알고 있어야 한다

async 함수는 프라미스를 기반으로 한다. 그렇기 때문에 async 함수를 이해하기 위해서는 프라미스를 이해하는 것이 가장 중요하다. 특히 프라미스 기반으로 되어 있지 않은 예전 코드를 async 함수와 연결할 때에는, 프라미스를 직접 사용하는 것 외에 다른 선택이 없다.

function httpGet(url, responseType="") {
    return new Promise(
        function (resolve, reject) {
            const request = new XMLHttpRequest();
            request.onload = function () {
                if (this.status === 200) {
                    // Success
                    resolve(this.response);
                } else {
                    // Something went wrong (404 etc.)
                    reject(new Error(this.statusText));
                }
            };
            request.onerror = function () {
                reject(new Error(
                    'XMLHttpRequest Error: '+this.statusText));
            };
            request.open('GET', url);
            xhr.responseType = responseType;
            request.send();
        }
    );
}

XMLHttpRequest API는 콜백을 기반으로 한다. 이 API를 프라미스화 시켜서 async 함수에서 사용한다는 것은, API 함수에서 반환되는 프라미스를 콜백 내부로부터 받아서 완료하거나 거절해야 한다는 의미가 된다. 이는 불가능한데, 왜냐하면 그렇게 할 수 있는 방법이 returnthrow 밖에 없기 때문이다. 그리고 함수의 실행 결과를 콜백 내부에서 return 할 수는 없다. throw 역시 마찬가지다.

그러므로 async 함수를 위한 일반적인 코딩 스타일은 다음과 같을 것이다.

  • 프라미스를 직접 사용해서 비동기 기반의 함수를 만든다
  • 위 함수를 async 함수를 통해서 사용한다

추가 읽을거리 : "Exploring ES6"의 비동기 프로그래밍을 위한 프라미스 챕터

2. async 함수는 동기적으로 시작해서 비동기적으로 해결된다

async 함수가 실행되는 방식은 다음과 같다.

  1. async 함수의 결과는 항상 프라미스 p이다. 이 프라미스는 해당 async 함수의 실행을 시작할 때 생성된다.
  2. 본문이 실행된다. 실행은 return이나 throw를 통해 영구적으로 종료될 수 있다. 혹은, await를 통해 일시적으로 종료될 수도 있는데, 이 경우 실행은 추후에 이어서 진행될 것이다.
  3. 프라미스 p가 반환된다.

async 함수의 본문이 실행될 때, return x는 프라미스 px 값으로 해결(resolve) 하는 반면, throw errperr 값으로 거절(reject)한다. 해결되었다는 알림은 비동기적으로 발생한다. 바꿔 말하면 then()catch()의 콜백 함수들은 항상 진행중인 코드가 종료되고 난 후에 실행된다.

다음 코드는 이러한 절차가 어떻게 진행되는지 보여준다.

async function asyncFunc() {
    console.log('asyncFunc()'); // (A)
    return 'abc';
}
asyncFunc().
then(x => console.log(`Resolved: ${x}`)); // (B)
console.log('main'); // (C)

// Output:
// asyncFunc()
// main
// Resolved: abc

다음의 순서로 실행된다고 생각하면 된다.

  1. 라인 (A): async 함수가 동기적으로 시작된다. 이 함수의 프라미스는 return에 의해 해결된다.
  2. 라인 (C): 실행이 계속된다.
  3. 라인 (B): 프라미스 해결에 대한 알림은 비동기적으로 발생한다.

3. 반환되는 프라미스는 감싸지지(wrapped) 않는다

프라미스를 해결하는 것은 기본 동작이다. return은 async 함수의 프라미스 p를 해결하기 위해 이 기능을 사용한다. 이는 다음의 의미와 같다.

  1. 프라미스가 아닌 값을 반환하면 p를 해당 값으로 해결한다.
  2. 프라미스를 반환하면 p가 해당 프라미스의 상태를 반영하게 된다.

그러므로 async 함수는 프라미스를 반환할 수 있고, 반환된 프라미스는 다른 프라미스에 의해 감싸지지 않는다.

async function asyncFunc() {
    return Promise.resolve(123);
}
asyncFunc()
.then(x => console.log(x)) // 123

흥미롭게도, 거절된(rejected) 프라미스를 반환하게 되면 해당 async 함수의 결과가 거절된다. (보통은 이를 위해 throw를 사용할 것이다)

async function asyncFunc() {
    return Promise.reject(new Error('Problem!'));
}
asyncFunc()
.catch(err => console.error(err));  // Error: Problem!

이것은 프라미스가 해결되는 방식과 같다고 볼 수 있다. 이를 통해 await를 사용하지 않고도 다른 비동기 연산의 완료(fulfillment)나 거절(rejection)을 넘겨줄 수 있다.

async function asyncFunc() {
    return anotherAsyncFunc();
}

위의 코드는 얼핏 다음의 코드와 비슷하지만 훨씬 효율적이다. (다음의 코드는 anotherAsyncFunc()의 프라미스를 꺼낸 다음 다시 프라미스로 감싸는 일을 할 뿐이다)

async function asyncFunc() {
    return await anotherAsyncFunc();
}

4. await를 빼먹지 말자

async 함수를 사용할 때 범하기 쉬운 실수중의 하나는 await를 빼먹는 것이다.

async function asyncFunc() {
    const value = otherAsyncFunc(); // missing `await`!
    ···
}

이 예제에서 value의 값은 프라미스가 되며, 보통은 async 함수에서 원하는 결과가 아닐 것이다.

await 키워드는 async 함수가 아무것도 반환하지 않을 때에도 의미가 있다. 이 때에 프라미스는 단순히 실행이 완료되었음을 호출자에게 알려주는 신호로써 사용된다. 예를 들면:

async function foo() {
    await step1(); // (A)
    ···
}

라인 (A)의 await는 다음 라인의 코드가 실행되기 전에 step1()이 완전히 완료되었음을 보증하는 역할을 한다.

5. 실행후 신경쓰지 않을 함수에 대해서는 await가 필요없다

가끔은 비동기 함수를 실행시킨 다음에, 언제 완료되는지에 대해서는 신경쓸 필요가 없을 때가 있다. 다음의 코드를 보자.

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello'); // don’t wait
    writer.write('world'); // don’t wait
    await writer.close(); // wait for file to close
}

여기서는 각각의 쓰기 함수가 언제 완료되는지가 아니라, 오직 올바른 순서로 실행되는지만 신경쓰면 된다. (API가 이를 보장해야 하는데, 이는 지금껏 보았듯이 async 함수의 실행 모델에서 권장되는 방식이다)

asyncFunc()의 마지막 라인의 await는 이 함수가 파일이 성공적으로 닫힌 후에야 완료된다는 것을 보장해준다.

반환되는 프라미스는 감싸지지 않기 때문에, await writer.close() 대신에 return을 사용할 수도 있다.

async function asyncFunc() {
    const writer = openFile('someFile.txt');
    writer.write('hello');
    writer.write('world');
    return writer.close();
}

두가지 버전 모두 장단점이 있는데, await를 사용한 버전이 아마도 약간은 더 이해하기 쉬울 것이다.

6. 병행성

다음의 코드는 asyncFunc1()asyncFunc2() 라는 두개의 비동기 함수 호출을 만든다.

async function foo() {
    const result1 = await asyncFunc1();
    const result2 = await asyncFunc2();
}

하지만, 이들 두 함수 호출은 순차적으로 실행된다. 이들을 병행적으로 실행하면 속도를 향상시킬 수 있다. Promise.all()을 사용하면 된다.

async function foo() {
    const [result1, result2] = await Promise.all([
        asyncFunc1(),
        asyncFunc2(),
    ]);
}

두개의 프라미스를 기다리는 대신, 이제 두개의 요소를 배열로 같는 하나의 프라미스만 기다리게 된다.

7. 콜백에서 await 사용하지 않기

await는 항상 감싸고 있는 가장 안쪽의 async 함수에만 영향을 주며, async 함수 바로 안쪽에서만 사용될 수 있다는 것을 기억하자. 배열의 유틸리티 함수들인 map(), forEach()등처럼 콜백에 의존하는 함수를 사용하려고 하면 문제가 발생한다.

7.1 Array.prototype.map()

배열의 map() 메소드부터 시작해보자. 다음의 코드는 배열에 담긴 URL이 가리키는 파일을 다운로드 받아서 배열 형태로 반환하려고 한다.

async function downloadContent(urls) {
    return urls.map(url => {
        // Wrong syntax!
        const content = await httpGet(url);
        return content;
    });
}

이는 작동하지 않는데, 왜냐하면 await를 일반 화살표 함수내에서 사용하는 것이 문법적으로 금지되어 있기 때문이다. async 화살표 함수를 사용하면 어떨까?

async function downloadContent(urls) {
    return urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
}

이 코드에는 두 가지 이슈가 있다.

  • 결과값이 문자열의 배열이 아닌 프라미스의 배열이 된다.
  • map()이 완료되었을 때 콜백에 의해 수행되는 작업까지 완료되지 않는다. 왜냐하면 await는 감싸고 있는 화살표 함수만을 정지시키고, httpGet()는 비동기적으로 해결되기 때문이다. 이는 downloadContent()가 완료되기를 기다리기 위해서 await를 사용할 수 없다는 의미이다.

Promise.all()을 사용하면 두 가지 이슈를 모두 해결할 수 있다. Promise.all()은 프라미스로 이루어진 배열을 프라미스에 의해 반환되는 값들을 갖는 배열로 변환해준다.

async function downloadContent(urls) {
    const promiseArray = urls.map(async (url) => {
        const content = await httpGet(url);
        return content;
    });
    return await Promise.all(promiseArray);
}

map()에 사용된 콜백은 httpGet()의 결과를 갖고 특별한 작업 없이 단지 전달할 뿐이다. 그러므로 여기서는 async 화살표 함수를 사용할 필요 없이 일반 화살표를 사용하면 된다.

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return await Promise.all(promiseArray);
}

아직 작은 개선의 여지가 하나 남아있다. 이 async 함수는 약간 비효율적인데, 먼저 await를 이용해 Promise.all()의 결과를 꺼낸 다음 다시 return을 통해 프라미스로 감싸고 있다. return은 프라미스를 다시 감싸지 않으므로, Promise.all()을 직접 반환할 수 있을 것이다.

async function downloadContent(urls) {
    const promiseArray = urls.map(
        url => httpGet(url));
    return Promise.all(promiseArray);
}

7.2 Array.prototype.forEach()

배열의 메소드인 forEach()를 사용해서 URL이 가리키고 있는 파일들의 내용을 로그로 남겨보자.

async function logContent(urls) {
    urls.forEach(url => {
        // Wrong syntax
        const content = await httpGet(url);
        console.log(content);
    });
}

이 코드는 문법 에러를 만들어 내는데, 아까와 마찬가지로 일반 화살표 함수 내에서는 await 함수를 사용할 수 없기 때문이다.

async 화살표 함수를 사용해보자.

async function logContent(urls) {
    urls.forEach(async url => {
        const content = await httpGet(url);
        console.log(content);
    });
    // Not finished here
}

이렇게 하면 동작은 하지만, 결점이 하나 있다. httpGet()에 의해 반환되는 프라미스는 비동기적으로 해결되기 때문에, 콜백들이 완료되지 않은 상태로 forEach()가 종료된다. 결과적으로 logContent() 함수가 끝날 때를 기다릴(await) 수가 없게 된다.

이게 원하는 결과가 아니라면, forEach()for-of 루프로 변경할 수 있다.

async function logContent(urls) {
    for (const url of urls) {
        const content = await httpGet(url);
        console.log(content);
    }
}

이제 for-of 루프가 끝나면 모든 것이 완료된다. 하지만 처리 단계가 순차적으로 발생하게 된다(첫번째 호출이 완료된 후에야 두번째 호출이 시작된다). 만약 처리 단계를 병행적으로 진행하고 싶다면, Promise.all()을 사용해야만 한다.

async function logContent(urls) {
    await Promise.all(urls.map(
        async url => {
            const content = await httpGet(url);
            console.log(content);            
        }));
}

map()은 프라미스의 배열을 만들어내기 위해 사용된다. 이들 프라미스의 실제 결과값에 대해서는 신경쓰지 않아도 되며, 단지 await을 사용해 완료되기를 기다릴 뿐이다. 이렇게 하면 async 함수의 마지막에서는 모든 것이 완료된 상태가 된다. 여기서도 그냥 return Promise.all()을 사용할 수 있지만, 그렇게 되면 이 함수의 결과값은 undefined를 요소로 갖는 배열이 될 것이다.

8. 즉시 실행 async 함수 표현식

가끔은 모둘이나 스크립트의 최상위 단계에서 await를 사용할 수 있으면 좋을 것이다. 슬프게도, await는 async 함수 내에서만 사용 가능하다. 이를 위한 몇가지 옵션이 있다. async main() 함수를 만든 다음에 즉시 실행하는 것이다.

async function main() {
    console.log(await asyncFunction());
}
main();

아니면 즉시 실행 async 함수 표현식을 사용할 수도 있다.

(async function () {
    console.log(await asyncFunction());
})();

또다른 옵션은 즉시 실행 async 화살표 함수이다.

(async () => {
    console.log(await asyncFunction());
})();

9. async 함수를 사용한 단위 테스트

다음의 코드는 mocha 테스트 프레임워크를 사용해서 비동기 함수인 asyncFunc1() 함수와 asyncFunc2() 함수를 단위 테스트한다.

import assert from 'assert';

// Bug: the following test always succeeds
test('Testing async code', function () {
    asyncFunc1() // (A)
    .then(result1 => {
        assert.strictEqual(result1, 'a'); // (B)
        return asyncFunc2();
    })
    .then(result2 => {
        assert.strictEqual(result2, 'b'); // (C)
    });
});

하지만 이 테스트는 항상 성공하는데, 왜냐하면 mocha가 라인 (B)와 라인 (C)가 실행될 때까지 기다려주지 않기 때문이다.

프라미스의 체인을 반환하면 이를 고칠 수 있는데, 테스트가 프라미스를 반환하면 mocha가 해당 프라미스가 해결될 때까지 기다려주기 때문이다. (타임아웃이 없는 경우에만)

return asyncFunc()  // A

편리하게도, async 함수는 항상 프라미스를 반환하기 때문에 이런 방식의 유닛 테스트에 사용하기에 완벽하다.

import assert from 'assert';
test('Testing async code', async function () {
    const result1 = await asyncFunc1();
    assert.strictEqual(result1, 'a');
    const result2 = await asyncFunc2();
    assert.strictEqual(result2, 'b');
}); 

mocha에서 비동기 단위 테스트에 async 함수를 사용하게 되면 두가지의 이점이 있다. 코드가 더 간결해지고, 프라미스를 반환하는 것을 보장할 수 있게 된다.

10. 처리되지 않은 거절(rejection)에 대해 걱정하지 말라.

자바스크립트 엔진은 처리되지 않은 거절에 대해 경고하는 능력이 점점 더 좋아지고 있다. 예를 들어 과거에는 다음의 코드가 조용히 실패하는 경우가 많았지만, 대부분의 최신 자바스크립트 엔진에서는 처리되지 않은 거절이라는 경고메시지를 준다.

async function foo() {
    throw new Error('Problem!');
}
foo();