[P2_S3] 자바스크립트 웹 개발 기본기_비동기 + Promise

보리·2024년 4월 1일
0

codeit-sprint

목록 보기
13/22

(3) 비동기 실행과 Promise 객체

console.log('Start!');

fetch('https://www.google.com')
  .then((response) => response.text())
  .then((result) => { console.log(result); });

console.log('End');

지금 이 코드에는 다음과 같은 2개의 콜백이 있다.

(1) (response) ⇒ response.text()
(2) (result) ⇒ { console.log(result); }

fetch 함수가 리퀘스트를 보내고, 서버의 리스폰스를 받게 되면 그때서야 이 콜백들이 순서대로 실행된다.

[전체 코드의 실행 순서]

  1. console.log('Start');
  2. fetch 함수(리퀘스트 보내기 및 콜백 등록)
  3. console.log('End');
  4. 리스폰스가 오면 2. 에서 then 메소드로 등록해뒀던 콜백 실행

특정 작업을 시작(리퀘스트 보내기)하고 완벽하게 다 처리(리스폰스를 받아서 처리)하기 전에, 실행 흐름이 바로 다음 코드로 넘어가고, 나중에 콜백이 실행되는 것을 '비동기 실행'이라고 한다. 이에 반해 한번 시작한 작업은 다 처리하고 나서야, 다음 코드로 넘어가는, 우리에게 익숙한 방식의 실행은 '동기 실행'이라고 한다.

👇🏻만약 이 코드에서 fetch 함수가 비동기 실행되지 않고, 동기 실행되는 함수였다고 가정한다면?👇🏻

  1. console.log('Start');
  2. fetch 함수(리퀘스트 보내기)
  3. 리스폰스가 올 때까지 코드 실행이 잠시 '정지'되고, 리스폰스가 오면 필요한 처리 수행
  4. console.log('End');

동기 실행은 한번 시작한 작업을 완료하기 전까지 코드의 실행 흐름이 절대 그 다음 코드로 넘어가지 않는다. 일단 시작한 작업을 완벽하게 처리하고 난 다음에야 그 다음 코드로 실행 흐름이 넘어간다. 따라서 동기 실행의 경우 코드가 보이는 순서대로, 실행된다.

이와 다르게 비동기 실행은 한번 작업을 시작해두고, 그 작업이 완료되기 전이더라도 콜백만 등록해두고, 코드의 실행 흐름이 바로 그 다음 코드로 넘어간다. 그리고 추후에 특정 조건이 만족되면 콜백이 실행됨으로써 해당 작업을 완료하는 방식이다. 따라서 비동기 실행에서는 코드가 꼭 등장하는 순서대로 실행되는 것이 아니다.

❓'비동기 실행'이라는 건 왜 존재하는 걸까

그건 바로 보통 '비동기 실행'이 '동기 실행'에 비해, 동일한 작업을 더 빠른 시간 내에 처리할 수 있기 때문이다.

fetch 함수가 '동기 실행'된다고 가정했을 때

fetch 함수가 실행되고 리스폰스가 올 때까지 기다린다는 것 → 바로 리스폰스가 올 때까지는 아무런 작업도 할 수 없다는 뜻이다. 그만큼 시간을 낭비하게 되는 셈.

하지만 만약 비동기 실행이라면 일단 리퀘스트 보내기, 콜백 등록까지만 해두고, 바로 다음 작업(console.log('End');)을 시작함으로써 시간을 절약할 수 있다.

이미지 상단은 fetch 함수가 동기 실행된다고 가정했을 때
이미지 하단은 fetch 함수가 비동기 실행되는 실제 모습

지금 동기 실행에서는 모든 작업이 순차적으로 수행되고 있다. 이에 비해, 비동기 실행에서는 리스폰스를 기다리는 시간 동안 그 이후의 작업을 미리 처리하고 있다. 그래서 비동기 실행이 최종 작업 종료 시간이 더 짧다.

📘setTimeout 함수

특정 함수의 실행을 원하는 시간만큼 뒤로 미루기 위해 사용하는 함수

console.log('a');
setTimeout(() => { console.log('b'); }, 2000);
console.log('c');

setTimeout 함수는 첫 번째 파라미터에 있는

() ⇒ { console.log('b'); },

이 콜백의 실행을, 두 번째 파라미터에 적힌 2000 밀리세컨즈(=2초) 뒤로 미룬다. 그래서 이 코드를 실행하면

이렇게 a와 c가 먼저 출력되고, 약 2초가 지난 후에 b가 출력된다.

fetch 함수에서는 콜백이 실행되는 조건이, '리스폰스가 도착했을 때'였다면, setTimeout에서 콜백이 실행되는 조건은, '설정한 밀리세컨즈만큼의 시간이 경과했을 때'이다. 어쨌든 둘 다 콜백의 실행을 나중으로 미룬다는 점에서는 비슷하다.

📘setInterval 함수

특정 콜백을 일정한 시간 간격으로 실행하도록 등록하는 함수

console.log('a');
setInterval(() => { console.log('b'); }, 2000);
console.log('c');

이렇게 쓰면 이제 b를 출력하는 콜백이 2초 간격으로 계속 실행된다. 실제로 확인해보면

a와 c가 출력되고, 약 2초 뒤에 b가 출력된 후 그 뒤로 계속 2초 간격으로 b가 반복 출력된다.

📘addEventListener 메소드

만약 사용자가 웹 페이지에서 어떤 버튼 등을 클릭했을 때, 실행하고 싶은 함수가 있다면

(1) 해당 DOM 객체의 onclick 속성에 그 함수를 설정하거나,
(2) 해당 DOM 객체의 addEventListener 메소드의 파라미터로 전달하면 된다.

(1) onclick 속성

...

btn.onclick = function (e) { 
// 해당 이벤트 객체가 파라미터 e로 넘어온다.
  console.log('Hello Codeit!');
};

// 또는 arrow function 형식으로 이렇게 나타낼 수도 있다.
btn.onclick = (e) => {
  console.log('Hello Codeit!');
};

...

(2) addEventListener 메소드

...

btn.addEventListener('click', function (e) { 
// 해당 이벤트 객체가 파라미터 e로 넘어온다.
  console.log('Hello Codeit!');
});

// 또는 arrow function 형식으로 이렇게 나타낼 수도 있다.
btn.addEventListener('click', (e) => {
  console.log('Hello Codeit!');
});

...

이렇게 클릭과 같은 특정 이벤트가 발생했을 때 실행할 콜백을 등록하는 addEventListener 메소드도 비동기 실행과 관련이 있다. 파라미터로 전달된 콜백이 당장 실행되는 것이 아니라, 나중에 특정 조건(클릭 이벤트 발생)이 만족될 때(마다) 실행되기 때문이다.


setTimeout(콜백, 시간)
setInterval(콜백, 시간)
addEventListener(이벤트 이름, 콜백)

→ 함수의 아규먼트로 바로 콜백을 넣는다.

그런데 fetch 함수는 이 함수들과는 전혀 다르게 생겼다.

fetch('https://www.google.com')
  // fetch 함수가 리턴하는 객체의 then 메소드를 사용해서 콜백을 등록
  .then((response) => response.text()) 
  .then((result) => { console.log(result); });

fetch 함수는 콜백을 파라미터로 바로 전달받는 게 아니라, fetch 함수가 리턴하는 어떤 객체의 then 메소드를 사용해서 콜백을 등록한다.

위에서 본 함수들처럼, fetch 함수도 이런 식으로 코드를 써야할 것만 같은데.

fetch('https://www.google.com', (response) => response.text())

❓왜 fetch 함수만 사용하는 형식이 다른 걸까?

그건 바로 fetch 함수는, 좀 더 새로운 방식으로 비동기 실행을 지원하는 자바스크립트 문법과 연관이 있기 때문. 사실 fetch 함수는 Promise 객체라는 것을 리턴하고, 이 Promise 객체는 비동기 실행을 지원하는 또 다른 종류의 문법에 해당하기 때문.

✨Promise

어떤 작업에 관한 “상태 정보”를 가지고 있는 것.

  • pending: 진행 중
  • fulfilled: 성공 → 작업 성공 결과(response)
  • rejected: 실패 → 작업 실패 정보

then 메소드란 프로미스 객체가 fulfilled 상태가 되었을 때 실행할 콜백을 등록하는 메소드

📘Promise Chaining

