자바스크립트 이론 - 동기와 비동기

[자바스크립트 기본 개념] - 동기와 비동기

시작

코드 작성을 하면서 런타임 시점이 다가왔을때, 내가 작성한 코드가 과연 직렬로 작동하는지, 아니면 병렬로 작동하는지 한번 생각해본적이 있었다.

만일 이 질문에 현재 답이 안나온다면 그때 과연 내가 문제에 관한 키워드를 찾아낼 수 있을까? 의구심이 들었다. 때문에 오늘은 다시 한번 자바스크립트의 동작 방식에 대해 알아낼 것이다.

자바스크립트는 결국 동기식 언어이다.

자바스크립트 엔진은 싱글 스레드로 동작합니다. 프로세스와 스레드 개념은 따로 다루도록 하겠습니다. 아무튼 이어 설명해보도록 하겠습니다.

프로그램이란 어떤 작업을 위해 실행할 수 있는 파일을 의미하죠. 실행이 되면 하나의 프로세스가 실행되어 OS로부터 시스템 자원을 할당받습니다. 이러한 자원으로 프로그램이 컴퓨터에 연속적으로 프로세스를 실행할 수 있게 되는 것이며, 프로세스란 '실행된' 프로그램을 의미합니다. 여기 하나의 프로세스에서 실행되는 흐름의 단위로 스레드가 있죠. 프로세스가 할당 받은 자원의 일부를 할당받고 프로그램을 수행하게 됩니다. 결국 스레드는 하나의 '일꾼'이라고 보면 될것 같군요.

일반적으로 프로그래밍 언어로 작성된 프로그램이 프로세스가 되기 위해 "OS로부터 시스템 자원을 할당" 받아야 하는데 여기서 자바스크립트는 프로세스와 OS 사이 "브라우저"가 존재하는 것을 기억해주세요.

싱글 스레드로 작업 수행 시 다양한 장/단점이 존재합니다. 장점으로는 언어의 난이도가 쉽고, 일반적인 상황에서 멀티 스레드로 작업하는 것보다 비용이 적고, 빠르죠. 단, 연산량이 많거나 구조적으로 시간이 걸리는 작업의 경우 해당 작업이 완료되어야 다른 작업을 수행할 수 있다는 것과 에러에 신경을 더 써야하는 단점이 존재합니다.

연산량이 많거나 구조적으로 시간이 많이 걸리는 작업은 렌더, 통신이 있습니다. 예로 통신으로 서버의 응답을 기다리면서 응답이 오면 화면에 렌더하는 경우를 봅시다. 하지만 CPU는 서버에서 응답이 오기를 마냥 기다리지는 않습니다. 예로 카페 같은 경우 주문한 후 커피가 나오기까지 마냥 기다리지만은 않죠? 진동벨이 울리면 각자 가져갑니다. 이 과정을 비동기 처리라고 합니다. 마냥 기다리지 않는거죠. 하지만 자바스크립트는 싱글스레드입니다. 과연 자바스크립트가 어떻게 이런 동작을 수행할 수 있을까요?

답은 "브라우저"에 있습니다. "브라우저"는 다른 서버, 네트워크와 통신기능이 탑재(Web API)되어있죠. 결론적으로 자바스크립트 엔진은 싱글 스레드로 동작하지만 비동기 처리를 위한 별도의 스레드가 존재한다고 이해해주시면 좋을 것 같습니다.

자바스크립트는 왜 싱글 스레드인가?

자바스크립트의 메인 스레드인 Event Loop가 싱글 스레드이므로 자바스크립트를 싱글 스레드라고 부릅니다. 하지만 이벤트 루프만 독립 실행하지 않고 웹 브라우저, NodeJS 같은 멀티 스레드 환경에서 실행됩니다. 즉, JS 자체는 싱글 스레드가 맞습니다만, 런타임 과정에서는 싱글 스레드가 아닙니다.

싱글 스레드로 어떻게 여러 요청을 처리할까?

