자바스크립트로 웹 통신을 하는 코드를 작성하려면 비동기 실행을 다루어야 한다. 비동기 처리를 하기 위해서는 Promise 객체와 async/await 구문 등에 대한 이해가 필수적이다.
들어가기전에 동기 실행과 비동기 실행의 의미에 대해 이해하고 Promise 객체에 대해 공부해보도록 하자.
동기(Synchronous) 실행의 경우, 한번 시작한 작업을 완벽하게 완료할 때까지 다음 코드가 실행하지 않는다.
console.log('시작');
fetch('https://www.naver.com')
.then((response) => response.text())
.then((result) => { console.log(result); });
console.log('끝');
예제 코드에서 fetch 함수가 동기 실행을 한다고 가정한다면 전체 코드의 실행 순서는 다음과 같다.
비동기(ASynchronous) 실행의 경우, 작업을 시작하고, 시작한 작업이 완료될 때 실행할 콜백만 등록해둔 후에 다음 코드로 바로 실행 흐름이 넘어간다.
console.log('시작');
fetch('https://www.naver.com')
.then((response) => response.text())
.then((result) => { console.log(result); });
console.log('끝');
예제 코드를 살펴보면 2개의 콜백이 있다.
(1) (response) ⇒ response.text()
(2) (result) ⇒ { console.log(result); }
fetch 함수가 리퀘스트를 보내고, 서버의 리스폰스를 받게 되면 그때서야 이 콜백들이 순서대로 실행된다.
전체 코드의 실행 순서는 다음과 같다.
그림과 같이 동기식 처리 모델은 직렬적으로 일처리를 수행한다. 즉, 순차적으로 일처리를 실행하므로 어떤 작업이 수행 중이면 다음 작업은 대기중인 것을 확인 할 수 있다. 반면에 비동기식 처리 모델은 일처리의 효율성이 있어 보인다.
그림을 통해 보았을 때 '비동기 실행'이 '동기 실행'에 비해, 동일한 작업 진행을 더 빠른 시간 내에 처리할 수 있고, 상대적으로 작업 효율성이 높아보인다.
특정 함수의 실행을 원하는 시간만큼 뒤로 미루기 위해 사용하는 함수이다.
setTimeout(callback, milliseconds);
console.log('a');
setTimeout(() => { console.log('b'); }, 1000);
console.log('c');
이 콜백의 실행을, 두 번째 파라미터에 적힌 1000 밀리세컨즈(=1초) 뒤로 미룬다.
개발자 도구에서 확인해보면 아래와 같이 실행된다.
1. a
2. c
3. 1초 뒤에 b
특정 콜백을 일정한 시간 간격으로 실행하도록 등록하는 함수이다.
setInterval(callback, milliseconds);
console.log('a');
setInterval(() => { console.log('b'); }, 1000);
console.log('c');
이렇게 쓰면 이제 b를 출력하는 콜백이 1초 간격으로 계속 실행된다.
개발자 도구에서 확인해보면 아래와 같이 실행된다.
1. a
2. c
3. 1초 뒤에 b가 1초 간격으로 계속 실행
addEventListener 메소드는 DOM 객체의 메소드이다.
만약 사용자가 웹 페이지에서 어떤 버튼 등을 클릭했을 때, 실행하고 싶은 함수가 있다면 해당 DOM 객체의 onclick 속성에 그 함수를 설정하거나, 해당 DOM 객체의 addEventListener 메소드의 파라미터로 전달하면 된다.
addEventListener(eventname, callback);
특정 이벤트가 발생했을 때 실행할 콜백을 등록하는 addEventListener 메소드도 비동기 실행과 관련이 있다. 파라미터로 전달된 콜백이 당장 실행되는 것이 아니라, 나중에 특정 조건(클릭 이벤트 발생)이 만족될 때(마다) 실행되기 때문이다.
fetch 함수는 첫번째 인자로 URL, 두번째 인자로 옵션 객체를 받고, Promise 타입의 객체를 반환한다.
fetch(url, options)
.then((response) => response.text()) // fetch 함수가 리턴하는 객체의 then 메소드를 사용해서 콜백을 등록
.then((result) => { console.log(result); });
(1) url : 접근하고자 하는 URL
(2) options : 선택 매개변수, method나 header 등을 지정할 수 있음
fetch 함수는 콜백을 파라미터로 바로 전달받는 게 아니라, fetch 함수가 리턴하는 어떤 객체의 then 메소드를 사용해서 콜백을 등록한다. fetch 함수는 Promise 객체를 리턴한다.
console.log('시작');
fetch('https://www.naver.com')
.then((response) => response.text())
.then((result) => { console.log(result); });
console.log('끝');
fetch 함수가 request 를 보내고 정상적으로 response 를 받았을 때 fulfiled 상태가 된다. 그리고 작업 성공 결과(서버가 보내준 response) 를 가진다.
fetch 함수가 requesrt 를 보냈는데 인터넷이 끊긴다든지 url 주소가 존재하지 않는다든지 등의 이유로 실패가 되면 rejected 상태가 된다. 그리고 마찬가지로 작업 실패 정보를 가진다.
사진 출처 : https://www.codeit.kr/
fetch(url)
.then(fulfilled 상태가 되면 실행될 콜백 함수, rejected 상태가 되면 실행될 콜백 함수);
fetch('https://www.naver.com')
.then((response) => response.text(), (error) => { console.log(error); });
.then((result) => { console.log(result); });
자바스크립트에서 비동기 처리를 하는 방식을 크게 두 가지로 나눌 수 있다.
Promise와 콜백 패턴이 있다. Promise가 보급되기 전에는 콜백 패턴이 많이 사용되었다.
function requestData(callback) {
setTimeout(() => {
callback({ name: '홍길동', age: 20 });
}, 1000);
}
// 중첩된 콜백 패턴
function requestData1(callback) {
// ...
callback(data);
}
function requestData1(callback) {
// ...
callback(data);
}
function onSuccess1(data) {
console.log(data);
requesData2(onSuccess2);
}
function onSuccess2(data) {
console.log(data);
// ...
}
requesData1(onSuccess1);
콜백 패턴은 콜백이 조금만 중첩되어도 코드가 상당히 복잡해지는 단점이 있고, 이렇게 함수에 콜백을 직접 넣는 형식은 콜백 헬(callback hell) 이라고 하는 문제를 일으킬 수도 있다.
Promise를 사용하면 비동기 프로그래밍을 할 때 동기 프로그래밍 방식으로 코드를 작성할 수 있다.
📌 Promise 객체 ( ES6 문법 )
- callback hell 문제 해결
- 비동기 작업 처리에 관한 좀 더 세밀한 처리를 해결하기 위해 등장한 문법
- 오늘날 Promise는 자바스크립트 비동기 실행에 있어서 아주 핵심적인 문법
1️⃣ new 키워드 사용
const promise1 = new Promise((resolve, reject) => {});
new Promise에 두 매개변수는 모두 함수이고, 'executor 함수' 라고 부른다.
처음에는 pending 상태가 되고, 두 함수 중에 하나를 호출하기 전에는 상태가 변경되지 않는다. resolve 를 호출하면 fulfilled 상태가 되고, reject 를 호출하면 rejected 상태가 된다.
2️⃣ reject 함수 사용 : rejected 상태인 Promise 객체 생성
const promise2 = Promise.reject('error');
3️⃣ resolve 함수 사용 : fulfilled 상태인 Promise 객체 생성
const promise3 = Promise.resolve(param);
비동기 처리가 끝나지 않았을 때는 pending 상태, 비동기 처리가 끝나고 성공했을 때는 fulfilled 상태가 된다. 그리고 실패했을 때는 rejected 상태가 된다.
Promise 객체는 3가지 상태 중 하나의 상태로 존재한다.
fulfilled 상태이고, rejected 상태를 settled 라고 부르기도 한다.
settled 상태가 되면 더 이상 다른 상태로 변경되지 않는다. pending 상태일때만 다른 상태로 변할 수 있다.
비동기 처리가 끝난 다음 처리 할 일을 then 메소드로 정의할 수 있다.
requestData().then(onResolve, onReject);
Promise.resolve('Hello World!').then(data => console.log(data));
Promise.reject('error').then(null, data => console.log(data));
then 메소드가 리턴하는 Promise 객체의 상태(fulfilled or rejected)와 결과(작업 성공 결과 or 작업 실패 정보)가 결정된다.
then 메서드는 then 메소드는 항상 Promise 객체를 반환하기 때문에 chain 형식으로 연결할 수 있다.(Promise Chaining)
따라서 항상 연결된 순서대로 호출된다. Promise 로 비동기 프로밍을 할 때 동기 프로그래밍 방식으로 코드를 작성할 수 있게 해준다.
then 메소드는 기존 객체를 수정하지 않고 새로운 Promise 객체를 반환한다.
Promise Chaining 이 필요한 경우
Promise Chaining 은 좀 더 깔끔한 코드로 여러 비동기 작업을 순차적으로 처리할 수 있다. 이렇게 Promise 객체를 사용하면 callback hell 문제를 해결할 수 있다.
catch 메소드는 rejected 상태인 Promise 객체를 처리하기 위해 사용한다.
아래와 같이 사용할 수 있다.
fetch('https://www.naver.com')
.then((response) => response.text());
.then((result) => { console.log(result); });
.catch((error) => { console.log(error); });
catch 메소드는 사실 then 메소드를 사용해서 두 번째 함수로 입력하는 것과 같다.
.catch(callback)
라고 써있는 코드는 .then(undefined, callback)
랑 동일한 의미를 가진다.
아래와 같이 표현할 수 있다.
fetch('https://www.naver.com')
.then((response) => response.text());
.then((result) => { console.log(result); });
.then(undefined, (error) => { console.log(error); });
그리고 아래와 같이 두 가지 코드를 비교해보자.
Promise.reject(1).then(null, error => {
console.log(error);
});
Promise.reject(1).catch(error => {
console.log(error);
});
catch 메소드를 사용하는 것이 좀 더 가독성에 좋은 것을 확인 할 수 있다.
따라서 예외처리를 할 때 catch 메소드를 사용하는 것이 더 가독성이 좋다.
then과 마찬가지로 catch도 Promise 객체를 반환한다.
catch 이후에도 then을 계속 계속 사용할 수 있지만 실무에서는 catch 메소드는 마지막에 사용한다.
finally 는 fulfilled 상태와 rejected 상태 모두를 처리할 수 있다.
finally 에는 데이터가 넘어오지 않는다. 그리고 이전에 있던 Promise 객체를 그대로 반환한다. finally 안에서 반환하는 값과는 상관이 없다.
finally 메소드는 Promise Chain에서 catch 보다 더 뒤에 사용한다.
아래와 같이 사용하게 되면 finally 메소드 안에 콜백은 그 전의 상태가 fulfilled 상태이든 rejected 상태이든 상관없이 항상 실행하게 할 수 있다.
fetch('https://www.naver.com')
.then((response) => response.text());
.then((result) => { console.log(result); });
.catch((error) => { console.log(error); });
.finally(() => console.log('finally'));
그리고 작업 성공 결과나 작업 실패 정보가 필요하지 않기 때문에 파라미터가 필요하지 않는 것이 특징이다.
Promise 를 사용할 때 then 메서드로 연결하면 순차적으로 실행이 되기 때문에 각각의 비동기 처리가 병렬로 처리되지 않는다는 단점이 있다.
아래와 같이 then 메소드로 연결된 코드가 있다.
requestData1()
.then(data => {
console.log(data);
return requestData2();
})
.then(data => {
console.log(data);
});
만약 두 함수 간의 의존성이 없다면 병렬로 처리하는게 더 빠르게 처리가 될 것이다.
따라서 아래와 같이 코드를 수정해서 병렬로 처리하는 코드로 만들어 보았다.
requesrData1().then(data => console.log(data));
requestData2().then(data => console.log(data));
이렇게 여러 Promise 를 병렬로 처리하고 싶은 경우에 all 메소드를 사용할 수 있다.
아래와 같이 Promise.all를 사용할 수 있다.
Promise.all([requesrData1(), requesrData2()]).then([data1, data2] => {
console.log(data1, data2)
});
매개변수로 배열을 원하는 개수만큼 romise 객체를 입력할 수 있다.
Promise.all 함수는 Promise 객체를 반환한다.
입력된 모든 Promise 객체가 fulfilled 상태가 되어야 마찬가지로 fulfilled 상태가 된다. 만약 하나라도 rejected 상태가 된다면 Promise.all 함수가 반환하는 Promise 객체도 rejected 상태가 된다.
따라서 all 메소드는 하나의 Promise 객체라도 rejected 상태가 되면, 전체 작업이 실패한 것으로 간주해야 할 때 사용한다.
race 메소드도 all 메소드와 마찬가지로 여러 Promise 객체들이 있는 배열을 아규먼트로 받는다. 그리고 race 메소드도 all 메소드처럼 Promise 객체를 리턴 하지만 그 적용 원리가 다르다.
race 메소드가 리턴한 Promise 객체는 아규먼트로 들어온 배열의 여러 Promise 객체들 중에서 가장 먼저 fulfilled 상태 또는 rejected 상태가 된 Promise 객체와 동일한 상태와 결과를 갖게 된다.