General11분 읽기

모던 자바스크립트 딥다이브 13장 ~ 15장 스코프, 전역 변수,

3

오늘도 야무지게 이어서 가보자... 오늘 범위는 그래도 가볍게 정리해보자.

13~15장은 스코프, 전역 변수, let/const다.
이 세 장은 따로 읽기보다 묶어서 읽는 게 맞다. 13장에서 "스코프가 뭔지, var가 왜 문제인지"를 깔고, 14장에서 "전역 변수가 왜 골치인지"를 보여주고, 15장에서 "그래서 let/const가 나왔다"로 마무리되는 흐름이다.

자바 하던 입장에서는 "변수에 스코프가 있다"는 개념 자체가 새롭진 않다. 자바도 블록 스코프니까. 근데 자바스크립트의 var가 만들어내는 기괴한 동작들을 보고 나서야 왜 이 세 장을 묶어서 다루는지 이해가 갔다.


13장. 스코프 — 변수가 보이는 범위

스코프란

스코프는 식별자(변수, 함수 등)가 유효한 범위다. 자바에서 메서드 안에 선언한 지역 변수가 메서드 밖에서 안 보이는 것과 같은 개념이다.

근데 자바스크립트는 같은 이름의 변수가 다른 스코프에 존재할 수 있고, 엔진이 어떤 변수를 참조할지 결정하는 규칙이 있다. 이걸 식별자 결정(identifier resolution) 이라고 부른다.

hljs js
var x = 'global';

function foo() {
  var x = 'local';
  console.log(x);  // 'local'
}

foo();
console.log(x);  // 'global'

자바에서도 당연한 동작이긴 한데, 자바스크립트에서는 var의 특성 때문에 이게 당연하지 않은 상황이 곧 나온다.

전역 스코프와 지역 스코프

전역에 선언하면 전역 스코프, 함수 안에 선언하면 지역 스코프. 여기까진 직관적이다.

지역 스코프는 함수가 만든다. 함수 안에서 선언한 변수는 함수 밖에서 참조할 수 없다. 자바의 메서드 지역 변수와 같은 원리다.

스코프 체인

함수가 중첩되면 스코프도 중첩된다. 이 중첩 구조를 스코프 체인이라고 부른다.

hljs js
var x = 'global';

function outer() {
  var y = 'outer';
  
  function inner() {
    var z = 'inner';
    console.log(x);  // 'global' — 여기서 찾고, 없으면 위로
    console.log(y);  // 'outer'  — outer 스코프에서 발견
    console.log(z);  // 'inner'  — 자기 스코프에서 발견
  }
  
  inner();
}

outer();

변수를 참조하면 엔진은 현재 스코프에서 먼저 찾고, 없으면 상위 스코프로 올라가며 찾는다. 전역 스코프까지 올라가도 없으면 ReferenceError. 이 검색이 안에서 바깥으로 단방향이라는 게 핵심이다. 바깥 스코프에서 안쪽 스코프의 변수를 참조하는 건 안 된다.

자바에서도 내부 클래스가 외부 클래스의 필드에 접근할 수 있는 것과 비슷한 구조인데, 자바스크립트는 함수 중첩이 훨씬 자유롭다 보니 이 체인이 깊어지는 경우가 많다.

함수 레벨 스코프 — var의 첫 번째 문제

여기서부터 자바 뇌가 또 깨진다.

자바는 블록 레벨 스코프다. if, for, while{}로 감싸진 블록이 스코프를 만든다. 근데 var함수 레벨 스코프만 인정한다. 오직 함수의 코드 블록만이 지역 스코프를 만든다.

hljs js
var x = 1;

if (true) {
  var x = 10;  // 같은 스코프의 전역 변수 x에 재할당!
}

console.log(x);  // 10 — if 블록이 스코프를 못 만듦

자바에서 이런 코드를 쓰면 if 블록 안의 x는 지역 변수가 된다. 바깥 x에 영향을 주지 않는다. 근데 자바스크립트의 varif 블록을 스코프로 인정하지 않아서, 기존 전역 변수 x를 덮어써버린다.

