본문 바로가기
Study/Javascript

[Javascript] 비동기를 통해 프로그램의 효율 향상시키기 (Callback, Promise, async / await)

by DevJaewoo 2023. 5. 13.
반응형

동기와 비동기

자바스크립트에서 코드가 실행되는 방법은 동기 / 비동기 두 가지로 나뉠 수 있다.

 

동기 (Synchronous): 코드가 순서대로 실행된다. 작업이 시작되면 끝날떄까지 대기한 후, 다음 작업을 실행한다.

비동기 (Asynchronous): 코드가 동시에 실행된다. 실행중인 작업이 끝나지 않더라도 다음 작업을 실행된다.

 

동기는 실행 흐름을 파악하기 쉽지만 느리다는 특징이 있고, 비동기는 빠르고 효율적이지만 실행 흐름을 파악하기 어렵다는 특징이 있다.

이번 시간엔 비동기로 코드를 실행하는 방법들에 대해 알아보자.


Callback

비동기로 대부분 콜백을 가장 먼저 접한다. 매개변수로 함수 실행 시 실행이 필요한 값들과, 또 다른 함수를 전달한다. 함수의 실행이 완료되면, 전달했던 함수가 호출되는 식으로 구현된다. 대표적으로 setTimeout이 있다.

 

console.log("Program Start!");

setTimeout(() => {
  console.log("setTimeout end!");
}, 1000);

console.log("Program End!");

/* 실행 결과 */
[20:05:35.390] [LOG]   Program Start!
[20:05:35.392] [LOG]   Program End!
[20:05:36.396] [LOG]   setTimeout end!

 

setTimeout의 첫번째 인자로 함수를 전달하고, 두번째 인자로 기다릴 시간을 전달한다. 그러면 일정 시간 이후, 전달한 함수를 실행해준다. 여기서 중요한건, 그 일정 시간 동안 멈춰있는 것이 아닌, 비동기 특성 상 다음 줄을 바로 실행한다는 것이다.

 

실행 결과를 보면, ["Program Start"  - "setTimeout end" - "Program End"] 가 아닌, ["Program Start" - "Program End"  - "setTimeout end"] 순서대로 로그가 출력되는 것을 볼 수 있다. setTimeout은 비동기적으로 실행되며, 때문에 timeout이 끝날때까지 대기하지 않고 다음 줄을 바로 실행하는 것이다.

 

예시를 하나 더 보자.

디렉토리에 접근이 가능할 경우 directory를 생성하고. 그 위치에 file.txt를 생성한다.

import fs from "fs";

console.log("Program Start!");

fs.access("./test", (err) => {
  fs.mkdir("./test/directory", (err) => {
    fs.writeFile("./test/directory/file.txt", "Hello, world!", (err) => {
      console.log("writeFile end!");
    });
    console.log("After writeFile");
  });
  console.log("After mkdir");
});

console.log("Program End!");

/* 실행 결과 */
[20:05:35.051] [LOG]   Program Start!
[20:05:35.053] [LOG]   Program End!
[20:05:35.054] [LOG]   After mkdir
[20:05:35.055] [LOG]   After writeFile
[20:05:35.056] [LOG]   writeFile end!

/* test/directory/file.txt */
Hello, world!

 

setTimeout 때와 비슷하게 console.log들이 상대적으로 위쪽 Line에 있다고 먼저 출력되는것이 아닌것을 볼 수 있다. "비동기는 병렬로 실행되며, 흐름을 파악하기 어렵다"는 것이 이런 의미이다. 하지만 잘 사용하면 작업을 효율적으로 진행해 실행 시간을 대폭 단축시킬 수 있다.

 

Javascript의 내장 함수들을 사용하다보면 Callback을 심심치 않게 볼 수 있다. 이 때 일부러 동기적인 Callback을 구현하지 않는 이상, Javascript의 Callback들은 기본적으로 비동기로 구현되었다 생각하면 된다. 동기적으로 Callback을 구현하면 Callback을 쓰는 의미가 없기 때문이다.


Callback 지옥

