[JavaScript] 비동기

Jun·2022년 7월 12일
0

JavaScript

목록 보기
12/13
post-thumbnail

동기 vs 비동기

하루의 활기찬 시작을 위해 커피를 사러 카페에 갔다고 생각해보자. 카페 사정상 커피를 주문한 손님이 커피를 받기 전까지 다음 손님은 주문조차 할 수 없다하겠다. 이를 우리는 blocking이라 한다. 하나의 작업이 끝날 때 까지, 이어지는 작업을 막는 것이다.

앞 손님의 커피가 나오고 나서야 다음 손님은 커피를 주문할 수 있다. 앞 손님의 주문 완료 시점과 다음 손님의 주문 시작 시점이 동일하다. 이를 보고 "동기적(synchronous)이다"라고 한다.

카페에서 효율적으로 커피를 주문받고 내어주기 위해선 어떻게 해야할까?

  • 주문이 blocking 되지 않고, 언제든 주문을 받을 수 있다.
  • 커피가 완성되는 즉시 서빙한다.

즉, 비동기적(Asynchronous)으로 카페를 운영하면된다.

동기와 비동기

JavaScript에서 비동기적 실행(Asynchronous execution)의 개념은 웹 개발에 있어 특히 유용하다.

  • 백그라운드 실행, 로딩 창 등의 작업
  • 클라이언트에서 서버로 요청을 보내고, 응답을 기다리는 작업
  • 큰 용량의 파일을 로딩하는 작업

이렇듯 비동기적 실행은 장점도 존재하지만 순서를 제어하기 힘들다는 단점이 존재한다.

다음 코드를 보자.

const printString = (string) => {
  setTimeout(
    () => {
      console.log(string)
    },
    Math.floor(Math.random() * 100) +1
    )
}

const printAll = () => {
  printString('A');
  printString('B');
  printString('C');
}
printAll(); // expected output : ???

printString() 함수는 전달받은 문자열을 무작위 시간 뒤에 출력하는 함수이다.
printAll() 함수에선 printString() 으로 문자열 A , B , C 를 비동기적으로 출력되게 하고있다.

그렇다면 printAll() 함수의 실행결과는 똑같을까?

printString() 에서 무작위 시간초를 내장 비동기함수 setTimeout() 에 설정해두었기 때문에 실행될때마다 결과는 달라지게 된다.

이렇듯 비동기적 실행을 제어하고 싶을 때 사용할 수 있는 3가지 방법이 존재한다.

  1. Callback
  2. Promise
  3. Async / Await

Callback

Callback 패턴

printString() 코드를 Callback 패턴으로 수정해보자.

const printString = (string, callback) => {
  setTimeout(
    () => {
      console.log(string)
      callback()
    },
    Math.floor(Math.random() * 100) +1
    )
}

const printAll = () => {
  printString('A', () => {
    printString('B', () => {
      printString('C', () => {})
    })
  })
}
printAll();
// expected output
// 'A'
// 'B'
// 'C'

printString() 함수에 두번째 인자로 callback을 가진다.
무작위의 시간초가 지나면 setTimeout() 이 실행되게 되고, 전달받은 문자열을 출력한 뒤 callback 인자로 전달받은 함수를 실행하게 된다.

그렇기에 printAll()에서 printString()printString() 의 callback 함수로 넘겨주게되어 순차적으로 실행되게 된다.

Callback error handling design

// Design
const somethingGonnaHappen = callback => {
  waithingUntilSomethingHappens();
  // 무언가 끝날 때까지 기다림
  
  if(isSomethingGood) {
    callback(null, something);
  }
  if(isSomethingBad) {
    callback(something, null);
  }
}

