P4Ch8. 비동기

동기와 비동기

동기(synchronous): 순차적으로 코드 실행
비동기(Asynchronous): 순차적인 코드 실행X (ex. 버튼 클릭, 요청)

아래는 setTimeout을 사용한 비동기 코드의 예시이다.

1
2
3
4
5
console.log(1);
setTmeout(() => {
  console.log(2);
}, 1000);
console.log(3);

결과: 1 3 2
=> 코드 순서대로 실행되지 않았다!


콜백과 콜백 지옥

1
2
3
4
5
6
7
8
9
const a = () => {
  setTimeout(() => {
    console.log(1);
  }, 1000);
};
const b = () => console.log(2);

a();
b();

위 코드는 1초 후에 console.log(1)을 실행하는 코드이다. 코드는 a(),b()의 순으로 순차적으로 실행되나 함수 a() 내부의 코드 console.log(1)setTimeout()으로 인해 비동기적으로 실행되기에 console.log(2) 이후에 실행된다.

콜백(Callback) 패턴

callback을 사용해 코드의 실행 순서를 보장할 수 있다.
1초 뒤 console.log(1)가 실행된 다음에 console.log(2) 가 실행되길 원한다.


const a = (callback) => {
  setTimeout(() => {
    console.log(1);
    callback();
  }, 1000);
};
const b = () => console.log(2);


a(() => {
  b();
});

a 함수의 callback 매개변수로 b함수 데이터를 넣음으로 a함수 실행 뒤 b함수의 실행을 보장할 수 있다.

콜백 지옥

비동기 패턴에서 실행순서를 보장하기 위해 콜백 함수를 중첩해 사용하고, 그로인해 연속적인 들여쓰기가 발생하는 것

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const a = (callback) => {
  setTimeout(() => {
    console.log(1);
    callback();
  }, 1000);
};
const b = (callback) => {
  setTimeout(() => {
    console.log(2);
  }, 1000);
};
const c = () => console.log(3);

a(() => {
  b(() => {
    c();
  });
});

여기서 const d = () => console.log(4)를 추가해 1,2,3,4 순서로 값이 출력되길 원한다면…?😵‍💫
콜백 지옥은 코드의 가독성을 떨어뜨리고, 프로그래머가 코드를 유지보수하기 어렵게 만든다.


콜백 지옥 탈출법

Promise

  • Promise를 사용하면 콜백 지옥을 피할 수 있다.
    • callback 대신 resolve 매개변수를 사용해 실행순서를 보장할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const a = () => {
	return new Promise((resolve) => {
		setTimeout(() => {
			console.log(1)
			resolve()
		},1000)
	})
}
const b = () => {
	return new Promise((resolve) => {
		setTimeout(() => {
			console.log(2)
			resolve()
		},1000)
	})
}
const c = () => {
	return new Promise((resolve) => {
		setTimeout(() => {
			console.log(3)
			resolve()
		},1000)
	})
}
const d = () => console.log(4)

a().then(() => {
	return b()
}).then(() => {}
	return c()
).then(() => {
	d()
})
  • 함수 a는 Promise를 통해 생성한 인스턴스를 반환한다.
    • Promise로 만들어진 인스턴스에서는 then 메소드를 이어서 사용 가능하다.
    • 이때 then의 콜백 함수는 Promise 생성자 함수의 resolve 매개변수로 사용된다.
    • then의 콜백이 Promise 인스턴스를 반환하는 경우, 메소드 체이닝으로 then 메소드를 한 번 더 사용 가능하다.


1
2
3
4
5
6
a()
  .then(() => b())
  .then(() => c())
  .then(() => {
    d();
  });

위의 코드에서 then이 인수로 가진 화살표 함수 내부에 return 외 다른 코드가 존재하지 않으므로, 위 코드는 다음과 같이 쓸 수 있다.

1
a().then(b).then(c).then(d);

또한 resolve 매개변수로 넘어가는 데이터는 함수 데이터이기 때문에, 위와 같은 코드 대신 화살표 함수 대신 실행하려는 a,b,c,d 함수 데이터 자체를 넣어줄수도 있다.


Async Await 패턴

우리는 a 함수 실행 후 b함수를 실행하는 순서를 보장하기 위해 Promise를 사용할 수 있다는 것을 앞서 학습했다.

이와 같이 실행 순서를 보장하는 또다른 방법으로 Async Await 패턴을 사용하는 방법이 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const a = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(1);
      resolve();
    }, 1000);
  });
};
const b = () => console.log(2);