이걸 처음 봤을 때 "아 이게 진짜 의도된 동작이야?" 싶었다. for문에서도 마찬가지다.

hljs js
var i = 10;

for (var i = 0; i < 5; i++) {
  console.log(i);  // 0 1 2 3 4
}

console.log(i);  // 5 — for문의 i가 전역 변수 i를 덮어씀

자바에서는 for (int i = 0; ...) 하면 i가 for 블록 안에서만 존재한다. 자바스크립트의 var는 이게 안 되니까, 의도치 않게 바깥 변수를 오염시킨다. 이 시점에서 "아 이래서 let이 나온 거구나" 하는 감이 확 왔다.

렉시컬 스코프 — 정의된 위치가 기준이다

이 부분이 13장에서 제일 중요하다고 느꼈다.

스코프를 결정하는 방식은 두 가지가 있다.

  • 동적 스코프(dynamic scope): 함수가 호출되는 위치에 따라 상위 스코프가 결정
  • 렉시컬 스코프(lexical scope, 정적 스코프): 함수가 정의되는 위치에 따라 상위 스코프가 결정

자바스크립트는 렉시컬 스코프를 따른다.

hljs js
var x = 1;

function foo() {
  var x = 10;
  bar();
}

function bar() {
  console.log(x);
}

foo();  // 1 — bar는 전역에서 정의됐으니까 전역의 x를 참조

처음엔 foo() 안에서 bar()를 호출했으니까 barfoo의 스코프에 접근할 수 있지 않을까 싶었다. 근데 아니다. bar는 전역에서 정의됐기 때문에 상위 스코프가 전역이다. 호출 위치는 상관없다.

이게 자바에서는 당연한 동작이다. 자바의 메서드도 정의된 클래스를 기준으로 필드를 참조하지, 호출된 위치의 지역 변수에 접근하지 않으니까. 근데 자바스크립트는 함수를 변수에 담아서 여기저기 넘기다 보면 "이 함수가 어디서 정의됐더라?" 하는 상황이 생긴다. 그래서 렉시컬 스코프를 명확히 이해하는 게 중요하다.

이 개념이 나중에 클로저로 직결된다고 하는데, 그건 24장에서 다룬다고 하니 일단 "정의 위치가 기준"이라는 것만 확실히 잡고 넘어갔다.


14장. 전역 변수의 문제점 — 왜 전역은 나쁜가

변수의 생명 주기

지역 변수는 함수가 호출될 때 생성되고, 함수가 종료되면 소멸한다. 자바의 메서드 지역 변수와 같다.

전역 변수는 다르다. 전역 변수는 프로그램이 종료될 때까지 살아 있다. 브라우저 환경에서는 페이지를 닫을 때까지, Node.js에서는 프로세스가 끝날 때까지 전역 변수가 메모리를 차지한다.

자바에서도 static 필드가 클래스가 언로드될 때까지 살아 있는 것과 비슷한데, 자바스크립트의 전역 변수는 훨씬 쉽게 만들어진다는 게 문제다. 함수 밖에서 var를 쓰기만 하면 전역 변수니까.

전역 변수가 문제인 이유

책에서 네 가지를 들었는데, 읽으면서 다 공감이 갔다.

암묵적 결합. 전역 변수는 어디서든 참조하고 수정할 수 있다. 코드 어디에서 이 변수를 바꿨는지 추적하기 어렵다. 자바에서도 public static 필드를 남발하면 같은 문제가 생기는데, 자바스크립트는 기본이 이 상태라서 더 위험하다.

긴 생명 주기. 오래 살아 있으니 의도치 않은 재할당 위험이 크다. 특히 var는 같은 이름으로 중복 선언이 되니까, 실수로 덮어쓸 가능성이 높다.

스코프 체인 종점. 전역 변수는 스코프 체인의 가장 끝에 있어서 검색 속도가 제일 느리다. 실질적으로 체감할 차이는 아니라고 하지만, 구조적으로 비효율적이긴 하다.

네임스페이스 오염. 이게 제일 인상적이었다. 자바스크립트는 파일이 분리되어 있어도 하나의 전역 스코프를 공유한다. a.js에서 var count = 0을 선언하고, b.js에서도 var count = 0을 선언하면 같은 전역 변수를 건드리는 거다.