then 메소드를 연속적으로 붙이는 것

  • promise객체의 then 메소드는 또 다른 promise 객체를 리턴한다.
  • 이 새로운 promise 객체는 처음에는 pending 상태지만 then 메소드 안의 콜백이 실행되고 어떤 값을 리턴하는지에 따라 그 상태가 달라진다.
  • 만약 콜백에서 promise 객체를 리턴하면 앞으로 promise 객체가 갖게 될 상태와 결과를 그대로 따라서 갖게 된다.
  • 프로미스 객체 이외의 값(ex. 숫자, 문자열, 일반 객체)이라면 fulfilled 상태가 되고 해당 리턴값을 작업 성공 결과로 갖게 된다.

📘text, json 메소드도 Promise 객체를 리턴한다.

console.log('Start!');

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.text())
  .then((result) => {
    const users = JSON.parse(result);
    // ...
  });

console.log('End');

response 객체의 text 메소드로 리스폰스의 내용을 추출(response.text();)하고 이것을 Deserialize하거나(JSON.parse(result);)


console.log('Start!');

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.json())
  .then((users) => {
    // ...
  });

console.log('End');

response 객체의 json 메소드로 리스폰스의 내용 추출과 Deserialize를 한 번에 수행(response.json())할 수 있다.

중요한 사실!! → text 메소드와 json 메소드가 사실은 Promise 객체를 리턴하는 메소드라는 사실!

1. text 메소드

fetch 함수로 리스폰스를 잘 받으면, response 객체의 text 메소드는, fulfilled 상태이면서 리스폰스의 바디에 있는 내용을 string 타입으로 변환한 값을 '작업 성공 결과'로 가진 Promise 객체를 리턴한다.

이때 그 작업 성공 결과는 string 타입. 이때 그 값이 만약 JSON 데이터라면 이전에 배운 것처럼 JSON 객체의 parse 메소드로 Deserialize를 해줘야한다.(JSON.parse(result);)

2. json 메소드

fetch 함수로 리스폰스를 잘 받으면, response 객체의 json 메소드는, fulfilled 상태이면서, 리스폰스의 바디에 있는 JSON 데이터를 자바스크립트 객체로 Deserialize해서 생겨난 객체를 '작업 성공 결과'로 가진 Promise 객체를 리턴한다.

만약 리스폰스의 바디에 있는 내용이 JSON 타입이 아니라면 에러가 발생하고 Promise 객체는 rejected 상태가 되면서 그 '작업 실패 정보'를 갖게 된다.

👇🏻👇🏻❓'then 메소드가 리턴했던 Promise 객체(A)는 그 콜백에서 리턴한 Promise 객체(B)와 동일한 상태와 결과를 갖게 된다'는 규칙과 연관지어서 생각해보면

→ 콜백에서 리턴한 Promise 객체로부터 새로운 Chain이 시작된다는 말과도 같다.

response 객체의 text 메소드 또는 json 메소드 이후에 등장하는 then 메소드부터는 string 타입의 값이나 자바스크립트 객체를 갖고 바로 원하는 작업을 할 수 있다.

📘then 메소드

const successCallback = function () { };
const errorCallback = function () { };

fetch('https://jsonplaceholder.typicode.com/users') // Promise-A
  .then(successCallback, errorCallback); // Promise-B

(1) fetch 메소드가 리턴하는 Promise 객체를 Promise-A 객체라고 하고,
(2) then 메소드가 리턴하는 Promise 객체를 Promise-B 객체라고 해보자.

  1. fetch 함수의 작업이 성공해서 Promise-A 객체가 fulfilled 상태가 된 경우 : then 메소드 안의 "첫 번째" 콜백인 successCallback이 실행된다.
  2. fetch 함수의 작업이 실패해서 Promise-A 객체가 rejected 상태가 된 경우 : then 메소드 안의 "두 번째" 콜백인 errorCallback이 실행된다.

여기서 중요한 점)

Promise-B는, 실행된 successCallback 또는 errorCallback에서 무엇을 리턴하느냐에 따라

  • 그 상태(fulfilled or rejected)와
  • 결과(작업 성공 결과 or 작업 실패 정보)가 결정된다.

1. 실행된 콜백이 어떤 값을 리턴하는 경우

successCallback이 실행되든, errorCallback이 실행되든, 실행된 콜백에서 어떤 값을 리턴하는 경우다. 이때 그 값의 종류에 따라

  • Promise 객체인 경우와
  • Promise 객체 이외의 경우,

이 2가지 경우로 다시 나눌 수 있다.

(1) Promise 객체를 리턴하는 경우


fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.json())
  .then((result) => { console.log(result) });
  • (response) ⇒ response.json() 이 콜백은 Promise 객체를 리턴하는 코드다.
  • 콜백에서 Promise 객체를 리턴하는 경우에는 그 콜백을 등록한 then 메소드가 리턴했던 Promise 객체가 콜백이 리턴한 Promise 객체의 상태와 결과를 똑같이 따라 갖게 된다.
  • 즉, 코드의 첫 번째 then 메소드가 리턴했던 Promise 객체는, response 객체의 json 메소드가 리턴한 Promise 객체가 추후에 갖게 되는 상태와 결과를 그대로 따라서 갖게 된다는 뜻다.
  • 콜백에서 리턴하는 Promise 객체를 then 메소드가 그대로 리턴한다고 생각해도 됨. 그 다음부터는 콜백에서 리턴한 Promise 객체로부터 다시 Promise Chain이 쭉 이어져 나간다고 보면 된다.

(2) Promise 객체 이외의 값을 리턴하는 경우

콜백이 꼭 Promise 객체만을 리턴하는 것은 아니다. 그냥 단순한 숫자, 문자열, 일반 객체 등을 리턴할 수도 있다. 이런 경우에 then 메소드가 리턴했던 Promise 객체는 fulfilled 상태가 되고 작업 성공 결과로 그 값을 갖게 된다.


// Internet Disconnected

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.json(), (error) => 'Try again!')
  .then((result) => { console.log(result) });

예시)

지금 인터넷이 안 되는 상황에서 이 코드를 실행했다고 해보자.

  • fetch 함수의 작업이 실패해서 두 번째 콜백인 (error) ⇒ 'Try again! 이 실행된다.
  • 두 번째 콜백은 'Try again!'이라는 문자열을 리턴하고 있다. 이렇게 하면 해당 콜백을 등록한 then 메소드가 리턴했던 Promise가 fulfilled 상태가 되고, 그 작업 성공 결과로 'Try again' 문자열을 갖게 된다.

2. 실행된 콜백이 아무 값도 리턴하지 않는 경우


// Internet Disconnected

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.json(), (error) => { alert('Try again!'); })
  .then((result) => { console.log(result) });

콜백이 무언가를 리턴하는 게 아니라 이 코드에서처럼 단순히 alert 함수만 실행하고 끝난다고 해보자.

  • 결과적으로 이 콜백은 아무런 값도 리턴하지 않은 것과 같다. 자바스크립트에서는 함수가 아무것도 리턴하지 않으면 undefined를 리턴한 것으로 간주된다.
  • 방금 전 1. (2) 규칙에 따라 then 메소드가 리턴했던 Promise 객체는 fulfilled 상태가 되고, 그 작업 성공 결과로 undefined를 갖게 된다.

3. 실행된 콜백 내부에서 에러가 발생했을 때

콜백이 실행되다가 에러가 발생하는 경우가 있다.

예시)


fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => {
        ...
        add(1, 2); // ReferenceError 발생
        ...
  });

이렇게 정의하지도 않은 함수를 콜백에서 사용해서 에러가 발생하거나


fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => {
        ...
        throw new Error('failed');
        ...
  });

특정 경우에 인위적으로 throw 문을 써서 에러를 발생시키는 경우도 있다.

이렇게 콜백이 실행되다가 에러가 발생한다면, then 메소드가 리턴했던 Promise 객체는 어떻게 될까?

이 경우에는 Promise 객체가 rejected 상태가 되고, 작업 실패 정보로 해당 에러 객체를 갖게 된다.


const promise = fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => { throw new Error('test'); });

promise 를 입력해 then 메소드가 리턴한 Promise 객체의 내부를 살펴보면👇🏻

https://bakey-api.codeit.kr/api/files/resource?root=static&seqId=4374&directory=Untitled.png&name=Untitled.png

지금 [[PromiseState]]는 Promise 객체의 상태를, [[PromiseResult]]는 Promise 객체의 결과(작업 성공 결과 또는 작업 실패 정보)를 나타내는 내부 슬롯이다.

현재 Promise 객체가 rejected 상태이고, 발생한 Error 객체를 그 작업 실패 정보로 갖고 있다는 것을 알 수 있다. 이렇게 콜백 실행 중에 에러가 발생하면, then 메소드가 리턴한 Promise 객체는 rejected 상태가 되고, 그 작업 실패 정보로 해당 Error 객체를 갖게 된다.

4. 아무런 콜백도 실행되지 않을 때


// Internet Disconnected