const wrap = async () => {
  await a();
  b();
};
wrap();
// a().then(() => b())
  • a는 Promise 인스턴스를 반환하는, 비동기 코드로 만들어진 함수이다.
    • await 키워드는 Promise 인스턴스를 반환하는 비동기 함수 바로 앞에 붙여 사용한다.
    • await는 해당 비동기 함수의 Promise 인스턴스가 반환될때까지 기다린다.
    • await를 사용하는 코드는 async를 사용하는 함수 내부에 존재해야한다.





Resolve, Reject 그리고 에러 핸들링

아래는 콜백을 사용해 에러를 핸들링하는 예제코드다.


const delayAdd = (index, cb, errorCb) => {
  setTimeout(() => {
    if (index > 10) {
      errorCb(`${index}는 10보다 클 수 없습니다.`);
      return;
    }
    console.log(index);
    cb(index + 1);
  }, 1000);
};

delayAdd(
4,
(res) => console.log(res),
(err) => console.error(err)
);

위 코드는 비동기적으로 동작하며, 로직이 정상 동작할 시 delayAdd의 두 번째 인수 부분의 콜백(cb)이 실행되고, 정상적으로 처리 되지 않았다면 세 번째 인수의 콜백(errorCb)이 실행되는 방식으로 에러를 핸들링하는 코드이다.

⚠️
하지만 이러한 방식의 에러 핸들링은 코드의 가독성을 떨어뜨리고, 여러개의 비동기 함수가 이어지는 경우 콜백 지옥과 같은 문제가 발생할 수 있다.
따라서 비동기 작업에서의 에러 핸들링은 Promise를 사용하는 것이 선호된다.

Promise를 사용한 에러 핸들링

함수 delayAdd의 정의는 아래와 같다.


const delayAdd = (index) => {
  return new Promise(( resolve, reject) => {
    setTimeout(() => {
      if (index > 10) {
        reject(`${index}는 10보다 클 수 없습니다.`);
        return;
      }
      console.log(index);
      resolve(index + 1);
    }, 1000);
  });
};

이때, Promise를 사용해 에러 핸들링을 하는 3가지 방법이 있다.

then, catch 메소드 사용

1
2
3
4
delayAdd(4)
  .then((res) => console.log(res))
  .catch((err) => console.error(err))
  .finally(() => console.log("Done!"));
  • then과 catch 메소드를 사용해 에러 핸들링이 가능하다.
    • then 메소드의 콜백은 resolve 매개변수로 들어간다.
    • catch 메소드의 콜백은 reject 매개변수로 들어간다.
  • reject와 resolve 둘 중 하나가 실행되면 다른 하나는 실행되지 않는다.
  • finally 메소드는 reject 혹은 resolve의 동작 여부와 관계없이 항상 동작한다.

Async Await 패턴

1
2
3
4
5
const wrap = async () => {
  const res = await delayAdd(2);
  console.log(res);
};
wrap();
  • 데이터를 받아와(then과 catch 메소드의 res, err와 같이) 변수에 할당 할 수 있다.

Async Await 패턴에서의 catch 사용

1
2
3
4
5
6
7
8
9
10
11
const wrap = async () => {
  try {
    const res = await delayAdd(12);
    console.log(res);
  } catch (err) {
    console.error(err);
  } finally {
    console.log("Done!");
  }
};
wrap();
  • error가 발생하면 catch 구문이 실행된다.
    • reject의 인수는 catch 구문의 err 변수로 들어간다.
  • finally 구문은 reject 혹은 resolve의 동작 여부와 관계없이 항상 동작한다.

반복문에서의 비동기 처리

forEach에서의 await는 실행 순서를 보장하지 못한다!

1
2
3
4
5
6
7
8
9
10
11
12
13
const getMovies = (movieName) => {
  return new Promise((resolve) => {
    fetch(`https://www.omdbapi.com/?apikey=7035c60c&s=${movieNAme}`)
      .then((res) => res.json())
      .then((res) => resolve(res));
  });
};
const titles = ["frozen", "avengers", "avatar"];

titles.forEach(async (title) => {
  const movies = await getMovies(title);
  console.log(title, movies);
});

forEach 메소드는 비동기 작업의 완료를 기다리지 않고 다음 반복을 시작한다. 따라서 getMovies 함수 호 frozen, avengers, avatar의 순차적인 실행을 보장할 수 없다.

반복문에서의 비동기 처리를 위해 forEach 메소드 대신 for 반복을 사용해야 한다.

1
2
3
4
5
6
7
const wrap = async () => {
  for (const title of titles) {
    const movies = await getMovies(title);
    console.log(title, movies);
  }
};
wrap();