본문 바로가기

Javascript/Basic

JavaScript 비동기 완벽하게 이해하기

 

*목차

 - intro

 - JavaScript는 Single Thread

 - Event Loop란?

 - 비동기(Asynchronous) 동작 예시

  예제 1.

  예제 2.

  예제 3.

  예제 4.

 - 비동기 프로그래밍

  1. Callback Function

  2. Promise

  3. Async, await

 

 


Intro

웹 개발의 첫 단추를 끼우며 JavaScript언어를 처음 접했을때의 기억이 떠오릅니다. JavaScript는 어설픈 규칙('1' + 1 = '11') 이 있었으면서도 Web Application에서 거의 유일하게 사용 가능한 프로그래밍 언어였기 때문에 피해갈 수 없는 관문 같은 느낌이었습니다. 그래도 몇 주 지나니 친숙한 언어로 금방 사용이 가능했습니다.

 

그러던 도중에 JavaScript 비동기(Asynchronous) 프로그래밍을 알게 되었습니다. 다행히 필자는 ES6 표준이 배포될 때여서 Promise 개념을 사용하여 비동기 프로그래밍을 진행했었습니다. 하지만 비동기 프로그래밍 관련 내용을 검색할 때마다 완벽히 이해하기 어려웠던 개념이 하나 있었습니다. 그것은 바로 Callback function이란 단어였습니다. 이 단어는 비동기 프로그래밍을 검색하면 항상 따라오는 단어였습니다. 

 

도대체 Callback Function이 뭔데?

 

 

처음에는 Callback Function이 비동기 처리 기법과 동일한 개념으로 생각했습니다.(Backend로 api를 call하면 응답이 back하는...) 하지만 Callback Function을 검색하면 비동기와는 전혀 상관없는 답변이 돌아왔습니다.

 

Callback Function이란 : 다른 코드에 인수로 전달되는 실행 가능한 코드에 대한 참조(reference)

 

 

알고보니 Callback이란 용어가 생소했을 뿐, 함수를 Library처럼 인자로 호출해서 사용하는 것을 Callback function이라는 것을 알게 되었습니다. 이런 설계방식은 비동기와 상관없이 이전부터 작업했던 것입니다.

 

아래는 Callback function을 사용한 C++ code와 Python code입니다.

(여기서 add와 sub가 바로 Callback Function입니다.)

 

* Python Callback Function Example

def add(a, b):
    return a + b

def sub(a, b):
    return a - b

def calculate(a, b, func):
    return func(a, b)

print(calculate(3, 2, add))  # 5
print(calculate(3, 2, sub))  # 1

 

* C++ Callback Function Example

#include <iostream>

int add(int a, int b) {
    return a + b;
}

int sub(int a, int b) {
    return a - b;
}

int calculate(int a, int b, int (*func)(int, int)) {
    return func(a, b);
}

int main() {
    std::cout << calculate(3, 2, add) << std::endl; // 5
    std::cout << calculate(3, 2, sub) << std::endl; // 1

    return 0;
}

 

 

보시다시피 Callback Function을 다룰 때 전혀 비동기와는 상관없는 것을 알 수 있습니다. 하지만 JavaScript에서 Callback function을 물어보면 항상 이런 예제를 보여주곤 합니다.

 

* JavaScript Callback Function Example (from ChatGPT)

// 비동기 작업을 수행하는 함수
function doAsyncTask(callback) {
  setTimeout(function() {
    console.log("비동기 작업 완료");
    callback("결과 데이터");
  }, 2000); // 2초 후에 작업이 완료됨
}

// 콜백 함수 정의
function handleResult(result) {
  console.log("결과 처리:", result);
}

// 비동기 작업 시작
console.log("작업 시작");
doAsyncTask(handleResult);
console.log("작업 중"); // 비동기 작업이 완료될 때까지 이 부분은 먼저 실행됩니다.

 

왜 항상 JavaScript는 Callback Function과 비동기가 따라다니는 것까요? 그 이유를 알기 위해서는 JavaScript의 2가지를 이해해야 합니다.

 1. JavaScript는 Single Thread로 동작합니다.

 2. JavaScript는 Event Loop를 통해 비동기 처럼 동작할 방법을 구현했습니다.

 

 

 