fetch('https://www.google.com') // Promise-1
  .then((response) => response.text()) // Promise-2
  .then((result) => { console.log(result) }, (error) => { alert(error) });

then 메소드의 아무런 콜백도 실행되지 않는 경우가 있다.

인터넷을 끊고 나서 위 코드를 실행했다고 해보자.

  • fetch 함수가 리턴한 Promise-1 객체는 rejected 상태가 되기 때문에, 첫 번째 then 메소드의 두 번재 콜백이 실행되어야 다. 그런데 지금 두 번째 콜백이 없다. 이 경우에는 아무런 콜백도 실행되지 않는다. 이런 경우에 then 메소드가 리턴한 Promise-2 객체는 어떻게 될까? 이런 경우에 then 메소드가 리턴했던 Promise-2 객체는, 이전 Promise 객체와 동일한 상태와 결과를 갖게 된다. → 지금 Promise-2 객체는 Promise-1 객체처럼 rejected 상태가 되고, 똑같은 작업 실패 정보를 갖게 된다.
  • 그럼 rejected 상태가 된 Promise-2의 then 메소드에는 이제 두 번째 콜백이 존재하기 때문에 그 두 번째 콜백이 실행된다.
  • 아무런 콜백도 실행되지 않는 경우에는 그 이전 Promise 객체의 상태와 결과가 그대로 이어진다는 사실!

예시)

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => {
    return response.json(); // <- Case(1)
    return 10; // <- Case(2)
    // <- Case(3)
    throw new Error('failed'); // <- Case(4)
})
  .then((result) => {
    console.log(result);
  });

// 존재하지 않는 URL
fetch('https://jsonplaceholder.typicode.commmmmm/users')
  .then((response) => response.json()) // <- Case(5)
  .then((result) => { }, (error) => { console.log(error) });

then 메소드가 리턴한 Promise객체를 A라고 할 때

Case(1): 콜백에서 Promise 객체를 리턴

  • 콜백이 리턴한 Promise 객체를 B라고 하면 A는 B와 동일한 상태와 결과를 갖게 된다.

Case(2): 콜백에서 Promise 객체가 아닌 일반적인 값을 리턴

  • A는 fulfilled 상태가 되고, 해당 리턴값을 작업 성공 결과로 갖게 된다.

Case(3): 콜백에서 아무것도 리턴하지 않음

  • undefined 리턴함 → A는 fulfilled 상태가 되고, undefined를 작업 성공 결과로 갖게 된다.

Case(4): 콜백 실행 중 에러 발생

  • A는 rejected 상태가 되고, 해당 에러 객체를 작업 실패 정보로 갖게 된다.

Case(5): 콜백이 실행되지 않음

  • A는 호출된 then 메소드의 주인에 해당하는, 이전 Promise 객체와 동일한 상태와 결과를 가진다.

✨catch

❓어떻게 fetch 함수에서 발생한 에러가 catch 메소드 안의 콜백에까지 전달될 수 있는 걸까?

// catch문
fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.text())
  .catch((error) => { console.log(error); })
  .then((result) => { console.log(result); });

// catch 말고 then을 쓸 때
fetch('https://jsonplaceholder.typicode.com/users') // Promise-A
  .then((response) => response.text()) // Promise-B
  .then(undefined, (error) => { console.log(error); }) // Promise-C
  .then((result) => { console.log(result); }); // Promise-D
 
  • catch 메소드는 사실 then 메소드의 첫 번째 인자로 undefined을 넣은 것과 같다.
  • fetch 함수의 작업이 실패해서 Promise-A 객체가 rejected 상태가 되면, 첫 번째 then 메소드의 두 번째 콜백이 실행되어야 한다. 하지만 지금 첫 번째 then 메소드에는 두 번째 콜백이 없기 때문에 아무 콜백도 실행되지 않는다.
  • Promise-B 객체가 Promise-A와 똑같은 rejected 상태가 되고, 동일한 작업 실패 정보를 갖게 된다.
  • rejected 상태가 된 Promise-B에 붙은 then 메소드에는 두 번째 콜백이 있기 때문에 이 두 번째 콜백이 실행된다. 즉, catch 메소드의 콜백이 실행된다.

📘catch 메소드를 여러 개 쓰는 경우

catch 메소드를 마지막뿐만 아니라 Promise Chain 중간중간에 쓰는 경우도 존재한다. 만약 중간에 에러가 발생해도 catch 메소드가 그 대안을 뒤로 넘겨줄 수 있으면 catch 메소드를 중간에 써도 된다.

fetch('https://friendbook.com/my/newsfeeds')
  .then((response) => response.json()) // -- A
  .then((result) => { // -- B
    const feeds = result;
    // 피드 데이터 가공...
    return processedFeeds;
  })
  .catch((error) => { // -- C
    // 미리 저장해둔 일반 뉴스를 보여주기
    const storedGeneralNews = getStoredGeneralNews();
    return storedGeneralNews;
  })
  .then((result) => { /* 화면에 표시 */ }) // -- D
  .catch((error) => { /* 에러 로깅 */ }); // -- E
  • 만약 서버로부터 뉴스피드가 잘 조회되면 현재 코드에서 A, B, D 줄에 있는 콜백들이 잘 실행되고, 사용자에게 뉴스피드가 잘 표시될 것이다.
  • 하지만 만약 사용자의 컴퓨터가 인터넷에 연결되어 있지 않은 상태라서 fetch 함수의 작업이 실패한다면?
    • 이 Promise Chain의 작업은 실패했다고 생각하고, 마지막에만 catch 메소드를 두고 끝내면 되는 걸까? 꼭 그렇지는 않다.
  • fetch 함수의 작업이 실패하면 C 줄의 콜백이 실행된다. (SNS 서비스의 웹 페이지에서는 나중에 오프라인 상태가 될 때를 대비해서 모든 사람이 공통으로 볼 수 있는, 텍스트로만 이루어진 최근 일반 뉴스 데이터를 갱신해서 웹 브라우저에 저장한다고 해보자)
    • C줄의 콜백은 바로 이렇게 저장해둔 일반 뉴스 데이터를 그대로 가져오는 기능을 한다. 이렇게 되면 인터넷이 안 되는 상황에서도 나만을 위한 최적화된 뉴스피드는 못 보지만 일반적인 세상 뉴스는 사용자가 볼 수 있다.
  • Promise Chain 중에서 비록 에러가 발생했다고 해도 만약 실패한 작업 대신 다른 방법을 통해서 작업을 정상적으로 끝마칠 수 있는 상황이라면 catch 메소드를 중간에 사용하기도 한다.
  • Promise Chain 중에서 단 하나의 작업이라도 실패하면 전체 작업이 실패했다고 봐도 되는 경우에는 그냥 Promise Chain 마지막에만 catch 메소드를 써주면 되겠지만, 어떤 작업들은 에러가 발생하더라도 다른 방식으로 복구해서 살려낼 방법이 있다면 catch 메소드 안의 콜백에서 그런 복구 작업을 해주면 된다.

⇒ catch 메소드를 Promise Chain의 마지막에 늘 써줘야 하는 것은 맞지만, 작업을 살릴 방법이 있다면 Promise Chain 중간에 catch 메소드를 써도 된다는 사실!!

✨finally

어떤 작업이 성공하든 실패하든 상관없이(Promise 객체가 fulfilled상태가 되든 rejected 상태가 되든 상관없이) 항상 실행하고 싶을 콜백이 있을 때 씀.


❓Promise 객체는 왜 등장했을까?9

사실 Promise 객체가 등장하기 전에도 비동기적인 처리를 할 수 있는 방법은 있었다.
(setTimeout 함수나, addEventListener 메소드)

setTimeout(callback, milliseconds);
addEventListener(eventname, callback);

이것들은 모두 직접 파라미터에 콜백을 전달하는 형식으로 정의되어 있다. 만약 fetch 함수를 이런 식으로 만들었다면

fetch('https://first.com', callback)

그런데 왜 이런 방법이 선택되지 않고, 굳이 Promise 객체라는 문법이 도입된 것일까?

그 이유는 바로 함수에 콜백을 직접 넣는 형식은 콜백 헬(callback hell)이라고 하는 문제를 일으킬 수도 있기 때문이다.

만약 fetch 함수가 지금과 같이 Promise 객체를 리턴하는 게 아니라 setTimeout 함수처럼 콜백을 직접 집어넣는 형식의 함수였다면…


fetch('https://first.com', (response) => {
  // Do Something
  fetch('https://second.com', (response) => {
    // Do Something
    fetch('https;//third.com', (response) => {
      // Do Something
      fetch('https;//fourth.com', (response) => {
        // Do Something
      });
    });
  });
});

⇒ 가독성이 떨어진다.