위의 fs 관련 예제와 관련하여 한가지 거슬리는 점이 있다.

바로 Callback이 하나씩 늘어날 때마다, 중괄호 중첩도 계속 쌓여간다는 것이다. 2~3번이야 그럴 수 있다 치고 넘어갈 수 있지만, Callback이 5개, 6개, ... 이런 식으로 계속 늘어날 수록 코드가 엄청 더러워질 것이다.

 

예를 들어 1초 뒤에 "1", 그 후 1초 뒤에 "2", 다음 1초 뒤에 "3", ... , 1초 뒤에 "10" 을 출력하는 코드를 Callback만으로 작성해보자.

console.log("Program Start!");

setTimeout(() => {
  console.log("1");
  setTimeout(() => {
    console.log("2");
    setTimeout(() => {
      console.log("3");
      setTimeout(() => {
        console.log("4");
        setTimeout(() => {
          console.log("5");
          setTimeout(() => {
            console.log("6");
            setTimeout(() => {
              console.log("7");
              setTimeout(() => {
                console.log("8");
                setTimeout(() => {
                  console.log("9");
                  setTimeout(() => {
                    console.log("10");
                  }, 1000);
                }, 1000);
              }, 1000);
            }, 1000);
          }, 1000);
        }, 1000);
      }, 1000);
    }, 1000);
  }, 1000);
}, 1000);

console.log("Program End!");

/* 실행 결과 */
[21:09:33.061] [LOG]   Program Start!
[21:09:33.063] [LOG]   Program End!
[21:09:34.069] [LOG]   1
[21:09:35.083] [LOG]   2
[21:09:36.098] [LOG]   3
[21:09:37.115] [LOG]   4
[21:09:38.125] [LOG]   5
[21:09:39.127] [LOG]   6
[21:09:40.129] [LOG]   7
[21:09:41.144] [LOG]   8
[21:09:42.147] [LOG]   9
[21:09:43.150] [LOG]   10

 

코드가 엄청나게 흉측한것을 볼 수 있다. 심지어 기다리는 시간이 Callback 함수보다 뒤에 있어 각 Callback이 몇 초 뒤 실행되는지 알기가 힘들다.

 

Callback마다 중첩을 하나씩 쌓지 않고 비동기 처리를 할 수 없을까?

여기서 Promise가 사용된다.


Promise

Promise는 메소드 체이닝을 사용해 Callback의 중첩 문제를 해결할 수 있다.

 

먼저 기본적인 사용법을 알아보자.

new Promise((resolve, reject) => {
  /* Run some callback job... */
  doSomeCallbackFunc((err) => {
    if (err) reject(err);
    resolve(value);
  });
})
  .then((value) => {
    console.log(`Run job success! Value: ${value}`);
  })
  .catch((reason) => {
    console.log(`Run jon failed! Reason: ${reason}`);
  });

 

Promise는 생성 시 (resolve, reject) 두 개의 함수를 갖는 함수를 인자값으로 받는다.

resolve는 성공적으로 비동기 작업이 종료되었을 때, reject는 작업 중 에러가 발생했을 때 호출한다.

 

Promise는 .then과 .catch를 체이닝하여 비동기 작업이 끝난 이후 처리를 정해줄 수 있다.

  • then: value를 매개변수로 갖는 함수를 인자값으로 받는다. 이 함수는 Promise에서 resolve가 실행될 시 호출된다. resolve 실행 시 전달한 값이 value에 들어오게 된다.
  • catch: reason을 매개변수로 갖는 함수를 인자값으로 받는다. 이 함수는 Promise에서 예외가 발생되거나, reject가 실행될 시 호출된다. Error message 또는 reject 실행 시 전달한 값이 reason에 들어오게 된다.

then에 또다른 then을 체이닝할 수 있다. 만약 then에서 Promise를 return하는 경우, 다음 then은 Promise가 resolve된 이후 실행된다.

 

