자바스크립트는 비동기 처리를 위한 하나의 패턴으로 콜백 함수를 사용한다. 하지만 이러한 콜백 패턴은 콜백 헬로 인해 가독성이 나쁘고 비동기 처리 중 발생한 에러의 처리에는 한계가 있다.

비동기 처리를 위한 콜백 패턴의 단점

  • 비동기 함수 내부의 비동기로 동작하는 코드는 비동기 함수가 종료된 이후에 완료된다. 따라서 비동기 함수 내부의 비동기로 동작하는 코드에서 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하면 기대한대로 동작하지 않는다.
let g = 0;

// 비동기 함수인 setTimeout 함수는 콜백 함수의 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하지 못한다.
setTimeout(() => { g = 1000; }, 0);
console.log(g); // 0

여기서 개발자가 기대한 g의 값은 1초뒤에 변수 g에 1000이 할당되고 이후에 출력해서 1000이 출력되기를 기대했으나, 실제 실행결과는 g에 1000을 할당하기 전에 다음 코드인 0이 그대로 출력되고 있다.

setTimeout 뿐만이 아니라 비동기로 동작하는 setInterval 메서드나 이벤트 핸들러들도 마찬가지다. 따라서 우리는 이러한 비동기 함수의 처리 결과를 비동기 함수의 바깥이 아니라 비동기 함수 내부에서 처리를 해줘야 한다. 전통적인 방식으로는 이러한 처리를 위해 비동기 처리를 할 때 그 처리의 결과가 성공일 때 실행할 콜백함수와 실패할 때 실행할 콜백함수를 모두 전달해서 비동기 함수 내부에서 성공 여부에 따라 콜백함수를 선택적으로 실행시켰다.

하지만 각각의 비동기 함수에 대한 처리를 또 다시 비동기 함수로 처리하게 된다면 어떻게 될까? 콜백함수를 통한 비동기 처리 결과에 대한 후속 처리를 수행하는 코드가 다시 콜백함수 방식으로 결과를 처리하고 다시 비동기 방식으로 코드를 기다리고 하면 결국 콜백함수의 호출이 중첩되어 depth가높아지면서 콜백 헬에 이른다.

에러 처리의 한계

콜백 헬에서 일어나는 코드에서 가장 문제가 되는 점은 바로 에러 처리이다. 비동기 처리를 통해 내부에서 후속 처리를 해결하는 코드는 사실 자바스크립트 엔진 입장에서는 맨 위의 비동기 함수를 실행만 시키고 바로 다음 코드로 넘어가면 되기 때문에 그 내부에서 일어나는 오류에 대해서는 잡아내지 못한다. 다음 코드를 보자.

try {
	setTimeout(() => { throw new Error("Error!"); }, 1000);
} catch (e) {
	// 에러를 캐치하지 못한다.
  console.log('캐치한 에러', e);
}

위 코드의 catch에서는 오류를 잡지 못한다. setTimeout 메서드는 1초 뒤 에러를 고의적으로 만들지만 이미 setTimeout 메서드를 실행시키는 시점에서 에러가 발생하지 않았기 때문에 catch문은 실행되지 않고 그 다음 코드로 넘어가버렸기 때문이다.

이러한 비동기 처리를 위한 콜백 패턴은 콜백 헬이나 에러 처리가 곤란하기에 이를 극복하기 위해 ES6에서 프로미스가 도입되었다.

프로미스

  1. Promise 생성자 함수를 new 연산자와 함께 호출하면 프로미스 객체를 생성한다.
  2. Promise 생성자 함수는 비동기 처리를 수행할 콜백 함수를 인수로 전달받는데 이 콜백함수는 resolvereject 함수를 인수로 전달받는다.
  3. 비동기 처리가 성공하면 콜백함수의 인자인 resolve함수 호출하고, 실패하면 reject함수를 호출한다.
// 프로미스 생성
const promise = new Promise((resolve, reject) => {
	// Promise 함수의 콜백 함수 내부에서 비동기 처리를 수행한다.
  if (/*비동기 처러 성공 */) {
      	resolve("result");
      } else { /*비동기 처러 실패 */
      	reject("fail");
      }
});

프로미스는 다음과 같이 비동기 처리가 어떻게 진행되고 있는지를 나타내는 상태 정보를 갖는다.