이런 현상을 콜백 지옥 또는 콜백 헬(callback hell)이라고 한다. 또는 지옥의 피라미드(Pyramid of Doom)라고도 한다.

하지만 fetch 함수는 Promise 객체를 리턴하기 때문에

fetch('https://first.com')
  .then((response) => {
    // Do Something
    return fetch('https://second.com');
  })
  .then((response) => {
    // Do Something
    return fetch('https://third.com');
  })
  .then((response) => {
    // Do Something
    return fetch('https://third.com');
  });

Promise Chaining을 해서 좀 더 깔끔한 코드로 여러 비동기 작업을 순차적으로 처리할 수 있다.
이렇게 Promise 객체를 사용하면 callback hell 문제를 해결할 수 있다.

이 뿐만 아니라 기존에 콜백을 직접 넣는 방식에 비해 Promise 객체의 문법은 비동기 작업에 관한 좀 더 세밀한 개념들이 반영되어 있다. 이전의 방식에서는 콜백에 필요한 인자를 넣어주고 실행하면 되는 단순한 방식이었다면, Promise 객체 문법에는 pending, fulfilled, rejected 상태, 작업 성공 결과 및 작업 실패 정보(이유), then, catch, finally 메소드 등과 같은 비동기 작업에 관한 보다 정교한 설계가 문법 자체에 반영되어 있다는 것을 알 수 있다.

Promise 객체라는 개념은,

(1) callback hell 문제를 해결하고,
(2) 비동기 작업 처리에 관한 좀 더 세밀한 처리를 자바스크립트 문법 단에서 해결하기 위해 등장했고,

자바스크립트의 2015년도 표준인 ES6(=ES2015)에 추가되었다.

✨promisify

언제 Promise 객체를 직접 만들게 되는 걸까? 다양한 경우들이 있지만, 전통적인 형식의 비동기 실행 함수를 사용하는 코드를, Promise 기반의 코드로 변환하기 위해 Promise 객체를 직접 만드는 경우가 많다.

1. setTimeout 함수 예시

function wait(text, milliseconds) {
  setTimeout(() => text, milliseconds);
}

wait 함수는 특정 밀리세컨즈만큼 시간이 지난 후에 text 파라미터로 전달받은 값을 리턴하는 함수다. 이 wait 함수를 Promise Chaining 코드에서 사용하면

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.text())
  .then((result) => { console.log(result); });

바로 이 Promise Chaining 코드에 wait 함수를 추가하면

function wait(text, milliseconds) {
  setTimeout(() => text, milliseconds);
}

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.text())
  // 2초 후에 리스폰스의 내용 뒤에 'by Codeit' 추가하고 리턴
  .then((result) => wait(`${result} by Codeit`, 2000)) 
  .then((result) => { console.log(result); });

기존 코드에 두 번째 then 메소드를 추가하고, 그 안에서 wait 함수를 호출했다. 이렇게 쓰면 2초 후에 리스폰스의 내용 뒤에 by Codeit이라는 문구를 붙여서 출력될 것 같다.

리스폰스의 내용과 by Codeit이 출력되지 않았다. 그 대신 undefined가 출력되었다.

왜 그런 걸까?
그 이유는 바로 wait 함수에 있다.

function wait(text, milliseconds) {
  setTimeout(() => text, milliseconds);
}

이 wait 함수는 내부에서 setTimeout 함수를 호출한다.

그리고 setTimeout 함수의 첫 번째 파라미터로 들어간 콜백이 2초 후에 text를 리턴한다.

그런데 여기서 혼동하면 안 되는 것은 wait 함수가

...
  .then((result) => { return wait(`${result} by Codeit`, 2000); })
...

이 두 번째 then 메소드 안의 콜백에서 실행될 때,

wait 함수는 setTimeout 함수를 실행할 뿐 아무것도 리턴하지 않는다.
setTimeout 함수 안의 콜백이 2초 후에 리턴하는 text는, wait 함수의 리턴값이 아니다.

  • wait 함수는 단지 setTimeout 함수를 실행하고 아무것도 리턴하지 않는 함수일 뿐.
  • 자바스크립트에서는 이전에 배운대로 함수에서 아무것도 리턴하지 않으면 undefined를 리턴하는 것으로 간주하기 때문에 wait 함수의 리턴값은 undefined.
  • 따라서 세 번째 then 메소드의 콜백으로 undefined가 넘어가고, undefined가 출력된 것.

setTimeout은 비동기 실행되는 함수다. Promise Chaining 안에서 이렇게 비동기 실행되는 함수를 바로 사용하면, 나중에 실행되는 부분의 리턴값(여기서는 text)를 Promise Chain에서 사용할 수 없게 된다.

해결방법)

// function wait(text, milliseconds) {
//   setTimeout(() => text, milliseconds);
// }

function wait(text, milliseconds) {
  const p = new Promise((resolve, reject) => {
    setTimeout(() => { resolve(text); }, 2000);
  });
  return p;
}
  • wait 함수 안에서 Promise 객체를 직접 생성했고, executor 함수 안에서 setTimeout 함수를 호출했다.
  • setTimeout 함수 안의 콜백에서 resolve 함수를 호출하는데 이 때 그 아규먼트로 text를 넣었다.
  • Promise 객체 p는 2초 후에 fulfilled 상태가 될 것이고, 그 작업 성공 결과는 파라미터 text의 값이 될 될 것이다. wait 함수는 이제 Promise 객체 p를 리턴한다.

자, 이 상태에서 코드를 다시 실행해보면


function wait(text, milliseconds) {
  const p = new Promise((resolve, reject) => {
    setTimeout(() => { resolve(text); }, 2000);
  });
  return p;
}

fetch('https://jsonplaceholder.typicode.com/users')
  .then((response) => response.text())
  .then((result) => wait(`${result} by Codeit`, 2000)) // 2초 후에 리스폰스의 내용 뒤에 'by Codeit' 추가하고 리턴
  .then((result) => { console.log(result); });

이번에는 약 2초 후에 리스폰스의 내용이 잘 출력되고,

⇒ 기존의 비동기 실행 함수(여기서는 setTimeout)의 콜백이 리턴하는 값을 Promise Chain에서 사용하고 싶다면, 해당 함수를 감싸서 Promise 객체를 직접 생성하는 코드를 작성해야 한다. 그리고 그 Promise 객체를 리턴해야 방금처럼 Promise Chain에서 해당 리턴값을 받아서 사용할 수 있다.

이렇게 전통적인 형식의 비동기 실행 함수를 Promise 객체로 감싸서 그 Promise 객체를 리턴하는 형식으로 만드는 작업을 Promisify(프로미스화하다)라고 한.

2. 콜백 헬(callback hell)과 Promise

Node.js는 오늘날 자바스크립트를 서버에서도 실행할 수 있게 해주는 또 다른 '자바스크립트 실행 환경'이다. 이 Node.js에서는 브라우저에서와는 또 다른 비동기 함수들이 제공된다.

Node.js에는 다음과 같이 특정 파일의 내용을 읽기 위해 사용되는 readFile이라는 비동기 실행 메소드가 있다.

fs.readFile('file1.txt', 'utf8', (error, data) => {
  if (err) {
    console.log(err);
  } else {
    console.log(data);
  }
});
  • fs는 readFile 메소드를 가진 객체로, 파일에 관한 기능들을 갖고 있다.
  • readFile 메소드는 첫 번째 파라미터로 파일의 이름, 두 번째 파라미터로 파일 해석 기준(인코딩 기준), 세 번째 파라미터로 콜백을 받는다.
  • readFile 함수는 파일을 읽다가 에러가 발생하면 콜백의 첫 번째 파라미터(error)에, 해당 에러 객체를 전달하고 콜백을 실행한다. 만약 파일을 정상적으로 다 읽었으면 콜백의 두 번째 파라미터(data)에, 읽어들인 파일 내용을 전달하고 콜백을 실행한다.
  • readFile 메소드도, 콜백을 파라미터에 바로 넣는 비동기 실행 함수라는 점에서 setTimeout 함수, addEventListener 메소드와 비슷하다.
    • 콜백 헬(callback hell) 문제) 위 코드에서 이제 file1.txt 파일의 내용을 출력하고 나서 그 다음에 file2.txt라는 파일의 내용을 또 출력해야한다고 해보자.

fs.readFile('file1.txt', 'utf8', (error1, data1) => {
  if (error1) {
    console.log(error1);
  } else {
    console.log(data1);
    fs.readFile('file2.txt', 'utf8', (error2, data2) => {
      if (error2) {
        console.log(error2);
      } else {
        console.log(data2);
      }
    });
  }
});