위의 fs.access, fs.mkdir, fs.writeFile을 사용했던 예제를 fs.promises를 사용하여 다시 작성해보자. fs.promises는 Callback을 받지 않고, Promise를 return해준다. 따라서, then 메소드를 체이닝할 수 있다.

 

import fs from "fs";

console.log("Program Start!");

fs.promises.access("./test")
  .then(() => fs.promises.mkdir("./test/directory", { recursive: true }))
  .then(() => fs.promises.writeFile("./test/directory/file.txt", "Hello, world!"))
  .then(() => console.log("writeFile end!"))
  .catch((reason) => console.log(`Write failed! Reason: ${reason}`));

console.log("Program End!");

/* 실행 결과 */
[22:25:02.775] [LOG]   Program Start!
[22:25:02.777] [LOG]   Program End!
[22:25:02.780] [LOG]   writeFile end!

 

중첩이 쌓이던 이전 코드와는 달리 Promise와 then을 사용해 중첩이 쌓이지 않도록 구현할 수 있었다.

 

한 가지 알아야 할 사실은, Promise 자체는 비동기가 아니라는 것이다. Promise 안에서 비동기 코드를 실행 후 Callback에서 resolve를 호출하거나, then을 사용해야 비동기 코드가 된다. 예를 들자면, 아래의 코드는 동기적으로 작동한다.

console.log("Program Start!");

const func = (num) => {
  return new Promise((resolve) => {
    console.log(num);
    resolve(num);
  });
};

for (let i = 1; i <= 3; i++) {
  func(i);
}

console.log("Program End!");

/* 실행 결과 */
[22:07:39.593] [LOG]   Program Start!
[22:07:39.596] [LOG]   1
[22:07:39.596] [LOG]   2
[22:07:39.597] [LOG]   3
[22:07:39.597] [LOG]   Program End!

 

반면, then 또는 setTimeout의 Callback을 사용한 아래의 코드들은 비동기적으로 작동한다.

console.log("Program Start!");

const func1 = (num) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`func1: ${num}`);
      resolve(num);
    }, 0);
  });
};

const func2 = (num) => {
  return new Promise((resolve) => {
    resolve(num);
  }).then((value) => console.log(`func2: ${value}`));
};

for (let i = 1; i <= 3; i++) {
  func1(i);
  func2(i);
}

console.log("Program End!");

/* 실행 결과 */
[22:10:47.797] [LOG]   Program Start!
[22:10:47.799] [LOG]   Program End!
[22:10:47.800] [LOG]   func2: 1
[22:10:47.800] [LOG]   func2: 2
[22:10:47.800] [LOG]   func2: 3
[22:10:47.803] [LOG]   func1: 1
[22:10:47.803] [LOG]   func1: 2
[22:10:47.803] [LOG]   func1: 3

Promise 안의 함수는 동기적으로 실행된다는 점을 주의하자.

 

위의 내용들을 응용하여 이전의 Callback 지옥을 다음과 같이 해결할 수 있다.

console.log("Program Start!");

const delay = (time) => {
  return new Promise((resolve) => setTimeout(resolve, time));
};

delay(1000)
  .then(() => {
    console.log("1");
    return delay(1000);
  })
  .then(() => {
    console.log("2");
    return delay(1000);
  })
  .then(() => {
    console.log("3");
    return delay(1000);
  })
  .then(() => {
    console.log("...");
    return delay(1000);
  })
  .then(() => {
    console.log("9");
    return delay(1000);
  })
  .then(() => {
    console.log("10");
    return delay(1000);
  });

console.log("Program End!");

/* 실행 결과 */
[22:26:43.309] [LOG]   Program Start!
[22:26:43.311] [LOG]   Program End!
[22:26:44.324] [LOG]   1
[22:26:45.327] [LOG]   2
[22:26:46.329] [LOG]   3
[22:26:47.331] [LOG]   ...
[22:26:48.347] [LOG]   9
[22:26:49.363] [LOG]   10

Promise의 불편함

예시를 보면 이전의 10중첩 Callback보다 훨씬 나아진것을 볼 수 있다.

