자바스크립트의 데이터 타입은 크게 2가지가 존재한다.

기본형 데이터는 Stack에, 참조형 데이터는 heap 메모리에 저장된다.

위 예시를 보면 기본형 데이터인 name은 스택에 바로 저장되었지만 object인 person과 dog 그리고 함수 getOwner는 식별자만 스택에, 변수 값은 힙에 저장된 것을 볼 수 있다. 만약 프로그램에서 person을 호출하면 컴퓨터는 스택에서 식별자 person을 찾고, 그것이 가리키는 힙의 주소로 접근하여 person이라는 오브젝트에 담긴 값을 불러온다. 즉, 기본형 데이터와 다르게 참조형 데이터는 값에 접근할 때 한 번 더 '참조'를 해야한다.
이렇게 기본형 데이터와 참조형 데이터의 저장 방식의 근본적 차이는 자바스크립트가 데이터를 다루는데 있어 차이점을 만들게 된다.
메모리 공간 절약을 위하여 기본형 데이터가 같은 값을 가진 경우, 이 값을 재활용하여 다른 변수에 할당할 수 있다. 따라서 기본형의 값은 메모리 안에 단 하나의 값만 존재하게 된다. 예를 들어 위 그림에서 name에 이미 'John' 이라는 문자열이 할당되어 있다. name2를 만들어 name2 = 'John'으로 할당하면 name과 name2는 메모리의 동일한 주소를 값으로 가지고 있다. 만약 이후 name2에 'Jane'이라는 다른 값을 넣게 되면, 그제서야 메모리에는 'Jane'을 위한 공간이 생기고 이곳의 주소를 name2가 가지게 된다.
그러나 참조형 데이터의 경우 조금 다르게 동작한다. 위 그림에서 person이라는 객체를 선언과 동시에 값을 할당하였다. 이후 newPerson이라는 새 객체에 그 값을 대입하였다. 그럼 newPerson은 Person과 같은 메모리 영역을 참조하게 된다. newPerson.name에 'Jane'을 넣으면 어떻게 될까? 기본형 데이터와 달리 newPerson을 위한 새로운 메모리 공간이 할당되지 않는다. 대신 'Jane'을 위한 새 공간이 만들어진 후, 이 값을 newPerson이 참조하는 힙 속 name이 새로 참조하게 된다. 여전히 person과 newPerson이 참조하는 메모리 주소는 동일하다. 즉, person을 복제한 newPerson의 값을 바꾸었는데 원본인 person의 값도 함께 변경된 것이다.
이러한 차이점 때문에 두 데이터 타입의 차이를 이해하지 못하면 개발자가 의도하지 않은 일들이 벌어지기 쉽다.
[Felix Gerschau] JavaScript's Memory Management Explained
[Medium] Memory Management in JavaScript
코드 실행 문맥(조건, 환경)으로 크게 네 가지, 전역공간, 함수, eval, moudle에 대하여 실행 컨텍스트가 존재한다. 조금 더 단순화하면, 함수를 실행하는 환경 조건을 모아둔 객체라고 생각할 수 있다. 이러한 객체는 함수가 호출될 때마다 스택에 저장된다. 이것이 바로 Call Stack(Execution Stack)이다. 프로그램이 동작하기 시작하면 먼저 전역 컨택스트(Global Execution Context, GEC)가 콜 스택 하단에 저장된다. 이후 호출되는 함수는 그 위에 context가 생성되어 쌓이고, 함수가 종료되어 반환하면 그 context가 pop되어 사라진다.
실행 컨텍스트는 크게 3가지 정보를 저장한다. 식별자 정보를 저장하는 Variable Environment, 식별자의 데이터(값)을 추적하는 Lexical Environment, 그리고 This Binding이다. Variable Environment는 식별자의 정보만 저장하기 때문에 실행 중에 변화가 반영되지 않지만 Lexical Environment에서는 선언, 대입 등 식별자가 가진 값이 변경될 때마다 그 변화가 반영된다. Lexical Environment에는 또 다시 2가지 정보가 저장된다.
var name = "Joe";
let input = "Hey!!!";
function broadcast(message) {
return `${name} says ${message}`;
}
console.log(broadcast(input));
위의 짧은 코드를 실행하는 모습을 살펴보자. 우선 hoisting으로 변수 name, input와 함수 broadcast가 전역 컨텍스트에 선언된다. var로 선언한 name에는 초기값인 undefined가 들어가지만 let으로 선언된 input에는 어떠한 초기값도 지정되지 않는다.
이제 코드가 실행되면서 차례대로 "Joe"와 "Hey!!!"라는 문자열을 각각 name과 input에 넣어준다. console문을 만나면 인자를 바로 evaluate 해주어야한다. 그 인자인 broadcast가 호출되면서 컨택스트가 새로 생성되어 스택에 쌓인다. broadcast의 인자를 수집한다. message라는 식별자가 만들어지고, 호출시 입력된 값(input의 값인 "Hey!!!")이 저장된다. return 문에서 변수 name을 만나게 된다. 이 변수를 Environment Record에 찾아도 값이 없다. Outer Environment Referrence를 통하여 전역 컨택스트를 참조한다. "Joe"라는 값이 선언되어 있고, 이것을 가져와 사용한다. broadcast 함수는 "Joe says Hey!!!"라는 문자열을 반환하고 종료된다. 컨택스트도 함께 pop되어 사라지고 다시 전역 컨택스트로 돌아온다. console에 기다리던 인자가 전달되고 콘솔에는 "Joe says Hey!!!"라는 문자열이 찍힌다.
이 짧은 코드의 실행이 이렇게 복잡하게 이루어진다. 이때의 컨택스트를 구조화하면 다음과 같이 된다.
GlobalExecutionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
DeclarativeEnvironmentRecord: {
input: "Hey!!!",
broadcast: {
LocalExecutionContext: {
LexicalEnvironment: {
EnvironmentRecord: {
DeclarativeEnvironmentRecord: {
message: "Hey!!!",
},
ObjectEnvironmentRecord: {
arguments: {
0: message,
length: 1
}
this: < ref.to window obj. >
},
},
OuterEnv: < ref.to LexicalEnvironment of the GlobalExecutionContext > ,
},
},
},
},
ObjectEnvironmentRecord: {
window: < ref.to Global obj. > ,
this: < ref.to window Obj. > ,
},
OuterEnv: < null > ,
},
},
VariableEnvironment: {
EnvironmentRecord: {
DeclarativeEnvironmentRecord: {
name: "Joe"
},
},
},
}