본문 바로가기

programming/Javascript

[JS] 비동기 프로그래밍 Callback -> Promise -> Async-await

비동기 프로그래밍에 대해서 정확하게 알지 못하고 늘 주변만 뱅뱅 돌면서 의미도 모르는 채로 기계처럼 사용해왔다.

 

회사에서 코드리뷰도 하고, 다른사람이 하던 작업을 넘겨 받아 리팩토링도 하면서 내가 그동안 얼마나 빈약한 개념을 가지고 있었는지 새삼 깨닫게 되었다.

 

당장 움직이지 않는 손가락이 너무 답답해서 이번에야 말로 비동기 프로그래밍을 이해해보자는 생각으로 정리를 해보았다.

 

Sync & Async

  • Sync
    • JS 는 Sync → 동기적으로 작동
    • hoisting이 된 이후부터 코드가 작성된 순서대로 실행된다
  • async
    • 비동기, 언제 코드가 끝날 지 모른다
    • 끝날 때까지 기다리지 않음
    • ex) setTimeout - callback 함수

 

Callback

  • Sync callback
console.log('1')

function printNow(print) {
	print()
)
printNow(() => console.log('hello')	

console.log('2')
  •  결과
1
hello
2
  • Async callback
console.log('1')
function printLater(print, delay) {
	setTimeout(print(), delay)
}
printLater(() => console.log('hello', 2)
console.log('2')
  •  결과
1
2
hello

⇒ 어쨌든 JS는 모든 코드에 똑같이 작동한다

  • 한 줄 한 줄 동기적으로
  • 실행할 함수 안에서 동기적으로 작동하게 하느냐, 비동기적으로 작동하게 하느냐를 구현하는 것
  • setTimeout 함수 자체가 시간이 걸리는 작업이라고 생각 → 사실상 JS는 동기적으로 실행을 순서에 맞게 시작했지만, 늦게 끝나서 도착한 결과만 놓고 봤을 때 코드 순서와 다를 뿐 (비동기적)

 

setTimeout 함수가 가지고 있는 콜백함수처럼 함수를 정의할 때 콜백함수를 정의 할 수 있다.

A함수 처리 하고 나서 B 함수 처리해 그리고 C 함수 처리해 그리고 D....

 

예제

1. 로그인 검사

2. user 정보를 가지고 권한 return

 

이런 일련의 과정들을 콜백지옥 체험 코드로 구현 해보자

class UserStorage {
  loginUser(id, password, onSuccess, onError) {
    setTimeout(() => {
      if (id == "apple" && password === "banana") {
        onSuccess(id);
      } else {
        onError(new Error("wrong user info"));
      }
    }, 2000);
  }

  getRoles(user, onSuccess, onError) {
    setTimeout(() => {
      if (user === "apple") {
        onSuccess({ id: "apple", role: "admin" });
      } else {
        onError(new Error("no role"));
      }
    }, 1000);
  }
}

const userStorage = new UserStorage();
const id = "apple";
const password = "banana";

userStorage.loginUser(
  id,
  password,
  (res) => {
    console.log("Welcom", res);
    userStorage.getRoles(res, (res) => {
      console.log(`hello ${res.id}, user Role is ${res.role}`);
    }),
      (error) => console.log("error", error);
  },
  (error) => console.log("error", error)
);
정의해둔 class와 메소드를 사용하여 로직을 구현하는 부분을 보면 가독성이 떨어진다.
콜백함수 안에 또 콜백함수가 들어가 있는 방식으로, 에러처리도 힘들고, 무엇을 진행하려고 하는 지 모르겠다

 

Promise

  • 프로미스는 JS 에서 제공하는 Class (Object)이다
  • 프로미스 Object 안에는 state라는 속성이 있다
    • pending : 진행중
    • fulfilled : 완료
    • rejected : 실패
  • Producer & Consumer
    • 필요한 정보를 만들어주는 Producer → Promise 객체
    • 그 정보를 소비하는 Consumer
  • Promise 생성자
    • executer 콜백 함수를 전달해 주어야 한다
      • executer 함수는 resolve와 reject 2가지의 콜백함수를 가지고 있다

  • Promise를 만드는 순간 내부의 콜백함수 (executer) 가 바로 실행된다
  • 사용자가 액션을 한 순간에 작업이 진행되어야 하면, Promise를 생성하는 방식으로 사용 불가 → 생성과 동시에 바로 실행되기 때문에 (유저의 액션을 체크 불가 & 불필요한 네트워크 통신)
const promise = new Promise((resolve, reject) => {
  console.log("Network connection");
});
  • Producer(resolve/reject) & Consumer(then/catch/finally)
  • Promise 에서 성공시 객체를 resolve에 담고, 실패시 에러를 reject에 담는다
  • 생성한 promise 객체에서는 then을 통해 성공에 대한 결과를 받아내고, catch를 통해 에러를 받아낸다
// Producer
const promise = new Promise((resolve, reject) => {
  console.log("Network connection");
  setTimeout(() => {
    result = "apple";
    if (result === "apple") resolve(result);
    else reject(new Error("no network"));
  }, 2000);
});

//Consumer
promise
  .then((res) => {
    console.log(res);
  })
  .catch((error) => {
    console.log(error);
  })
  .finally(() => {
    console.log("finally");
  });
  • then/catch/finally 메소드는 각각 모두 Promise를 반환 → Chaining 적용하여 처리
// Promise Chaining
const fetchNumber = new Promise((resolve, reject) => {
  setTimeout(() => resolve(1), 1000);
});

fetchNumber
  .then((num) => num * 2)
  .then((num) => num * 3)
  .then((num) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => resolve(num - 1), 1000);
    });
  })
  .then((num) => console.log(num));
  • then을 통해서 값을 return 하거나, 새로운 Promise를 return 하거나
  • Promise Object 에 매개변수를 넘겨줄 수 있다
