JavaScript
를 공부한 지 어느덧 4주 정도가 지났는데, 가장 핵심적인 부분인 동기 처리와 비동기 처리에 관해 정리를 할 필요가 있어 글로써 적게 되었다. 자바스크립트 개발은 주로 Node.js
로 이루어지기 때문에 이에 대한 특징을 잘 알아야 한다. 싱글스레드, 비동기, 이벤트 기반 같은 특징이 있는데, 이는 다 연결된 개념으로 이어지기 때문에 이들을 중심으로 설명해보려고 한다.
스레드는 명령을 실행하는 단위를 의미한다. 흔히 일을 하는 사람이라고도 표현할 수 있는데, 한 개의 스레드는 한 번에 한가지 동작 밖에 수행을 하지 못한다. 장점이라면 프로세스 단위에서 DeadLock
에 대한 걱정을 하지 않아도 되고, 경쟁 상태도 신경 쓸 필요가 없지만 작업 처리 효율이 떨어진다는 단점이 있긴 한다. 대규모 프로젝트 같은 경우 백엔드를 spring
쪽으로 선호하는 이유도 자바는 멀티스레드 방식이기 때문이다. 이를 위해 Node.js
에서는 비동기 동작으로 스레드 기반의 작업을 최소화한다.
비동기와 대비되는 동기적(Synchronous) 방식부터 설명하자면 기본적으로 우리가 알고 있는 코드의 흐름이라고 보면 된다. 한 줄의 코드가 실행된 후에 그 다음 작업이 수행되는 절차적인 방식이다. 흐름에 있어서 헷갈릴 걱정이 없긴 하지만 가끔 앞선 작업이 오래 걸릴 경우 다음 작업이 그만큼 늦춰지게 되어 효율성을 잃게 되는 경우가 있다. 간단한 예를 들어보자.
for (let i = 0; i < 1000; i++) console.log("First");
for (let i = 0; i < 1000; i++) console.log("Second");
for (let i = 0; i < 1000; i++) console.log("Third");
동기적 방식으로 실행된다고 하면 First
가 1000번 루프를 돌 때까지 Second
는 기다려야 하고, Third
는 최종적으로 2000번의 루프를 기다려야 한다. 그 다음에 또 작업이 존재한다고 하면 골치 아파질 것이다. 블로킹
은 하나의 작업을 마치고 나서 다음 작업을 시작하는 순차적인 실행 방식이다. 자바스크립트에서는 이를 위해 await
과 같은 방법을 사용한다.
반면 비동기 방식은 특정 코드를 큐에 넣어놓고, 이후의 코드부터 실행하는 방식으로 진행된다. 만약 이전의 코드에서 도출된 결과값이 필요 없다면 이를 활용하여 훨씬 효율적인 코드를 구성할 수 있다.
function wait() {
setTimeout(() => {
console.log("비동기");
}, 3000);
}
function Hello() {
console.log("Hello");
}
wait();
Hello();
/* Console Output
Hello
비동기 */
가장 대표적인 쓰임이 setTimeout()
함수이다. 두 번째 인자에 있는 ms
만큼 기다렸다가 첫 번째 인자에 있는 함수를 실행한다. 동기적인 방식으로 진행된다면 wait()
함수가 실행되고 5초 후에 Hello
가 출력되어야 하는데, setTimeout()
은 비동기적인 방식으로 진행되기 때문에 Hello()
부터 실행이 된다.
논블로킹은 하나의 작업을 실행시키고, 그 작업이 마치지 않아도 다음 작업을 실행하는 방식이다. 작업을 실행시켜 놓기만 하는 느낌인데, 이렇게 구현하면 다음 작업의 지연을 걱정할 필요가 없다. 보통 결과값을 바로 리턴 받지 못하고, 콜백이나 그 외 다른 방식으로 받는 경우가 많다. 기술적으로는 사실 싱글스레드 만으로 불가능한데, 이는 나중에 설명하는 것으로 미뤄두겠다.
setTimeout
은 그렇다 치고, 그냥 일반적인 코드를 비동기 방식으로 변환할수는 없을까? 이에 대한 방법은 크게 세 가지 방법을 이용할 수 있다. 바로 callback
, promise
, async/await
이다.
콜백함수라는 말이 처음에는 잘 와닿지가 않았다. 인터넷에 예시를 찾아봐도 이해하기 힘든 말들로 가득하고 말이다. 그래서 알기 쉽게 설명하자면, 콜백함수는 특정 함수가 존재할 때 인자로 넘기는 함수의 형태로 존재한다, 외형적으로 봤을 때는 그렇다.
function someFunction(callback) {
console.log("not callback");
callback();
}
someFunction(() => {
console.log("callback");
});
이와 같은 방법을 응용하면 someFunction
에서 어떠한 데이터를 로드한다고 쳤을 때, 그 결과를 처리하기 위해 callback
함수를 비동기적으로 사용할 수 있다. 하지만 callback
함수를 무분별하게 사용한다면 콜백 지옥에 빠져버리게 될수도 있다. 성능을 떠나서 코드의 가독성과 유지보수 모두 헤치는 요인이 될 수 있기에 새로운 Promise
라는 기술이 도입되었다.
Promise
는 비동기 작업을 표현하는 자바스크립트의 객체인데, 비동기 작업의 진행(then, catch ..), 성공(resolve), 실패(reject)
상태를 표현할 수 있다. 구현 방법은 객체이기 때문에 생성자가 존재하는데, 인자로 resolve
와 reject
를 받는다. 각각은 성공과 실패를 의미하고, 보통 조건문을 통해 resolve
를 실행할지, reject
를 실행할지를 결정한다. 예시 코드로 보는게 이해가 쉬울 것이다.
let promise = new Promise((resolve, reject) => {
if (value > 30) {
return reject("실패");
}
resolve(value);
});
이를 직접 사용하는 방식은 다음과 같다.
promise
.then((data) => {
console.log(data);
})
.then((data) => {
console.log(data++);
})
.catch((e) => {
console.log(e);
})
.finally(() => {
console.log("종료");
});
then
을 계속해서 메서드 체이닝 시켜주는 방식으로 비동기 동작을 실행시킬 수 있다. 앞의 then
이 끝나야 뒤의 then
이 실행되고, 작업 실패 시 reject
로 넘어가 이를 catch
로 사용한다. resolve - then
, reject - catch
로 이어져있다고 생각하면 된다. 마지막으로 finally
는 말 그대로 최종적인 의미를 담고 있는데, 위의 실행 결과가 어떻든 최종적으로 수행할 작업을 의미한다. 진행이 then
, catch
둘 중 어느것이 되었든 간에 종료를 출력하는 코드는 실행된다는 말이다.
Promise.all([promise1, promise2, promise3])
.then((data) => {
console.log("All clear: ", data);
})
.catch((err) => {
console.log("Something Failed: ", err);
});
다음과 같이 Promise
배열을 받아 모두 성공시에만 then
을 실행시키는 방식으로도 구현할 수 있는데, 여기서 사용되는 것이 Promise.all
이다. 하나라도 실패할 경우 에러를 출력하기 위해서, 무조건 모든 과정이 성공적으로 실행시켜야 하는 경우에 사용한다. 사실 오래된 코드의 콜백함수를 Promise
로 변환하는 과정을 현업에서 많이 거친다고 한다. 하지만 이것보다 더 쉽게 사용할 수 있는 방법인 async/await
함수가 있다.
Promise
를 활용한 방법인데, await
키워드를 이용한다. 단순히 함수 앞에 async
를 써주는 것 만으로도 사용할 수 있고, async
로 선언된 함수는 반드시 Promise
를 리턴하여야 한다.
async function asyncFunc() {
let data = await fetchData();
let user = await fetchUser(data);
return user;
}
fetchData = () => {
return new Promise((res, rej) => {
if (fetch('http://sample ...')) res();
else rej();
});
};
function fetchUser ...
대충 이런식으로 작성해봤는데, 가장 주목해야 할 것은 맨 처음의 async function
이다. user
에서 data
를 쓰기 때문에 data
의 작업이 다 끝날때까지 기다려야 한다. Promise
같은 경우 then
을 사용했지만 async function
에서는 단순히 앞에 await
키워드를 붙여주는 것만으로도 끝난다. 가독성의 끝판왕이라고 볼 수 있고, 코드를 작성하기도 편하기 때문에 유지보수 측면에서 정말 좋다. 에러가 발생하는 경우, async function
에서는 try, catch
를 사용하면 된다.
async function asyncFunc() {
try {
let data = await fetchData();
return fetchAnotherData(data);
} catch (err) {
console.log(err);
}
}
아까 싱글스레드의 비동기에 대해 간략하게 설명하고 넘어갔는데, 좀 더 자세하게 설명하기 위해서는 Event Loop
개념이 들어간다. 이전 코드의 마무리를 짓지 않고 다음 코드에 들어갔을 때, 그 코드는 어떻게 될까? 멀티 스레드면 관리 못하는거 아냐?
와 같은 질문의 대답을 해줄 수 있는 개념이다.
위의 사이트에 들어가서 직접 코드를 실행시켜보면 어떠한 방식으로 진행되는지 알 수 있다. 그래도 좀 더 자세히 설명하기 위해 위의 사이트 내 존재하는 소스코드를 가져와봤다.
function logA() {
console.log("A");
}
function logB() {
console.log("B");
}
function logC() {
console.log("C");
}
function logD() {
console.log("D");
}
// 실행
logA();
setTimeout(logB, 0);
Promise.resolve().then(logC);
logD();
A -> B -> C -> D
순서대로 적혀있는 만큼 순서대로 출력하면 좋겠지만, 비동기적인 방식 두 가지가 섞여 있기 때문에 예상과는 다르게 진행된다. 우선 맨 처음으로 logA
함수가 Call Stack
에 올라갈 것이다, 그리고 A
라는 값이 출력된다. 그 다음에는 setTimeout
에 있는 logB
가 실행될 차례인데, 지연시간이 0초임에도 불구하고 logB
의 실행은 Task Queue
로 가게 된다. 다음으로 Promise
내부에 있는 logC
함수가 실행되게 되는데 Promise
같은 경우 Microtask Queue
로 간다. 이 두 가지 큐에 있는 작업들은 Call Stack
이 모두 비워진 후에야 비로소 실행되게 된다. 아직 Call Stack
에 들어갈 logD
가 남았으므로 D
가 출력되고 나면 Microtask Queue
부터 순차적으로 Call Stack
에 쌓이기 시작하고, 실행된다. 그래서 A -> D -> C -> B
순으로 실행된다.
Task Queue
는 대기 중인 작업들의 처리를 담당하는 큐이다. setInterval
, setTimeout
과 같은 비동기 함수나 콜백함수에서 사용하는 큐이기도 하다. Microtask Queue
는 Task Queue
보다 우선순위가 높은 작업들의 처리를 담당하는데 Promise
와 같은 비동기 함수들이 이 큐에 들어가게 된다. 일반적으로 Task Queue = Message Queue
라고 부르기도 하고, Microtask Queue = Job Queue
라고 부르기도 한다.