이렇게 코드를 쓰면 file1.txt의 내용이 출력되고, 그 다음에 file2.txt의 내용이 출력될 것이다. 코드가 그런데 이제 그 다음으로 file3.txt의 내용도 출력해야 한다고 해보자.

fs.readFile('file1.txt', 'utf8', (error1, data1) => {
  if (error1) {
    console.log(error1);
  } else {
    console.log(data1);
    fs.readFile('file2.txt', 'utf8', (error2, data2) => {
      if (error2) {
        console.log(error2);
      } else {
        console.log(data2);
        fs.readFile('file3.txt', 'utf8', (error3, data3) => {
          if (error3) {
            console.log(error3);
          } else {
            console.log(data3);
          }
        });
      }
    });
  }
});

⇒ 코드 읽기 어려워짐

콜백을 바로 파라미터에 집어넣는 전통적인 형식의 비동기 실행 함수들은 이런 문제가 있다. 바로 순차적으로 비동기 실행 함수들을 실행하려고 하면 콜백 안에 또 콜백이 있고, 그 안에 또 콜백이 있는 콜백 헬(콜백 지옥, callback hell) 현상을 초래하게 된다는 것이다.

해결책) Promisify

function readFile_promisified(filename) {
  const p = new Promise((resolve, reject) => {
    fs.readFile(filename, 'utf8', (error, data) => {
      if (error) {
        reject(error); // 에러 발생 시 -> rejected
      } else {
        resolve(data); // 파일 내용 읽기 완료 -> fulfilled
      }
    });
  });
  return p;
}
  • readFile_promisified라는 이름의 함수를 정의하고, 함수 안에서는 Promise 객체를 직접 생성하고 있다.
  • Promise 객체가 생성될 때 실행되는 executor 함수 안에서는 fs 객체의 readFile 메소드를 호출했다.
  • 여기서 중요한 것은 작업을 수행하다가 에러가 나면 readFile 함수의 콜백에서 reject 함수를 호출하고, 파일의 내용을 정상적으로 다 읽었을 때는 resolve 함수를 호출한다는 사실.

...                         (error, data) => {
  if (error) {
    reject(error); // 에러 발생 시 -> rejected
  } else {
    resolve(data); // 파일 내용 읽기 완료 -> fulfilled
  }
}
  • reject 함수의 파라미터에는 error 객체를, resolve 함수의 파라미터에는 파일의 내용인 data를 전달했다.

readFile_promisified('file1.txt')
  .then((data) => { console.log(data); return readFile_promisified('file2.txt'); })
  .then((data) => { console.log(data); return readFile_promisified('file3.txt'); })
  .then((data) => { console.log(data); })
  .catch((error) => { console.log(error); });

readFile_promisified 함수는 Promise 객체를 리턴하기 때문에 이렇게 자유롭게 Promise Chain 안에서 사용할 수 있다.

3. Promisify를 하면 안 되는 함수들도 있다.

기존의 전통적인 형식의 비동기 실행 함수도 원하는 경우에는 Promisify해서 콜백 헬을 방지할 수 있다. 하지만 전통적인 형식의 비동기 실행 함수라고 해서 모두 Promisify해서 사용해도 되는 것은 아니다.

기존의 비동기 실행 함수들 중에서도 그 콜백을 한번만 실행하는 것들(setTimeout, readFile 등)만 Promisify해서 사용해도 된다.

이것들과 달리 만약 콜백을 여러 번 실행하는 함수들(setInterval, addEventListener 등)인 경우에는 이렇게 Promisify하면 안 된다.

Promise 객체는 한번 pending 상태에서 fulfilled 또는 rejected 상태가 되고나면 그 뒤로는 그 상태와 결과가 바뀌지 않기 때문

const box = document.getElementById('test');
let count = 0;

function addEventListener_promisified(obj, eventName) { // 이런 Promisify는 하지 마세요
  const p = new Promise((resolve, reject) => {
    obj.addEventListener(eventName, () => { // addEventListener 메소드
      count += 1;
      resolve(count);
    });
  });
  return p;
}

addEventListener_promisified(box, 'click')
.then((eventCount) => { console.log(eventCount); });

addEventListener_promisified 함수는 DOM 객체의 addEventListener 메소드를 Promisify한 함수다.

지금 Promise 객체가 생성될 때 실행되는 executor 함수 안에서는, DOM 객체에 어떤 이벤트가 발생할 때, 실행할 콜백을 등록하고 있다.
특정 이벤트가 발생할 때마다 count라고 하는 변수의 값을 1씩 늘려서 resolve 함수의 파라미터로 전달해서 실행하도록 하는 내용이 들어있다.

마지막 코드를 보면,

addEventListener_promisified 함수의 아규먼트로 DOM 객체 box와 문자열 'click'을 넣어서 box 객체가 클릭 이벤트에 반응하도록 했다.

하지만 이 코드를 실행하고 box를 클릭해보면
처음에 1이 딱 출력되고 나서 그 다음 count 값들은 출력되지 않는다.

왜냐하면 pending 상태에 있던 Promise 객체(여기서는 p 객체)가 한번 fulfilled 상태 또는 rejected 상태가 되고 나면 Promise 객체의 상태 및 결과가 고정되어 그 뒤로는 바뀌지 않기 때문.

따라서 지금 위 코드에 보이는 resolve(count)라고 하는 코드가 box 버튼을 클릭할 때마다 여러 번 실행된다고 해도 p 객체가 갖고 있는 상태와 결과는 변하지 않는다. 그래서 then 메소드 안의 콜백도 처음 클릭했을 때 딱 한번 실행되고 끝이다.

이렇게 콜백이 여러 번 실행되어야하는 비동기 실행 함수인 경우에는 Promisify를 하면 안 된다.

처음부터 바로 fulfilled 상태이거나 rejected 상태인 Promise 객체를 만드는 것도 가능하다.👇🏻👇🏻

✨이미 상태가 결정된 Promise 객체 만들기

(1) fulfilled 상태의 Promise 객체 만들기

const p = Promise.resolve('success');

Promise의 resolve라는 메소드를 사용하면 바로 fulfilled 상태의 Promise 객체를 만들 수 있다. 위와 같이 쓰면 fulfilled 상태이면서, 작업 성공 결과로 문자열 'success'를 가진 Promise 객체를 만들 수 있다.

(2) rejected 상태의 Promise 객체 만들기

const p = Promise.reject(new Error('fail'));

Promise의 reject라는 메소드를 사용하면 바로 rejected 상태의 Promise 객체를 만들 수 있다. 위와 같이 쓰면 rejected 상태이면서, 작업 실패 정보로, fail이라는 메시지를 가진 Error 객체를 가진 Promise 객체를 만들 수 있다.

Promise 객체를 직접 생성하는 방법)

const p = new Promise((resolve, reject) => {

});

new 생성자와 executor 함수를 사용하는 것 말고도 resolve 메소드나, reject 메소드를 사용하는 방법도 있다는 사실!!
resolve 메소드나 reject 메소드로 생성한 Promise 객체도 이때까지 우리가 배운 것과 동일하게 작동한다.

const p = Promise.resolve('success');
p.then((result) => { console.log(result); }, (error) => { console.log(error); });

이 코드에서는 첫 번째 콜백이 실행되어서 작업 성공 결과인 문자열 success가 출력되고

const p = Promise.reject(new Error('fail'));
p.then((result) => { console.log(result); }, (error) => { console.log(error); });

이 코드에서는 두 번째 콜백이 실행되어서 작업 실패 정보인 Error 객체의 내용이 출력된다.

어떤 비동기 작업을 처리할 필요가 있다면, new 생성자와 executor 함수를 사용해서 Promise 객체를 만들어야 하지만, 그렇지 않고 바로 상태가 이미 결정된 Promise 객체를 만들고 싶을 때는 이 resolve 또는 reject 메소드를 사용한다.

구체적으로 예를 들자면, 함수 안에서 리턴하는 값이 여러 개인 경우 모든 리턴값을 Promise 객체로 통일하고 싶을 때, 종종 resolve 또는 reject 메소드를 쓴다.

function doSomething(a, b) {
    //~~
  if (problem) {
    throw new Error('Failed due to..'));
  } else {
    return fetch('https://~');
  }
}

문제(problem이 falsy인 경우)가 없는 경우에만 fetch 함수를 호출해서 Promise 객체를 리턴하는 함수가 있다고 해보자.

만약 문제가 발생하는 경우에는 바로 Error 객체를 throw하고 있다. 만약 문제가 존재하는 경우에도 Promise 객체를 리턴하고 싶다면 reject 메소드를 써서 작성할 수 있다.

function doSomething(a, b) {
  // ~~
  if (problem) {
    return Promise.reject(new Error('Failed due to..'));
  } else {
    return fetch('https://~');
  }
}

