티스토리 뷰

반응형

JS의 쓰임새 중 많은 부분을 차지하고 있는 부분은 "비동기 처리"이다. JS에서는 비동기 처리 방식을 크게 3가지로 구분짓는다. 첫 번째는 콜백 함수, 두 번째는 프로미스, 세 번째는 async/await이다. 이 중 가독성과 편의성을 위해서 보통 두 번째 방법과 세 번째 방법을 섞어서 사용하는 방법을 위주로 현대에선 사용한다.

 

여러 JS 앱들을 만들었을 경우, 반드시 한 번 쯤은 비동기 처리 관련 코드를 작성해봤을 것이고, JS의 비동기 처리 3가지를 안다고 해서 비동기 처리 관련 코드를 만드는 것과 효율적인 비동기 처리 관련 코드를 만드는 것은 다른 의미를 지니고 있다고 생각한다.

 

일단, 비동기 처리에 대한 기본 지식(콜백 함수, 프로미스, async/await)에 대한 지식을 가지고 있다는 전제 하에 글을 작성하고, 현대 시대에 맞추어 async/await와 프로미스를 중심으로 설명하자.

 

thumbnail
Async를 효율적으로 처리하여보자.


Promise & async/await Quick Revision

비동기 프로그래밍에서는 언어에 구애받지 않고 Coroutine, Promise와 Future에 대한 개념이 항상 등장한다. 코루틴부터 설명하면 글이 너무 길이지므로 잠깐 생략하고, Promise와 Future부터 다시 살펴보자. 사전에 찾아보면 Promise는 약속, Future는 미래라는 뜻을 가지고 있고, 실제로 두 용어 모두 비동기 프로그래밍에서 비슷한 맥락을 지니고 있다.

 

Promise와 Future는 미래에 어떤 데이터를 생산 혹은 반환하겠다는 약속이다. 둘의 차이점은 Future의 경우 미래에 실행이 완료될 것이라 예상되는 객체이고, Promise의 경우 Future가 참조하는 객체를 한 번 쓰기가 가능한 컨테이너라 생각할 수 있다. 그러니까, Future는 읽기 전용 객체, Promise의 경우 할당 공간이라 생각할 수 있다. Java의 경우 Promise는 CompletableFuture, Completer와 비슷한 개념이라 생각해도 된다. 다른 언어에서는 Promise를 deferred, task 등등 여러 이름으로 불리고 있다.

 

JS의 프로미스는 엄밀히 말하면 promise와 future의 개념을 모두 포함하고 있다. JS의 프로미스 객체에 resolve, reject 함수를 넘겨주는 것은 일반적인 promise의 개념을 담고 있고(데이터를 어떻게 write 것인지), then이나 catch 등 코드 순차 실행 개념이 들어가는 것은 future의 개념이 들어가있다(받아온 데이터를 어떻게 read하고 활용할 것인지). resolve, reject의 경우 받아온 값을 어떻게 쓸 것인지에 대한 맥락을 담고 있고, then과 catch를 통해 프로미스 체이닝을 한 다는 것은 "나보다 앞선 코드에서 분명 데이터를 쓰겠지."라는 암시를 담고 있다. JS에서는 두 개념을 구분하지 않고 뭉뚱그려 사용하고 있으니 이번 글에서도 비동기 처리 방식으로 프로미스를 사용한다까지만 짚고 넘어가자.

 

