티스토리 뷰
1️⃣ 동기식 처리 모델 vs 비동기식 처리 모델
글로 설명하기 전에 동기와 비동기를 비유한 아래 그림을 보면 이해가 훨씬 쉬울 것 같다.
동기식 처리 모델 (Synchronous processing model)
동기식 처리 모델은 태스크(task)를 직렬적으로 처리한다.
→ 태스크는 순차적으로 실행되며 어떤 작업이 수행 중이면 다음 작업은 대기하게 된다.
동기 프로그래밍
const name = 'Miriam';
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
브라우저는 위 코드를 프로그램을 작성한 순서대로 한 줄씩 실행한다.
1) name이라는 문자열을 선언한다.
2) name을 사용하여 greeting이란 또다른 문자열을 선언한다.
3) greeting을 JavaScript 콘솔에 출력한다.
각 라인의 코드는 이전 라인에 의존하고 있으므로 브라우저는 다음 라인의 코드를 실행하기 전에 현재 라인의 작업이 끝날 때까지 기다린다.
이렇게 직렬적으로 실행되는 동기 함수가 만약 실행되는데 엄청 오랜 시간이 걸린다고 하면 그 동기 함수의 실행이 끝나기 전까지는 중지 상태가 되어 다른 작업을 할 수 없게 된다.
예시 코드 - 장기 실행 동기 함수 (출처 : MDN)
아래 코드에서 "Generate primes"버튼을 클릭하면 textarea에 텍스트를 작성할 수도 "Reload"버튼을 누를수도 없다.
이는 동기 함수인 generatePrimes() 함수의 실행이 끝나기까지 프로그램이 응답하지 않기 때문이다.
<label for="quota">Number of primes:</label>
<input type="text" id="quota" name="quota" value="1000000">
<button id="generate">Generate primes</button>
<button id="reload">Reload</button>
<textarea id="user-input"></textarea>
<div id="output"></div>
<script>
function generatePrimes(quota) {
function isPrime(n) {
for (let c = 2; c <= Math.sqrt(n); ++c) {
if (n % c === 0) {
return false;
}
}
return true;
}
const primes = [];
const maximum = 1000000;
while (primes.length < quota) {
const candidate = Math.floor(Math.random() * (maximum + 1));
if (isPrime(candidate)) {
primes.push(candidate);
}
}
return primes;
}
document.querySelector('#generate').addEventListener('click', () => {
const quota = document.querySelector('#quota').value;
const primes = generatePrimes(quota);
document.querySelector('#output').textContent = `Finished generating ${quota} primes!`;
});
document.querySelector('#reload').addEventListener('click', () => {
document.querySelector('#user-input').value = 'Try typing in here immediately after pressing "Generate primes"';
document.location.reload();
});
</script>
이런 동기 함수의 문제점을 해결하기 위한 방법이 비동기 처리 방식이다.
비동기식 처리 모델 (Asynchronous processing model / Non-Blocking processing model)
비동기식 처리 모델은 태스크를 병렬적으로 수행한다.
→ 태스크가 순차적으로 실행되는 것이 아니라 태스크가 종료되지 않은 상태라도 대기하지 않고 다음 태스크를 실행한다.
즉, 비동기(asynchronous) 프로그래밍이란 작업이 완료될 때까지 기다리지 않고 오래 실행되는 작업을 시작하여 해당 작업이 실행되는 동안에도 다른 이벤트에 응답할 수 있게 하는 기술이다. 예를 들면, 브라우저가 제공하는 많은 기능 중 "fetch()를 이용해 HTTP 요청 만들기", "getUserMedia()를 사용해 사용자의 카메라 또는 마이크에 접근하기", "showOpenFilePicker()를 통해 사용자에게 파일 선택을 요청하기" 등이 비동기적으로 동작한다.
서버에서 데이터를 가져와 화면에 표시하는 작업을 수행할 때
- 동기 처리 모델의 경우
서버에 데이터를 요청하고 데이터가 응답될 때까지 이후 태스크들은 블로킹(blocking)된다.
- 비동기 처리 모델의 경우
서버에 데이터를 요청한 이후 서버로부터 데이터가 응답될 때까지 대기하지 않고 즉시 다음 태스크를 수행한다. 이후 서버로부터 데이터가 응답되면 이벤트가 발생하고 이벤트 핸들러가 데이터를 가지고 수행할 태스크를 계속해 수행한다.
자바스크립트의 대부분의 DOM 이벤트 핸들러와 Timer 함수(setTimeout, setInterval), Ajax 요청은 비동기식 처리 모델로 동작한다.
이벤트 핸들러는 이벤트가 발생할 때마다 이벤트 핸들러(함수)를 제공하여 "이벤트"가 비동기 작업 완료 상태인 경우, 이 "이벤트"를 사용하여 호출자에게 비동기 함수 호출 결과를 알린다.
일부 초기 비동기 API는 이벤트 핸들러와 같은 방식의 이벤트를 사용했다.
XMLHttpRequest는 자바스크립트로 원격 서버에 HTTP 요청을 할 수 있는 API이다. HTTP 요청은 시간이 걸리 수 있는 작업이라 비동기 API이며, 이벤트 수신기를 XMLHttpRequest 객체에 연결하여 요청의 진행 상태 및 최종 완료에 대한 알림을 받는다.
예시 코드 - XMLHttpRequest (출처 : MDN)
"Click to start request" 버튼을 클릭하여 HTTP 요청을 보낼 수 있다.
버튼을 클릭하면 새로운 XMLHttpRequest를 생성하고 이것의 loadend 이벤트를 수신한다. 요청이 시작된 이후 "Started XHR request"라는 텍스트가 나타나는데 이는 요청이 진행되는 동안 코드의 실행이 중지되지 않고 아래 코드가 계속 실행되고 있다는 것이고, 요청이 완료되면 이벤트 핸들러가 호출된다.
일반적인 이벤트 핸들러와 유사하지만, 이벤트가 사용자의 행동(click 등)이 아니 어떤 객체의 상태변화라는 점에서 차이가 있다.
<button id="xhr">Click to start request</button>
<button id="reload">Reload</button>
<pre readonly class="event-log"></pre>
<script>
const log = document.querySelector('.event-log');
document.querySelector('#xhr').addEventListener('click', () => {
log.textContent = '';
const xhr = new XMLHttpRequest();
xhr.addEventListener('loadend', () => {
log.textContent = `${log.textContent}Finished with status: ${xhr.status}`;
});
xhr.open('GET', 'https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json');
xhr.send();
log.textContent = `${log.textContent}Started XHR request\n`;});
document.querySelector('#reload').addEventListener('click', () => {
log.textContent = '';
document.location.reload();
});
</script>
다음으로 Web API로 제공되는 timer 함수 setTimeout을 살펴보자.
아래 코드의 콘솔 출력결과는 "func1 func2 func3"이 될 것 같지만 setTimeout은 비동기 함수이기 때문에 아래 코드를 실행하면 setTimeout 메소드에 시간을 0초로 설정하여도 콘솔에는 "func1 func3 func2"가 찍힌다.
function func1() {
console.log('func1');
func2();
}
function func2() {
setTimeout(function() {
console.log('func2');
}, 0);
func3();
}
function func3() {
console.log('func3');
}
func1();
"func1"
"func3"
"func2"
위 코드의 동작과정은 아래와 같다.
1) func1이 호출되면 func1은 Call Stack에 쌓인다.
2) func1은 func2를 호출하므로 func2가 Call Stack에 쌓이고 setTimeout이 호출된다.
3) setTimeout의 콜백함수는 즉시 실행되지 않고 지정 대기 시간만큼 기다리다 "tick" 이벤트가 발생하면 Task Queue로 이동한 후 Call Stack이 비워졌을 때 Call Stack으로 이동되어 실행된다.
💡 이벤트 루프
이벤트 루프에 대해서는 따로 정리한 글을 참고하자.
2022.09.30 - [👩💻 개발공부/🟨 자바스크립트 | JavaScript] - [JS] 이벤트 루프 (Event Loop)
2️⃣ Promise
위에서 살펴본 이벤트 핸들러나 setTimeout처럼 자바스크립트에서 비동기 함수를 구현하는 주요 방식은 콜백 함수를 이용하는 것이었다. 하지만 콜백 기반의 코드는 비동기적으로 처리해야 하는 일이 많아질 수록 콜백 내부에서 또 콜백을 호출하는 것이 반복되면서 코드의 깊이가 깊어진다.
이렇게 콜백이 중첩되면 읽기도 어렵고 디버깅하기도 어려워진다. 이것을 "콜백지옥" 도는 "피라미드 오브 둠"이라고 하고, 이러한 이유로 대부분의 최신 비동기 API는 콜백을 사용하지 않는다. Promise를 사용하면 콜백 지옥을 방지할 수 있다.
💡 콜백 함수 (Callback)
함수 타입의 값을 파라미터로 넘겨주어 파라미터로 받은 함수를 특정 작업이 끝나고 호출해주는 것
예시 코드 - 콜백지옥 (출처 : MDN)
아래 코든느 세 단계로 나뉘며 각 단계는 이전 단계에 의존적인 단일 작업을 동기식 프로그래밍으로 작성한 코드이다.
function doStep1(init) {
return init + 1;
}
function doStep2(init) {
return init + 2;
}
function doStep3(init) {
return init + 3;
}
function doOperation() {
let result = 0;
result = doStep1(result);
result = doStep2(result);
result = doStep3(result);
console.log(`result: ${result}`);
}
doOperation();
위 코드를 콜백을 사용하여 구현한다면 아래처럼 깊게 중첩된 doOperation()가 생긴다. 이 코드만으로는 지옥이라고 까지 보기 힘들 수도 있지만 이런한 방식으로 복잡한 함수를 구현하게 된다면 중첩이 엄청나게 생겨 코드를 한눈에 알아보기도 힘들고, 오류처리도 어려워질 수 있다.
function doStep1(init, callback) {
const result = init + 1;
callback(result);
}
function doStep2(init, callback) {
const result = init + 2;
callback(result);
}
function doStep3(init, callback) {
const result = init + 3;
callback(result);
}
function doOperation() {
doStep1(0, result1 => {
doStep2(result1, result2 => {
doStep3(result2, result3 => {
console.log(`result: ${result3}`);
});
});
});
}
doOperation();
Promise 란?
Promise는 이전 작업이 완료될 때까지 다음 작업을 연기시키거나, 작업 실패를 대응할 수 있는 ES6에 도입된 기능이다.
Promise는 어떤 작업의 중간상태를 나타내는 객체(Object)로, 미래에 어떤 종류의 결과가 반환됨을 약속(promise)해주는 객체라고 생각하면 된다. 결과를 반환해주는데 얼마나 걸리는지에 대한 정확한 시간을 보장해주지는 않지만, 결과를 반환되었을 때 어떤 코드를 진행 시킬지, 에러가 발생했다면 그 에러를 깔끔하게 처리할 수 있게 해준다.
- Promise는 한 번에 성공/실패 중 하나의 결과 값을 가진다.
- 하나의 요청에 두번 성공하거나 실패할 수 없다.
- 이미 성공한 작업이 다시 실패로 돌아갈 수 없고, 실패한 작업이 성공으로 돌아갈 수 없다.
💡 Promise 자세히 알아보기
이 글에서는 자바스크립트에서의 비동기에 대해 포괄적인 내용을 다루기 위해 Promise에 대한 상세한 내용을 따로 공부하여 정리하기로 하자.
2023.01.30 - [👩💻 개발공부/🟨 자바스크립트 | JavaScript] - [JS] Promise
Promise가 왜 좋은지 콜백을 사용하는 것을 비교하며 살펴보기 위해 피자를 주문하는 코드를 작성한다고 가정해보자.
코드의 동작과정은 아래와 같다고 했을 때 각 과정에서는 메뉴의 결정을 오래하거나, 피자가 만들어지는데 오래걸리거나 등의 이유로 동작이 지연되거나 취소될 수 있다.
1) 원하는 토핑을 고른다.
2) 피자를 주문한다.
3) 주문한 피자를 받아서 먹는다.
콜백을 이용하면 아래와 같이 코드를 작성할 수 있다.
chooseToppings(function(toppings) {
placeOrder(toppings, function(order) {
collectOrder(order, function(pizza) {
eatPizza(pizza);
}, failureCallback);
}, failureCallback);
}, failureCallback);
콜백을 사용한 코드는 읽기도 힘들 뿐 아니라 failureCallback()을 여러번 작성해야하는 문제가 있다.
위 코드를 Promise를 사용하여 바꾼다면 가독성도 좋고, .catch()를 통해 모든 에러를 처리할 수 있으며, main thread를 차단하지 않는다.
또한, 각 함수가 실행되기 전 이전 작업이 끝날 때까지 기다리므로 여러 개의 비동기 작업을 연쇄적으로 처리 가능하다.
(.then 블럭은 자신이 속한 블럭의 작업이 끝났을 때의 결과물을 새로운 Promise로 반환해주기 때문)
chooseToppings()
.then(function(toppings) {
return placeOrder(toppings);
})
.then(function(order) {
return collectOrder(order);
})
.then(function(pizza) {
eatPizza(pizza);
})
.catch(failureCallback);
화살표 함수로 더욱 간단하게 표현한다면 위 코드를 단 5줄로도 간단하게 표현할 수 있다.
chooseToppings()
.then(toppings => placeOrder(toppings))
.then(order => collectOrder(order))
.then(pizza => eatPizza(pizza))
.catch(failureCallback);
이처럼 Promise는 함수의 반환 값이나 반환하는데 걸리는 시간을 모를 때 비동기 통신을 처리하기 좋은 방법이다. 깊게 중첩된 콜백 없이 쉽게 표현할 수 있으며, 동기 문과 유사한 오류 처리 스타일(try...catch)을 가진다.
3️⃣ async / await
위에서 보았듯이 Promise를 사용하면 비동기 작업의 갯수가 많아져도 코드의 깊이가 길어지는 것을 방지할 수 있다. 하지만 Promise를 사용할 때도 불편한 점들이 있는데 에러를 catch할 때 어디서 에러가 발생했는지 알아내기 어렵고, 특정 조건에 따라 분기를 나누는 작업도 어렵다. 또한, 특정 값을 공유하면서 작업을 처리하기도 까다롭다.
이러한 부분들에 대해 Promise를 더욱 편하게 사용할 수 있게 ES8에 도입된 것이 async/awiat 이다.
async/await함수의 목적은 사용하는 여러 Promise의 동작을 동기스럽게 사용할 수 있게 하고, 어떠한 동작을 여러 promise의 그룹에서 간단하게 동작하게 하는 것이다. Promise가 구조화된 Callback과 유사한 것처럼 async/await 또한 제네레이터(generator)와 프로미스(promise)를 묶는것과 유사하다.
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function process() {
console.log('안녕하세요!');
await sleep(1000); // 1초쉬고
console.log('반갑습니다!');
}
process();
위 코드는 sleep() 함수의 파라미터로 넣어준 시간만큼 기다리는 Promise를 만들고, 이를 process() 함수에서 사용한다.
위 코드에서와 같이 함수를 선언할 때 함수 앞에 async 키워드를 붙이면 async 함수를 선언할 수 있다.
async 함수 선언은 AsyncFunction객체를 반환하는 하나의 비동기 함수를 정의한다. async 함수는 항상 promise를 반환하는데, 만약 async 함수의 반환값이 명시적으로 promise가 아니라면 암묵적으로 promise로 감싸진다.
async 함수에는 await식이 포함될 수 있는데 Promise 앞부분에 await 키워드를 넣어주면 이 await식은 async 함수의 실행을 일시 중지하고 전달된 Promise의 해결을 기다린 다음 async 함수의 실행을 다시 시작하고 완료 후 값을 반환한다.
(await 키워드는 async 함수에서만 유효하다.)
await는 말 그대로 Promise가 처리될 때까지 async 함수의 실행을 기다리게 한다. Promise가 처리되길 기다리는 동안에는 엔진이 다른 스크립트 실행, 이벤트 처리 등을 할 수 있기 때문에 CPU 리소스가 낭비되지 않는다.
async 함수는 Promise로 반환하므로 위 코드를 다음과 같이 작성할 수 있다.
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function process() {
console.log('안녕하세요!');
await sleep(1000); // 1초쉬고
console.log('반갑습니다!');
}
process().then(() => {
console.log('작업이 끝났어요!');
});
async 함수에서 에러를 발생시킬때는 throw를 사용하고, 에러를 잡아낼 때는 try...catch문을 사용한다.
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function makeError() {
await sleep(1000);
const error = new Error();
throw error;
}
async function process() {
try {
await makeError();
} catch (e) {
console.error(e);
}
}
process();
이렇게 async/await을 사용하면 Promise를 조금 더 동기적인 코드와 비슷하게 쉽고 편하게 작성할 수 있다.
참고 자료
🔗 [MDN] Asynchronous JavaScript
🔗 [poiemaweb] 동기식 처리 모델 vs 비동기식 처리 모델
🔗 [벨로퍼트와 함께하는 모던 자바스크립트] 3장. 자바스크립트에서 비동기 처리 다루기
🔗 [Toast UI] 자바스크립트는 어떻게 약속을 지킬까?
'개발공부 > 🟨 JavaScript' 카테고리의 다른 글
[JS] 식별자 (Identifier) (0) | 2023.04.09 |
---|---|
[JS] 프로토타입 (Prototype) (0) | 2023.02.19 |
[JS] 호이스팅 (Hoisting) (0) | 2023.01.18 |
[JS] 클로저 (Closure) (0) | 2023.01.09 |
[JS] 실행 컨텍스트 (Execute context) (0) | 2023.01.04 |
프론트엔드 개발자 삐롱히의 개발 & 공부 기록 블로그