성장, 그리고 노력

부족하더라도 어제보다 더 잘해지자. 노력은 절대 배신하지 않는다.

Javascript

자바스크립트 - 스코프(Scope)와 메모리 모델

제이콥(JACOB) 2019. 12. 25. 07:32

(입사 초기에 사내 위키에 올렸던 내용이나, 지식 공유 차원에서 쉽게 정리하여 다시 공유한다.)

 

바인딩(binding)

일반적으로 A를 B에 명시적 또는 암시적인 선언에 의해 매핑하는 것. 프로그래밍에서는 개체(데이터와 코드)들을 연결(할당)하는 것이라고 생각하면 된다. 대부분의 C 기반 언어에서 변수는 선언한 위치에서 바로 생성되지만, 자바스크립트의 경우 변수 선언 방식에 따라 변수 생성 위치가 다르다.

 

동적 바인딩

런타임(runtime) 시에 발생하며, 실행 중인 경우 변경될 수도 있다.

 

정적 바인딩

컴파일시에 발생한다. 다시 말해 프로그램이 메모리에 로드될 때, 메모리 셀에 바인딩된다.

 


Scope

 자바스크립트 프로그래밍에서 가장 까다로운 부분이 아닐까 싶다. 나 역시 자바스크립트를 맨 처음 배울 때 이 부분이 어려워서 쓱 보고 넘겼던 기억이 있다. 하지만 겁먹지 말자!

var 선언과 호이스팅(hoisting)

 최근 코드는 var로 선언된 모습을 거의 찾아볼 수 없지만, 불과 몇 년 전까지만 해도 var로 선언된 코드를 많이 볼 수 있었다. var를 이용하여 변수를 선언하면 선언한 위치와 상관없이 함수의 맨 위(함수 바깥에 선언되어 있다면, 전역 스코프)에 있는 것처럼 처리된다. 이것을 호이스팅(hoisting)이라고 한다.

function scopeTest(condition) {
  console.log("1", value); // value = undefined
  if (condition) {
    var value = "jacob";
    console.log("2", value);
  } else {
    console.log("3", value); // value = undefined
  }
  console.log("4", value); // value = undefined
}

scopeTest(false);

 간단한 예제가 있다. 

자바스크립트를 잘 모른다면, value라는 변수는 condition이 참일 경우에 생성된다고 생각할 것이다. 그리고는 value에 접근하려고 했을 때 아래와 같은 오류가 나길 바랬지만, 그렇지 않다.

 오히려 value라는 변수는 이미 생성되어 있고 초기화하지 않았으므로 undefined 값이 할당되어 있다. 이러한 자바스크립트의 독특한(?) 동작은 개발자가 예상치 못한 버그를 생성할 가능성이 커진다. 그래서 ECMAScript 6에서 블록 레벨 스코프가 도입되었다(사실 같은 의미로 렉시컬 스코프(lexical scope)가 더 많이 쓰이는 말이니, 이 말로 알아두자).  

 

Lexical Scope

 렉시컬 스코프란, 주어진 블록 스코프 밖에서는 접근할 수 없는 바인딩을 선언하는 것이다. 함수 내부 또는 블록 내부({})에서 생성되며, 많은 C 기반 언어에서 렉시컬 스코프로 동작하기도 한다.

 

let 선언

let 선언은 var의 문법과 같다. 다만 let으로 대체된 변수의 스코프는 현재의 코드 블록으로 제한된다. 

function scopeTest(condition) {
  console.log("1", value); // ReferenceError: value is not defined!
  if (condition) {
    let value = "jacob"; // let 으로 선언
    console.log("2", value);
  } else {
    console.log("3", value); 
  }
  console.log("4", value); 
}

scopeTest(false);

  위 코드를 실행시켜 보면, 이제는 에러가 발생한다. if문 블록 안에서 let으로 선언될 value에 대해 그 밖 스코프에서 접근하려고 하기 때문이다. 즉 변수는 호이스팅되지 않고 if문 바깥 블록에서는 접근할 수 없다.

 