JavaScript는 Single Thread

JavaScript가 SingleThread로 동작한다는 개념은 보편적인 사실입니다. 하지만 생각해보니 Single Thread로 어떻게 비동기로 작성할 수 있을까 라는 의구심이 들었습니다. C/C++, Java, Python은 쉽게 Multi Thread로 구현이 가능하여 비동기로 작업을 처리할 수 있지만 JavaScript는 어떻게 비동기가 가능할까요? 우선 Multi Thread로 비동기를 어떻게 구현하는지 그림을 통해 알아보겠습니다.

 

먼저, C++ 환경에서 Single Thread로 작업을 처리하는 방식과 Multi Thread로 I/O BoundCPU Bound를 처리하는 방법에 대해 그림으로 표현해 보았습니다.

Single Thread로는 34초 걸리는 작업을

 

Multi Thread로는 8초로 단축 시킬수 있다.

 

이렇게 병렬로 처리하는 것이 쉬운 C++은 Callback Function을 통해 비동기를 처리할 필요가 없습니다. Thread를 생성해서 비동기 처리를 하면 그만이기 때문입니다.

 

Python의 경우는 GIL이 있기 때문에 사실상 Single Thread처럼 동작합니다. 하지만 Python에서는 Thread모듈을 지원하여 I/O Bound정도는 Multi Thread로 처리할 수 있습니다.

(사실 Multi Processing을 도입하면 CPU Bound도 단축 시킬 수 있지만 이 글에서는 자세히 언급하지 않도록 하겠습니다.)

 

GIL때문에 CPU Bound는 Thread로 처리할 수 없다.

 

JavaScript는 Single Thread입니다. 그러므로 처음에 봤던 방식으로 JavaScript는 아래와 같이 작업을 처리하게 됩니다. file 읽기 작업을 JavaScript로 하려면 Node.js로 작업해야 겠군요.

 

 

이 작업방식대로 진행한다면 34초 걸리게 됩니다. 하지만 Node.js는 비동기 함수를 지원합니다. 비동기 함수를 사용해서 File을 읽는 API('fs')를 호출한다고 하면 시간을 아래와 같이 단축할 수 있게 됩니다.

Main Thread 하나만으로 File Read를 한 번에 진행했다.

 

어떻게 Single Thread로 비동기 작업을 진행할 수 있는 것일까요? 이 방법에 대한 이해를 위해서는 JavaScript Event Loop에 대한 이해가 필요합니다.

 

 


Event Loop란?

JavaScript에서의 Event Loop란 무엇일까요? Event Loop란 Stack의 상태를 감시하다가 Stack이 비어 있으면 Callback Queue에 있는 작업들을 Stack으로 올리는 메커니즘입니다. 이 문장을 이해하기 위해 하나씩 그림으로 살펴보겠습니다. 아래는 JavaScript의 Interpreter인 V8 engine입니다. 

JavaScript의 Interpreter

 

 

다른 개발 언어들과 마찬가지로 JavaScript Interpreter(V8 engine)는 Heap, Stack 메모리 구조를 가지고 있습니다. Heap에는 Object들이 보관되어 있고 Stack에는 실행할 함수들이 차례대로 쌓여 있습니다. Stack 구조이므로 Function 1, 2, 3, 4순서로 쌓인 후에 Function 4, 3, 2, 1순으로 실행되고 사라질 예정입니다. Stack에 쌓인 작업이 끝나면 Stack은 비워지게 됩니다.

Stack이 비워졌다.

 

 

만약에 빈 스택에 오랜 시간이 걸리는 Big Function이 들어온다면 어떻게 될까요?

오래 걸리는 작업이 들어왔다.

 

 

JavaScript는 Single Thread이므로 이 뒤에 어떤 작업이 기다리던간에 Big Function을 먼저 해결해야 합니다. 그러므로 Big Function을 연산하는 동안 다른 동작은 수행할 수 없습니다. 이것은 매우 큰 문제입니다. 만약 Browser에서 이런 상황이 벌어졌다면 어떻게 되는 것일까요? Big Function 작업을 하느라 화면 클릭조차 동작하지 않는 먹통이 된 Browser를 경험할 수 있습니다. 이런 문제가 발생하지 않도록 Big Function을 처리할 방법이 없을까요?

 