프로미스의 상태 정보의미상태 변경 조건
pending비동기 처리가 아직 수행되지 않은 상태프로미스가 생성된 직후 기본 상태
fulfilled비동기 처리가 수행된 상태(성공)resolve 함수 호출
rejected비동기 처리가 수행된 상태(실패)reject 함수 호출

프로미스의 후속 처리 메서드

프로미스 객체는 비동기 처리에 대한 처리 상태를 가진다고 했다.
프로미스가 fulfilled 상태가 되면 프로미스의 처리 결과를 가지고 무언가를 해야하고, 프로미스가 reject 상태가 되면 프로미스의 에러를 가지고 에러처리를 해야한다.

이를 처리하는 후속 메서드

1. then

두 개의 콜백 함수를 인수로 전달 받는다.

new Promise(resolve => resolve('fulfilled'))
	.then(v => console.log(v), e => console.error(e)); // Promise {<fulfilled>: undefined}

new Promise((_, reject) => reject(new Error('reject')))
	.then(v => console.log(v), e => console.error(e));  // Error: reject

즉, 첫번 째 콜백함수는 비동기 처리가 성공했을 때 호출되는 성공 처리 콜백함수이며, 두번 째 콜백함수는 비동기 처리가 실패했을 때 호출된 실패 처리 콜백함수다.

2. catch

catch 메서드는 한 개의 콜백 함수를 인수로 전달 받는다. catch 메서드의 콜백 함수는 프로미스가 rejected 상태인 경우만 호출한다.

// rejected
new Promise((_, reject) => reject(new Error('reject')))
	.catch(e => console.error(e));  // Error: reject

3. finally

finally 메서드는 한 개의 콜백 함수를 인수로 전달 받는다. finally 메서드의 콜백함수는 프로미스의 성공 또는 실패와 상관없이 무조건 한 번 호출된다. (프로미스 상태 상관없이 무조건 수행해야할 경우 사용!)

