웹 개발자로 년차가 쌓이고 있는 현 시점에서 자바스크립트의 내부 동작을 이해할 필요가 있었다. 자바스크립트의 내부에서 어떤식으로 돌아가는지에 대한 내용들을 파악해보고 참고한 내용들을 참고하여 작성하는 글이다.
🪄자바스크립트의 변화
과거 웹 페이지의 동작만을 위해 동작하던 자바스크립트는 현대에 와서 많은 변화가 있었는데, 더 이상 웹 페이지만을 위해 존재하지 않는다. 2008년 구글에서 V8엔진을 사용하여 크롬을 출시한다. 이를 시작으로 2009년 노드가 탄생하였고, 서버와 클라이언트, 어플리케이션 개발 등 다양한 분야에서 사용되게 된다.
🔥자바스크립트 엔진
V8은 자바스크립트의 엔진이다. 크롬 브라우저의 엔진이면서, Node.js의 기반이다. 싱글스레드로 동작하면서 메모리힙과 콜스택으로 구성되어 있다.
메모리 힙(Memory Heap): 원시타입(Primitive type), 객체(Array, Object, Function 등)타입이 선언되면 메모리힙에 할당된다. 사용이 끝나면 자동으로 해제(Garbage collection)된다. 콜스택(Call Stack): 실행해야 할 코드가 쌓인다.
🧙Call Stack
실행할 코드가 쌓인다고 가볍게 설명했는데, 더 자세히 알아보자. 일단 여기서 이름 자체에 Stack이라는 키워드로 어느정도 동작을 유추할 수 있다. 후입선출를 가진 스택은 마지막에 쌓인 데이터가 먼저 출력되는 방식이다. 요청이 들어올 때마다 이 콜스택이라는곳에 쌓아두는데, 아래와 같은 소스가 있다면 어떻게 쌓이고 동작할까?
functiontenSum(x){
return x + 10;
}
functionprintSum(x){
const sum = tenSum(x);
console.log(sum);
}
printSum(5);
이후 현재시간의 1초뒤의 시간을 wakeUpTime에 작성하여 while문에서 시간으로 체크한다.
1초동안 강제로 동기화를 시켜서 페이지가 살짝 멈추게 된다.
이후 단순한 console.log들을 출력한다.
예상한 결과가 나오길 빌면서 아래 출력 결과를 공유한다.
setTimeout메소드가 2번째에 있고 동작시간을 0으로 처리했음에도 콜백안에 있는 console.log는 가장 나중에 출력되고 있는걸 볼 수 있다. 심지어 4번째 while문에서 강제로 블로킹 처리를 해서 페이지가 멈추는 현상까지 발생는데 어떻게 이런 결과가 나왔을까? 이런 현상에 대해서는 이벤트 루프, 테스트 큐에 대해 알아야한다.
먼저 테스크 큐는 콜백 함수들이 대기하는 큐 형태이다. Queue는 선입선출 구조로 먼저 들어온 것들이 먼저 나간다. 위 예제의 실제 동작을 설명하자면 아래와 같다.
콜스택에 console,log("start")가 push되고 출력되면서 pop된다.
setTimeout이 콜스택에 push되고 콜백 내용이 테스크 큐에 등록되면서 pop된다.
0ms가 지나고 이벤트 큐는 콜 스택이 비워질때까지 기다리면서 콜백을 실행시킬 준비를 한다.
wakeUpTime의 변수가 메모리 힙에 올라가고 이후부턴 콜스택들이 동작된다...
위와 같은 비동기 처리나 I/O의 작업이 자바스크립트에서 발생하면 콜백함수가 콜스택에서 처리되는게 아니라 별도의 테스크 큐라는 곳에 쌓였다가 이벤트 루프에 의해 실행되기 때문에 뜻밖의 결과를 얻을 수 있다.
⭐브라우저의 내부 환경
자바스크립트의 엔진을 구동하는 환경의 브라우저의 내부 모습은 아래와 같다.
그림에서도 볼 수 있듯이 개발하면서 자주 사용했던 Ajax, setTimeout과 같은 비동기 처리들은 자바스크립트가 해주는게 아니라 Web API영역에 따로 정의되어 있다. Node.JS에서는 동시성을 위해 libuv라이브러리를 도입했는데, 이 libuv라이브러리가 이벤트 루프를 제공한다.
정리하자면 비동기 작업을 하게되면 Node.js의 API가 호출되고, 그러면서 작성된 콜백 함수들은 이벤트 루프에 의해 관리가 되고 실행이 된다.
웹 개발을 하다보면 서버에서도 유효성 검증을 필수로 하지만 프론트에서 1차적으로 필터링을 해주면 아무래도 서버에 부하도 적어지고 데이터 타입등 유효하지 않은 데이터를 걸러낼수 있어서, 자바스크립트를 통해 필수적으로 ajax나 submit 처리 전에 데이터 검사를 하게 됩니다.
문자열 검사하기
문자열을 검사하고 싶으면 생각보다 굉장히 간단하게 검사할 수 있습니다.
기존에는 undefined, null, 공백값등을 체크하는 조건문을 길게 쓰기 싫어서 함수를 만들어서 적용하는 방식으로 사용하거나 조건문에 길게 나열하곤 했었는데, 그 모습은 아래와 같습니다.
let text;
if(text === undefined || text === null || text === ''){
alert("문자열이 비었습니다.");
}
하지만 실상 빈값에 대한 유효성 검증은 변수에 !(not 연산자) 하나만 붙여주면 됩니다.
NOT연산자 하나를 통한 유효성 검증
let text = '';
if(!text){
alert("문자열이 비었습니다.");
}
!undefined//true
!null//true
!''//true
!0//true
!NaN//true
함수 test를 호출함으로써 "hi"라는 문자열이 정상적으로 반환되어서 콘솔에 hi가 잘 찍히는걸 볼 수 있습니다.
실질적으로 정의된 test함수는 출력하는 콘솔로그보다 아래에 위치하지만 신기하게도 동작하는걸 볼 수 있는데 이러한 동작을 호이스팅이라고 합니다.
변수와 함수간에 동작이 조금 다른데, 이번엔 변수 동작부분을 확인해보겠습니다.
변수의 호이스팅 동작
var a = "테스트입니다~";
functiontest(){
console.log("a1 >",a);
var a = "테스트 함수에서 a변수를 재정의합니다!";
console.log("a2 >",a);
}
test();
이번에 작성한 코드의 결과는 어떨까요? 저도 이부분에 처음에 당황을 했습니다.
결과는 아래와 같습니다.
예상한 결과와 같나요?
제가 처음 예상했던 답은 "테스트입니다~"가 먼저 출력되고, 재정의되었다는 문자열이 출력 될것이라고 생각했지만, 호이스팅으로 인해 전혀 다른 결과가 나왔습니다.
아래는 호이스팅 방식을 기반으로 해석한 코드입니다.
var a = "테스트입니다~";
functiontest(){
var a;
console.log("a1 >",a); //undefined
a = "테스트 함수에서 a변수를 재정의합니다!";
console.log("a2 >",a); //테스트 함수에서 a변수를 재정의합니다!
}
test();
해석한 코드와 기존 코드의 차이가 보이나요?
호이스팅으로 인해 test함수 최상단에 a라는 변수가 선언되었고, 콘솔로그에서 출력이 되면서 undefined라는 결과물이 나왔고, 그 이후 대입연산자를 통해 재정의라는 문자열이 대입된 것을 볼 수 있습니다.
이처럼 블록 구문(함수) 최상단에 변수 또는 함수명인 식별자를 최상단으로 끌어올리는 효과를 보여주는 것을 호이스팅이라고 부릅니다.
복잡한 호이스팅 동작과 전역 스코프
이번엔 복잡해진 호이스팅을 확인해보겠습니다.
var a = 1;
functionouter () {
functioninner () {
console.log(a);
var a = 3;
console.log(a);
var a;
console.log(a);
var a = 5;
console.log(a);
}
inner();
console.log(a);
}
outer();
console.log(a);
복잡한 결과물
복잡하지만 차근차근 호이스팅을 분석해보겠습니다.
var a;
a = 1;
functionouter () {
functioninner () {
var a;
var a;
var a;
console.log(a); //undefined
a = 3;
console.log(a); //3console.log(a); //3
a = 5;
console.log(a); //5
}
inner();
//내부에 a변수가 없으므로 외부 스코프영역에서 a가 존재하는지 찾음console.log(a); //1
}
outer();
console.log(a); //1
어떤가요? 분석구문과 실제 결과와 동일한가요?
내부 스코프에 해당 변수의 선언된게 없으면 외부 스코프에서 해당 변수가 선언되었는지 확인을 합니다.
아우터부분의 a에서 그래서 1이 찍히는걸 볼 수 있습니다.
정리
다만, 변수와 함수는 또 다른 차이점이 있습니다.
- 변수는 변수명만 최상단에 정의 된 후 선언 위치에서 값이 대입되는 모습을 보여줍니다.
- 함수는 정의부 자체와 함께 최상단에 선언되는 것을 볼 수 있습니다.
이러한 차이점때문에 함수는 처음에 호출하여도 정의부가 잘 동작 후 출력되는것을 볼 수 있엇고, 변수는 최초 선언만 되었기 때문에, 결과 호출시 undefined와 같은 결과를 만날 수 있습니다.
다만, 함수 동작의 호이스팅에서 함수 정의 방법에 따라 달라는 결과를 볼 수 있습니다.
다양한 함수 표현방법의 호이스팅 결과
console.log(test1());
console.log(test2());
console.log(test3());
functiontest1(){
return"test1";
}
var test2 = function(){
return"test2";
}
var test3 = () => {
return"test3";
}
function 키워드를 통해 함수를 정의하지만 변수에 함수를 대입하여 함수의 동작을 처리할 수도 있고, 화살표 함수를 통해 표현할 수도 있습니다.
다만 호이스팅 처리방식의 변수와 함수 표현식의 차이로 인해 test2, test3는 정상적으로 동작하지 않습니다.
아래는 동작 결과입니다.
초점을 동작하는 행위에 두지 않는다면 당연한 결과일수도 있습니다. (변수로 시작했는지, 함수로 시작했는지)
상단에 정의된 내용까지 호이스팅하는 동작은 function키워드로 정의한 부분만 동작하는 것을 볼 수 있습니다.