똑똑한 사람들은 Big Function같이 큰 작업을 Web APIs로 돌리기로 설계 합니다. 아래 그림은 V8 engine 이 Stack에 있는 Big Function을 직접 처리하지 않고 Web APIs로 넘기려고 하고 있습니다.

Web API 전용 함수를 사용한 Big Function이 호출되고 있다.

 

 

이런 Big Function은 어떤 것들이 있을까요? setTimeout이나 fetch같은 함수가 되겠군요. 이런 함수들은 Web APIs에서 지원하는 함수들입니다. V8 engine 이 자체적으로 해결하기에는 너무 큰(혹은 Stack을 멈추게 하는) 함수들이죠. Web APIs로 넘어가게 되면 이제는 OS Kernel단에서 대신 계산해주기 때문에 V8 engine은 Stack을 처리하는데 집중할 수 있게 됩니다.

 

하지만 이대로 Web APIs에 작업을 호출하면 문제가 생깁니다. Web APIs에서 작업이 끝난 후 결과가 Return 될텐데 이 결과를 V8 engine 은 어떻게 다뤄야 할지 모릅니다. Web APIs가 잘 포장해서 전달해주지도 않고요. 즉 Web APIs에 보낼 때 결과에 대해서 어떻게 진행할지 미리 작성해야 합니다. 이런 어떻게 진행할지를 서술한 함수를 Callback Function기법을 활용해 보도록 합시다.  즉 Big Function에 Callback Function을 같이 붙여서 Web APIs에 넘기는 것이지요. 그러면 이제는 Web APIs의 연산 결과를 V8 engine은 어떻게 처리할 지 Callback Function을 통해 진행할 수 있게 됩니다. 아래는 setTimeout의 callback Function 예제 입니다.

setTimeout(() => {console.log("next job")}, 1000);

// setTimeout은 Big Function이다. Stack에서 직접 실행하면 Stack이 막히게 된다.
// Stack이 막힐 수는 없으니 Web APIs에 보낸다. Callback Function과 함께.
// setTimeout은 Web APIs에서 실행 된 후 완료되면 callback Function을 실행하게 된다.
// () => {console.log("next job")은 Callback Function이다.
// 즉 1초가 지난 후에 console에는 "next job"이 실행되게 된다.

 

Big Function 후에 다음 작업을 진행할 Callback Function을 같이 작성하여 전달한다.

 

 

Big Function에 Callback Function을 달아 Web APIs 영역으로 전달할 준비가 되었습니다. 아래에 다음 과정을 확인해보니 Big Function이 잘 전달되었습니다. 다행히 Stack이 비워졌군요. Stack이 비워짐과 동시에 새로운 작업이 들어왔습니다. 이번에는 click과 같은 가벼운 작업이 들어왔네요. ms 이내로 바로 연산하여 Stack을 비우게 될 것입니다. 이렇게 Web APIs로 Big Function을 보내게 된다면 새로운 Function 작업이 들어와도 처리가 가능해집니다. 마치 Multi Thread처럼 동작하게 됩니다.

 

 

Stack에 있는 가벼운 연산량을 가진 function(click, form 입력 등 user interface단위 작업)들은 ms단위 이하로 빠르게 return됩니다. 시간이 몇 초 지나자 Web API Big Function작업이 끝나게 됩니다. 작업이 끝났으니 Callback Function이 호출되겠죠? Web APIs는 이 Callback Function을 Stack에 직접 넣지 않고 Callback Queue에 넣게 됩니다.

바로 Stack에 넣어서 다음 작업을 시작하는 것이 아니라 Callback Queue에 Push한다.

 

 

이제 이 Callback Queue에 있는 Callback Function을 Stack에 넣어서 V8 engine 이 다시 처리할 수 있도록 해야 합니다. 이 작업이 진행되는동안 새로운 function 5, 6, 7이 Stack에 쌓였군요. 이 Stack이 빌때까지 Big Function result는 기다립니다. Stack에는 가벼운 작업이 쌓이기 때문에 ms이하 단위로 Stack이 비게 됩니다. 아래 그림에서는 드디어 Stack에 쌓인 모든 Function을 처리하여 빈 Stack이 되었습니다.