자바스크립트는 인터프리터 언어입니다. 한 줄을 읽고 실행해나가죠. 이렇게 되면 앞 구문의 작업이 길수록 시간, 자원 낭비가 심해집니다. 자바스크립트는 하나의 요청이 완료될 때까지 기다리지 않습니다. 다른 작업도 동시에 실행하는 비동기 호출을 사용합니다.

자바스크립트의 비동기 처리 과정

[그림1]

자바스크립트가 실행될 때 위와같은 요소들이 실행을 도와주게 됩니다.

  • Call Stack: 자바스크립트에서 수행해야 할 함수를 스택에 쌓아 최상위부터 처리
  • Web API: 웹 브라우저에서 제공한 API로 AJAX, Timeout등 비동기 작업 수행
  • Task Queue: Callback Queue라고 하며 Web API에서 넘겨받은 Callback 함수 저장
  • Event Loop: Call Stack이 비어있다면 Task Queue 작업을 Call Stack에 Push
js
setTimeout(()=>{
  console.log('hello async')
});
console.log('hello')

// hello
// hello async

위 코드를 예시로 보겠습니다. 처음에 setTimeout함수가 실행되면 Call StacksetTimeout함수가 추가됩니다. 여기서 setTimeout함수는 자바스크립트 엔진이 처리하지 않고 Web API가 처리합니다(브라우저 사이드인 경우) 서버의 경우(Node의 경우 Timers 모듈)

setTimeout함수는 Web APITimeout작업을 요청한 시간이 지난 경우 Task Queue로 인자로 받은 callback함수를 전달합니다. 이후 두 번째 라인에 작성한 console.logCall Stack에 추가됩니다. 그리곤 Call Stackconsole.log가 실행되며 콘솔에는 'hello' 문자열이 출력됩니다. 처리가 끝난 함수는 Call Stack에서 pop되어 비워집니다. 이때 Event LoopCall Stack이 비워진 것을 확인합니다.

이때 Event LoopTask Queue에 존재하던 callback 함수를 Call Stack으로 옮겨 작업을 수행합니다. 이후 async hello가 출력되죠.

자바스크립트의 비동기 처리는 이와 같은 방식으로 진행됩니다.

다시 커피 예시로 돌아와봅시다.

[그림2]

코드에 커피를 만들라는 함수가 존재한다고 봅시다. 그리고 함수들을 호출해봅시다. 그렇다면 Task Queue에 해당 커피를 만드는 콜백 함수가 쌓이게 될 겁니다. Event LoopCall Stack을 감시하면서 Task Queue의 일감들을 Call Stack으로 옮기면서 커피를 만들게 됩니다.

비동기 처리의 시작 - 콜백

setTimeout함수는 인수를 받을 매개변수로 콜백함수(실행할 함수), 시간을 가집니다. setTimeout는 브라우저 API입니다. 브라우저에게 요청을 보내는거죠. 1초 뒤 콜 스택에 옮겨지고 실행되는 방식이죠. 즉 나중에 불러서 콜스택에 전달해줘 즉, 실행해줘 라는 것을 콜백이라고 하는겁니다. 이러한 콜백은 비동기에만 사용할까요? 아닙니다. 동기, 비동기 두 가지 방식에 모두 적용할 수 있습니다.

동기 처리 콜백

동기 처리는 간단합니다. 선언 후 즉시 실행하면 됩니다.

js
function printImmediately(print) {
  print();
}
// 콜백 함수
printImmediately(() => console.log('hellop'));

비동기 콜백

아래 코드와 같이 순서에 상관없이 "브라우저에게 2초 뒤 해당 콜백을 실행해줘"와 같이 비동기적으로 실행할 수 있죠.

js
function printWithDelay(print, timeout){
  setTimeout(print, timeout);
}
printWithDelay(() => console.log('async callback'), 2000);

콜백 지옥

콜백 지옥은 뭘까요? 콜백 안에 콜백 함수를 호출하고 그 안에서 또 콜백 함수를 호출하는 러시안 인형같은 모습을 콜백 지옥이라고 볼 수 있습니다. 코드로 봅시다.
아래는 가상의 코드로, 유저 정보를 호출하고 권한을 가져옵니다. 각 호출 시점에 성공한다면 onSuccess 콜백을, 실패한다면 onError 콜백을 실행해봅시다.