자바에서는 패키지와 클래스로 네임스페이스가 분리되니까 이런 문제가 거의 안 생긴다. 근데 자바스크립트는 원래 파일 간 스코프 분리가 없었다. 이게 꽤 충격이었다.

전역 변수를 줄이는 방법들

책에서 소개하는 방법이 네 가지 있었다.

즉시 실행 함수(IIFE). 코드를 즉시 실행 함수로 감싸서 모든 변수를 지역 변수로 만드는 패턴이다.

hljs js
(function () {
  var x = 10;
  // 여기서 x는 지역 변수
}());

console.log(x);  // ReferenceError

자바에서 굳이 비유하면 익명 클래스의 인스턴스 초기화 블록 같은 느낌인데... 좀 억지 비유다. 어쨌든 스코프를 인위적으로 만들어서 전역 오염을 막는 거다. 옛날 자바스크립트 코드에서 많이 보이는 패턴이라고 한다.

네임스페이스 객체. 전역에 하나의 객체를 만들고, 그 객체의 프로퍼티로 변수를 관리하는 방식이다.

hljs js
var MYAPP = {};
MYAPP.name = 'Lee';
MYAPP.person = { name: 'Kim', age: 20 };

전역 변수 수를 줄이긴 하지만, 네임스페이스 객체 자체가 전역이라서 근본적인 해결은 아니다.

모듈 패턴. 클로저를 활용해서 private/public을 흉내내는 패턴이다. 클로저를 아직 안 배웠으니 깊이 들어가진 않았는데, 자바의 접근 제어자(private, public)를 자바스크립트에서 구현하려는 시도로 이해했다.

ES6 모듈. 이게 결정적인 해결책이다. <script type="module">로 로드하면 파일별로 독자적인 모듈 스코프가 생긴다. var로 선언해도 전역 변수가 아니다.

hljs html
<script type="module" src="a.js"></script>
<script type="module" src="b.js"></script>

이렇게 하면 a.jsb.js가 각자의 스코프를 가진다. 자바의 패키지처럼 파일 단위로 네임스페이스가 분리되는 거다. 현대 자바스크립트 개발에선 번들러(Webpack, Vite 등)를 통해 모듈 시스템을 쓰는 게 기본이라고 하니, IIFE나 네임스페이스 객체는 레거시 코드에서나 볼 패턴인 것 같다.


15장. let, const 키워드와 블록 레벨 스코프 — var의 문제를 해결하다

13장과 14장에서 var의 문제를 충분히 느끼고 나니, 15장에서 letconst가 나오는 게 당연하게 느껴졌다. "아 드디어 해결책이 나오는구나" 싶었다.

var의 문제점 정리

15장 도입부에서 var의 문제를 세 가지로 정리하고 있다. 13~14장에서 이미 체감한 것들이라 복습하는 기분이었다.

1) 변수 중복 선언 허용. 같은 스코프에서 var로 같은 이름을 또 선언해도 에러가 안 난다. 기존 값이 덮어씌워진다.

hljs js
var x = 1;
var x = 100;  // 에러 없이 x가 100이 됨

자바에서 같은 스코프에 같은 이름의 변수를 두 번 선언하면 컴파일 에러가 난다. 자바스크립트의 var는 이걸 허용하니까, 큰 코드베이스에서 실수로 변수를 덮어쓸 위험이 크다.

2) 함수 레벨 스코프. 13장에서 봤던 그 문제다. if, for 같은 블록이 스코프를 만들지 못한다.

3) 변수 호이스팅. 12장에서도 다뤘는데, var로 선언한 변수는 선언이 코드 최상단으로 끌어올려지고 undefined로 초기화된다. 선언 전에 참조해도 에러가 안 나고 undefined가 나온다.

hljs js
console.log(x);  // undefined — 에러가 아니라 undefined
var x = 10;

이 세 가지가 합쳐지면 디버깅이 정말 힘들어질 것 같다는 생각이 들었다. 변수가 어디서 선언됐는지, 어떤 스코프에 속하는지, 호이스팅으로 끌어올려졌는지... 추적해야 할 게 너무 많다.