const getHen = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve("hen"), 1000);
  });

const getEgg = (hen) =>
  new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error("no Egg!")), 1000);
  });

const cook = (egg) =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(`${egg} -> fry`), 1000);
  });

getHen()
  .then((res) => getEgg(res))
  .catch((error) => {
    console.log(error);
    return "bread";
  })
  .then((res) => cook(res))
  .then((res) => console.log(res));

// 한가지만 받아서 그대로 넘겨줄 때 축약표현 가능
// getHen()
// .then(getEgg)
// .then(cook)
// .then(console.log);
  • 결과
// CASE1
hen -> egg -> fry

// CASE2
Error: no Egg!
    at Timeout._onTimeout (D:\dev\yakbang_front\promise.js:45:29)
    at listOnTimeout (node:internal/timers:559:17)
    at processTimers (node:internal/timers:502:7)
bread -> fry

 

예제

위에서 작성했던 콜백지옥을 Promise를 사용하여 레벨업을 시켜보자

class UserStorage {
  loginUser(id, password) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (id === "apple" && password === "banana") {
          resolve(id);
        } else {
          reject(new Error("wrong user info"));
        }
      });
    });
  }
  getRoles(user) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (user === "apple") {
          resolve({ id: "apple", role: "admin" });
        } else {
          reject(new Error("no role"));
        }
      });
    });
  }
}

const userStorage = new UserStorage();
const id = "apple";
const password = "banana";

userStorage
  .loginUser(id, password)
  .then((id) => userStorage.getRoles(id))
  .then((user) => console.log(`Welcom ${user.id} Role : ${user.role}`))
  .catch((error) => console.log(error));
이제 구현부의 내용을 보면, 순차적으로 실행되는 로직이 눈에 들어온다

async-await

  • Promise 를 간결하게 표현, 동기적으로 실행되는 것 처럼 표현
  • Promise도 좋은데, 좀 더 좋은 방법 없을까? -> 갑자기 new Promise 등장하는게 읽기 불편해
  • syntactic sugar : 코드상 슈가 → 기존에 있는 기술을 좀 더 편하게 사용할 수 있도록 하는 것
  • 모든 Promise를 대체하는 건 아니다
  • 동기적인 실행
    • apple을 받아오는데 2초가 걸리는데, 다 기다렸다가 Next Content 실행
function sleep(ms) {
  const wakeUpTime = Date.now() + ms;
  while (Date.now() < wakeUpTime) {}
}

function fetchUser() {
  sleep(2000);
  return "apple";
}

const user = fetchUser();
console.log("user", user);

console.log("Next Content");

-> 2초 뒤 콘솔로그

  • Promise 로 비동기적 실행 구현 (뒤에 관련 없는 Next Content를 먼저 표현한다)
function sleep(ms) {
  const wakeUpTime = Date.now() + ms;
  while (Date.now() < wakeUpTime) {}
}

function fetchUser() {
  return new Promise((resolve, reject) => {
    sleep(2000);
    resolve("apple");
  });
}

const user = fetchUser();
user.then(console.log);

console.log("Next Content");

 

  • 함수 앞에 “async”를 붙여주면 자동으로 Promise로 return 해준다
    • Producer (Promise를 리턴하는 부분) 에서 Promise를 간결하게 표현하는 방법
