React 게시글은 대부분 인프런의 '한입 크기로 잘라 먹는 리액트(React.js) : 기초부터 실전까지' 강의를 기반으로 내용을 정리했습니다.
동기&비동기
동기와 비동기 파트는 자바스크립트의 실행방식과 매우 연관이 깊다. 자바스크립트 엔진이 동작하는 원리까지 파악해보자.
function taskA() {
console.log("TASK A");
}
function taskB() {
console.log("TASK B");
}
function taskC() {
console.log("TASK C");
}
taskA();
taskB();
taskC();
세 개의 함수가 작성되어 있고, task A B C 순서대로 함수를 호출한다. 그럼 콘솔에는 당연히 호출한 순서대로 출력됐을 것이다.
자바스크립트는 단일 스레드(싱글 스레드) 방식이다. 간단하게 말하자면 스레드를 은행 창구라고 생각하면 된다.
참고로 싱글 스레드의 반대는 멀티 스레드이다. 이름만봐도 딱 느낌이 온다. 그냥 스레드를 여러 개 둬서 프로그램을 병렬적으로 처리하는 방식이다.
아무튼! 자바스크립트는 단일 스레드이다. 코드에서 taskA, taskB, taskC 순서대로 호출했으니 단일 스레드에 똑같은 순서대로 도착했을 것이다. 즉, 코드가 작성된 순서대로 작업이 들어와서 처리된다. taskA가 실행되고 있으면 taskB는 대기 상태로 실행되기만을 기다린다. 이 부분을 바로 동기 방식의 처리(블로킹 방식)라고한다.
동기를 더 쉽게 말해보자. 위에서 스레드를 은행 창구라고 생각하면 된다고 말했다. 은행 창구가 하나만 있으면 손님들은 그 하나의 창구를 사용하기 위해 기다려야한다. 먼저 온 순서대로 이용할 수 있고, 앞의 사람의 용무가 끝나야지만 다음 사람의 용무를 시작할 수 있다.
동기 처리 방식의 문제점(블로킹 방식)
동기 처리 방식은의 문제점은 조금만 생각해도 알 수 있다. 위에서 만든 함수들이야 너무나 간략해서 각자의 실행시간이 너무나도 짧아 빨리 빨리 출력되지만 만약에 거대한 프로그램에서는 실행 시간이 오래 걸리는 함수가 있을 것이다. 그 함수의 실행 시간을 모두 기다려야하기 때문에 전체적으로 속도가 느려질 수 밖에 없다.
은행 창구를 또 다시 생각해보자. 앞에 있는 사람이 카드 만드는 간단한 용무만 있을 줄 알았는데 아니 보니까 상담에다가 적금까지, 펀드까지 상담한다하면 뒤에 있는 사람은 온전히 그냥 기다릴 수 밖에 없다.
이런 문제점을 해결하려면 멀티 스레드로 구성하면 된다. 여러 개의 창구가 있는 만큼 내 바로 앞의 사람이 카드를 만들던, 상담을 하던 다른 창구가 비게 되면 거기에서 용무를 처리할 수 있다. 그런데...? 위에서 말했다시피 자바스크립트는 단일 스레드 방식을 채택했다.
비동기 작업(논 블로킹 방식)
자바스크립트에서는 동기 처리의 문제점을 어떻게 해결했을까? 간단하다. 동기 처리 방식을 버리고 비동기 처리 방식을 선택했다. 동기 처리 방식이 하나의 작업을 수행하고, 종료될 때까지 다른 작업들이 기다려야 하는 방식이었다면, 비동기 처리 방식은 단일 스레드에서 여러 개의 작업을 동시에 수행한다.
다시 은행을 소환해보자. 요즘은 모바일 시대이다. 핸드폰으로 안 되는 것이 없다. 계좌이체는 물론 새로운 금융상품에 가입할 수 있다. 모바일 어플에서는 대기? 그런거 없다(트래픽 초과 상황이 아닌 이상). 앞 사람을 기다릴 필요 없이 그냥 접속해서 내 용무를 보면 끝난다.
즉 비동기 작업을 정리하자면
- 싱글 스레드 방식을 이용하면서 여러 개의 작업을 동시에 실행시킨다.
- 먼저 작성된 코드의 결과를 기다리지 않고 다음 코드를 바로 실행한다.
그럼 만약에 값을 받아와서 그 값으로 다음 처리를 하려면 어떻게 해야할까? 바로 Callback을 이용하면된다.
코드로 보자.
function taskA() {
setTimeout(() =>{
console.log("A TASK END");// 콜백함수
}, 2000);
}
taskA();
console.log("코드 끝");
위에 작성된 코드는 뭐가 먼저 출력될까? 참고로 taskA는 딜레이 타임을 이용해서 taskA callback 함수 실행 시간을 늦췄다. 딜레이 타임은 millisecond 단위로 전달하니 2000이면 2초다. 즉 2초 뒤에 콜백함수를 실행하라는 의미가 된다. 아무튼 위의 코드가 실행되면 원래 우리가 여태 봐왔던 순서와는 다르게 출력되는 것을 볼 수 있다.
만약 동기 처리로 동작했으면 실행 후 2초 뒤에 A TASK END가 출력되고 코드 끝이 출력됐어야 한다. 하지만 비동기 처리로 동작해서 먼저 호출된 taskA의 콜백 함수를 기다리지 않고 코드 끝이 먼저 출력되게 된다.
이번에는 Calback 함수로 값을 전달 받는 코드를 작성해보자.
function taskA(a, b, cb) {
setTimeout(() =>{
const res = a + b;
cb(res);
}, 3000);
}
taskA(3, 4, (res) => {
console.log("A TASK RESULT : ", res);
});
console.log("코드 끝");
딜레이 타임으로 인해 taskA의 콜백 함수가 3초 늦게 실행되어 코드 끝이 먼저 실행된다. 이 코드에서 자세하게 볼 건 taskA의 함수를 호출할 때 a, b의 매개 값과 함수를 넘겨주었다. 그러면 taskA의 매개변수에는 순서대로 3, 4, 함수가 저장되어있다. 콜백 함수를 보면 a와 b를 더하고 난 후 cb에 더한 값을 넘겨준다. 그래서 taskA의 호출 부분의 함수가 a와 b를 더한 값을 넘겨 받을 수 있게 되어 7이라는 값을 출력한다.
JS Engine
자바스크립트 엔진은 동기적인 코드와 비동기적인 코드를 어떻게 구분해서 처리할까?
자바스크립트 엔진은 Heap과 Call Stack으로 이루어져 있다. Heap은 변수나 상수들이 메모리 할당이 이뤄지는 곳이며 동기와 비동기와는 크게 중요한 부분은 아니다.
중요한 요점은 Call Stack이다.
Call Stack(동기)
콜 스택은 작성한 코드의 실행에 따라서 호출 스택이 쌓는 곳이다.
function one() {
return 1;
}
function two() {
return one() + 1;
}
function three() {
return two() + 1;
}
console.log(three());
위의 코드가 Call Stack에서 어떻게 동작하는지 파악해보자.
일단 자바스크립트를 실행하면 Main Context가 Call Stack에 가장 먼저 적재된다. Main Context는 자바스크립트 문맥의 최상위 문맥이다. Main Context가 Call Stack에 들어오는 순간이 프로그램 실행 순간이고 나가는 순간이 프로그램이 종료되는 순간이다. Java의 Main() 메서드와 비슷하다.
그리고 three() 메서드가 호출되는 순간에 Call Statck에는 three()가 적재된다.
어? 그런데 three() 함수 안에는 two()라는 함수를 호출한다. 그러면 Call Stack에는 two()가 적재된다.
two()를 실행하려하는데 이번엔 one() 함수를 호출한다. 그럼 또 Call Stack에는 one() 함수가 적재된다.
자 이제 one() 함수에서는 호출하는 함수도 없다. 이제 one() 함수를 실행하려고 보니까 그냥 return 1만하는 함수였다. return 1이란 명령을 종료하면 Call Stack에서 one() 함수는 제거된다.
이렇게 Call Stack에서는 종료되는 함수는 Call Stack에서 바로바로 제거된다. Call Stack은 가장 나중에 들어온 것부터 먼저 제거가 되는 구조이다. 좀 더 유식하게 말하자면 이러한 구조를 LIFO(List in Frist Out) 구조라고 한다.
이런식으로 함수 호출이 제거되다가 Main Context까지 제거되어 프로그램이 종료된다.
Call Stack(비동기)
Call Stack이 비동기 방식과 동기 방식의 동작이 똑같지는 않다. 코드만해도 비동기 방식이 더욱 복잡하니까!
Call Stack이 비동기 방식으로 동작되려면 Call Stack말고도 다른 추가적인 요소가 필요하다.
바로 Web APIs와 callback Queue이다.
코드와 그림으로 알아보자.
function asyncAdd(a, b, cb) {
setTimeout(() => {
const res = a + b;
cb(res);
}, 3000);
}
asyncAdd(1, 3, (res) => {
console.log("결과 : ", res);
});
위 코드가 실행되면 초반 Call Stack에는 다음와 같이 Main Context와 asyncAdd()가 적재된다.
자 Call Stack에 적재되어 있는 asyncAdd()를 실행하려고 봤더니 안에 setTimeout()가 있고 또 cb라는 콜백함수가 있다. 그러면 자바스크립트 엔진에서는 Call Stack에 다음과 같이 이 두 개의 함수를 적재한다.
setTimeout() 함수는 빨간 박스 안에 있는데 그 이유는 다른 함수들과 달리 비동기 함수이기 때문이다.
자바스크립트 엔진은 setTimeout()과 같은 비동기 함수를 Call Stack에서 Web APIs로 옮긴다.
Web APIs에 격리당한 setTimeout()의 경우는 실행이 멈추는게 아니라 지정해놨던 3초동안 그냥 가만히 대기한다.
그래서 결국 Call Stack에서는 asyncAdd() 메서드가 제일 상단에 있기 때문에 asyncAdd() 메서드를 수행 후 제거되게 된다.
그러다가 3초 뒤에 Web APIs에서 setTimeout() 함수는 종료되어 제거되고 Web APIs에 남아있는 콜백 cb() 함수는 Callback Queue로 옮겨진다. 그리고 Callback Queue에서 cb() 함수를 Event Loop라는 기능으로 인해 Call Stack으로 다시 옮겨지게 된다.
Evnet Loop는 그럼 콜백 함수를 어떻게 언제 Call Stack으로 옮길까?
바로 Call Stack에 Main Context를 제외한 다른 함수가 남아 있는지를 계속해서 확인한다. 만약에 아무것도 남아 있지 않다면 cb()를 수행할 수 있게 된다.
마지막으로 함수끼리 콜백을 이용해서 값을 넘겨받는 예제를 보자.
function taskA(a, b, cb) {
setTimeout(() => {
const res = a + b;
cb(res);
}, 3000)
}
function taskB(a, cb) {
setTimeout(() => {
const res = a * 2;
cb(res);
}, 1000)
}
function taskC(a, cb) {
setTimeout(() => {
const res = a * -1;
cb(res);
}, 2000)
}
taskA(4, 5, (a_res) => {
console.log("A result : ", a_res);
taskB(a_res, (b_res) => {
console.log("B result : ", b_res);
taskC(b_res, (c_res) => {
console.log("C result : ", c_res);
})
})
})
console.log("코드 끝");
뭔가 되게 어렵지만 결국엔 아래 이미지와 같이 동작한다.
A에서 어떤 처리를 한 값을 B에게 넘겨주고 B에서 처리한 다음에 C에게 넘겨주는 형식.
이런 방식으로 코드를 작성하면 콜백 함수를 타고타고타고타고타고가는 현상이 생길 수 있다. 이런 현상을 콜백 지옥(callback hell)이라 부른다.
다음 챕터는 이 현상을 해결할 수 있는 Promise에 대해 알아보자.