하지만 Promise가 끝날 때마다 then을 일일이 사용하는것이 번거롭다. 또한 작업 간에 값을 공유하기가 매우 힘들다.

 

여기서 async / await이 등장한다.

async / await을 사용하면 코드를 동기적으로 작성하여, then과 같은 코드를 계속 추가하지 않아도 되며, 동기적으로 작성되었기 때문에 같은 Scope 내에서 변수를 공유할 수 있다.


async / await

async / await은 Promise를 동기적으로 돌아가는것처럼 사용할 수 있게 해준다.

우선 async 사용법부터 알아보자.

 

함수 앞에 async를 붙이면 그 함수는 Promise를 return해준다. 또한, 해당 함수의 return 문은 Promise의 resolve와 같은 역할을 한다.

 

예를 들어, 아래 코드의 Promise를 return해주는 func1async functionfunc2는 똑같이 동작한다.

const func1 = (num) => {
  return new Promise((resolve) => {
    console.log(num);
    resolve(num);
  });
};

const func2 = async (num) => {
  console.log(num);
  return num;
};

 

위에 말했듯이, 위 Promise는 then, callback이 없기 때문에 동기적으로 동작하며, 똑같이 동작하는 async도 마찬가지로 동기적으로 돚악한다.우선은 "이렇게 변환할 수 있다" 정도만 이해하고 넘어가자.

 

async function 안에선 Promise에 then을 붙이는 대신, await Promise와 같이 사용할 수 있다. 이렇게 사용할 경우, 해당 Promise가 resolve될 때까지 기다린 후, resolve된 값을 반환한다.

 

async / await을 이용해 이전의 Promise로 작성했던 코드를 개선해보자.

console.log("Program Start!");

const delay = (time) => {
  return new Promise((resolve) => setTimeout(resolve, time));
};

const func = async (ms) => {
  await delay(ms);
  console.log("1");

  await delay(ms);
  console.log("2");

  await delay(ms);
  console.log("3");

  await delay(ms);
  console.log("...");

  await delay(ms);
  console.log("9");

  await delay(ms);
  console.log("10");
};

func(1000);

console.log("Program End!");

/* 실행 결과 */
[00:15:01.445] [LOG]   Program Start!
[00:15:01.447] [LOG]   Program End!
[00:15:02.460] [LOG]   1
[00:15:03.473] [LOG]   2
[00:15:04.475] [LOG]   3
[00:15:05.475] [LOG]   ...
[00:15:06.489] [LOG]   9
[00:15:07.502] [LOG]   10

 

then으로 체이닝 했던 이전 코드에 비해 훨씬 깔끔해진것을 볼 수 있다. 또한, 변수 ms를 만들고 이 변수를 공유하여 다른 Task들의 delay 시간을 일괄적으로 변경할 수 있게 했다.

 

아래와 같이 await의 retuirn 값을 이용할 수도 있다.

console.log("Program Start!");

const delay = (time) => {
  return new Promise((resolve) =>
    setTimeout(() => {
      console.log(`Current timeout: ${time}`);
      resolve(time + 1000);
    }, time)
  );
};

const func = async (ms) => {
  let time = ms;
  time = await delay(time);
  time = await delay(time);
  time = await delay(time);
};

func(1000);

console.log("Program End!");

/* 실행 결과 */
[00:32:55.196] [LOG]   Program Start!
[00:32:55.198] [LOG]   Program End!
[00:32:56.205] [LOG]   Current timeout: 1000
[00:32:58.215] [LOG]   Current timeout: 2000
[00:33:01.220] [LOG]   Current timeout: 3000

 

Promise 자체는 동기적으로 동작하지만 then 부터는 비동기로 동작하는것과 비슷하게,

async function도 그 자체로는 동기적으로 동작하지만, await이 붙은 이후부터는 비동기로 동작한다.


여기까지 Javascript에서 비동기 코드를 작성하기 위한 방법들을 알아봤다.

Callback, Promise, async / await 등 비동기 코드를 적절히 사용하여 프로그램을  효율적으로 동작시켜보자.

 

참고자료

반응형