지금 문제가 있는 경우에도 에러를 바로 throw하는 게 아니라, 생성한 에러를 Promise 객체의 작업 실패 정보로 설정해서, 그 Promise 객체를 리턴하는 것으로 바꿨다. 만약 어떤 함수가 어떤 상황이든 항상 Promise 객체를 리턴하는 것으로 통일하고 싶은 경우에는 resolve나 reject 메소드를 유용하게 사용할 수 있다.

2. Promise 객체의 작업 성공 결과 또는 작업 실패 정보

fulfilled 또는 rejected 상태가 결정된 Promise 객체라도 then 메소드를 붙이면, 콜백에서 해당 작업 성공 결과 또는 작업 실패 정보를 받아올 수 있다.

Promise 객체의 상태가 fulfilled 또는 rejected 상태이기만 하면, 어느 시점이든, 몇 번이든 then 메소드를 붙여서 해당 결과를 가져올 수 있다.

const p = new Promise((resolve, reject) => {
	// 2초 후에 fulfilled 상태가 됨
  setTimeout(() => { resolve('success'); }, 2000); 
});

// Promise 객체가 pending 상태일 때 콜백 등록
p.then((result) => { console.log(result); }); 

// Promise 객체가 fulfilled 상태가 되고 나서 콜백 등록
setTimeout(() => { p.then((result) => { console.log(result); }); }, 5000); 

Promise가 pending 상태일 때 등록한 콜백이든, fulfilled 상태가 된 후에 등록한 콜백이든 잘 실행되는 것을 알 수 있다.
이렇게 어느 시점이든, 몇 번의 then 메소드를 붙이든 상관없이, pending 상태만 아니라면 항상 then 메소드로 Promise 객체의 결과를 추출할 수 있다.

Promise 객체는 항상 결과를 줄 수 있는 공급자(Provider)이고 그것의 then 메소드는 그 결과를 소비하는 콜백인 소비자(Consumer)를 설정하는 메소드라는 사실!!

✨여러 Promise 객체 다루기

1. all 메소드

// 1번 직원 정보
const p1 = fetch('https://learn.codeit.kr/api/members/1').then((res) => res.json());
// 2번 직원 정보
const p2 = fetch('https://learn.codeit.kr/api/members/2').then((res) => res.json());
// 3번 직원 정보
const p3 = fetch('https://learn.codeit.kr/api/members/3').then((res) => res.json());

Promise
  .all([p1, p2, p3])
  .then((results) => {
    console.log(results); // Array : [1번 직원 정보, 2번 직원 정보, 3번 직원 정보]
  });
  • 서로 다른 3개의 URL로 리퀘스트를 보내는 fetch 함수들이다. 1번, 2번, 3번 직원의 정보를 각각 요청하고 있다.
  • Promise의 all이라는 메소드를 호출하고 있고, all 메소드의 아규먼트로는 배열 하나가 들어있다. 그 배열의 요소들은, 각 직원 정보를 요청하고 받아서 Deserialize까지 수행한 작업 성공 결과를 담고 있는 Promise 객체들인 p1, p2, p3 객체다.
  • all 메소드도 then 메소드처럼 새로운 Promise 객체를 리턴한다.
  • all 메소드는 이렇게 아규먼트로 들어온 배열 안에 있는 모든 Promise 객체가 pending 상태에서 fulfilled 상태가 될 때까지 기다린다.
  • 모든 Promise 객체들이 fulfilled 상태가 되면, all 메소드가 리턴했던 Promise 객체는 fulfilled 상태가 되고, 각 Promise 객체의 작업 성공 결과들로 이루어진 배열을, 그 작업 성공 결과로 갖게 된다.

이렇게 all 메소드가 리턴한 Promise 객체는,

(1) 각 개별 Promise 객체의 작업 성공 결과로 이루어진 배열을
(2) 자신의 작업 성공 결과로 갖는다는 것을 알 수 있다.

⇒ all 메소드는 여러 Promise 객체의 작업 성공 결과를 기다렸다가 모두 한 번에 취합하기 위해서 사용한다.

그런데 만약 p1~3 객체들 중 하나라도, rejected 상태가 되면?

// 1번 직원 정보
const p1 = fetch('https://learn.codeit.kr/api/members/1').then((res) => res.json());
// 2번 직원 정보
const p2 = fetch('https://learn.codeit.kr/api/members/2').then((res) => res.json());
// 3번 직원 정보
const p3 = fetch('https://learnnnnnn.codeit.kr/api/members/3').then((res) => res.json());

Promise
  .all([p1, p2, p3])
  .then((results) => {
    console.log(results); // Array : [1번 직원 정보, 2번 직원 정보, 3번 직원 정보]
  });
  • fetch 함수에서 문제가 발생해서 p3가 rejected 상태가 되면, all 메소드가 리턴한 Promise 객체는 p3 객체처럼 rejected 상태가 되고 동일한 작업 실패 정보를 갖게 된다.
  • all 메소드는 하나의 Promise 객체라도 rejected 상태가 되면, 전체 작업이 실패한 것으로 간주해야 할 때 사용한다.

그리고 이렇게 Promise 객체가 하나라도 rejected 상태가 되는 경우에 대비하려면

// 1번 직원 정보
const p1 = fetch('https://learn.codeit.kr/api/members/1').then((res) => res.json());
// 2번 직원 정보
const p2 = fetch('https://learn.codeit.kr/api/members/2').then((res) => res.json());
// 3번 직원 정보
const p3 = fetch('https://learnnnnnn.codeit.kr/api/members/3').then((res) => res.json());

Promise
  .all([p1, p2, p3])
  .then((results) => {
    console.log(results); // Array : [1번 직원 정보, 2번 직원 정보, 3번 직원 정보]
  })
  .catch((error) => {
    console.log(error);
  });

그냥 이렇게 catch 메소드를 붙여주면 된다.

2. race 메소드

  • race 메소드도 all 메소드와 마찬가지로 여러 Promise 객체들이 있는 배열을 아규먼트로 받는다. race 메소드도 all 메소드처럼 Promise 객체를 리턴한다.
  • 작동방식의 차이점이 있음) race 메소드가 리턴한 Promise 객체는 아규먼트로 들어온 배열의 여러 Promise 객체들 중에서 가장 먼저 fulfilled 상태 또는 rejected 상태가 된 Promise 객체와 동일한 상태와 결과를 갖게 된다.
const p1 = new Promise((resolve, reject) => {
  setTimeout(() => resolve('Success'), 1000);
});
const p2 = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('fail')), 2000);
});
const p3 = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('fail2')), 4000);
});

Promise
  .race([p1, p2, p3])
  .then((result) => {
    console.log(result); // hello 출력
  })
  .catch((value) => {
    console.log(value);
  });

지금 race 메소드 안의 배열에 들어있는 Promise 객체들 중에서 무엇이 가장 빨리 fulfileld 또는 rejected 상태가 될까?
→ 1초 후에 fulfilled 상태가 되는 p1 객체

p1 객체는 1초 후에 fulfilled 상태가 되고, 그 작업 성공 결과로 문자열 Success를 가지게 된다.
p2는 2초 후에, p3는 4초 후에 rejected 상태가 된다.

race 메소드가 리턴한 Promise 객체는 이 중에서 가장 빨리 상태 정보가 결정된 p1 객체와 동일한 상태와 결과를 가진다.
⇒ 말그대로 race 메소드는 여러 Promise 객체들을 레이스(race, 경쟁)시켜서 가장 빨리 상태가 결정된 Promise 객체를 선택하는 메소드

그 외 메소드

각 메소드가 리턴한 Promise 객체가 A라고 할 때,

allSettled 메소드 : 배열 내의 모든 Promise 객체가 fulfilled 또는 rejected 상태가 되기까지 기다리고, pending 상태의 Promise 객체가 하나도 없게 되면, A의 상태값은 fulfilled 상태가 되고 그 작업 성공 결과로, 하나의 배열을 갖게 된다.

이 배열에는 아규먼트로 받았던 배열 내의 각 promise 객체의

(1) 최종 상태를 status 프로퍼티,
(2) 그 작업 성공 결과는 value 프로퍼티,
(3) 그 작업 실패 정보는 reason 프로퍼티

에 담은 객체들이 요소로 존재한다.

[
   {status: "fulfilled", value: 1},
   {status: "fulfilled", value: 2},
   {status: "fulfilled", value: 3},
   {status: "rejected",  reason: Error: an error}
]

참고로 fulfilled 상태와 rejected 상태를 묶어서 settled 상태라고 한다.

allSettled 메소드는 말 그대로 배열 속 Promise 객체들이 settled 상태가 되기만 하면 되는 것. 이에 반해 위에서 배운 all 메소드는 모든 Promise 객체들이 fulfilled 상태가 되기를 기다리는 것.

