개발을 하다보면 에러를 마주치는 상황이 자주 발생하는데
대부분 외부 서버에서 데이터를 받아와서 처리하는 상황 등에서 발생하게 된다.
이에 대해 리서치를 해보면 대부분 자바스크립트의 비동기 함수로 인해 생긴 이슈들이라고 하는데
자바스크립트는 API 요청 등으로 대표되는 비동기 함수들에 대해서 평소와 다른 실행을 하게 되어 발생 한다고 한다.
자바스크립트는 어떤 특성을 가지고 있길래 이런 이슈가 발생하는건지, 평소에는 어떻게 처리하는지 자바스크립트 엔진의 내부를 조금 살짝 깊게 한번 살펴보자.
자바스크립트의 특성과 자바스크립트로 작성된 코드의 실행을 담당하는 엔진의 특성에 대해 알아보자.
javascript는 웹을 구성하는 3대장 중 하나다. HTML 그리고 CSS와 함께 웹 페이지를 구성하는데 사용된다. HTML이 웹페이지의 UI 구성을 만드는데 사용되고 CSS가 스타일링을 입힌 다면, 그 둘을 유저와의 상호작용을 구현하는데 javascript를 사용한다.
초보자들이 배우기 쉽고 매우 자유로운 언어라고 한다.
자유롭다는 말은 문법이 간단하고 멀티-패러다임 언어로 명령형, 함수형, 객체지향형 언어등 다양하게 활용이 가능해서 그런것 같다.
또한 객체 기반의 언어지만 하지만 상속과 클래스라는 개념은 없다.
(이 부분은 추가 리서치 필요)
자바스크립트는 실행되는 환경에 따라 사용되는 엔진이 상이하기도 하다.
일반적으로 chrome에 내장된 chrome V8 엔진이 대표적이다.
V8엔진에 의해 실행되는 javascript는 compile(번역)과 interpreter(통역) 두개의 일련의 과정을 거쳐야한다.
여담으로 V8엔진에는 원래 interpreter가 없었지만 2017년 5.9버전이 나오면서 추가 되었다.
어쨋든 compile도 하고 interpret도 거쳐야 하는 자바스크립트는 컴파일언어라고 하기도,
인터프리터 언어라고 하기도 애매하다.
아무튼 compile과 interpreter 두 개의 과정을 거치는 것이 특징인 자바스크립트는 이 과정들을 머신코드로 해석되고 통역되어 브라우저에서 인식할 수 있게 된다.
또 다른 특징은 JIT(just-in-time)이라고 불리는 방식으로 위의 과정을 거치는데
말 그대로 브라우저가(대표적인 javascript가 실행되는 환경) javascript를 읽어 들이는 순간부터 compiler과 interpreter가 실행된다는 것이다.
그래도 가장 대표적인 특징은 V8엔진은 자바스크립트 코드를 단일 스레드, call stack에서 동기적으로 실행 한다.
이는 아마 V8엔진이 JIT 방식으로 compile과 interprete를 거쳐 머신 코드로 변환시키기 때문에 안정화 때문이지 않을까 하는 추측을 해본다.
자바스크립트 엔진, 대표적으로 V8은 자바스크립트 코드를 동기적으로 실행 한다고 했다.
그럼 동기적으로 실행한다는 것이 무슨 뜻일까? 엔진 내부를 살짝 들여다보면서 살펴보자.
동기적으로 어떤 작업들을 한다는 말은 작업들을 한 작업 한 작업씩 처리한다는 뜻이다.
조금 더 자세히 살펴보자.
V8엔진은 싱글 스레드인 하나의 call stack을 가지고 있다.
작업을 처리하는 공간인 call stack은 하나 밖에 없기 때문에 실행이 필요한 javascript 코드들은 이 하나의 call stack에 차곡 차곡 쌓인다.
만약 5개의 코드가 실행이 필요해 call stack에 차곡 차곡 쌓였다.
(실행 순서는 나중에 조금 더 자세히 살펴볼 것이다. 지금은 단지 동기적 처리 방식에 대해서만 설명)
이 5개의 작업들을 동기적으로 처리하면 다음과 같은 순서로 진행될 것이다.
1) 첫 번째 순서의 코드가 실행이 된다.
2) 이 때 다음 순서인 두 번째 순서의 코드는 실행이 되지 않는다. 그 대신 첫 번째 순서의 코드의 실행이 완료될 때 까지 대기하게 된다.
3) 일정 시간이 흐른 후 첫 번째 순서의 코드 실행이 완료되었다. 이제 두 번째 순서의 코드 실행이 시작된다.
4) 똑같이 세 번째 순서의 코드는 실행이 되지 않고 자신 앞 순서의 코드 실행이 완료 될 때까지 대기한다.
5) 쭉 반복
이렇게 작동하는 것이 동기적인 작동 방식인데 실행의 요청에 의해 작업이 시작되고 응답이 오면 작업을 완료하고 종료시킨다. 그 후 다음 작업에 대해 실행을 시작한다.
자바스크립트 엔진의 call stack은 이 방식으로 javascript 코드를 실행한다.
하지만 그 순서가 조금은 특이한데 바로 first-in, last-out, 즉 선입후출 방식으로 진행된다.
말로 먼저 설명하자면 함수 호출의 코드를 엔진이 읽어 들이면 call stack에 쌓는다.
근데 방금 호출된 함수 안에는 또 다른 함수의 호출이 있다.
그럼 안에서 호출된 함수에 대응하여 그 함수의 실행을 call stack에 쌓는데 이때는 처음 쌓인 call stack 위에 쌓인다.
안에서 호출된 함수 내부에는 또 다른 함수 호출이 없다. 그래서 엔진은 call stack에 쌓인
함수들을 실행하려 하는데 이 때 늦게 들어온 순서대로 코드실행을 진행한다.
말로하니 역시나 어렵다. 다음 예제로 살펴보자.
const funcOne =() => {
2️⃣console.log("No.1")
3️⃣funcTwo()
}
const funcTwo =() => {
4️⃣console.log("No.2")
5️⃣funcThree()
}
const funcThree =()=> {
6️⃣console.log("No.3")
}
1️⃣funcOne()
// 결과값
'No.1'
'No.2'
'No.3'
위 코드는 중첩된 함수에 대한 예제이다. 실제로 호출이 되면 call stack에 쌓이는 모습을 살펴보자.
위의 움짤처럼 쌓이고 위의 쌓인 순서대로 실행되고 실행이 완료되면 사라진다.
함수가 호출되어 call stack에 쌓이면 내부 코드를 순서대로 call stack에 쌓는걸 볼 수 있다.
그 후 함수 내부에 다른 함수의 호출이 존재하면 그 함수를 call stack에 쌓는데
그 함수가 실행이 완료되어 call stack에서 사라지기전에 외부함수의 실행도 완료되지 않는점을 명심하자.
javascrit 엔진 V8이 자바스크립트 코드를 실행하는 과정을 보면 정말 똑똑하다.
(실제로 공식 문서를 보면 매번 업데이트에 성능 향상과 최적화에 엄청 공을 들인다.)
성능적으로도 똑똑하지만 (컴퓨터도, 인터넷도 성능이 뛰어난 요즘시대이기에 처음 자바스크립트를 실행시켰을 때
나는 동기적으로 한번에 실행된다고 느꼈다. 하지만 정말 미묘하게 아~주 미묘하게 순서대로 실행되는 것이였다.) 브라우저애서 실행 된다는 점에서 JIT방식으로 실행을 하기 때문에 코드을 읽어 들어와 연산하여 인식하는 과정은 조금 불안정할 수 도 있다.
그렇기 때문에 동기적인 방식으로 실행하는 것 같다는 느낌이 든다.
순차대로 적은 코드들에 대해 순서를 보장해 주는 것이다.
그래서 자바스크립트 엔진을 어느 정도 (아주 조금이지만) 파해쳐보니
조금 더 효율적이고 나은 코드 작성을 준수해야 되겠다는 생각이 크게 든다.
(hoisting, execution context, lexical closure 등과 함께 날잡고 정리해야겠다.)
하지만 자바스크립트 엔진이 아무리 최적화하여 똑똑하게 실행을 처리한다고 한들 순서가 보장되지 못하는 경우가 발생하기도 한다. 바로 비동기 함수다.
간단하게 비동기 함수는 자바스크립트의 원칙, 실행 순서를 보장 받는다,를 깨고 비동기로 처리된다. 즉 순서를 보장받지 못하고 뒤로 밀리게 된다.
그럼 비동기 함수는 무엇이며 자바스크립트 엔진은 왜 이런식으로 처리하는 것일까?
대표적인 비동기 함수는 DOM 이벤트 API요청 setTimeout같은 내장 함수들이 있다.
setTimeout으로 대표되는 DOM API, fetch 등으로 대표되는 XMLHttpRequest 등의 AJAX 등이 바로 비동기로 처리되는 대표적인 함수들이다.
그런데 이들의 위치가 조금 생소하다. 이 형태를 javascript runtime의 모습으로 살펴보자.
자바스크립트 엔진에는 memory heap이라는 공간도 존재한다.
설명상으로는 변수와 객체에 대한 모든 메모리 할당이 이루어 지는 곳이라고 하는데
조금 더 리서치가 필요한 부분이다.
하지만 이번 자바스크립트의 코드 실행 방식에는 큰 영향을 주는 것 같지 않다. (그래서 넘어가겠다는 소리)
어찌돼었던 이런 자바스크립트의 실행환경을 보면 엔진 밖에 Web API들이 모여있는 곳이 있다.
자바스크립트 엔진이 제공하는 것이 아닌 브라우저에서 제공해주는 것이다.
다시 말해 이들은 다른 누군가에 의해 정의되고 브라우저에 내장된 내장함수라고 생각해도 무관한데 이들은 비동기 함수이고 브라우저에 위치하고 있다.
다른말로는 자바스크립트 엔진이 콜스텍에 쌓고 실행을 처리하는 함수는
우리가 자바스크립트라는 언어로 작성한 커스텀 함수다.
자바스크립트 엔진은 자바스크립트로 작성된 코드들의 실행을 담당한다.
물론 실행을 하기 위해 머신코드로 변환하는 작업을 거치긴 하지만 지금은 논외로 해보자.
자바스크립트로 작성된 코드는 순차적으로 call stack에 쌓이게 된다.
call stack에 쌓이는 순간은 바로 코드가 해당 코드를 실행시키도록 작성되어있을 때 이다.
즉 자바스크립트 엔진은 call stack에 코드를 실행하라고 요청을 보내고 그 요청이 call stack에 쌓이는 것이다.
그러면 call stack에서는 해당 코드에 대한 작업을 하고 완료되면 응답을 보내 call stack에서 빼버린다.
명심해야 할 것은 코드 작업의 실행과정은 요청과 응답 이라는 것이다.
하나의 코드 실행은 요청에 의해 작업이 시작되고 완료되어 응답이 오면 비로소 완료되어 사라지는 것이다.
만약 우리가 Web API 작업을 포함하는 함수를 자바스크립트로 작성하고 실행시킨다고 가정해보면
call stack에 쌓고 실행 요청을 보낸다.
함수 내부를 살펴보니 Web API를 실행해야하는데 자바스크립트 엔진내에 존재하는 함수가 아니다.
그러면 자바스크립트는 이 실행을 본인 내부에서 하지 않고 Web API가 있는곳에 그에 관련한 요청을 보낸다.
요청에 대한 응답으로 필요한 다른 응답에 대해 다른곳에 요청을 보내는 것이다.
그 요청을 비동기 함수의 callback함수와 같이 보낸다.
브라우저는 요청에 대한 응답으로 callback함수를 보내오는데 그 응답을 call stack에서 처리한다. 그러면 예제의 실행은 완료되는데 자바스크립트는 비동기 함수를 조금 특이하게 처리한다.
앞서 call stack에 요청된 Web API의 실행은 call stack에서 하지 않고 Web API에 위임한다고 했다.
이때 call stack에서는 해당 요청을 사라진다. 즉 해당 요청에 대한 응답을 본인이 처리하지 않기에 브라우저에 요청을 보내고 해당 작업에 대한 모든 정보를, 즉 callback을 call stack에서는 Web API쪽으로 함께 보내버리는 것이다.(위임)
그렇기 때문에 같이 보내진 콜백 함수는 순서를 보장 받기도 전에 다른 곳에 위임이 되어버린다.
하지만 브라우저는 Web API 실행 요청에 대한 응답으로 callback 함수를 보내고 그 함수를 다시 call stack에서 처리한다고 하지 않았는가?
브라우저의 응답으로 온 callback 함수를 실행하라고 call stack에 요청하고 쌓는다.
이때는 제대로 실행 순서를 보장받는다.
쉽게 말해 어린아이가 귤을 먹고 싶은데 (call stack에 요청) 껍질을 본인이 깔 수 없어 엄마한테 껍질을 까달라고 요청하고 (Web API 요청 with callback)
그 요청의 응답으로 알맹이가 돌아오고 (콜백) 그 알맹이를 먹을 수 있게 (call stack에서 실행) 되는 것이다.
그렇기 때문에 call stack은 본인이 수행할 수 있는 요청이 아니기에 브라우저에 껍대기를 까달라고 요청과 함께 비동기 함수안에 정의된 callback 함수도 함께 보낸다.
(API 함수에 대한 실행요청으로 실행하려 했지만 실행을 하지도 않고 다른곳으로 위임한다.
그리고 내부에 정의된 콜백함수는 순서를 배정받기도 전에 같이 보내지는 것이다.)
그 후 브라우저에서 껍대기를 까고 알맹이를 돌려주면 (callback 함수) 다시 call stack에서 실행을 요청하고 연산 후 응답으로 보내주면 실행을 완료하고 call stack에서 사라진다.
하지만 그 callback 함수가 call stack으로 들어가기전에 규칙이 있다.
Web API가 요청으로 응답을 보내주기까지 call stack은 기다리지 않고 다른 일을 순차적으로 동기적으로 처리한다.
아직 100개의 일이 call stack에서 처리되어야 한다고 가정해보자.
callback함수는 그 중간에 끼어들어 순서를 바꿔버릴까?
그렇게 되면 코드 실행은 엉창진창이 되버릴 것이다.
그래서 대신 callback queue라는 곳에서 기다린다. 일종의 유명한 식당에서 줄을 서며 기다리는 것이다. 이 callback queue에서 기다리가닥 들어온 순서대로 call stack으로 옮겨지는데
event loop라는 친구가 항상 call stack과 callback queue를 지켜보고 있다가 call stack이 비워지게 되면 callback queue에서 대기중인 함수 실행을 call stack을 보내 callback 함수를 실행하게 된다.
결국 call stack과 callback queue는 동기적으로 작동하는데 비동기 함수는 그 순서가 밀리는 것이다.
말로하니 역시 어렵다. 예제 코드를 작성하고 어떤 이동이 일어나는지 살펴보자.
let someVal = 1
let API_EXAMPLE = ()=> {
setTimeout(function() {
someVal + 10
},3000)
return someVal
}
let someFinalVal = API_EXAMPLE() + 100
console.log(someFinalVal)
콘솔창에 어떤 결과값이 찍힐까? 111? 아니면 101?
진행과정을 보고 한번 살펴보자.
setTimeout
이라는 Web API는 비동기 방식으로 처리되었기 때문에
someVal
1에 10을 더하지 못한채 API_EXAMPLE
함수 실행은 완료되었다.
그래서 결국 콘솔에 찍힐 someFinalVal
의 값은 1+100인 101이 되는 것이다.
이렇게 자바스크립트 엔진은 비동기 함수를 실행시킬 때 그 안에 작성된 콜백 함수를 Web API를 처리하는 곳으로 보내고 비동기 함수가 실행되는 동안 call stack에 보장된 순서대로 다음 함수들을 처리한다.
그 사이 비동기 함수가 연산을 마치면 같이 보내진 콜백 함수를 callback queue로 보내고
event loop가 지켜보고 있다가 call stack이 비워지면 콜백 함수를 call stack에 다시 밀어넣고 보장된 순서대로 실행 시키는 것이 바로 자바스크립트가 비동기 함수를 처리하는 방법이다.
비동기 함수가 어떻게 처리되는지 알아봤는데 왜 자바스크립트 엔진은 헷갈리게 브라우저 내장 함수들을 이렇게 처리하는지 이유에 대해서 알아보자.
이유는 간단하게 blocking script를 방지하기 위해서다.
위의 예제에서 setTimeout라는 Web API를 사용했는데
이 함수는 지정된 시간이 지난 후 실행 되는 대표적인 비동기 함수다.
예제에 이 함수를 사용한 이유는 fetch
나 axios
등으로 대표되는
서버와의 통신으로 데이터를 받아오는데 사용되는 Web API를 구현하기 위해서다.
만약 통신으로 데이터를 받아오는데 위 예제처럼 3초가 아니라 10분이 걸린다고 가정해보자.
그리고 이런 함수를 비동기가 아닌 동기로 처리한다고 가정해보자.
그러면 10분동안 아무것도 화면에 나오지 않을 것이다.
만약 통신으로 뉴스의 사진을 받아온다고 가정한다면 통신 다음에 실행되어야 할 코드들이
실행을 계속 기다리게 된다.
이것이 바로 blocking script다.
자바스크립트는 동기로 코드를 실행시키는데 한 코드에 소요 되는 시간이 너무 길기 때문에
그 코드 이후에 실행으로 순서를 부여받은 코드들은 10분을 더 기다려야 실행이 된다.
하지만 만약 비동기로 처리한다면 어떨까?
화면에는 사진만 나오지 않고 글은 보일 것이다.
그리고 유저는 10분뒤 사진을 받아볼 수 있을 것이다.
AJAX 요청이, 대표적으로, 비동기로 처리되는 이유는 웹 페이지에 필요한 리소스들을 대부분 서버로부터 전송받아 viw에 보여주기 때문이다.
그런데 이런 리소스들 때문에 스크립트가 막힌다면 페이지 자체를 보여줄 수 없기 때문이다.
이로 인해 발생하는 이슈는 무엇일까?
바로 위 예제와 같다.
만약 서버와의 통신으로 데이터를 받아와서 그 값을 재연산해서 최종값으로 사용하려 한다면
위의 예제처럼 "+10"이라는 재연산을 하지 못한채 값을 사용하게 된다.
그러면 어떻게 해야할까?
바로 Promise를 사용하는 것이다.
Promise는 비동기 함수를 동기적으로 처리할 수 있게 해주는 아주 대단한 친구다.
Promise에 대한 정리는 다음에 하도록 하겠다.
인터넷에서 재밌는 글을 읽었는데 바로 Job Queue에 관한 것이다.
먼저 다시 비동기 함수 작동방식을 살펴보면,
비동기 함수는 본인의 함수를 실행하고 콜백으로 넘겨진 콜백함수를 callback queue로 보낸다.
그리고 event loop가 call stack을 지켜보고 있다가 다 비워지면 callback queue에서
first-in, first-out 방식으로 call stack에 밀어 넣는다.
즉 다른 일반 함수의 실행이 모두 끝나야 비동기 함수가 실행 순서를 배부받고 보장받는 것인데
비동기 함수는 call stack 맨 끝부분에 추가된다고 봐도 무방하다.
그런데 이 job queue라는 재밌는 녀석은 비동기 함수한테 조금 더 빠른 순서를 제공한다.
물론 조건이 필요한데 그 조건은 비동기 함수가 실행되는 현재 함수가 끝나기전에 비동기 함수의 실행이 완료되면 현재 함수의 실행이 완료된 후 바로 실행되는 순서를 제공받는다.
다시 말해 다른 일반 함수들의 실행이 끝나 call stack이 비워지기 전에 순서를 제공받는 것이다.
참고할께요 감사합니다.