커피숍에서 커피를 주문하려고 줄을 서는 모습을 상상해 보자. 커피숍 사정상, 커피를 주문한 먼저 온 김코딩이 주문한 커피를 받을 때까지, 줄 서 있는 박해커가 주문조차 할 수 없다고 한다면 이를 우리는 블로킹(blocking)이라고 부른다. 하나의 작업이 끝날 때까지, 이어지는 작업을 "막는 것"이다.
박해커는 김코딩이 주문한 커피가 나오고 나서야 커피를 주문할 수 있습니다. 김코딩의 커피 주문 완료 시점과 박해커의 커피 주문 시작 시점이 같다. 이렇게 시작 시점과 완료 시점이 같은 상황을 "동기적(synchronous)이다." 라고 한다.
효율적인 커피숍 운영을 위해서 아래와 같이 커피숍 주문 과정을 변경해 보자.
- 커피 주문이 블로킹(blocking) 되지 않고, 언제든지 주문을 받을 수 있다.
- 커피가 완성되는 즉시 커피를 제공한다.
- 김코딩의 주문 완료 시점과 박해커의 주문 시작 시점이 같을 필요가 없다.
Node.js를 만든 개발자도 위 대안이 합리적이라고 생각해서 Node.js를 논 블로킹(non-blocking)하고 비동기적(asynchronous)으로 작동하는 런타임으로 개발하게 된다. JavaScript의 비동기적 실행(Asynchronous execution)이라는 개념은 웹 개발에서 특히 유용하다. 특히 아래 작업은 비동기적으로 작동되어야 효율적이다.
- 백그라운드 실행, 로딩 창 등의 작업
- 인터넷에서 서버로 요청을 보내고, 응답을 기다리는 작업
- 큰 용량의 파일을 로딩하는 작업
학습 목표
- 어떤 경우에 중첩된 콜백(callback)이 발생하는지 이해할 수 있다.
- 중첩된 콜백(callback)의 단점, Promise의 장점을 이해할 수 있다.
- async/await 키워드에 대해 이해하고, 작동 원리를 이해할 수 있다.
동기(synchronous)
JavaScript의 동기 처리란 ‘특정 코드의 실행이 완료될 때까지 기다리고 난 후 다음 코드를 수행하는 것’을 의미한다.
동기적 콜백의 예 (순차적인 실행)
function fakeSetTimeout(callback, delay) {
callback();
}
console.log(0)
fakeSetTimeout(function(){
console.log('hello');
}, 0);
console.log(1)
// 결과
0
hello
1
비동기(asynchronous)
JavaScript의 비동기 처리는 ‘특정 코드의 실행이 완료될 때까지 기다리지 않고 다음 코드들을 수행하는 것’을 의미한다.
비동기적 콜백의 예 (비순차적인 실행)
function fakeSetTimeout(callback, delay) {
callback();
}
console.log(0)
// 시간을 재는 타이머는 자바스크립트 밖에 있다.
// (브라우저에서 제공-WebAPI 통해서)
// 자바스크립트는 인터페이스를 제공
setTimeout(function(){ // 자바스크립트 인터페이스
console.log('hello');
}, 0);
// 0초 뒤에 콜백을 que에 넣어라
// 0초인데도 hello가 1 뒤에 나온 것은 비동기로 실행되었기 때문
console.log(1)
// 결과
0
1
hello
* setTimeout 함수는 자바스크립트 내장 비동기 함수이다.
(나만의 언어) 비켜봐! 새치기 좀 할게~ / 그래 너 먼저 가~
비동기적 콜백의 예시
- 웹 API .. 서버에 데이터를 가져온다거나, 타이머를 이용할 때 등
- 외부 API .. 버튼 onClick도 DOM에 넣는 것 (자바스크립트 밖)
-> 콜백은 나중에 실행하라고 인자로서 다른 함수에게 넘겨주는 것이지만 그 콜백을 받는 함수가 어떻게 동작하느냐에 따라 동기/비동기적일 수 있다. 즉 DOM의 onClick으로 콜백을 넣을 경우, 버튼을 클릭할때 집어넣는 곳이 que에 넣는지, 콜스택에 넣는지에 따라 나뉜다.
JavaScript의 작동원리
JavaScript는 싱글 스레드 기반으로 동작하는 언어이다. 따라서 동기적으로 작동하게 된다.
JavaScript는 싱글 스레드 기반 언어이다.
- 콜스택이 하나이기 때문에 yo(); 가 호출이 되면 setTimeout이 1.5초가 지나서 콜백함수를 실행시키려고 해도 이미 다른 일처리를 끝낼 때까지 기다려야 한다. (동기!)
- main에서의 일처리가 끝이 나서야 setTimeout의 콜백함수를 콜스택에 넣어서 콘솔 출력이 실행된다.
하지만!!!
JavaScript가 작동하는 환경(런타임)에서는 비동기 처리를 도와주기 때문에 특별한 작업 없이 비동기 처리를 할 수 있다.
타이머 관련 API
JavaScript에서 비동기를 경험하게 되는 첫번째 단계는 타이머와 관련된 API이다. 해당 API는 브라우저에서 제공하는 Web API이며 비동기로 작동하도록 구성되어 있다.
setTimeout(callback, millisecond)
일정 시간 후에 함수를 실행
- 매개변수(parameter): 실행할 콜백 함수, 콜백 함수 실행 전 기다려야 할 시간 (밀리초)
- return 값: 임의의 타이머 ID
setTimeout(function () {
console.log('1초 후 실행');
}, 1000);
// 123
clearTimeout(timerId)
setTimeout 타이머를 종료
- 매개변수(parameter): 타이머 ID
- return 값: 없음
const timer = setTimeout(function () {
console.log('10초 후 실행');
}, 10000);
clearTimeout(timer);
// setTimeout이 종료됨.
setInterval(callback, millisecond)
일정 시간의 간격을 가지고 함수를 반복적으로 실행
- 매개변수(parameter): 실행할 콜백 함수, 반복적으로 함수를 실행시키기 위한 시간 간격 (밀리초)
- return 값: 임의의 타이머 ID
setInterval(function () {
console.log('1초마다 실행');
}, 1000);
// 345
clearInterval(timerId)
setInterval 타이머를 종료
- 매개변수: 타이머 ID
- return 값: 없음
const timer = setInterval(function () {
console.log('1초마다 실행');
}, 1000);
clearInterval(timer);
// setInterval이 종료됨.
비동기 코드
const printString = (string) => {
setTimeout(function () {
console.log(string);
}, Math.floor(Math.random() * 100) + 1);
};
const printAll = () => {
printString('A');
printString('B');
printString('C');
};
printAll();
console.log(`아래와 같이 비동기 코드는 순서를 보장하지 않습니다!`);
// 순서
아래와 같이 비동기 코드는 순서를 보장하지 않습니다!
C
B
A
비동기 코드는 코드가 작성된 순서대로 작동되는 것이 아니라 동작이 완료되는 순서대로 작동한다. 즉, 실행되는 시간이 random인 코드의 순서를 예측할 수 없다. 프로그래밍을 하면서 개발자가 제어할 수 없는 코드를 작성하는 것은 옳지 않다. 개발자는 언제나 예측가능한 코드를 작성하도록 노력해야 한다. 따라서, 비동기로 작동하는 코드를 제어할 수 있는 방법에 대해 잘 알고 있어야 한다.
여러 방법 중 하나는 Callback 함수를 활용하는 방법이다. Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있다. 즉, 비동기를 동기화할 수 있다는 의미이다.
Callback
const printString = (string, callback) => { // callback() **
setTimeout(function () {
console.log(string);
callback(); // callback() **
}, Math.floor(Math.random() * 100) + 1);
};
const printAll = () => {
printString('A', () => { // callback 안에 printString('B', ...
printString('B', () => {
printString('C', () => {});
});
});
};
printAll();
console.log(
`아래와 같이 Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있다`
);
// 결과
아래와 같이 Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있다
A
B
C
Callback Hell
Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있지만 코드가 길어질 수록 복잡해지고 가독성이 낮아지는 Callback Hell이 발생하는 단점이 있다.
const printString = (string, callback) => {
setTimeout(function () {
console.log(string);
callback();
}, Math.floor(Math.random() * 100) + 1);
};
const printAll = () => {
printString('A', () => {
printString('B', () => {
printString('C', () => {
printString('D', () => {
printString('E', () => {
printString('F', () => {
printString('G', () => {
printString('H', () => {
printString('I', () => {
printString('J', () => {
printString('K', () => {
printString('L', () => {
printString('M', () => {
printString('N', () => {
printString('O', () => {
printString('P', () => {});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
};
printAll();
console.log(
`아래와 같이 Callback 함수를 통해 비동기 코드의 순서를 제어할 수 있지만 코드가 길어질 수록 복잡해지고 가독성이 낮아지는 Callback Hell이 발생하는 단점이 있다.`
);
Promise
앞서 확인한 Callback Hell의 현상을 방지하기 위해 Promise가 사용되기 시작했다. 비동기로 작동하는 코드를 제어할 수 있는 다른 방법은 Promise를 활용하는 것이다.
new Promise
Promise는 class이기 때문에 new 키워드를 통해 Promise 객체를 생성한다. 또한 Promise는 비동기 처리를 수행할 콜백 함수(executor)를 인수로 전달받는데 이 콜백 함수는 resolve, reject 함수를 인수로 전달받는다.
Promise 객체가 생성되면 executor는 자동으로 실행되고 작성했던 코드들이 작동된다. 코드가 정상적으로 처리가 되었다면 resolve 함수를 호출하고 에러가 발생했을 경우에는 reject 함수를 호출하면 된다.
let promise = new Promise((resolve, reject) => {
// 1. 정상적으로 처리되는 경우
// resolve의 인자에 값을 전달할 수도 있다.
resolve(value);
// 2. 에러가 발생하는 경우
// reject의 인자에 에러메세지를 전달할 수도 있다.
reject(error);
});
Promise 객체의 내부 프로퍼티
new Promise가 반환하는 Promise 객체는 state, result 내부 프로퍼티를 갖는다. 하지만 직접 접근할 수 없고 .then, .catch, .finally의 메서드를 사용해야 접근이 가능하다.
State
기본 상태는 pending(대기) 이다. 비동기 처리를 수행할 콜백 함수(executor)가 성공적으로 작동했다면 fulfilled(이행)로 변경이 되고, 에러가 발생했다면 rejected(거부)가 된다.
Result
처음은 undefined 이다. 비동기 처리를 수행할 콜백 함수(executor)가 성공적으로 작동하여 resolve(value)가 호출되면 value로, 에러가 발생하여 reject(error)가 호출되면 error로 변한다.
then, catch, finally
Then
executor에 작성했던 코드들이 정상적으로 처리가 되었다면 resolve 함수를 호출하고 .then 메서드로 접근할 수 있다.
- then 안에서 리턴한 값이 Promise면 Promise의 내부 프로퍼티 result를 다음 .then 의 콜백 함수의 인자로 받아온다.
- Promise.prototype.then은 Promise를 반환하므로 메서드 체이닝(Chaining)을 할 수 있다.
(즉, then을 통해 return 된 promise 를 다시 쓰는 것) - then 안에서 리턴한 값이 Promise가 아니라면 리턴한 값을 .then 의 콜백 함수의 인자로 받아올 수 있다.
let promise = new Promise((resolve, reject) => {
resolve("성공");
});
promise.then(value => {
console.log(value);
// "성공"
})
Catch
executor에 작성했던 코드들이 에러가 발생했을 경우에는 reject 함수를 호출하고 .catch 메서드로 접근할 수 있다.
let promise = new Promise(function(resolve, reject) {
reject(new Error("에러"))
});
promise.catch(error => {
console.log(error);
// Error: 에러
})
Promise.prototype.catch는 Promise를 반환하기 때문에 Promise.prototype.then처럼 메서드 체이닝이 가능하며 에러가 난 경우 실행된다.
* Promise.prototype.then은 사실 다음과 같이 두 개의 콜백 함수를 받을 수 있다.
Promise.prototype.then(onFulfilled, onRejected)
두번째 파라미터에는 에러가 났을 경우의 콜백 함수를 적어주면 Promise.prototype.catch와 같은 역할을 할 수 있다.
Finally
executor에 작성했던 코드들의 정상 처리 여부와 상관없이 .finally 메서드로 접근할 수 있다.
let promise = new Promise(function(resolve, reject) {
resolve("성공");
});
promise
.then(value => {
console.log(value);
// "성공"
})
.catch(error => {
console.log(error);
})
.finally(() => {
console.log("성공이든 실패든 작동!");
// "성공이든 실패든 작동!"
})
Promise
const printString = (string) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log(string);
}, Math.floor(Math.random() * 100) + 1);
});
};
const printAll = () => {
printString('A')
.then(() => {
return printString('B');
})
.then(() => {
return printString('C');
});
};
printAll();
console.log(
`아래와 같이 Promise를 통해 비동기 코드의 순서를 제어할 수 있습니다!`
);
//아래와 같이 Promise를 통해 비동기 코드의 순서를 제어할 수 있습니다!
//A
//B
//C
Promise chaining
Promise chaining가 필요하는 경우는 비동기 작업을 순차적으로 진행해야 하는 경우이다. Promise chaining이 가능한 이유는 .then, .catch, .finally 의 메서드들은 Promise를 리턴하기 때문이다. 따라서 .then을 통해 연결할 수 있고, 에러가 발생할 경우 .catch 로 처리하면 된다.
let promise = new Promise(function(resolve, reject) {
resolve('성공');
...
});
promise
.then((value) => {
console.log(value);
return '성공';
})
.then((value) => {
console.log(value);
return '성공';
})
.then((value) => {
console.log(value);
return '성공';
})
.catch((error) => {
console.log(error);
return '실패';
})
.finally(() => {
console.log('성공이든 실패든 작동!');
});
Promise.all()
const promiseOne = () => new Promise((resolve, reject) => setTimeout(() => resolve('1초'), 1000));
const promiseTwo = () => new Promise((resolve, reject) => setTimeout(() => resolve('2초'), 2000));
const promiseThree = () => new Promise((resolve, reject) => setTimeout(() => resolve('3초'), 3000));
Promise.all()은 여러 개의 비동기 작업을 동시에 처리하고 싶을때 사용한다. 인자로는 배열을 받는다.
해당 배열에 있는 모든 Promise에서 executor 내 작성했던 코드들이 정상적으로 처리가 되었다면 결과를 배열에 저장해 새로운 Promise를 반환 해준다.
앞서 배운 Promise chaining을 사용했을 경우는 코드들이 순차적으로 동작되기 때문에 총 6초의 시간이 걸리게 된다. 또한, 같은 코드가 중복되는 현상도 발생하게 된다.
// 기존
const result = [];
promiseOne()
.then(value => {
result.push(value);
return promiseTwo();
})
.then(value => {
result.push(value);
return promiseThree();
})
.then(value => {
result.push(value);
console.log(result);
// ['1초', '2초', '3초']
})
이러한 문제들을 Promise.all()을 통해 해결할 수 있다.
Promise.all()은 비동기 작업들을 동시에 처리한다. 따라서 3초 안에 모든 작업이 종료된다. 또한 Promise chaining로 작성한 코드보다 간결해진다.
// promise.all
Promise.all([promiseOne(), promiseTwo(), promiseThree()])
.then((value) => console.log(value))
// ['1초', '2초', '3초']
.catch((err) => console.log(err));
추가적으로 Promise.all()은 인자로 받는 배열에 있는 Promise 중 하나라도 에러가 발생하게 되면 나머지 Promise의 state와 상관없이 즉시 종료된다. 아래의 예시와 같이 1초 후에 에러가 발생하고 catch 메서드를 따라가서 Error: 에러1이 반환된 후로는 더 이상 작동하지 않고 종료된다.
Promise.all([
new Promise((resolve, reject) => setTimeout(() => reject(new Error('에러1'))), 1000),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('에러2'))), 2000),
new Promise((resolve, reject) => setTimeout(() => reject(new Error('에러3'))), 3000),
])
.then((value) => console.log(value))
.catch((err) => console.log(err));
// Error: 에러1
const promiseOne = () =>
new Promise((resolve, reject) => setTimeout(() => resolve('1초'), 1000));
const promiseTwo = () =>
new Promise((resolve, reject) => setTimeout(() => resolve('2초'), 2000));
const promiseThree = () =>
new Promise((resolve, reject) => setTimeout(() => resolve('3초'), 3000));
// 1. Promise chaining
const result = [];
promiseOne()
.then((value) => {
result.push(value);
return promiseTwo();
})
.then((value) => {
result.push(value);
return promiseThree();
})
.then((value) => {
result.push(value);
console.log(result);
});
// 2. Promise.all()
Promise.all([promiseOne(), promiseTwo(), promiseThree()])
.then((value) => console.log(value))
.catch((err) => console.log(err));
// 2-1. Promise.all()의 에러 발생시 동작방식
Promise.all([
new Promise(
(resolve, reject) => setTimeout(() => reject(new Error('에러1'))),
1000
),
new Promise(
(resolve, reject) => setTimeout(() => reject(new Error('에러2'))),
2000
),
new Promise(
(resolve, reject) => setTimeout(() => reject(new Error('에러3'))),
3000
),
])
.then((value) => console.log(value))
.catch((err) => console.log(err));
Promise Hell
Promise를 통해 비동기 코드의 순서를 제어할 수 있지만 Callback 함수와 같이 코드가 길어질수록 복잡해지고 가독성이 낮아지는 Promise Hell이 발생하는 단점이 있다.
// 터미널에 `node index.js`를 입력하여 비동기 코드가 작동하는 순서를 확인해보세요.
const printString = (string) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(string);
}, Math.floor(Math.random() * 100) + 1);
});
};
const printAll = () => {
printString('A').then((value) => {
console.log(value);
printString('B').then((value) => {
console.log(value);
printString('C').then((value) => {
console.log(value);
printString('D').then((value) => {
console.log(value);
printString('E').then((value) => {
console.log(value);
printString('F').then((value) => {
console.log(value);
printString('G').then((value) => {
console.log(value);
printString('H').then((value) => {
console.log(value);
printString('I').then((value) => {
console.log(value);
printString('J').then((value) => {
console.log(value);
printString('K').then((value) => {
console.log(value);
printString('L').then((value) => {
console.log(value);
printString('M').then((value) => {
console.log(value);
printString('N').then((value) => {
console.log(value);
printString('O').then((value) => {
console.log(value);
printString('P').then((value) => {
console.log(value);
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
});
};
printAll();
console.log(
`아래와 같이 Promise를 통해 비동기 코드의 순서를 제어할 수 있지만 Callback 함수와 같이 코드가 길어질수록 복잡해지고 가독성이 낮아지는 Promise Hell이 발생하는 단점이 있습니다.`
);
// 결과
아래와 같이 Promise를 통해 비동기 코드의 순서를 제어할 수 있지만 Callback 함수와 같이 코드가 길어질수록 복잡해지고 가독성이 낮아지는 Promise Hell이 발생하는 단점이 있습니다.
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
P
Async/Await
JavaScript는 ES8에서 async/await키워드를 제공하였다. 이를 통해 복잡한 Promise 코드를 간결하게 작성할 수 있게 되었다. 사용법은 함수 앞에 async 키워드를 사용하고 async 함수 내에서만 await 키워드를 사용하면 된다. 이렇게 작성된 코드는 await 키워드가 작성된 코드가 동작하고 나서야 다음 순서의 코드가 동작하게 된다.
// 함수 선언식
async function funcDeclarations() {
await 작성하고자 하는 코드
...
}
// 함수 표현식
const funcExpression = async function () {
await 작성하고자 하는 코드
...
}
// 화살표 함수
const ArrowFunc = async () => {
await 작성하고자 하는 코드
...
}
const printString = (string) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log(string);
}, Math.floor(Math.random() * 100) + 1);
});
};
const printAll = async () => {
await printString('A');
await printString('B');
await printString('C');
};
printAll();
console.log(
`Async/Await을 통해 Promise를 간결한 코드로 작성할 수 있게 되었습니다.`
);
// 결과
Async/Await을 통해 Promise를 간결한 코드로 작성할 수 있게 되었습니다.
A
B
C
async function call() {
console.log('calling');
const result = await resolveAfterRandomSeconds('codestates');
console.log(result);
}
call();
async function call() {
console.log('calling');
const result = await rejectAfterRandomSeconds(new Error('fail'));
console.log(result);
}
call();
'FE > JavaScript' 카테고리의 다른 글
[JS] Axios (0) | 2023.03.21 |
---|---|
[JS] 비동기 다시 정리 : callback / promise / async, await (0) | 2023.03.20 |
[JS] 비동기 흐름 : 배열 메서드 구현해보기 (Underbar) - 하 (0) | 2023.03.19 |
[JS] 프로토타입 체인 (0) | 2023.03.15 |
[JS] 객체 지향 프로그래밍(OOP)의 4가지 주요 개념 (0) | 2023.03.15 |