any 메소드 : 여러 Promise 객체들 중에서 가장 먼저 fulfilled 상태가 된 Promise 객체의 상태와 결과가 A에도 똑같이 반영된다. 만약 모든 Promise 객체가 rejected 상태가 되어버리면 AggregateError라고 하는 에러를 작업 실패 정보로 갖고 rejected 상태가 된다. any라는 단어의 뜻처럼 배열 속의 Promise 객체 중 단 하나라도 fulfilled 상태가 되면 되는 것.

✨axios

fetch 함수가 Ajax 통신을 하는 함수다. 개발 실무에서는 이 fetch 함수 말고도 Ajax 통신을 할 수 있는 방법이 존재한다. ⇒ axios 라고 하는 외부 패키지를 사용하는 것.

axios
  .get('https://jsonplaceholder.typicode.com/users')
  .then((response) => {
    console.log(response);
  })
  .catch((error) => {
    console.log(error);
  });
  • axios 패키지에서 제공하는 axios 객체를 사용해서 GET 리퀘스트를 보내고 그 리스폰스를 받는 코드다.
  • 자세히 보면 지금 코드에서 axios.get이라고 쓰여 있는 부분만 fetch로 바꾸면 fetch 함수와 사용법이 비슷하다
  • 사실 axios 객체에서 리퀘스트를 보내는 많은 메소드들이 fetch 함수처럼 Promise 객체를 리턴한다.
  • axios 객체에는 fetch 함수에는 없는 다음과 같은 몇 가지 기능 및 장점이 있다.)
    • 모든 리퀘스트, 리스폰스에 대한 공통 설정 및 공통된 전처리 함수 삽입 가능
    • serialization, deserialization을 자동으로 수행
    • 특정 리퀘스트에 대해 얼마나 오랫동안 리스폰스가 오지 않으면 리퀘스트를 취소할지 설정 가능(request timeout)
    • 업로드 시 진행 상태 정보를 얻을 수 있음
    • 리퀘스트 취소 기능 지원

https://github.com/axios/axios

[P1_S3] 자바스크립트 웹 개발 기본기

(4) async/await을 활용한 세련된 비동기 코드

(4) async/await을 활용한 세련된 비동기 코드

/* fetch('https://www.google.com')
    .then((response) => response.text())
    .then((result) => { console.log(result); }); */

async function fetchAndPrint() {
  console.log(2);
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  console.log(7);
  const result = await response.text();
  console.log(result);
}

console.log(1);
fetchAndPrint();
console.log(3);
console.log(4);
console.log(5);
console.log(6);
  • async/await 구문은 기존의 Promise 객체를 사용하는 코드(Promise Chaining)를

(1) 개발자가 더 편하게 작성할 수 있도록 하고
(2) 코드의 가독성을 높이기 위해서

도입된 일종의 Syntactic sugar(기존 문법을 더 편하게 사용할 수 있도록 하는 문법적 장치)에 해당한다.

원래는)

  • 전통적인 형식의 비동기 실행 함수에 콜백을 바로 전달하거나,
  • Promise 객체 뒤에 .then 메소드를 붙이는 것보다는

그냥 코드를 차례대로 써나가는 것이 더 익숙한 방식이다.
async/await 구문이 Promise 객체를 우리에게 이미 익숙한 동기 실행 코드 방식으로 다룰 수 있게 해주는 문법이다.

// 위 코드 출력 결과
1
2
3
4
5
6
7
[리스폰스의 내용]

원래코드)


/* fetch('https://www.google.com')
    .then((response) => response.text())
    .then((result) => { console.log(result); }); */

function fetchAndPrint() {
  console.log(2);
  fetch('https://jsonplaceholder.typicode.com/users')
    .then((response) => {
      console.log(7);
      return response.text();
    })
    .then((result) => { console.log(result); });
}

console.log(1);
fetchAndPrint();
console.log(3);
console.log(4);
console.log(5);
console.log(6);

async/await 구문을 사용하면,

(1) Promise 객체를 사용할 때, then 메소드 등을 사용하지 않고도
(2) 마치 동기 실행 코드처럼 코드를 훨씬 더 간단하고 편하게 작성할 수 있다.

  • async 함수 안의 코드가 실행되다가 await을 만나면, 일단 await 뒤의 코드가 실행되고, 코드의 실행 흐름이 async 함수 바깥으로 나가서 나머지 코드를 다 실행한다.
  • 그 이후로는, await 뒤에 있던 Promise 객체가 fulfilled 상태가 되기를 기다린다. 그리고 기다리던 Promise 객체가 fulfilled 상태가 되면 await이 Promise 객체의 작업 성공 결과를 리턴한다.

✨async 함수 안에서 리턴하는 값의 종류에 따라 어떤 Promise 객체를 리턴하게 될까.

1. 어떤 값을 리턴하는 경우

(1) Promise 객체를 리턴하는 경우

async 함수 안에서 Promise 객체를 리턴하는 경우에는 해당 Promise 객체와 동일한 상태와 작업 성공 결과(또는 작업 실패 정보)를 가진 Promise 객체를 리턴한다.

async function fetchAndPrint() {
  return new Promise((resolve, reject)=> {
    setTimeout(() => { resolve('abc'); }, 4000);
  });
}

fetchAndPrint();

이렇게 pending 상태의 Promise 객체를 리턴하기도 하고


async function fetchAndPrint() {
  return Promise.resolve('Success');
}

fetchAndPrint();

이미 fulfilled 상태인 Promise 객체나


async function fetchAndPrint() {
  return Promise.reject(new Error('Fail'));
}

fetchAndPrint();

이미 rejected 상태인 Promise 객체를 리턴하는 경우 전부 다 해당한다.

(2) Promise 객체 이외의 값을 리턴하는 경우

async 함수 내부에서 Promise 객체 이외에 숫자나 문자열, 일반 객체 등을 리턴하는 경우에는, fulfilled 상태이면서, 리턴된 값을 작업 성공 결과로 가진 Promise 객체를 리턴한다.

async function fetchAndPrint() {
  return 3;
}

fetchAndPrint();


async function fetchAndPrint() {
  return 'Hello';
}

fetchAndPrint();


async function fetchAndPrint() {
  const member = {
    name: 'Jerry',
    email: 'jerry@codeitmall.kr',
    department: 'sales',
  };

  return member;
}

fetchAndPrint();

2. 아무 값도 리턴하지 않는 경우


async function fetchAndPrint() {
  console.log('Hello Programming!');
}

fetchAndPrint();

함수에서 아무런 값도 리턴하지 않으면 자바스크립트에서 undefined를 리턴한 것으로 간주한다

이 경우에는 fulfilled 상태이면서, undefined를 작업 성공 결과로 가진 Promise 객체가 리턴

3. async 함수 내부에서 에러가 발생했을 때


async function fetchAndPrint() {
  throw new Error('Fail');
}

fetchAndPrint();

async 함수 안에서 에러가 발생하면, rejected 상태이면서, 해당 에러 객체를 작업 실패 정보로 가진 Promise 객체가 리턴된다.

✨async를 붙이는 위치

자바스크립트에서 함수를 표현하는 방법에는

  1. Function Declaration(함수 선언식)
  2. Function Expression(함수 표현식)
    1. 함수에 이름이 붙어있는 Named Function Expression
    2. 함수에 이름이 없는 Anonymous Function Expression
  3. Arrow Function(화살표 함수)

👇🏻async 붙이는 위치👇🏻

// 1) Function Declaration
async function example1(a, b) {
  return a + b;
}

// 2-a) Function Expression(Named)
const example2_1= async function add(a, b) {
  return a + b;
};

// 2-b) Function Expression(Anonymous)
const example2_2 = async function(a, b) {
  return a + b;
};

// 3) Arrow Function
const example3_1 = async (a, b) => {
  return a + b;
};

// 3) Arrow Function(shortened)
const example3_2 = async (a, b) => a + b;

👇🏻즉시실행함수(Immediately-invoked function expression, IIFE)👇🏻

(async function print(sentence) {
  console.log(sentence);
  return sentence;
}('I love JavaScript!'));

(async function (a, b) {
  return a + b;
}(1, 2));

(async (a, b) => {
  return a + b;
})(1, 2);

(async (a, b) => a + b)(1, 2);

async 함수를 작성할 때 주의해야할 성능 문제