ES6에서는 async/await 방식이 도입되었다. 프로미스가 프로미스 체이닝을 통해 정해진 순서대로 코드를 실행하도록 하지만, 다른 언어에서 사용하는 비동기 처리 방식을 사용하지 않고 굳이 프로미스를 써야하나? 에 대한 생각으로부터 async/await가 제안되기 시작했다.  async/await가 없던 경우 코루틴 방식으로 비동기 코드를 처리하고, 기존에 이를 활용하기 위해서는 제너레이터 형태를 취했어야 했지만, async/await를 도입하여 키워드만 붙이면 마치 동기 코드처럼 코드가 굉장히 깔끔해지는 장점이 있었다. 특히 if문을 통해 제어문을 도입할 수 있었고, 프로미스를 여러 개 호출할 수 있었고, 에러 핸들링을 더 세세하게 할 수 있었다. 마치 비동기 코드가 동기 실행되는 것처럼 보이고 코드를 작성할 수 있는 것이다. 즉, async/await는 예쁜 프로미스 보다 한 단락으로 이쁘게 핀, 예쁜 콜백 지옥에 더 가깝다.

 

async/await의 작동 방식은 다음 그림이 가장 깔끔하게 설명한다고 생각한다. main 함수 안에서 async 함수 하나를 호출하고, async 함수 안에서 두 개의 await 함수를 호출한다고 가정하자.

async1
async/await의 작동 방식.

위 그림에서 main함수가 async 함수를 만나면 몇 가지 instruction을 수행한 후, await를 만난다. await를 만나면 아직 promise1가 수행되지 않았다는 것이기에, IO 처리나 비동기 처리를 하러 다른 쓰레드에 해당 비동기 처리를 하라고 프로미스를 만든 다음, main 함수에게 yield하여 실행이 main으로 돌아온다. 이후 다른 쓰레드가 promise1이 완료되었다는 것을 관찰하고 알리면, promise1은 해당 async 함수의 실행 차례일 때 중단 지점 다음 줄부터 실행하라고 "점프"한다. 이후 async 함수의 코드를 순차 실행하다가 다른 await를 만나면, 같은 방법으로 promise2를 처리한다.

 

착각하기 쉬운 부분 중 하나가 await의 경우 정말로 wait의 의미를 가지지 않는다는 것이다. 해당 작업이 처리될때까지  CPU는 기다리지 않고, CPU는 호율적으로 일하기 위하여 다른 작업을 처리하러 간다. 만약 프로미스가 이미 처리가 완료되어있다면 CPU는 async 함수를 계속 실행할 것이고, 만약 프로미스를 처리해야 한다면 현재 stack과 instruction pointer와 같은 정보들을 메모리에 저장한 다음, 다른 일을 처리하러 간다. 즉, context switching이 발생하기 안성맞춤인 공간이다.

async2
async 함수가 반환값이 있는 경우

async 함수가 만약 반환값을 가지고 있다면, 프로미스 형태로 반환하게 된다. 아마 Typescript를 써본 프로그래머들은 async 함수의 반환값을 프로미스 타입인 것에 익숙할 것이다. async 함수 내부 동작은 앞에서 설명한 대로이고, 만약 async 함수의 작동이 끝나게 되면 전체적인 프로미스를 반환하게 된다. 만약 async 함수가 프로미스를 반환하기 않아도, JS 엔진은 자동으로 promise를 반환하도록 코드를 약간 수정한다.

 

그러나, async/await의 근본은 제너레이터 및 코루틴이다. 실제로 Babel이나 다른 트랜스파일러로 async/await를 넣으면, (_asyncToGenerator) 함수를 호출해 제너레이터 형태로 비동기 함수를 처리한다. 코루틴에 대한 설명은 다른 글로 찾아뵙겠다.


What's the bottleneck?

위의 내용을 이미 알고 있거나, 이해했다면 함수의 성능에 영향을 주는 부분이 단 번에 보일 것이다. 예상한 대로다. await 부분은 보통 하나의 임계 지점 역할을 한다. 비동기 처리를 위하여 새로운 임시 프로미스 객체 제작, 다른 쓰레드 넘어가기 위한 context switching 비용 등등을 고려하면, 상당히 많은 자원이 들어간다는 것을 알 수 있다. 따라서, 최대한 await 키워드를 활용한 호출은 줄이고, 비동기 처리를 여러 번 해야할 경우 한 번의 await를 통해 처리해야 한다고 예상할 수 있다.