new Promise(() => {})
	.finally(() => console.log('finally'); // finally

에러처리

위에서 공부한 것처럼 에러 처리하는 방법으로는 then 메서드의 두번째 콜백함수를 이용하는 것과 catch 메서드의 콜백함수를 이용하는 것이 있다.

then 메서드의 두 번째 콜백함수는 첫 번째 콜백 함수에서 발생한 에러를 캐치하지 못하고 코드가 복잡해지므로 가독성이 좋지 않다.

catch 메서드는 모든 then 메서드를 호출한 이후에 호출하면 비동기 처리에서 발생한 에러뿐만 아니라 then메서드 내부에서 발생한 에러까지 모두 캐치 할 수 있다. 그러므로 에러 처리는 꼭 catch로 하자 ! ! !

프로미스의 정적 메서드

Promise는 주로 생성자 함수로 사용되지만 함수도 객체이므로 메서드를 가질 수 있다. 프로미스는 5가지 정적 메서드를 제공한다.

  1. Promise.resolve / Promise.reject
// 배열로 resolve하는 프로미스를 생성
const resolvedPromise = Promise.resolve([1, 2, 3]);
resovedPromise.then(console.log); // [1, 2, 3]
// 에러 객체를 reject하는 프로미스를 생성
const rejectPromise = Promise.reject(new Error('Error!'));
rejectPromise.catch(console.log); // Error: Error!
  1. Promise.all
    여러 개의 비동기를 모두 병렬 처리할 때 사용된다.
const requestData1 = () => 
	new Promise(resolve => setTimeout(() => resolve(1), 3000));
const requestData2 = () => 
	new Promise(resolve => setTimeout(() => resolve(2), 2000));
const requestData3 = () => 
	new Promise(resolve => setTimeout(() => resolve(3), 1000));

// 세 개의 비동기 처리를 병렬로 처리
Promise.all([requestData1(), requestData2(), requestData3()])
	.then(console.log) // [1, 2, 3] => 약 3초 소요
	.catch(console.error);

Promise.all 메서드는 인수로 전달받은 프로미스가 하나라도 rejected 상태가 되면 나머지 프로미스가 fulfilled 상태가 되는 것을 기다리지 않고 즉시 종료한다.

  1. Promise.race
    Promise.race 메서드는 Promise.all 메서드처럼 모든 프로미스가 fulfilled 상태가 되는 것을 기다리는 것이 아니라 가장 먼저 fullfilled 상태가 된 프로미스의 처리 결과를 resolve하는 새로운 프로미스를 반환한다.
  1. Promise.allSettled
    Promise.allSettled 메서드는 프로미스를 요소로 갖는 배열 등의 이터러블을 인수로 전달 받는다. 하지만 인수로 받은 배열에 담긴 모든 프로미스 객체의 상태가 결정되기만 하면, 즉 fulfilled 상태나 rejected 상태가 되면 그때 각 프로미스 객체들의 결과값을 배열에 담은 것을 결과 값으로 갖는 프로미스 객체를 반환한다.

마이크로태스크 큐

setTimeout(() => console.log(1), 0);

Promise.resovle()
	.then(() => console.log(2))
	.then(() => console.log(3))

프로미스의 후속 처리 메서드도 비동기로 동작하므로 1 => 2 => 3의 순으로 출력될 것처럼 보이지만 2 => 3 => 1의 순으로 출력된다.

그 이유는 프로미스의 후속 처리 메서드의 콜백 함수는 태스크 큐가 아니라 마이크로태스크 큐에 저장되기 때문이다.
우선 순위 이벤트루프를 공부하면서 정리를 했었는데 정리에 의하면
우선 순위: 마이크로 태스트 큐 => 애니메이션프레임 => 태스크 큐

fetch

fetch 함수는 XMLHttpRequest 객체와 마찬가지로 HTTP 요청 전송 기능을 제공하는 클라이언트 사이드 Web API다.
fetch 함수는 XMLHttpRequest 객체보다 사용법이 간단하고 프로미스를 지원하기 때문에 비동기 처리를 위한 콜백 패턴의 단점에서 자유롭다.

fetch("https://jsonplaceholder.typicode.com/todos/1")
	// respone는 HTTP 응답을 나타내는 Respone 객체다.
	// json 메서드를 사용하여 Response 객체에서 HTTP 응답 몸체를 취득하여 역직렬화 한다.
	.then(respone => respone.json())
	//  json은 역직렬화 된 HTTP 응답 몸체다.
	.then(json => console.log(json));
	// {userId: 1, id: 1, title: "delicous~~~", completed: false}

오류 처리

const wrongUrl = "https://jsonplaceholder.typicode.com/XXX/1"

// 부적절한 URL이 지정되었기 때문에 404 Not Found 에러가 발생한다.
fetch(wrongUrl)
	.then(() => console.log("ok"))
	.catch(() => console.log("error"));

위를 보면 URL이 이상하게 지정되어서 404 Not Found 에러가 발생하기에 catch 후속 처리 메서드에 의해 "error"가 출력될 거 같지만 "ok"가 출력된다. 이건 fetch함수가 반환하는 프로미스는 404 Not Found와 같은 HTTP 에러가 발생해도 에러를 reject하지 않고 불리언 타입의 ok상태를 false로 설정한 Respone 객체를 resolve 하기 때문이다.

방어코드

const wrongUrl = "https://jsonplaceholder.typicode.com/XXX/1"

// 부적절한 URL이 지정되었기 때문에 404 Not Found 에러가 발생한다.
fetch(wrongUrl)
	// response는 HTTP응답을 나타내는 Respone 객체다.
	.then(response => {
		if (!response.ok) throw new Error(response.statusText);
  		return response.json();
	})
	.then(() => console.log("ok"))
	.catch(() => console.log("error"));

느낀점

공부하면서 프로미스라는 말을 많이 들어보긴 했지만 사용해보거나 공부해 본적이 없었고 나는 거의다 이때까지 더미 데이터를 통해서 개발을 많이 했었어서 프로미스와 fetch를 사용할 일이 거의 없었는데 프로젝트에서든 현업에서는 필수이고 데브코스에서도 프로미스와 fetch를 통해서 api를 가져오고 컨트롤하는 부분을 배우고 있기 때문에 이렇게 정리한 것이 엄청 크게 도움이 되었고 비동기 함수에서의 콜백 지옥과 그로 인한 에러 처리를 프로미스를 통해서 처리할수 있다는 것을 알게 되었고 이제 프로미스를 이해했으니 얼른 aysnc, await도 얼른 내꺼로 만들고 싶다.

profile
꺾여도 하는 마음

0개의 댓글