const 선언

 let과 기본 문법은 유사하며, const 선언으로 바인딩할 수 있다. 다만 const로 선언된 바인딩은 상수(constants)로 간주되어 변경할 수 없다. 그리고 선언 시에 초기화를 반드시 해줘야 한다.

const value1; // SyntaxError: Missing initializer in const declaration

const value2 = "babo"; // Works well!

 또한 let과 동일하게 블록 바깥에서는 접근할 수 없다. 하지만 let 선언과 다른 점은 값에 대한 재할당이 불가능하다. 

let name = "name";
name = "jacob"; // nmae = jacob

const nickName = "nickName";
nickName = "jacob"; // TypeError: Assignment to constant variable.

 

const로 객체 선언

 const 선언은 바인딩을 변경하지 못하도록 하는 것이지, 바인딩된 값의 변경을 막는 건 아니다. 다시 말해 객체를 const로 선언하면 객체 자체를 재할당하는 건 불가능하지만, 객체가 가진 값은 수정할 수 있다는 점이다.

const person = {
  name: "name"
};

person.name = "jacob"; // Works well!

person = {
    name = 'jacob' // SyntaxError: Invalid shorthand property initializer
};

 const는 바인딩된 값의 수정을 막는 것이 아니라, 바인딩의 수정을 막는다는 점을 기억해야 한다.

 

TDZ (Temporal Dead Zone)

 let과 const로 선언 시에 발생하며, 한국어로 번역하자면 임시 접근 불가 구역이다. 다만 ECMAScript 스펙에는 명시되어 있지는 않지만, 자바스크립트 커뮤니티에서 "왜 선언 이전에는 접근할 수 없는지?"에 대한 대답으로 종종 볼 수 있는 단어이다. TDZ는 간단히 말해, 블록 스코프의 시작부터 변수가 처음 선언되는 지점까지라고 생각하면 된다.

function tdzEx() {
  // NOTE: TDZ
  console.log("1", value); // ReferenceError: value is not defined

  let value = "jacob";

  console.log("2", value); // 2 jacob
}

tdzEx();

 코드를 해석할 때 자바스크립트 엔진은 다음 블록을 조사하고 그 블록에서 변수 선언을 발견하면, 그 선언을 (var의 경우) 함수 최상단이나 전역 스코프에 호이스팅하거나, (let, const의 경우) TDZ 내에 배치한다. TDZ 안에 있는 변수에 접근하면, 런타임 에러가 발생하며, 변수 선언이 실행된 후에만 TDZ에서 변수가 제거되며 안전하게 사용 가능하다.

 

전역 블록 바인딩

 전역 스코프에서 let과 const는 var와는 다르게 동작한다. var를 전역 스코프에서 사용하면, 새로운 전역 변수가 생성되어 기존 전역 변수를 덮어쓰게 된다(존재하지 않는 전역 변수라면 새로 생성).

 만약 전역 객체를 이용해야 한다면, 여전히 var를 사용할 수 있다. 하지만 그런 경우가 아니라면, 전역 객체 프로퍼티를 생성하지 말고 let이나 const를 사용하고 바인딩을 생성해서 사용하는 편이 더 안전하다. 

 

let과 const 뭘 사용하지?

 그냥 단순하게 따져봤을 때는 뭐든 let으로 선언하고, 변하지 않는 상수일 때만 const로 선언하는 편이 좋은 것처럼 보인 다. 하지만 많은 오픈 소스 코드를 보거나 실제 실무에서는 다르게 접근한다.

 const를 기본으로 사용하고 변수 값을 변경해야 할 때만 let을 사용하는 것이다. 예상치 못한 변경을 막고 불변성을 보장하며, 특정 타입 에러를 예방하기 위해서 이다. 

 


더 나아가기

바인딩에 대한 메모리는 어떻게 변하는지 궁금한 사람만 아래 내용을 참고하면 된다. 

 

Javascript Primitives 타입의 선언과 할당

let name = 'jacob';