let — 블록 레벨 스코프의 등장

중복 선언 금지. let은 같은 스코프에서 같은 이름을 중복 선언하면 SyntaxError를 던진다. 자바처럼 컴파일 타임(정확히는 파싱 타임)에 잡아주니까 실수를 미리 막을 수 있다.

hljs js
let x = 1;
let x = 100;  // SyntaxError: Identifier 'x' has already been declared

블록 레벨 스코프. 드디어. letif, for, while 등의 블록을 스코프로 인정한다.

hljs js
let x = 1;

if (true) {
  let x = 10;  // 이건 if 블록의 지역 변수
  console.log(x);  // 10
}

console.log(x);  // 1 — 전역 x는 안 바뀜

자바에서는 너무나 당연한 동작인데, var에서는 안 됐던 게 let에서 된다. for문도 마찬가지다.

hljs js
let i = 10;

for (let i = 0; i < 5; i++) {
  console.log(i);  // 0 1 2 3 4
}

console.log(i);  // 10 — for문의 i가 전역 i를 안 건드림

이걸 보고 좀 안도감이 들었다. "아 드디어 정상적으로 동작하는구나."

변수 호이스팅과 TDZ — let도 호이스팅은 된다

여기서 좀 놀랐다. let으로 선언한 변수도 호이스팅이 된다. 다만 var와 동작이 다르다.

var는 선언과 초기화가 동시에 일어난다. 호이스팅되면서 undefined로 초기화되니까, 선언 전에 참조해도 에러가 아니라 undefined가 나온다.

let은 선언과 초기화가 분리된다. 호이스팅으로 선언은 되지만 초기화는 실제 선언문에 도달했을 때 일어난다. 그 사이 구간에서 변수를 참조하면 ReferenceError가 발생한다.

이 구간을 일시적 사각지대(TDZ, Temporal Dead Zone) 라고 부른다.

hljs js
console.log(x);  // ReferenceError: Cannot access 'x' before initialization
let x = 10;

var였으면 undefined가 나왔을 텐데, let은 에러를 던진다. 이쪽이 훨씬 안전하다.

그런데 "let도 호이스팅된다"는 걸 어떻게 증명하냐. 호이스팅이 안 되면 아래 코드에서 전역 foo를 참조해야 한다.

hljs js
let foo = 1;  // 전역 변수

{
  console.log(foo);  // ReferenceError
  let foo = 2;       // 지역 변수
}

호이스팅이 안 됐다면 블록 안의 console.log(foo)는 전역 foo를 찾아서 1을 출력해야 한다. 근데 ReferenceError가 난다. 블록 안의 let foo가 호이스팅되어 해당 스코프에 등록됐기 때문에, TDZ에 걸려서 에러가 나는 거다.

이 동작 방식이 처음에 좀 복잡하게 느껴졌는데, 정리하면 이렇다.

  • var: 호이스팅 → 선언 + 초기화(undefined) 동시 → 할당은 나중에
  • let: 호이스팅 → 선언만 → (TDZ 구간) → 선언문에서 초기화 → 할당

결국 "호이스팅은 되지만 초기화 전에 접근하면 에러"라는 거다. var보다 엄격하고, 그래서 안전하다.

전역 객체와 let

var로 선언한 전역 변수는 window 객체(브라우저 환경)의 프로퍼티가 된다.

hljs js
var x = 1;
console.log(window.x);  // 1

let으로 선언한 전역 변수는 window의 프로퍼티가 아니다. 보이지 않는 개념적 블록(전역 렉시컬 환경의 선언적 환경 레코드)에 존재한다고 한다.

hljs js
let y = 1;
console.log(window.y);  // undefined

이 차이가 왜 중요한지 지금은 완전히 체감하진 못하겠는데, 전역 객체 오염을 줄인다는 점에서 let이 더 깔끔한 건 확실하다.

const — 재할당 금지

constlet과 거의 같은데, 재할당이 금지된다. 그리고 선언과 동시에 초기화해야 한다.