Stack이 비었다. 이제는 Callback Function을 수행할 차례다.

 

 

이제 Callback Queue에서 Stack으로 Callback Function을 Push해야 합니다. 그런데 Callback Queue는 Stack이 비었는지 어떻게 알고 Push할 수 있을까요? Stack이 비었는지 알기 위해서는 Stack을 지속적으로 감시해야 합니다. 그 Stack 감시자가 바로 Event Loop입니다. 

Event Loop는 Stack이 비었는지 감지 후 Callback Queue에서 Stack으로 Callback Function을 Push한다.

 

 

Stack에 Callback Function이 들어왔으므로 V8 engine은 이 작업을 처리하게 됩니다. 결과적으로, Big Function을 비동기 적으로 처리할 수 있게 되었습니다. V8 engine 자체는 Single Thread이지만 Web APIs와 Callback Queue, 그리고 Event Loop를 통해서 비동기를 구현하게 되었습니다.

 

이 구조가 바로 Chrome Browser가 JavaScript를 Single Thread로 비동기적으로 구동하는 방식입니다.

Chrome Browser가 비동기를 처리하는 방식

 

 

Web APIs대신 C++로 만들어진 APIs를 사용하면 Node.js가 됩니다.

Node.js가 비동기 처리하는 방식

 

위 그림을 통해 우리는 2가지 사실을 알 수 있습니다.

 

 1. JavaScript Interpreter인 V8 engine 자체로는 비동기 프로그래밍을 할 수 없다.

 2. 비동기 프로그래밍은 APIs와 Callback, Event Loop를 통해 이루어 진다.

 

이 2가지 사실을 잘 기억하도록 합시다.

 

(아래 Site에서 JavaScript의 Event Loop를 시각적으로 확인해 볼 수 있습니다.)

 

http://latentflip.com/loupe/

 

latentflip.com

 

 

예시를 통해 비동기 프로그래밍 동작을 확인해 봅시다.

 

 


비동기(Asynchronous) 동작 예시

예제 1.

아래와 같이 코드를 작성해 보았습니다. (앞으로 작성될 예시는 Node.js에서 작성됩니다만 Chrome Browser로 생각해도 상관 없습니다)

function func3(cb) {
    console.log("function3")
    return cb
}

function func2(cb) {
    setTimeout(() => {
        console.log("function2");
    }, 1000);
    return cb
}

function func1(cb) {
    console.log("function1")
    return cb
}

function main(){
    func1(
        func2(
            func3()
        )
    )
}

main()

 

function1, 2, 3이 순서대로 작성되었으며, main() 함수에서 function1, 2, 3을 Stack으로 쌓은 모습입니다. 

Stack에서 처리되는 console.log는 Console에 출력된다.

 

 

이제 Stack을 하나씩 처리할 차례입니다. 먼저 Function3이 처리됩니다. console.log("function3")이 수행됩니다.

Stack의 맨 위가 사라지고 Function3이 출력되었다.

 

 

다음은 Function2를 수행합니다. 이 Function2는 Web APIs에 있는 setTimeout 함수이기 때문에 Web APIs작업으로 옮겨집니다.

setTimeout은 APIs에서 지원한다. V8자체가 지원하지 않는다.

 

 

이제 Stack에 있는 Function1을 처리하도록 합니다. 여기까지 ms 이하 단위 시간이 걸립니다.

Function1이 수행되었다.

 

 

1초가 지나면 setTimeout도 완료가 됩니다. 이제 CallbackFunction은 Callback Queue에 들어가고 stack이 비어있으므로 stack으로 바로 push됩니다.

Callback Function이 Queue를 지나 Stack에 쌓이게 되었다.

 

Stack에 쌓인 Callback Function은 function2를 수행하게 됩니다.

Function2가 수행되었다.

 

 

이 설명을 정리하면 다음과 같습니다.

 1. API호출 함수를 사용하면 Stack에서 바로 처리되지 않고 APIs영역으로 넘어감

 2. APIs로 넘어간 작업(Task)은 OS kernel단에서 연산된 후 Callback function을 Queue에 push함

 3. Queue에 있는 Callback Function은 Stack이 비었는지 Event Loop가 확인 후 Stack으로 push함

 4. Stack은 Callback Function을 처리함

 