async function getResponses(urls) {
  for(const url of urls){
    const response = await fetch(url);
    console.log(await response.text());
  }
}
  • getResponses 함수는 urls라는 파라미터로, 여러 개의 URL들이 있는 배열을 받아서, 순서대로 각 URL에 리퀘스트를 보내고, 그 리스폰스의 내용을 출력하는 함수다.
  • 문제점 ⇒ 이전 URL에 리퀘스트를 보내고 리스폰스를 받아서 출력하고 나서야, 다음 URL에 대한 리퀘스트를 보낼 수 있다는 점이다. 즉, 순차적인 작업 처리를 한다는 점이다. 왜냐하면 이전 URL에 대해서 await 문이 붙은 Promise 객체가 fulfilled 상태가 될 때까지는 그 다음 URL에 대한 작업들이 시작될 수 없기 때문이다.

만약 리스폰스의 내용의 순서가 중요하지 않은 경우라면?

async function fetchUrls(urls){
  for(const url of urls){
    (async () => { // 추가된 부분!
      const response = await fetch(url);
      console.log(await response.text());
    })(); // 추가된 부분
  }
}
  • 각 url에 리퀘스트를 보내고 리스폰스를 받는 코드를, 별도의 즉시실행되는 async 함수로 감쌌다.
  • 이렇게 코드를 고치면 일단 각 URL에 대해서 fetch 함수를 실행해서 리퀘스트를 보내는 것을 순서대로 바로 실행한다. 이렇게 코드를 수정하면, 일단 모든 URL에 대한 리퀘스트를 쭉 보내버리고, 먼저 리스폰스가 오는 순서대로 그 내용이 출력된다.

✨비동기 실행 정리

📘비동기 실행의 정의

  • 특정 작업이 시작되고, 그 작업이 모두 완료되기 전에 바로 다음 코드가 실행되는 방식의 실행, 나머지 작업은 나중에 콜백을 통해 수행되는 방식의 실행
  • 특정 처리를 나중으로 미루는 방식의 실행
  • 콜백을 등록해두고, 추후에 특정 조건이 만족되면 그 콜백으로 나머지 작업을 수행하는 방식의 실행

특정 처리를 담당하는 존재(콜백)의 실행을 나중으로 미룬다!

📘비동기 실행 관련 문법 3가지

(1) 파라미터로 바로 콜백을 전달하는 형태의 전통적인 비동기 실행 함수
(2) Promise
(3) async/await

1. 파라미터로 바로 콜백을 전달하는 형태의 전통적인 비동기 실행 함수

setTimeout, setInterval 함수, DOM 객체의 addEventListener 메소드 등이 여기에 해당한다.

setTimeout(() => {
  console.log('asynchronously executed');
}, 2000);

button.addEventListener('click', (event) => { console.log('You Clicked'); });

함수의 파라미터로 콜백을 바로 전달하는 방식은 여전히 많은 경우에 쓰이고 있지만,
여러 비동기 작업의 순차적인(sequential) 처리가 필요한 경우에 이런 함수들로 코드를 작성하면,

fs.readFile('file1.txt', 'utf8', (error1, data1) => {
  if (error1) {
    console.log(error1);
  } else {
    console.log(data1);
    fs.readFile('file2.txt', 'utf8', (error2, data2) => {
      if (error2) {
        console.log(error2);
      } else {
        console.log(data2);
        fs.readFile('file3.txt', 'utf8', (error3, data3) => {
          if (error3) {
            console.log(error3);
          } else {
            console.log(data3);
          }
        });
      }
    });
  }
});

⇒ 코드의 가독성이 급격하게 떨어지는 콜백 헬(callback hell) 문제가 발생할 가능성이 높다.

2. Promise


fetch('https://www.google.com')
  .then((response) => response.text())
  .then((result) => { console.log(result); })
  .catch((error) => { console.log(error); })
  .finally(() => { console.log('exit'); });

Promise 객체를 사용하면 콜백 헬 문제를 방지하면서, 여러 비동기 작업을 순차적으로 처리할 수 있다.

기존의 1.과 같은 전통적인 비동기 실행 함수들 중에서도 그 콜백이 단 한 번만 실행되는 함수들은

function readFile_promisified(filename) {
  const p = new Promise((resolve, reject) => {
    fs.readFile(filename, 'utf8', (error, data) => {
      if (error) {
        reject(error); // 에러 발생 시 -> rejected
      } else {
        resolve(data); // 파일 내용 읽기 완료 -> fulfilled
      }
    });
  });
  return p;
}

이런 식으로 Promisify해서 콜백 헬의 가능성을 없애고, Promise Chain 안에서 그 콜백의 리턴값을 사용할 수 있다.

rejected 상태의 Promise 객체에 대비하기 위한 catch 메소드, 어느 상황이든 항상 마지막에 실행해야 할 코드가 있을 때 사용하는 finally 메소드도 있다.

3. async / await 구문

async function fetchAndPrint() {
  try {
    const response = await fetch('https://www.google.www');
    const result = await response.text();
    console.log(result);
  } catch(error) {
    console.log(error);
  } finally {
    console.log('exit');
  }
}

fetchAndPrint();

async/await 구문은 Promise 객체를 다루는 코드(Promise Chaining 코드 등)를 사람들이 좀더 익숙하게 느끼는 동기 실행 스타일의 코드로 작성할 수 있게 해주는 Syntactic sugar라고 했다.

async 함수 안의 내용이 순차적으로 실행되다가도, await 문을 만나면 await 바로 뒤에 붙은 코드를 실행해두고, 일단은 함수 바깥으로 코드 흐름이 바뀐다!!

Promise 기반의 코드들은 가능한 경우에 모두 async/await 구문으로 전환해서 작성하는 게 더 좋다.


2021년 1월을 기준으로 아직 위의 3가지 비동기 실행 관련 문법들은 서로 상호보완적인 것들이라고 할 수 있다. 왜냐하면 아직 아래와 같이 하나가 다른 하나를 완벽히 대체하지 못하는 측면이 있기 때문이다.

  1. 콜백을 함수의 파라미터로 바로 전달하는 전통적인 방식의 비동기 실행 함수들 중에서도 setInterval, addEventListener처럼 그 콜백이 단 한번이 아니라 여러 번 실행되어야 하는 것들은 Promisify해서 사용하면 안 된다. Promise 객체는 한번 fulfilled 또는 rejected 상태가 되고나면 그 상태와 결과가 다시는 바뀌지 않기 때문이다.
  2. async/await 구문의 경우, await은 async 함수 안에서만 사용할 수 있고, 코드의 top-level(어떤 함수 블록 안에 포함된 것이 아닌 코드의 최상위 영역)에서는 사용될 수는 없다. 그래서 코드의 top-level에서 async 함수가 리턴한 Promise 객체의 작업 성공 결과를 가져오려면 await을 사용할 수는 없고, 여전히 then 메소드를 사용해야한다.

콜백 헬 문제를 해결하기 위해 Promise 객체가 등장했고, Promise를 좀 더 편하게 다루기 위해서 async/await 구문이 등장했다.

✨두 가지 종류의 콜백

자바스크립트에서 콜백은 어떤 함수의 파라미터로 전달되는 모든 함수를 의미하는 개념이다. 그러니까 어떤 함수의 파라미터로 전달되기만 한다면 해당 함수는 그 함수의 콜백이 되는 것.

이런 콜백은

1. 동기 실행되는 콜백
2. 비동기 실행되는 콜백

으로 나뉜다.

그렇다면 1번에 해당하는 콜백에는 어떤 것들이 있을까?

예를 들어, 자바스크립트 배열의 메소드 중에서 filter라는 메소드

→ 이 메소드는 배열의 여러 요소들 중에서 특정 조건을 만족하는 요소들만을 추려서 그 요소들로만 이루어진 새로운 배열을 리턴하는 메소드다.

const arr = [1, 2, 3, 4, 5, 6];

const newArr = arr.filter(function isOdd(num) {
  return (num % 2 === 1);
});

console.log(newArr); // [1, 3, 5]
  • arr라는 배열에서 홀수만을 추출해서 해당 홀수들만으로 이루어진 새로운 배열을 리턴한다.
  • filter 함수의 파라미터 부분을 보면 isOdd(홀수인가요?)라는 함수가 들어있다.
  • filter 함수는 arr 배열에서 각 요소를 하나씩 순회하면서 매 요소마다 isOdd 함수를 실행하고, 해당 함수가 true를 리턴하는 요소들만을 추출한다.

👇🏻Arrow Function 형식👇🏻

const arr = [1, 2, 3, 4, 5, 6];

const newArr = arr.filter((num) => num % 2);

console.log(newArr); // [1, 3, 5]

filter 메소드 안의 콜백은 '동기 실행되는 콜백'이다.
즉, 이 콜백은 우리가 이번 토픽에서 배웠던 '비동기 실행되는 콜백'과는 달리, 아주 정직하게 순서대로 실행된다.

profile
정신차려 이 각박한 세상속에서

0개의 댓글