function fetchUser() {
  return new Promise((resolve, reject) => {
    sleep(2000);
    resolve("apple");
  });
}

// 위와 아래는 똑같은 결과를 준다
async function fetchUser() {
  sleep(2000);
  return "apple";
}
  • “await”은 “async”가 붙은 함수 안에서만 사용 가능
    • “async” (Promise) 안에서 동기적으로(다 할때까지 기다려) 코드가 실행되도록 하는 장치
  • await 없이 delay 함수 실행하면? 
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchUser() {
  delay(2000);
  return "apple";
}

const user = fetchUser();
user.then(console.log);

console.log("Next Content");

  • 바로 실행된다
  • why? fetchUser() 함수 안에서 delay를 기다리지 않고, 바로 apple을 return 해줌

 

  • awiat delay 주면 ? 
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchUser() {
  await delay(2000);
  return "apple";
}

const user = fetchUser();
user.then(console.log);

console.log("Next Content")

-> 2초 뒤에 

  • 2초 동안 기다렸다가 apple을 리턴해줌
  • Next content가 먼저 표현되고, apple을 2초 뒤에 표현

 

 

  • async-await을 통해서 코드를 동기적으로 표현 
  • 가독성이 좋아지고 에러처리도 간편하다 try-catch
function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchUser() {
  await delay(2000);
  console.log("2초 뒤");
  return "apple";
}

async function fetchRole() {
  await delay(1000);
  console.log("1초 뒤");
  return "admin";
}

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchUser() {
  await delay(2000);
  console.log("2초 뒤");
  return "apple";
}

async function fetchRole() {
  await delay(1000);
  console.log("1초 뒤");
  return "admin";
}

async function fetchUserAndRole() {
  try {
    const user = await fetchUser();
    const role = await fetchRole();
    return `${user} & ${role}`;
  } catch (error) {
    console.log(error);
  }
}

fetchUserAndRole().then(console.log);
console.log("Next Content");
  • 단점) 만약 user & role이 서로 연관 없는 데이터를 가져오는 행동이라면 불필요한 시간 소모가 된다
  • -> 2초 user 가져오고 난 후에 1초 role을 가져와서 총 3초가 걸린다
  • -> user 가져옴과 동시에 role을 병렬적으로 가져오면 총 2초로 단축 가능

 

 

  • 해결책
    • Promise는 생성과 동시에 실행되는 성격을 이용하자
async function fetchUserAndRole() {
  const userPromise = fetchUser(); // promise 만들자 마자 실행
  const rolePromise = fetchRole(); // promise 만들자 마자 실행
  const user = await userPromise;
  const role = await rolePromise;
  return `${user} & ${role}`;
}
  • Better Expression
    • 모든 promise를 한 줄 씩 각각 쓰지말고 -> Promise.all로 전체 실행 가능\
return Promise.all([fetchUser(), fetchRole()]).then((res) => res.join(" & "));
  • Another api
    • 실행하는 Promise 중에서 먼저 완료되는 것만 출력 -> Promise.race
async function fetchOne() {
  return Promise.race([fetchUser(), fetchRole()]);
}
fetchOne().then(console.log);
console.log("Next Content");
  • 단, 아예 실행이 안되는것이아니라, 내부적으로 콘솔로그나 로직은 모두 실행이 되는데, 최종 return 값만 가장 빨리 되는 놈으로 가져오는 것임!

 

 

예제

위에서 작성했던 콜백지옥 -> Promise를 최종적으로 Async-await을 이용해 작성해보자

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

  async loginUser(id, password) {
    await this.delay(2000);
    if (id === "apple" && password === "banana") {
      return id;
    } else {
      throw "wrong user Info";
    }
  }
  async getRoles(user) {
    await this.delay(1000);
    if (user === "apple") {
      return { id: "apple", role: "admin" };
    } else {
      throw "no role";
    }
  }
}

const userStorage = new UserStorage();
const id = "apple";
const password = "banana";

async function fetchUserAndRole(id, password) {
  const user = await userStorage.loginUser(id, password);
  const userWithRole = await userStorage.getRoles(user);
  return `Welcome ${userWithRole.id}, Role : ${userWithRole.role}`;
}

fetchUserAndRole(id, password).then(console.log);
가장 가독성이 좋다
따로 try-catch로 에러처리는 하지 않았지만, Class 내부 method에서 예외발생시 throw로 던져주고, 최종 구현부(fetchUserAndRole)에서 try - catch로 감싸면 메소드들에서 던진 예외상황들이 catch로 다 모여서 걸러진다