이런 방식으로 처리하기 때문에 setTimeout을 그냥 단순히 Delay처럼 생각하면 안됩니다. 기존의 Delay, sleep 함수는 Stack 자체를 지연시키는 방식이라면, setTimeout과 같은 비동기 API 함수들은 APIs 영역으로 넘어가 수행 후 Stack이 빌때까지 Callback function들은 Queue에서 기다리고 있어야 하는 형태 입니다.

 

 

 

예제 2.

다음과 같은 코드를 작성해 보았습니다. 11번 Function을 쌓아보았습니다. 

function func3(cb) {
    console.log("function3")
    return cb
}

function func2(cb) {
    setTimeout(() => {
        console.log("function2");
    }, 0);
    return cb
}

function func1(cb) {
    console.log("function1")
    return cb
}

function main(){
    func1(
        func1(
            func1(
                func1(
                    func1(
                        func1(
                            func1(
                                func2(
                                    func3()
                                )
                            )
                        )
                    )
                )
            )
        )
    )
}

main()

 

 

이렇게 작성된 코드가 동작하면 func1이 7번, 비동기 func2가 1번, func3이 1번 stack에 쌓이게 됩니다. 그리고 비동기 function2의 setTimeout이 0초로 설정되어 있습니다. 이렇게 작성된 코드를 단순하게 생각하면(Stack에서만 처리 된다고 생각하면) 다음과 같은 결과를 기대할 것입니다.

setTimeout이 없으면 function3, 2, 1순서대로 호출된 모습을 확인할 수 있다.

 

하지만 setTimeout으로 인해 func2는 APIs 영역으로 넘어가게 되고 그동안 Stack에 있는 작업(Task)를 수행하게 됩니다. 그래서 실제로 얻는 결과 순서는 다음과 같습니다.

function2가 가장 나중에 나타났다.

 

setTimeout(callback, 0초)로 설정해도 stack에 쌓이는 작업들보다 항상 후순위로 밀리는 것입니다. 이런 방식을 이해하고 있다면 이제 다른 예제도 이해하기 쉬울 겁니다.

 

만약 setTimeout을 4번 호출한다면 어떻게 될까요?

function func(num) {
    console.log("function", num);
}

function main(){
    setTimeout(() => {func(1)}, 1000);
    setTimeout(() => {func(2)}, 1000);
    setTimeout(() => {func(3)}, 1000);
    setTimeout(() => {func(4)}, 1000);
}

main()

 

1초의 지연 후에 console.log()를 수행하는 함수를 4번 수행하는 main()입니다. 이 작업이 Stack에서 바로 이루어지게 된다면 1초 간격으로 console.log가 출력 될 것입니다. 하지만 이 코드를 실제로 실행해보면 1초가 지나자 동시에 출력되는 모습을 확인할 수 있습니다.

function 1, 2, 3, 4가 동시에 출력된다. 직접 실행해보시길...

 

이는 Stack에 쌓이는 즉시 APIs로 넘어가서 병렬로 setTimeout이 수행되기 때문에 일어나는 현상입니다. 그림으로 표현하면 다음과 같습니다.

 

 

Stack이 4개 쌓인다. (실제로는 안쌓이고 하나씩 바로 처리되지만 이해를 위해 Stack에 몰아서 표현했다)

 

 

APIs에 모든 작업이 넘어가고 setTimeout이 병렬로 수행된다. 여기서 1초가 수행된다

 

 

API 작업이 끝나고 Callback Queue에 push되었다. (이것도 실제로는 하나씩 Stack으로 바로 이동되겠지만 이해를 돕기 위해 표현되었다)

 

 

stack에 Callback 1이 올라가고 Queue에 있는 Callback 순서는 한 칸씩 당겨진다.

 

 

Console에 function 1이 출력된다.

 

 

남은 모든 Queue가 처리되고 function1, 2, 3, 4가 console에 출력된다.

 

 

 

예제 3.

function func(num) {
    console.log("function", num);
}

function main(){
    setTimeout(() => {func(1)}, 1000);
    setTimeout(() => {func(2)}, 1000);
    setTimeout(() => {func(3)}, 1000);
    setTimeout(() => {func(4)}, 1000); 
    func(5);
    func(6);
    func(7);
    func(8);
}

