비동기식 자바스크립트는 꽤 고급 주제라 볼 수 있다. 공부하기 전에 자바스크립트 기초와 구성요소(함수, 이벤트 등)에 대해 공부를 하는 게 좋다고 한다.
먼저 비동기 프로그래밍에 대해 알아보자면
비동기 프로그래밍은 작업이 완료될 때까지 기다리지 않고, 잠재적으로 오래 실행되는 작업을 시작해 해당 작업이 실행되는 동안에도 다른 이벤트에 응답할 수 있게 하는 기술이다. 그리고 작업이 완료되면 프로그램이 결과를 제공한다.
브라우저가 제공하는 많은 기능들 중 특히 중요한 기능들은 오래 걸릴 가능성이 있다.
예를 들어
1)
fetch()를 이용해 HTTP의 요청을 만들거나
2)getUserMedia()를 사용해 사용자의 카메라나 마이크에 접근하거나
3)showOpenFilePicker()를 통해 사용자에게 파일 선택을 요청하는 경우가 있다.
그래서 비동기 함수를 자주 구현할 필요는 없지만, 비동기 함수를 올바른 위치에서 올바르게 사용해야 할 경우가 많이 있다.
비동기를 자세히 알기 전에 먼저 동기 프로그래밍에 대해 알아보는 게 쉬울 것이다.
아래 코드를 보면
const name = "Miriam";
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"
name이란 문자열을 선언하고
name을 사용해 greeting이란 또다른 문자열을 선언한다.
그 다음 greeting을 콘솔에 출력한다.
여기서 브라우저는 실질적으로 프로그램 작성한 순서대로 한 줄씩 실행한다는 점을 주목해야 한다. 브라우저는 각 지점에서 다음 줄로 넘어가기 전까지 현재 라인의 작업 끝날 때까지 기다린다.
각 라인들은 이전 라인에 의존하고 있기 때문에 이렇게 해야 한다.
따라서 동기 프로그래밍이 된다.
또한 아래 코드처럼 함수를 별도로 호출해도 동기식 프로그래밍이 된다.
function makeGreeting(name) {
return `Hello, my name is ${name}!`;
}
const name = "Miriam";
const greeting = makeGreeting(name);
console.log(greeting);
// "Hello, my name is Miriam!"
makeGreeting()은 동기 함수다. 호출자는 함수의 작업이 완료될 때까지 기다렸다가 값을 반환해야 계속 진행할 수 있기 때문이다.
동기 함수가 오랜 시간에 걸쳐 실행되면 어떻게 될까?
아래의 프로그램은 매우 비효율적인 알고리즘 사용해 Generate primes버튼 클릭할 때 여러 개 큰 소수를 생성한다. 사용자가 입력하는 숫자가 커질수록 작업 시간도 오래 걸린다.
링크를 통해 프로그램을 이용 가능하다.
비동기식 프로그래밍-장기 실행 동기함수
<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>
<div id="output"></div>
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.location.reload();
});
이 프로그램에서 Generate prime을 누르면 Finished가 나오는데 몇 초가 걸릴 것이다.
장기 실행 동기 함수의 문제는 함수가 실행되는 동안 프로그램이 완전히 응답하지 않는다는 것이다.
위의 링크를 통해 들어가서 실행해보면 버튼을 누르면 나머지가 작동을 안한다.
아무것도 입력할 수 없고 클릭도 안되고, 그 외에 다른 것도 할 수 없다.
이것은 장기 실행 동기 함수의 문제점이다. 이 문제를 해결하기 위해 이 프로그램에 필요한 것은 다음과 같다.
- 함수 호출함으로써 장기적으로 실행되는 작업을 시작한다.
- 이 함수로 작업 시작하고 즉시 복귀해 다른 이벤트에 계속 응답할 수 있게 한다.
- 작업 완료되면 결과 알려준다.
이게 바로 비동기 함수가 할 수 있는 일이다.
이 다음부터는 비동기 함수가 JS에서 구현되는 방법에 관해 설명한다.
비동기 함수에 대한 설명을 통해 우리는 이벤트 처리기를 생각해야 한다.
이벤트 처리기는 실제로 이벤트가 발생할 때마다 호출되는 함수(이벤트 처리기)를 제공하는 형태로 비동기 프로그래밍을 구현한다.
"이벤트"가 "비동기 작업 완료"인 경우, 이 이벤트를 사용해 호출자에게 비동기 함수 호출의 결과를 알릴 수 있다.
일부 초기 비동기 API는 이러한 이벤트 방식을 사용했다. XMLHttpRequest는 JavaScript를 사용해 서버에 HTTP 요청을 할 수 있는 API다. 이것은 HTTP 요청이 필요한 작업을 사용하기 때문에 비동기 API이다.
비동기식 프로그래밍-이벤트 처리기
<button id="xhr">Click to start request</button>
<button id="reload">Reload</button>
<pre readonly class="event-log"></pre>
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();
});
이 프로그램은 이벤트가 버튼 클릭과 같은 사용자 행동이 아니라 어떤 객체로 상태가 변화되므로 속도가 더 빠르다.
콜백(callback)
JavaScript에서 콜백이란, 함수가 다른 함수의 인자로 전달돼서 특정 작업이 완료된 후 호출되는 함수다. 콜백 함수는 주로 비동기 작업에서 작업 완료 후 동작을 정의하는 데 사용되고, JavaScript의 비동기 프로그래밍에서 중요 역할을 한다.
하지만 콜백 기반 코드는 콜백이 콜백을 가지는 함수를 호출하는 경우 이해하기 어려울 수 있다.
예시의 코드를 한 번 보면
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();
여기서 세 단계로 나뉘는 단일 작업이 있다.
각 단계는 이전 단계에 의존적이다. 이 예제에서 첫 번째 단계는 입력값에 1을 추가하고, 두 번째 단계는 2를 추가하고, 세 번째 단계는 3을 추가한다.
0의 입력 후 최종 결과는 6이 된다. 동기식 프로그램으로서, 이것은 매우 간단한데, 콜백을 사용해 단계를 구현하면 어떻게 될까?
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();
콜백 내부에서 콜백을 호출하므로 깊게 중첩된 doOperation() 함수가 생긴다. 이 함수는 읽고 디버깅을 하기가 어렵다.
이것을 보통 "콜백 지옥"이라 부르는 데, 콜백 지옥은 복잡한 비동기 로직에서 콜백이 중첩되면 코드가 엄청 복잡해지고 가독성이 떨어지게 된다.
보통 이런 중첩 콜백을 피하기 위해 Promise나 async/await와 같은 더 나은 비동기 처리 패턴이 도입됐다.