hljs js
const x = 1;
x = 2;  // TypeError: Assignment to constant variable.

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

자바의 final과 비슷하다. 한 번 할당하면 다시 바꿀 수 없다.

const와 객체 — 여기서 한 번 더 헷갈렸다

근데 const로 선언한 객체는 프로퍼티를 변경할 수 있다.

hljs js
const person = { name: 'Lee' };
person.name = 'Kim';  // 이건 됨!
console.log(person.name);  // 'Kim'

person = { name: 'Park' };  // TypeError — 재할당은 안 됨

처음엔 "const인데 왜 값이 바뀌지?" 하고 좀 혼란스러웠다. 근데 11장에서 배운 걸 떠올리면 납득이 된다. const가 금지하는 건 변수의 재할당이다. person에 저장된 건 객체의 참조 값(메모리 주소)이고, 프로퍼티를 바꾸는 건 그 주소가 가리키는 객체의 내용을 수정하는 거지 참조 값 자체를 바꾸는 게 아니다.

자바의 final 참조 변수도 같다. final List<String> list = new ArrayList<>(); 해놓고 list.add("hello")는 된다. list에 새 리스트를 할당하는 건 안 되지만, 기존 리스트에 원소를 추가하는 건 된다. 같은 원리다.

var vs let vs const — 뭘 쓸까

책에서 권장하는 방식이 명확했다.

  • 기본적으로 const를 쓴다
  • 재할당이 필요한 경우에만 let을 쓴다
  • var는 쓰지 않는다

이건 자바에서 "가능하면 final을 붙여라"는 조언과 같은 맥락이다. 변수가 재할당되지 않는다는 걸 코드 수준에서 보장하면, 읽는 사람이 "이 값은 안 바뀌는구나"를 바로 알 수 있다.

실무에서도 대부분의 자바스크립트/타입스크립트 프로젝트가 const 기본, 필요할 때만 let을 쓰는 컨벤션을 따르고 있는 것 같다. ESLint에서 no-var 룰을 켜놓으면 var 사용 시 경고를 띄워주기도 한다.


남은 질문들

  • 렉시컬 스코프가 클로저로 이어진다는 걸 계속 암시하는데, 아직 체감이 안 된다. "함수가 정의된 위치의 스코프를 기억한다"는 게 구체적으로 어떤 상황에서 유용한 건지, 24장에서 확인해야겠다.

  • TDZ가 호이스팅과 결합되는 부분이 한 번에 정리가 안 됐다. "호이스팅은 되는데 초기화는 안 된다"는 게 머리로는 이해가 가는데, 실제 디버깅 상황에서 이걸 바로 떠올릴 수 있을지 모르겠다. 좀 더 코드를 써보면서 체화해야 할 것 같다.

  • ES6 모듈이 전역 변수 문제를 해결한다고 했는데, 현실에서는 번들러를 통해 모듈을 사용하는 경우가 대부분이라고 한다. 번들러가 모듈 스코프를 어떻게 처리하는지는 아직 잘 모른다. 이건 프론트엔드 빌드 도구를 다루면서 알아가야 할 부분인 것 같다.


마치며

13~15장을 읽으면서 "아 var가 이래서 욕을 먹는구나"를 뼈저리게 느꼈다. 함수 레벨 스코프, 중복 선언 허용, 호이스팅으로 undefined 반환. 이 세 가지가 합쳐지면 진짜 예측 불가능한 코드가 나올 수 있다.

letconst가 이걸 블록 레벨 스코프, 중복 선언 금지, TDZ로 해결한 게 얼마나 큰 개선인지 체감됐다. 자바 입장에서는 "당연한 거 아닌가?" 싶지만, 자바스크립트가 이 "당연한 것"을 갖추기까지 ES6(2015년)을 기다려야 했다는 게 좀 놀랍다.

다음은 16장 프로퍼티 어트리뷰트부터다. 객체의 내부 동작을 파고드는 장이라고 하는데, 자바의 리플렉션 API를 떠올리면 될 것 같기도 하다. 가보자.

Comments 0

0/500

No comments yet. Be the first to leave one.

인기 글