main()

 

이 작업 방식을 그림으로 표현하면 다음과 같습니다.

이제는 비동기 동작 순서가 이해 되실까요?

 

 

결과는 다음과 같습니다.

예상한 순서와 같나요?

 

예제 4.

function main(){
    let i;

    console.log("start");

    for(i=0; i<5; i++){
        setTimeout(() => {console.log(i)}, 1000+1000*i);
    }
    i = 100;
}

main()

 

이번에는 Heap, Stack에서 Heap까지 다루는 예제를 보겠습니다. 예제 코드는 i가 0부터 5까지 증가하면서 setTimeout을 선언합니다. 선언할 때 callback function으로 console.log를 지정했고, 지연시간은 1초, 2초, ...로 1초 단위로 증가하도록 설정했습니다. 그리고 생뚱맞게도(?) 반복문이 끝난 후에 i=100으로 설정을 했네요. 

 

이렇게 되면 결과는 어떻게 나올까요? 0, 1, 2 ... 순서로 출력이 될까요?

100이 출력되었습니다.

 

왜 이렇게 되었을까요? 그림으로 설명해 보겠습니다. 아래 그림은 Heap에 i값이 저장되어 있고 setTimeout과 callback function으로 console.log(i)가 들어있는 그림입니다.

 

 

이 상황에서 for문으로 5번 돌면서 APIs에는 setTimeout이 계산되게 되고, Heap에 있는 i는 0부터 5까지 증가한 후 for문이 종료됩니다. 그러면 아래와 같은 그림 상황이겠네요.

 

그리고 setTimeout이 끝난 직후 CallbackQueue로 차례대로 들어올 것입니다. 그런데 for loop가 끝난 후 javascript code는 i = 100이란 명령을 수행해야 합니다. 그래서 그림에 있는 Heap 속의 i 변수값은 100으로 변경되게 됩니다.

 

i가 100으로 바뀌었습니다. 물론 setTimeout과 callback function들은 이러한 사실을 알 수 없습니다. APIs에서 처리되고 있기 때문이죠. 이런 APIs에 처리되고 난 후 Callback function은 Callback Queue를 지나 다시 stack에 쌓이게 됩니다. 아래 그림은 1초가 지난 후의 상황입니다.

callback function이었던 Console.log(i)가 stack으로 들어왔습니다. 그리고 이제 i를 출력해야 하는데 i = 100으로 저장된 변수가 Heap에 저장이 되어 있군요. 이 값을 출력해 주도록 합니다.

 

같은 방식으로 나머지 callback Function도 100을 출력하게 될 것입니다. 이제 예제 4 가 동작하는 방식에 대해서 이해가 가셨나요?

 

만약에 console.log(i)에 for loop를 돌면서 i값을 지정해서 넣고 싶다면 코드를 다음과 같이 작성하면 됩니다.

function main() {
    let i;

    console.log("start");

    for (i = 0; i < 5; i++) {
        setTimeout(
            ((j) => {return () => console.log(j)})(i),
            1000 + 1000 * i);
    }
    i = 100;
}

main();

 

이 코드는 Clousure와 IIFE(Immediately Invoked Function Expression)을 활용해 for문에 있는 i가 Closure에 보존되도록 만든 것입니다. 이 코드를 설명하기에는 현재 난이도가 조금 달라 다른 글에서 설명 드리도록 하겠습니다.

 


지금까지 예제 1, 2, 3, 4를 보면서 비동기 프로그래밍 동작 방식에 대해서 알아 보았습니다. 여기까지 천천히 정독하셨다면 Stack에서 바로 처리 가능한 동기적 처리와 APIs로 넘어가서 처리되는 비동기적 처리가 구분되실 겁니다. 그런데 이제는 새로운 질문이 생깁니다.

 

setTimeout 1초 설정 후에 setTimeout 1초를 진행하려면 어떻게 해야 하나요?

 

 

다음과 같은 상황을 만들고 싶다고 합시다.

1초씩 기다리는 setTimeout을 순차적으로 실행하고 싶다.

 