밑의 코드 예시가 여러 개 나올 텐데, await 키워드는 async 함수로 감싸져 있고, myFetch() 함수는 다른 공간에 정의되어 있다고 가정하자.

 

1. 병렬 처리 이후 await를 사용하자.

Promise는 약속이므로, 우리가 myFetch(file)을 하기 되면 OS에게 파일 입출력을 부탁하게 된다. myFetch 함수 앞에 await를 붙임으로써 프로미스가 resolve될 때 까지 그 동안 다른 일을 하러 가야 한다. 만약 3개의 파일을 불러와야 한다면, OS를 순차적으로 3번 부르는 것 보다 병렬적으로 3개의 파일을 들고오도록 하면 더욱 효율적이지 않을까? 다음 예시를 보자.

 

1
2
3
4
5
6
7
8
9
10
const getFile = (file) => new Promise(res => myFetch(file, res));
 
const getFile1 = getFile('file1');
const file1 = await getFile1;
 
const getFile2 = getFile('file2');
const file2 = await getFile2;
 
const getFile3 = getFile('file3');
const file3 = await getFile3;
cs

 

이 코드를 다음과 같이 수정하면 어떤 장점이 있을까?

 

 

1
2
3
4
5
6
7
8
9
const getFile = (file) => new Promise(res => myFetch(file, res));
 
const getFile1 = getFile('file1');
const getFile2 = getFile('file2');
const getFile3 = getFile('file3');
 
const file1 = await getFile1;
const file2 = await getFile2;
const file3 = await getFile3;
cs

 

첫 번째 코드와 두 번째 코드의 가장 큰 차이점은 await의 위치이다. 첫 번째 코드의 경우 file1부터 file2, file3까지 순차적으로 파일을 읽도록 하였고, 두 번째 코드의 경우 병렬적으로 file1, file2, file3를 읽도록 코드를 작정하였다. 첫 번째 코드의 경우 file1을 읽을 때까지 다음 코드를 실행하지 못하지만, 두 번째 코드의 경우 file1을 읽는 과정에 생성된 프로미스가 아직 resolve되지 않아도 두 번째 file2를 읽도록 실행시킬 수 있다. 결국 await가 하나의 중단점이 되기 때문이다. 만약 file2가 file1의 영향을 받거나 연관되어 있다면 두 번째 코드와 같이 작성하면 안되지만, 독립적인 파일이라면 아래와 같은 병렬 처리 방법으로 조금 더 빠른 입출력이 가능해진다.

 

여러 개의 Promise를 처리하는 방법으로 Promise.all() 메서드를 활용해 await의 횟수를 줄일 수 있고, 가독성을 높일 수 있다!

 

1
const fileArray = await Promise.all([getFile('file1'), getFile('file2'), getFile('file3')]);
cs

 

2. Promise가 담긴 Array를 생성하여 처리하자.

위의 경우, 파일 3개를 읽도록 하였지만, 만약 임의의 여러 개의 파일을 처리해야 한다면 어떻게 해야할까? 임의의 길이를 가진 객체를 생성하여 이 객체를 처리하는 방식을 활용하면 된다. 다른 말로, 프로미스가 담긴 객체를 만들어 이를 한 번에 처리하면 된다. 다음 예시를 보자.

 

1
2
3
4
5
6
const getFile = (file) => new Promise(res => myFetch(file, res));
 
const fileArray = ['file1''file2''file3', ... , 'fileN'];
 
const fetchAll = fileArray.map(getFile);
const files = await Promise.all(fetchAll);
cs

 

프로미스 여러 개를 array로 묶은 다음, 이를 map을 활용해서 파일 읽기를 병렬적으로 처리할 수 있도록 하였다. 이후 이터러블을 받는 프로미스 메서드를 활용하여 한 번에 파일을 array에 담아올 수 있다.

 

3.  Loop over Promises