js
class UserStorage{
  loginUser(id, password, onSuccess, onError){
    setTimeout(() => {
      if(
        (id === 'ellie' && password === 'dream') ||
        (id === 'coder' && password === 'academy')
      ){
        // 성공하면 id 를 인수로 넘겨 onSuccess 함수 실행
        onSuccess(id);
      } else {
        onError(new Error('not found'));
      }
    }, 2000);
  }

  getRoles(user, onSuccess, onError) {
    setTimeout(() => {
      if(user === 'ellie'){
        onSuccess({name: 'ellie', rold: 'admin'});
      } else {
        onError(new Error('no access'));
      }
    }, 1000)
  }
}

위 코드는 서버라고 가정하고 아래 코드는 클라이언트라고 생각해봅시다!

js
// 사용자에게 id, password 입력 받아오기

// 1. 서버에게 loginUser 함수로 로그인 요청. 만일 성공한 경우
// 2. id를 인자로 넘겨준 onSuccess 함수 실행.
// 3. onSuccess 함수에서는 getRoles 함수로 권한을 요청
// 4. 성공한다면 사용자 이름과 권한 출력
const UserStorage = new UserStorage();
const id = prompt('enter your id');
const password = prompt('enter your password');

UserStorage.loginUser(
  id,
  password,
  user => {
    //success
    UserStorage.getRoles(
      user,
      userWithRole =>{
        // success
        // 넘겨준 user인자가 ellie가 맞다면 성공!
        // 성공적으로 받은 {name: 'ellie', rold: 'admin'} 인자에서 이름과 권한을 꺼내 출력합니다.
        alert(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`);
      },
      error => {
        // 넘겨준 user인자가 ellie가 아니라면 에러!
        console.log(error);
      }
    )
  },
  error => {
    console.log(error)
  }

자 어떠신가요 가독성이 아주 떨어지죠? 이게 바로 콜백 지옥입니다. 로직을 알기도 어려워요 마치 장품을 맞은거같죠? 이제 이 콜백 지옥을 없애봅시다.

콜백 지옥 없애버리기 - Promise

위와 같은 콜백 지옥을 없애 깔끔한 코드를 한번 구성해봅시다. 우선 Promise부터 시작하겠습니다.
Promise를 번역하면 약속이죠. 자바스크립트에서 제공하는 비동기 처리를 간편하게 처리하기 위한 Object입니다. 정해진 기능이 수행되고 나서 성공이 됐다면 성공의 메시지를, 혹은 예상치 문제가 발생한 경우 에러를 발생시킵니다.

Promise는 현재 상태 state, 정보를 제공하는 Producer, 정보를 소비하는 Consumer 에 대한 견해가 핵심입니다.
state는 과정 마다 상태가 변경됩니다. Promise로 처리를 요청하여 진행되는 구간은 pending, 성공적으로 끝나면 fulfilled, 찾을 수 없거나 네트워크 오류가 발생하는 경우 rejected상태가 됩니다.

또한 데이터를 가공하여 제공하는 Producer, 원하는 데이터를 소모하는 Consumer가 존재하죠.

그럼 콜백을 쓰지 않고 Promise 객체만을 이용해 비동기 처리를 진행해봅시다.

js

// [1] Producer
// 1. promise는 클래스 이므로 객체로 생성할 수 있습니다.
// 2. 또한 처리 성공 시 호출되는 resolve, 실패 시 호출되는 reject 콜백 함수가 존재합니다.
// 3. Promise를 생성한 순간 즉시 실행 되어 doing something...이 바로 표시된다.
// 4. 3번의 이유로 실행 시점을 잘 유의해야하죠.
const promise = new Promise((resolve, reject) => {
  // 로직 처리. 서버에서 파일을 읽어오는 등. 헤비한 작업은 비동기적으로 수행해야한다.
  console.log('doing something...');

  // 네트워크 통신을 예로 서버의 데이터를 기다리는 시간? 을 구성해보기위해
  // setTimeout함수를 사용해보겠습니다.
  setTimeout(() => {
    // 성공한 경우 서버는 ellie와 같이 가공된 데이터를 응답과 동시에 데이터를 동봉하여 보낸다.
    // 아래 Consumer에 1. then에서 해당 데이터 사용 가능
    resolve('ellie');

    // Error 클래스는 자바스크립트가 제공하는 일종의 클래스 주로 에러 핸들링으로 사용
    // 아래 Consumer에 2. catch에서 해당 데이터 사용 가능
    reject(new Error('no network'));
  }, 2000);
})

// [2] Consumer: then, catch, finally

promise
  // 1. then : 위 promise 객체에서 정상적으로 수행이 되었다면(resolve) then메서드로 행동을 취할 수 있다.
  .then((value) => {
    // resolve 에 전달된, 즉 서버에서 보낸 데이터 ellie가 value로 들어온다.
    console.log(value); // ellie
  })
  // 2. catch : 위 promise 객체에서 처리 과정 중 실패했다면(reject) catch메서드로 행동을 취할 수 있다
  .catch(error => {
    console.log(error);
  })
  // 3. finally : 위 promise 처리 과정이 끝나면 마지막에 수행 (성공, 실패 유무 X)
  .finally(() => {
    console.log('finally');
  })

// [3] Promise chaining 또한 then, catch, finally 등 점표 . 을 통해 체이닝할 수 있습니다.
// 즉 동시에 관리하는거죠.
const fetchNumber = new Promise((resolve, reject) => {

  // 서버 통신 기다리는 중... 의 예시로 사용하는 setTimeout 입니다.
  setTimeout(() => resolve(1), 1000);
})

fetchNumber
  // 1. 첫번째 then : 위 promise 객체 fetchNumber 의 resolve시 서버로부터 전달 받은 데이터 1을 num에 가져옵니다.
  .then(num => num * 2)
  // 2. 두번째 then : 위 then 에서 num * 2 값, 즉 2를 가져옵니다.
  .then(num => num * 3)
  // 3. 세번째 then : 위 then 에서 num * 3 값, 즉 6을 가져옵니다.
  // 또한 안에서 또 서버와 통신을 할 비동기 객체 Promise를 생성합니다.
  .then(num => {
    return new Promise((resolve, reject) => {
      // 서버 통신 기다리는 중... 의 예시로 사용하는 setTimeout 입니다.
      setTimeout(() => resolve(num - 1), 1000);
    });
  })
  // 4. 네번째 위 세 번째 then의 resolve의 결과 5를 받습니다.
  // 위 5라는 값은 서버에서 보내준 데이터라고 생각합시다.
  .then(num => consooe.log(num)); // 5

자 지금까지는 정상적으로 데이터가 서버로부터 넘겨온 경우 resolve에 한해 예시를 봤습니다. 이번에는 reject와 마주해 봅시다.

js
// [4] 오류 처리하기
const getHen = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve('🦕'), 1000);
  });
  // 닭을 받아와서 알을 낳도록 할꺼예요.
const getEgg = hen =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(`${hen} => 🥚`), 1000);
  });
  // 낳은 알로 프라이 할겁니다.
const cook = egg =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(`${egg} => 🍳`), 1000);
  });

getHen() // 결과는 3초 뒤에 보일거예요.
  .then(getEgg) // hen => getEgg(hen)를 이렇게 생략할 수 있어요. 단 넘어온 인자가 한 개인 경우!
  .then(cook) // egg => cook(egg)
  .then(console.log) // meal => console.log(meal)
  // 🦕 => 🥚 => 🍳

위 코드에서 에러를 발생시켜봅시다.

js
// [4] 오류 처리하기
const getHen = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve('🦕'), 1000);
  });
  // 닭을 받아와서 알을 낳도록 할꺼예요.
const getEgg = hen =>
  new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error(`error! ${hen} => 🥚`)), 1000);
  });
  // 낳은 알로 프라이 할겁니다.
const cook = egg =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve(`${egg} => 🍳`), 1000);
  });

getHen() // 결과는 3초 뒤에 보일거예요.
  .then(getEgg)
  .catch(error => {
    // 위 then 과정에서 에러가 발생하면 잘 가공해서 빵을 던져줍니다.
    // 그럼 아래 then도 정상적으로 수행될거예요. 에러가 어쨌든 getEgg에서 나긴 했지만
    // 지금 catch로 잘 가공해줬거든요.
    return '🥐';
  })
  .then(cook)
  .then(console.log)
  // 🥐 => 🥚
  // 빵이 대신 전달 됐어요. 아래 catch는 이제 안봐도 되겠네요
  .catch(console.log)
  // error! 🦕 => 🥚 at

위와 같이 catch 구문을 적절히 사용한다면 에러를 발생시키지 않을 수 있습니다. 마지막으로 이번엔 위에서 보았던 콜백 지옥 함수 코드도 Promise로 잘 정돈해봅시다.

js
// [5] 콜백 지옥 없애기
class UserStorage{
  loginUser(id, password){ // onSuccess, onError 제거! Promise객체가 대신 할 것임.
    return new Promise((resolve, reject) =>{
      setTimeout(() => {
        // 서버에 데이터 요청 중 ...
        if(
          (id === 'ellie' && password === 'dream') ||
          (id === 'coder' && password === 'academy')
        ){
          // 서버로부터 응답이 성공적으로 전달되었다.
          resolve(id);
        } else {
          // 서버로부터 응답이 비정상적으로 전달되었다.
          reject(new Error('not found'));
        }
      }, 2000);
    })
  }

  getRoles(user) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 서버에 데이터 요청 중 ...
        if(user === 'ellie'){
          // 서버로부터 응답이 성공적으로 전달되었다.
          resolve({name: 'ellie', rold: 'admin'});
        } else {
          // 서버로부터 응답이 비정상적으로 전달되었다.
          reject(new Error('no access'));
        }
      }, 1000)
    });
  }
}

const userStorage = new UserStorage();
const id = prompt('enter your id');
const password = prompt('enter your password');

// 콜백 지옥의 해결
userStorage
  .loginUser(id, password)
  .then(userStorage.getRoles) // userStorage.getRoles(user) 축약!
  .then(user => alert(
    `Hello ${user.name}, you have a ${user.role}`));
  .catch(console.log);

// 콜백 지옥의 잔해
// userStorage.loginUser(
//   id,
//   password,
//   user => {
//     //success
//     userStorage.getRoles(
//       user,
//       userWithRole =>{
//         // success
//         // 넘겨준 user인자가 ellie가 맞다면 성공!
//         // 성공적으로 받은 {name: 'ellie', rold: 'admin'} 인자에서 이름과 권한을 꺼내 출력합니다.
//         alert(`Hello ${userWithRole.name}, you have a ${userWithRole.role} role`);
//       },
//       error => {
//         // 넘겨준 user인자가 ellie가 아니라면 에러!
//         console.log(error);
//       }
//     )
//   },
//   error => {
//     console.log(error)
//   }

비동기의 꽃 Javascript async await

async awaitPromise를 보다 간단하고 동기적으로 실행되는것처럼 만들 수 있습니다.

async await은 보다 깔끔하게 Promise를 사용할 수 있는 방법이지, 대체해서 사용하는것은 아닙니다.

Promise의 경우 다음과 같이 사용했습니다.

js
// Promise
function fetchUser() {
  return new Promise((resolve, reject) => {
    resolve('ellie');
  })
}

const user = fetchNumber(); // 비동기로 처리하지 않는다면 사용자 데이터를
                            // 가지고오는데 10초가 걸리기 때문에 다음 코드가 10초간 동작을 못함.
                            // 비동기로 적용해야함!
user.then(console.log);
console.log(user)

만일 비동기 처리를 하지 않는 경우 다음과 같은 현상이 발생합니다. 자바스크립트는 동기 처리 방식입니다.
때문에 다음과 같이 비동기 처리를 하지 않은 채 서버와 통신을 하게된다면 이후 코드는 통신을 기다려야하죠.

js
// 비동기 처리 X
function fetchUser() {
  // 네트워크 통신으로 백엔드로부터 데이터를 가져오는 코드가 있다고 가정 ...
  // 10초 뒤에 백엔드로부터 데이터를 받는다.
  return 'ellie'; // 백엔드로부터 보내져 온 데이터를 반환
}

const user = fetchNumber(); // 비동기로 처리하지 않는다면 사용자 데이터를
                            // 가지고오는데 10초가 걸리기 때문에 다음 코드가 10초간 동작을 못함.
                            // 비동기로 적용해야함!
console.log(user);

이제 async await을 이용해 비동기 처리를 간편하게 적용하면서 보기 쉽게 바꿔보도록 하겠습니다. 서버와 통신을 하는 함수에 async키워드를 붙여 준다면 Promise로 변환됩니다.

js
// 1. async
async function fetchUser() {
  // 네트워크 통신으로 백엔드로부터 데이터를 가져오는 코드가 있다고 가정 ...
  // 10초 뒤에 백엔드로부터 데이터를 받는다.
  return 'ellie'; // 백엔드로부터 보내져 온 데이터를 반환
}

const user = fetchNumber(); // fetchNumber 함수 앞에 async 키워드를 붙여
                            // Promise로 변환
user.then(console.log); // Promise {} 
console.log(user); // ellie

이제 await도 알아봅시다. await기다려와 같습니다. 그리고 async가 붙은 함수안에서만 사용할 수 있습니다.

js
// 2. await
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function getApple(){
  // 통신이 완료되기를 기다려.. await
  await delay(1000);
  return '🍎';
}
🍌
async function getBanana(){
  await delay(1000);
  return '🍌';
}

async function pickFruits(){
  // 아래는 await 구문이 2개 존재하네요..? 바나나는 애플이 완료되기까지 실행이 안되나요?
  // 맞습니다. 아래 1번의 경우 총 2초가 소요됩니다.

  // 1. 잘못된 사용
  // 통신이 완료되기를 기다려.. await
  // const apple = await getApple();
  // 통신이 완료되기를 기다려.. await 응답이 올때까지 기다림 ...
  // const banana = await getBanana();

  // 2. 올바른 사용
  // async가 붙은 함수의 경우 Promise로 변환됩니다.
  // 이제 아래 구문에서 서버 응답을 기다릴 await은 제외하고 async 함수만을 호출해서
  // 병렬적으로 처리할 수 있습니다.
  const applePromise = getApple(); // 사과의 Promise
  const bananaPromise = getBanana(); // 바나나의 Promise
  const apple = await applePromise;
  const banana = await bananaPromise;

  return `${apple} + ${banana}`
}

pickFruits().then(console.log);

2번과 같이 올바른 사용의 예를 보았지만 불필요한 코드가 몇개 보입니다. useful Promise APIs를 사용해 좀 더 정리해봅시다.
Promiseall메서드를 이용하면 좀 더 간편한 코드를 작성할 수 있습니다. all메서드의 경우 인자로 Promise가 담긴 배열을 받습니다.
모든 Promise들이 병렬적으로 응답을 받을 때까지 결과를 모아줍니다.

js
function pickAllFruits(){
  return Promise.all([getApple(), getBanana()])
  .then(fruits => fruits.join((' + ')))
}
pickAllFruits().then(console.log);
//(2) 🍎 + 🍌

가장 빠른 응답을 받은 Promise를 선택할 수도 있습니다. 예로 바나나가 1초, 사과가 1.5초가 걸린다고 생각해봅시다.

js
function pickOnlyOne(){
  return Promise.race([getApple(), getBanana()]);
}

// 응답을 더 빨리 받은 바나나를 출력합니다.
pickOnlyOne().then(console.log);
// 🍌

async await을 이용한 HTTP 통신