이렇게 동작하게 하려면 어떻게 해야 할까요? APIs로 넘기지 말고 그냥 Stack에 setTimeout 1초를 실행시켜볼까요? 하지만 JavaScript는 Single Thread입니다. Stack 영역에 sleep(1초) 같은 함수를 실행했다간 코드 전체가 1초동안 멈춰버립니다. 그리고 애초에 Stack에 sleep과 같은 함수를 실행시킬 방법도 없습니다.

 

사실 setTimeout이 끝나고 또 setTimeout을 실행하는 것은 바로 비동기 함수를 순차적으로 수행하겠다는 뜻입니다. 지금까지는 비동기 함수를 만나면 바로바로 APIs 영역에 전달했는데 어떻게 해야 비동기 함수를 순차적으로 처리할 수 있을까요? 여기서 바로 Callback Function이 이 문제를 해결할 수 있습니다.

 

 

 


비동기 프로그래밍

연쇄적으로 setTimeout을 진행하게 하려면 어떻게 해야 할까요? 바로 setTimeout의 Callback Function을 setTimeout으로 작성하면 됩니다. 그 뒤에 또 setTimeout이 동작하게 하려면? 또 Callback Function에 setTimeout을 작성하면 됩니다.

 

1. Callback Function

Callback Function을 앞에서 정의할 때 이렇게 말했었습니다.

Callback Function : 다른 코드에 인수로 전달되는 실행 가능한 코드에 대한 참조

 

 

그리고 필자는 JavaScript에서 callback function의 위치를 그림에서 이렇게 표현했습니다.

setTimeout에 Callback Function이 붙어 있다.

 

여기에 Callback Function을 setTimeout으로 작성하면 어떻게 될까요? 그러면 빨간색, 주황색 setTimeout과 마지막 돌아올 Callback Function이 그림과 같이 표현됩니다.

빨간색 setTimeout은 Web APIs로 넘어가서 몇 초간 기다리게 됩니다. 그리고 몇 초가 끝나면 Callback Queue에 들어가게 되는데 그 함수는 주황색 setTimeout이군요.

 

이 주황색 setTimeout은 Stack으로 들어간 후 다시 Web APIs로 들어가게 됩니다. 그리고 마지막으로 파란색 Callback Function이 Stack에서 작업을 진행하겠네요. 

JavaScript에서는 이러한 Callback Function 방식으로 비동기 함수를 절차적으로 실행했습니다. 이렇기 때문에 비동기 프로그래밍에 Callback Function이 항상 키워드로 등장했던 것입니다.

 

위의 예제를 코드로 구현하면 다음과 같습니다.

function main(){
    console.log("start")
    setTimeout(
        () => {
            console.log("step1");
            setTimeout(
                () => {console.log("step2")}
            , 1000)
        }
    , 1000)
}

main()

 

만약에 setTimeout을 3번 직렬적으로 실행하게 하려면 어떻게 하면 될까요? Callback Function에 또 setTimeout을 작성하면 됩니다.

function main(){
    console.log("start")
    setTimeout(
        () => {
            console.log("step1")
            setTimeout(
                () => {
                    console.log("step2");
                    setTimeout(
                        () => {console.log("step3")}
                    , 1000)
                }
            , 1000)
        }
    , 1000)
}

main()

 

네 뭔가 이상하지 않나요? Callback Function을 쌓고 쌓을 수록 점점 코드가 말 그대로 옆으로 누운 산 모양이 되어가고 있습니다. 이런 코드는 가독성이 매우 떨어져 유지보수에 좋지 않은 영향을 줍니다. 이런 현상을 콜백 지옥(Callback Hell)이라고 합니다. 비동기 처리를 Callback Function기법으로 사용하면 함수들이 필연적으로 중첩되어 나타나는 현상입니다.

 

하지만 이제는 더이상 이런 콜백지옥에 빠지지 않아도 됩니다. ES6(2015년)부터 Promise라는 문법이 새로 생겼기 때문이죠.

 

 


2. Promise

Callback function을 활용한 비동기 프로그래밍은 가독성에서 너무나 최악의 상황을 보여줍니다. 이런 점을 너무나 잘 알고 있는 ECMA(표준화 기구)는 ES6부터 비동기 프로그래밍을 할 때 Promise라는 것을 활용해 콜백지옥에 빠지지 않는 것을 도와줍니다.

 