위의 경우 map을 활용하면 병렬적으로 처리할 수 있지만, 만약 프로미스를 순차적으로 해야한다면? 혹은 정해진 순서대로 프로미스를 수행해야 한다면?

 

무엇을 순차적으로 할 때는 for ... in 문을 활용하거나 for ... of 문을 활용하여 순차적으로 처리할 수 있다. for ... of 문을 통해 프로미스를 처리하면 다음과 같이 작성할 수 있다.

 

1
2
3
for (const promise of promises) {
    console.log(await promise);
}
cs

 

그러나, 이는 for문이 비동기 작업을 기다려 준다는 전제 하에 작성된 코드이다. 실제로 for문과 forEach는 모든 비동기 작업이 끝날 때 까지 기다려주지 않는다. 따라서, 다른 방법을 사용해야 한다. 이 경우 사용하는 문법이 for await ... of ... 이다.

MDN에 따른 for await ... of ... 문은 다음과 같이 설명한다.

for await ... of 구문은 보통 비동기에 대응하는 열거자를 나열할 때 쓰인다. ... 일반적인 for ... of 문과 마찬가지로 열거자 심볼이 정의한 속성을 실행하게 되어 열거한 값을 변수로 받아 처리한다.

따라서 위의 코드를 다시 작성하게 되면 원하는 순서대로 실행이 될 것이다.

 

1
2
3
for await (const res of promises) {
    console.log(res);
}
cs

 

4. Promise.allSettled()를 활용하자.

Promise의 allSettled 메서드는 ES2020에 도입된 메서드로, Promise.all()과는 유사하지만 조금 다른 동작은 하는 메서드이다. Promise.all()의 경우 여러 개의 프로미스 중 reject된 프로미스가 있다면 reject를 최종적으로 하고 첫 번째 rejected 프로미스를 반환하지만, 다른 프로미스를 처리하지는 못한다. 근데 다른 resolved된 프로미스에 대한 값을 받고 싶다면?

 

첫 번째 방법은 catch 단에서 rejected된 프로미스를 따로 처리하여 다른 프로미스에 영향이 가지 않도록 한다.

 

1
2
3
4
const fetchFiles = filesArray.map(file => fetchFile(file).catch(() => 'rejected'));
 
const result = await Promise.all(fetchFiles);
const resolvedResult = result.filter(res => res !== 'rejected');
cs

 

만약 Promise.allSettled()를 사용하게 된다면 코드의 로직 분리가 매우 쉬워진다. 각 프로미스에 대하여 reject가 하나 발생하면, 전체적인 프로미스 결과는 rejected가 아니라 다른 프로미스를 모두 기다렸다가, 이에 대한 결과 array를 반환한다. Array 내부의 프로미스가 resolved면 결과를 담고, rejected이면 그 이유를 담는다.

 

Promised.allSettled()를 활용하면 다음과 같이 작성할 수 있다.

 

1
2
3
4
const fetchFiles = filesArray.map(file => fetchFile(file));
 
const result = await Promise.allSettled(fetchFiles);
const resolvedResult = result.filter(res => res.status !== 'rejected');
cs

마무리

위의 내용에서 핵심만 정리하면 다음과 같다.

 

  1. 병렬적으로 처리하려면 await는 async한 함수를 모두 호출한 다면 뒤에 사용하자.
  2. Promise가 여러 개라면 Array에 담아 처리해라.
  3. Promise가 순차적으로 실행되어야 하면 for await ... of 문을 활용하자.
  4. 모든 Promise에 대한 결과가 필요하다면 Promise.all() 대신 Promise.allSettled()를 활용하자.

이외에도 여러 종류의 비동기 처리 최적화가 있지만, 선험적인 최적화는 자칫 코드 흐름을 망칠 수 있으니 이 정도의 최적화만 한다면 흐름 내에 조금 더 빠른 비동기 함수 처리가 가능할 것이다.

반응형
댓글
Total
Today
Yesterday
공지사항
최근에 올라온 글
최근에 달린 댓글
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함