위 코드가 실행될 때, 자바스크립트는 과연 어떻게 행동할까?

 

1. 변수의 고유 식별자(Identifier, 여기서는 name)를 만든다.

2. 런타임 시에 메모리에 주소를 할당한다.

3. 할당된 주소에 값(여기서는 jacob)을 저장한다.

 

이것을 그림으로 표현하면 아래와 같다. 

메모리 주소값은 구분하기 쉽게 표현해 놨다.

let name = 'jacob';

let newName = name;

그렇다면 할당된 변수를 다른 변수에 할당하면 어떻게 될까?

name의 주소 값은 "0011AA"이므로 newName도 'jacob'을 보여하고 있는 메모리 주소인 "0011AA"이다.

그렇다면 아래와 같이 실행하면 newName을 어떤 주소를 가리킬까?

let name = 'jacob';

let newName = name;
newName = name + " hi";

자바스크립트의 기본 데이터 유형은 변경할 수 없으므로 메모리에 새 주소를 할당하고, 새 값(여기서는 jacob hi)을 저장한다.

 

Javascript의 메모리 모델: CallStack & Heap

콜스텍(CALL STACK)은 함수 호출 외에 프리미티브 타입이 저장되는 위치이다. 반면 힙(HEAP)은 그 외의 오브젝트 타입(배열과 객체 등)이 저장되는 위치이다.

변수의 값을 표시하기 위해 메모지 주소를 추상화, 실제로 변수는 메모지 수로를 가리키고 값을 보유하고 있다.

 위 그림과 같이 프리미티브 타입은 콜스텍에 저장된다. 그렇다면 Object 타입은 어떻게 저장될까?

let objectArray = [];

먼저 글로 나열해 보자면 아래와 같다.

 

1. 변수의 고유 식별자("objectArray")를 만든다.

2. 런타임 시에 메모리에 주소를 할당한다.

3. 런타임시에 힙에 할당된 메모리 주소의 값을 저장한다.

4. 힙의 메모리 주소는 할당된 값(여기서는 빈 배열[])을 저장한다.

 

그림으로 표현하면 아래와 같다.

당연히 배열에 값을 추가하거나 빼(pop)는 것도 가능하다.

let objectArray = [];

objectArray.push(1);
objectArray.push(2);
objectArray.push(3);
objectArray.push(4);
objectArray.pop();

 

자, 이제 기본기가 갖춰졌으니 위에서 다뤘던 몇 가지 논점에 대해 다시 설명해 보자.

 

Q) const 선언된 프리미티브 타입에 대해 재할당을 하면 왜 오류가 발생할까?

A) 프리미티브 타입은 메모리 상에 새로운 주소를 할당하고 값이 저장된다. 그런데 let과 달리 const로 선언한다면 새로운 메모리 주소를 할당하는 것을 허용하지 않기 때문에 오류가 발생한다.

const name = 'jacob';

name = name + " hi";

Q) const로 선언된 Object 타입이 가진 값을 수정하는 것은 왜 가능할까?

A) 위에서 배열을 다시 한번 봐보자. 분명 Non-primitive 타입(Object)이지만, push를 해도 objectArray의 주소 값은 변하지 않고 있다. 여전히 "0044DD"를 가리키며, 값으로는 힙 메모리 주소 "0055EE"를 가리키고 있다. 따라서 const로 선언했을지라도 새로운 주소를 할당하는 것이 아니기 때문에 수정할 수 있다.

Q) const로 선언된 Object 타입에 재할당 하는 것은 왜 불가능할까?

const objectArray = [1,2,3];

objectArray = 3; // TypeError

objectArray = [1]; // TypeError

A) 만약 objectArray에 프리미티브 타입을 재할당 하려고 한다면, 콜스텍에 메모리 주소가 할당되고 값이 저장되며, 그것을 objectArray에 할당하려고 한다면 const이기 때문에 재할당을 막아 허용되지 않느다.

 

objectArray = 3; 이 불가능한 이유
objectArray = [1]; 이 불가능한 이유

 

반응형