Promise의 기본 용법은 다음과 같습니다.

const myPromise = new Promise((resolve, reject) => {
    // 비동기 작업 수행 후
    if (작업이 성공) {
        resolve(결과);
    } else {
        reject(에러);
    }
});

 

Promise를 생성할 때 Callback Function을 input으로 넣어야 합니다. 그리고 Callback Function의 input값으로 resolve, reject를 넣어야 하며, resolve, reject또한 Callback Function입니다.

 

Promise의 작업(resolve, reject)이 끝나면 그 다음 작업으로 then을 선언해서 다음 비동기 작업을 직렬적으로 이어 나갈 수 있습니다.

function main(){
    const myPromise = new Promise((resolve, reject) => {
        // 비동기 작업 : setTimeout
        setTimeout(() => {
            const isSuccess = Math.random() > 0.5; // 50% 확률로 성공 또는 실패
            if (isSuccess) {
                resolve("비동기 작업 성공! 결과 데이터");
            } else {
                reject("비동기 작업 실패! 에러 메시지");
            }
        }, 1000);
    });

    myPromise
        .then((result) => {
            console.log("성공:", result);
        })
        .catch((error) => {
            console.error("에러:", error);
        });
}

main()

 

콜백지옥 예시를 Promise로 대체하면 좀 더 쉽게 이해할 수 있습니다.

function delay(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

function main() {
    console.log("start");

    delay(1000)
        .then(() => {
            console.log("step1");
            return delay(1000);
        })
        .then(() => {
            console.log("step2");
            return delay(1000);
        })
        .then(() => {
            console.log("step3");
        })
        .then(() => {
            console.log("another step");
        })
        .catch((error) => {
            console.error("An error occurred:", error);
        });
}

main();

 

이 Promise 덕분에 비동기 작업에서 더이상 산(?)을 쌓지 않고, 보다 가독성 좋은 코딩을 할 수 있게 됩니다.

 

하지만 기존 JavaScript의 에러처리 방법인 try - catch 방법을 사용하기 어렵고, 기존 코딩과 비동기 코딩이 여전히 스타일이 다르다는 점은 남아 있었습니다. 이러한 점을 해소해 주기 위해 생긴 것이 바로 async / await입니다.

 

 

 


3. Async, Await

새로 도입된 ES8(2017년)에서는 async, await 키워드가 추가되었습니다.

 

Promise와 다르게 try, catch 구문을 사용할 수 있으며, 비동기 코드를 동기적으로 작성하여 가독성을 높일 수 있습니다.

function delay(ms) {
    return new Promise((resolve) => {
        setTimeout(resolve, ms);
    });
}

async function main() {
    console.log("start");
    
    await delay(1000);
    console.log("step1");

    await delay(1000);
    console.log("step2");

    await delay(1000);
    console.log("step3");
}

main();

 

async, await의 규칙은 다음과 같습니다.

 

async : 함수 앞에 선언되며, 선언된 함수는 비동기로 동작한다.

await : async가 선언된 함수에서만 사용되며, await가 적힌 줄은 그 줄이 끝날 때까지 다음 줄로 넘어가지 않는다.

 

이 단순한 규칙 덕분에 이제는 JavaScript의 비동기 프로그래밍을 쉽게 할 수 있습니다.

 


 

마치며...

Single Thread에서 비동기 프로그래밍을 하기 위해 참 많은 기법들과 많은 사람들의 노력이 들어갔다 생각합니다. 이 글을 읽으시는 많은 분들이 비동기 프로그래밍이 무엇인지, 그리고 JavaScript가 비동기를 어떤 식으로 처리하는지를 좀 더 깊게 알게 되었으면 합니다. 이제 JavaScript로 다양한 비동기적 상황을 설계할 때 어떻게 코딩해야 하는지 확실하게 아시길 바라면서 여기서 마치도록 하겠습니다. 감사합니다.

 

 

*reference

https://developer.mozilla.org/ko/docs/Web/JavaScript/Event_loop

http://latentflip.com/loupe

https://en.wikipedia.org/wiki/Callback_(computer_programming) 

 

 

'Javascript > Basic' 카테고리의 다른 글

Node.js란?  (0) 2023.09.26