// Usage
somethingGonnaHappen((err, data) => {
  if(err) {
    console.log('Error occured!');
    return;
  }
  return data;
}

somethingGonnaHappen 함수는 callback 함수를 인자로 받고 내부에서 비동기 실행이 끝날때까지 기다린다. 비동기가 끝난 후 결과가 성공적이라면 callback 함수에 인자로 (null, something) 을 순서대로 넘겨주고, 문제가 발생했다면 (something, null) 을 순서대로 넘겨준다.

somethingGonnaHappen 함수는 errdata 를 인자로 받는 callback 함수를 전달받는다.

만약 waitingUntilSomethingHappens 의 결과가 실패라면 callback 함수의 인자로 (something, null) 을 넘겨줘 err === something 이 되고 data === null 이 되어 error message를 출력하게 한다.

반대의 경우엔 결과가 성공적이므로 data 를 반환하게 된다.

Callback Hell

Callback의 경우 비동기 처리를 제어할 수 있다는 장점이 있으나, 내용이 길어지게 될 경우 여러 개의 callback 함수가 중첩되어 Callback Hell에 빠질 수 있다.

Callback Hell

결과적으로 코드의 가독성이 떨어지게 되고, 코드의 관리에 어려움이 생긴다.
이에 대한 해결 방법으로 PromiseAsync / Await가 존재한다.

Promise

ES6에선 비동기 처리를 위한 또 다른 패턴으로 Promise를 도입했다.

위에서 말했듯이 callback 패턴의 경우 callback hell에 빠질수도 있고, 추가적으로 에러 핸들링에 있어 한계점이 존재했다.

Promise는 callback 패턴이 가진 이러한 단점을 보완하며 비동기 처리 시점을 명확하게 표현할 수 있다.

Promise 생성

Promise는 Promise 생성자 함수를 통해 인스턴스화한다. 이 때 Promise 생성자 함수는 비동기 작업을 수행할 callback 함수를 전달받는데 이를 executor라고 한다.
이 executor는 비동기가 성공적으로 마쳤을 때 불러올 resolve와 반대로 실패했을 때 불러올 reject 함수를 인자로 받는다.

// Promise 인스턴스
const newPromise = new Promise ((resolve, reject) => {
  ...
});

// 함수형으로 Promise 반환
const functionPromise = () => {
  return new Promise((resolve, reject) => {
    // 비동기 작업 수행

    // 비동기 작업 수행에 성공했다면
    if (...) {
        resolve('result');
    }
    // 비동기 작업 수행에 실패했다면
    else {
        reject('failure reason');
    }
  })
};

비동기 처리에 성공하면 resolve 메소드를 호출하되, 메소드의 인자로 비동기 처리 결과를 전달한다. 이 처리 결과는 Promise 객체의 후속 처리 메소드로 전달된다. 비동기 처리에 실패하는 경우 reject 메소드를 호출한다. 이 때 reject 메소드의 인자로 에러 메세지 혹은 에러 객체를 전달한다. 이 또한 Promise 객체의 후속 처리 메소드로 전달된다.

여기서 Promise는 비동기 처리의 상태(state) 정보를 가진다.

상태의미구현
pending비동기 처리가 아직 수행되지 않은 상태resolve 또는 reject 함수가 아직 호출되지 않은 상태
fulfilled비동기 처리가 수행된 상태(성공)resolve 함수가 호출된 상태
rejected비동기 처리가 수행된 상태(실패)reject 함수가 호출 된 상태
settled비동기 처리가 수행된 상태(성공 또는 실패)resolve 또는 reject 함수가 호출된 상태

Promise 후속 처리 메소드

Promise로 구현된 비동기 함수는 Promise 객체를 반환한다. 이 때 반환된 Promise 객체는 비동기 처리 결과(성공 혹은 실패)를 전달받고 비동기 함수의 상태에 따라 후속 처리 메소드를 체이닝 방식으로 호출한다.

후속 처리 메소드는 다음과 같다.

.then()

then 메소드는 두 개의 callback 함수를 인자로 전달 받는다. 첫 번째 callback 함수는 비동기 함수의 처리 결과가 성공적일 때(resolve 함수가 호출됐을 때, 즉 상태가 fulfilled 일 때) 호출되고, 두 번째 callback 함수는 실패했을 때(reject 함수가 호출됐을 때, 즉 상태가 rejected일 때) 호출된다.

then 메소드 또한 Promise를 반환한다.

const asyncNameHelloWorld = function (name) {
	return new Promise((resolve, reject) => {
      // 비동기 처리전 pending 상태
      console.log('pending now...');
      // 2초 후 실행되는 비동기 함수
      setTimeout(() => {
        resolve(name);
        // 비동기 처리 완료 후 fulfilled 상태
        console.log('fulfilled now!');
      },2000);
    })
}

// then 메소드로 체이닝
asyncNameHelloWorld('Jun')
  .then((name) => { // state가 fulfilled 일 때
  console.log(`${name}, Hello World!`);
}, (error) => { // state가 rejected 일 때
  console.error(error);
});

// output :
// pending now...
// (2초 후)
// fulfilled now!
// Jun, Hello World!

🎯 then 메소드의 매개변수 중 하나 이상을 생략했거나 함수가 아닌 값을 전달할 경우, then은 핸들러가 없는 것이 되지만 오류가 발생하진 않는다. then 바로 이전의 Promise 객체가 then에 핸들러가 없는 상태로 완료(이행이나 거부)했을 경우, 추가 핸들러가 없는 Promise가 생성되며, 원래 Promise의 마지막 상태를 그대로 이어받는다.

.catch()

예외(비동기 처리에서 발생한 에러와 then 메소드에서 발생한 에러)가 발생하면 호출된다. catch 메소드 또한 Promise를 반환한다.

catch 메소드를 호출하면 내부적으로 then(undefined, onRejected) 를 호출한다.

const wrongUrl = 'https://...';

// 잘못된 Url이기에 에러가 발생.
promiseAjax(wrongUrl)
	.then(res => console.log(res))
	.catch(err => console.error(err)); // Error: 404

// 내부적으로 then을 호출
promiseAjax(wrongUrl)
	.then(res => console.log(res))
	.then(undefined, err => console.error(err)); // Error: 404

catch 메소드를 모든 then 메소드를 호출한 이후에 호출하면 비동기 처리에서 발생한 에러(상태가 rejected일 때)뿐만 아니라 then 메소드 내부에서 발생한 에러까지 모두 캐치할 수 있다.

Promise 체이닝

callback hell이 일어난 이유는 비동기 함수의 처리 결과로 다른 빋오기 함수를 호출하는 경우, 함수의 호출이 중첩(nesting)되어 복잡도가 높아졌기 때문이다.

Promise는 후속 처리 메소드를 체이닝하여 여러 개의 Promise를 연결 할 수 있다. 이로써 callback hell을 해결할 수 있다.

const multiply = (num) => {
  return new Promise((res, rej) => {
    console.log(`I got ${num}`);
    res(num);
  })
}

const result = multiply(2)
  .then(num => {
  console.log(`multiply 2, result = ${num*2}`);
  return num*2;
})
  .then(num => {
  console.log(`multiply 3, result = ${num*3}`);
  return num*3;
});

console.log(result);

// output :
// I got 2
// multiply 2, result = 4
// multiply 3, result = 12

async / await

ES7에선 Promise를 좀 더 쉽게 쓰기 위해 asyncawait 라는 문법을 추가했다.

async

async는 기본적으로 함수 앞에 위치한다. 함수 앞에 async 를 붙이면 해당 함수는 항상 Promise 객체를 반환하게된다. Promise가 아닌 값을 반환하더라도 fulfilled 상태의 Promise 객체로 값을 감싸 반환하게된다.

async function f() {
  return 1;
}

// 명시적으로 async를 작성하면 다음과 같다.
async function f() {
  return Promise.resolve(1);
}

// 함수 f는 async로 인해 Promise 객체를 반환하므로 후속 처리 메소드를 사용할 수 있다.
f().then(console.log); // output : 1

await

awaitasync 함수 안에서만 동작한다. JavaScript는 await 키워드를 만나면 Promise가 처리될 때까지 기다린다. 결과는 그 이후 반환된다.

async function f() {
  let promise = new Promise((res, rej) => {
    setTimeout(() => res('success!'), 2000)
  });
  
  // Promise가 처리될 때까지 기다린다.
  let result = await promise;
  
  console.log(result);
}

f(); // output : (2초 후) 'success!'

만약 Promise가 rejected 상태의 Promise 객체를 반환한다면, await 는 reject된 값을 throw하게 된다.

profile
FrontEnd Engineer를 목표로 공부합니다.

0개의 댓글