본문 바로가기

let, const와 블록 스코프

var의 함수 스코프·재선언·호이스팅 문제를 확인하고 let·const의 블록 스코프와 TDZ, 재할당 규칙을 익힙니다.

7장에서는 비동기 처리, 모듈 시스템, 디자인 패턴처럼 실무에서 자주 마주치는 주제를 다뤘습니다. 8장에서는 최신 ECMAScript 표준이 더한 문법을 정확하게 쓰는 방법을 익힙니다. 자바스크립트는 ECMAScript 표준에 따라 해마다 기능이 추가되며, 같은 일도 예전보다 안전하고 읽기 쉬운 문법으로 쓸 수 있게 바뀌어 왔습니다. 특히 ES2015(ES6)는 문법을 몇 개 더한 데 그치지 않고, 실수를 줄이고 의도를 분명히 드러내는 쪽으로 언어의 방향을 바꾼 전환점이었습니다.

첫 주제는 변수를 선언하는 키워드입니다. 클로저와 실행 컨텍스트에서 이름을 찾는 환경과 블록마다 생기는 환경을 살펴봤는데, 이 글에서는 이를 var, let, const에 적용합니다. 이름이 어느 범위에서 보이는지를 정하는 스코프와, 그 이름에 언제 값이 들어가는지를 정하는 초기화 시점을 나눠 보면 세 키워드의 차이가 분명해집니다.


var의 문제점

var는 함수 스코프를 따르며, 몇 가지 독특한 동작 때문에 헷갈리기 쉽고 뜻하지 않은 버그를 만들기 쉬웠습니다. 이 절에서는 그 동작인 함수 스코프, 호이스팅, 재선언 허용을 차례로 봅니다. 셋 모두 오류가 나야 할 상황을 조용히 넘긴다는 공통점이 있어서, 실수한 줄과 문제가 드러나는 줄이 멀리 떨어지기 쉽습니다.

함수 스코프

var로 선언한 변수는 함수 안에서만 지역 변수가 됩니다. if나 for 같은 블록 안에서 선언해도 블록의 지역 변수가 되지 않고, 그 블록을 감싼 함수의 스코프나 전역 스코프에 속합니다.

if (true) {
    var x = 10;
    console.log(x); // 10
}
console.log(x); // 10 (if 블록 밖에서도 접근 가능)

function introduce() {
    var personName = "김철수";
    console.log(personName); // 김철수
}
introduce();
// console.log(personName); // ReferenceError: personName is not defined (함수 스코프)

x는 if 블록 안에서 선언했지만 블록 밖에서도 읽힙니다. 반면 함수 안에서 선언한 personName은 함수 밖에서 보이지 않으므로, 마지막 줄의 주석을 풀면 ReferenceError가 납니다. 브라우저에는 이미 window.name이라는 속성이 있어서, 이 예제의 변수 이름을 name으로 바꾸면 오류 대신 빈 문자열이 출력됩니다. 함수의 지역 변수와 주변 환경에 원래 있던 같은 이름을 헷갈리지 않도록 이름을 구분해 두었습니다.

호이스팅

var로 선언한 변수는 선언이 스코프의 맨 위로 끌어올려진 것처럼 동작하며, 이를 호이스팅(hoisting)이라고 합니다. 값을 넣는 대입은 원래 자리에서 일어나고 선언만 먼저 처리되므로, 선언하기 전에 변수를 읽어도 오류 없이 undefined가 나옵니다.

console.log(myVar); // 결과: undefined
var myVar = "hello";
console.log(myVar); // 결과: hello
// 실제로는 다음과 같이 동작합니다.
// var myVar;
// console.log(myVar);
// myVar = "hello";
// console.log(myVar);

선언보다 앞에서 변수를 읽는 코드가 오류 없이 넘어가므로, 값이 아직 들어가지 않은 변수를 쓰는 실수를 알아채기 어렵습니다. 호이스팅은 실제로 코드 줄을 옮기는 것이 아니라, 실행 전에 스코프의 이름을 먼저 만드는 규칙을 설명하는 표현입니다.

재선언 허용

var는 같은 이름의 변수를 여러 번 선언해도 오류를 내지 않습니다. 다음 코드는 myName을 var로 두 번 선언하는데, 두 번째 선언은 새 변수를 만들지 않고 같은 변수에 값을 다시 넣는 것처럼 동작합니다.

var myName = "Alice";
console.log(myName); // 결과: Alice

var myName = "Bob"; // 동일한 이름으로 재선언해도 에러 없음
console.log(myName); // 결과: Bob

다른 사람이 쓴 코드나 멀리 떨어진 줄에서 같은 이름을 다시 선언하면, 오류 없이 기존 값을 덮어씁니다. 파일이 길어지고 여러 사람이 함께 작업할수록 이런 실수가 버그로 이어지기 쉽습니다.


let 키워드

let은 var의 문제를 해결하기 위해 ES2015에 추가된 변수 선언 키워드입니다. 앞에서 본 세 문제에 하나씩 답하듯, let은 블록 스코프를 따르고, 같은 스코프에서 다시 선언하는 것을 막으며, 선언보다 앞에서 읽으면 오류를 냅니다. 이어지는 세 절에서 각각을 코드로 확인합니다.

블록 스코프

let으로 선언한 변수는 선언한 블록 안에서만 유효합니다. 블록은 함수, if, for, while 등에서 중괄호({})로 둘러싼 영역입니다.

같은 블록 코드를 var와 let으로 실행했을 때 블록 밖 접근 결과 비교

COMPARISON

var와 let: 블록 밖 접근 결과

선언 키워드만 바꾼 같은 예제다. var는 함수 안에 남고, let은 if 블록 밖에서 접근할 수 없다.

var

function compareVar() {
  if (true) {
    var count = 10;
  }
  console.log(count);
}

compareVar();

출력 10 — compareVar 함수 스코프의 바인딩이 if 블록 밖에도 남는다.

let

function compareLet() {
  if (true) {
    let count = 10;
  }
  console.log(count);
}

compareLet();

결과 ReferenceError — 바인딩은 선언된 블록 안에서만 유효하다.

비교 조건은 동일하며 선언 키워드만 다르다. 오류는 let의 결함이 아니라 블록 경계를 지키는 결과다.

위 그림은 선언 키워드만 바꾼 같은 코드를 비교합니다. compareVar()의 if 블록에서 var count = 10을 선언하면 count는 함수 스코프에 속하므로, 블록 밖의 console.log(count)가 10을 출력합니다. compareLet()에서 let count = 10으로 바꾸면 count는 if 블록 안에서만 유효해, 블록 밖에서 읽을 때 ReferenceError가 납니다. 이 오류는 let의 결함이 아니라 선언한 블록의 범위를 지킨 결과입니다.

let y = 100; // 전역 스코프 또는 상위 블록 스코프

if (true) {
    let z = 20; // if 블록 내부에서만 유효
    console.log(z); // 결과: 20
    console.log(y); // 결과: 100 (상위 스코프 변수는 접근 가능)
}
// console.log(z); // ReferenceError: z is not defined (블록 밖에서 접근 불가)

for (let i = 0; i < 3; i++) {
    console.log(i); // 결과: 0, 1, 2
}
// console.log(i); // ReferenceError: i is not defined (for 블록 밖에서 접근 불가)

블록 안에서는 바깥 스코프의 y를 읽을 수 있지만, 블록 안에서 선언한 z와 for의 i는 블록 밖에서 보이지 않습니다. 변수가 쓰이는 범위가 좁아지므로 코드를 예측하기 쉽고, 다른 곳의 같은 이름과 부딪칠 위험도 줄어듭니다.

재선언 불가

let은 같은 스코프에서 같은 이름을 다시 선언하는 것을 허용하지 않습니다. 다음 코드의 두 번째 줄은 주석 처리되어 있고, 주석을 풀었을 때 나오는 오류 메시지를 함께 적어 두었습니다. 다른 블록이나 함수 안에서 같은 이름을 새로 선언하는 것은 허용됩니다.

let fruit = "사과";
// let fruit = "바나나"; // SyntaxError: Identifier 'fruit' has already been declared

주석을 풀면 코드를 실행하기 전에 SyntaxError가 나서, 스크립트의 어떤 줄도 실행되지 않습니다. 이름이 겹치는 실수를 실행하기 전에 알려 주는 셈입니다.

호이스팅과 TDZ

let 변수도 스코프에 들어갈 때 이름이 먼저 만들어지지만, var와 달리 선언문에 이르러 초기화되기 전까지는 접근할 수 없습니다. 이 구간을 TDZ(temporal dead zone)라고 부르며, TDZ에 있는 변수를 읽으면 ReferenceError가 납니다.

try {
    console.log(myLetVar); // TDZ에서 읽기
} catch (error) {
    console.log(error.name); // ReferenceError
}
let myLetVar = "hello";
console.log(myLetVar); // hello

선언 전의 읽기는 ReferenceError가 되고, 선언문을 지난 뒤에는 hello가 출력됩니다. try...catch로 감싼 덕분에 오류가 난 뒤에도 다음 줄을 실행해 두 시점을 비교할 수 있습니다. TDZ는 선언하기 전에 변수를 쓰는 실수를 오류로 드러내 줍니다.

let·const 바인딩 초기화와 재할당 — 상태 추적

같은 바인딩의 상태가 미초기화 TDZ→선언 초기화→재할당 시도에 따라 달라진다. let의 새 값과 const 오류 뒤 기존 값 유지, 바깥 동명 변수 미탐색을 추적한다.

let·const 바인딩 초기화와 재할당

STATE · WEB PLATFORM CONTRACT

let·const 바인딩 초기화와 재할당

선언 위치에서 초기화되기 전에는 읽을 수 없다. 초기화 이후 let은 재할당할 수 있고, const의 재할당 오류는 기존 값을 바꾸지 않는다.

이름 생성·초기화·두 재할당 경로스코프에 진입하면 이름이 생성되지만 아직 초기화되지 않습니다. 초기화 전 TDZ에서 읽으면 ReferenceError가 발생하며, 바깥의 동명 변수로 넘어가지 않습니다. 선언 평가 뒤에는 값을 읽을 수 있습니다. let의 재할당은 새 값을 저장한 뒤 읽기 상태로 돌아갑니다. const의 재할당은 TypeError를 던지지만 catch로 처리하면 기존 값을 읽을 수 있습니다. 모바일도 두 별도 경로와 반환 방향을 유지합니다. 객체 내부 변경은 이 바인딩 그림의 범위 밖입니다.스코프 진입이름 생성 · 아직 초기화 전TDZ읽으면 ReferenceError선언 평가 · 초기화let 초기값 없음: undefined현재 값 읽기 가능let 또는 constlet 재할당새 값 저장const 재할당 시도TypeError · 기존 값 유지오류를 catch하면 기존 값 읽기를 계속할 수 있음
이름 생성·초기화·두 재할당 경로스코프에 진입하면 이름이 생성되지만 아직 초기화되지 않습니다. 초기화 전 TDZ에서 읽으면 ReferenceError가 발생하며, 바깥의 동명 변수로 넘어가지 않습니다. 선언 평가 뒤에는 값을 읽을 수 있습니다. let의 재할당은 새 값을 저장한 뒤 읽기 상태로 돌아갑니다. const의 재할당은 TypeError를 던지지만 catch로 처리하면 기존 값을 읽을 수 있습니다. 모바일도 두 별도 경로와 반환 방향을 유지합니다. 객체 내부 변경은 이 바인딩 그림의 범위 밖입니다.스코프 진입이름 생성TDZ · 초기화 전ReferenceError선언 평가초기값 저장값 읽기 가능현재 값let 재할당새 값 저장letconst 재할당TypeError기존 값 유지const의 다른 경로catch 뒤 읽기 계속 가능
  1. scope 진입 → TDZ

    let·const binding은 생기지만 uninitialized라 declaration 전 읽기는 ReferenceError다.

  2. declaration initialization

    초기값을 평가해 저장하면 읽을 수 있다. 초기값 없는 let 선언은 undefined로 초기화한다.

  3. let

    재할당하면 새 값을 저장하고 같은 readable 상태로 돌아간다.

  4. const

    재할당 시도는 TypeError를 던진다. catch 뒤에도 기존 binding과 값은 그대로 readable이다.

초기화 완료 뒤에 읽기 가능

let은 재할당한 새 값을 읽는다. const의 재할당 시도는 오류를 던지지만 기존 값은 그대로 유지한다.

주황색 반환선은 let의 새 값 저장, 점선은 const 오류 이후 기존 값 유지를 뜻한다. TypeError는 catch로 처리할 수 있다. const가 가리키는 객체의 속성 변경은 별개다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석scope 진입 → TDZ: let·const binding은 생기지만 uninitialized라 declaration 전 읽기는 ReferenceError다.
원도표 조건·주석declaration initialization: 초기값을 평가해 저장하면 읽을 수 있다. 초기값 없는 let 선언은 undefined로 초기화한다.
원도표 조건·주석let: 재할당하면 새 값을 저장하고 같은 readable 상태로 돌아간다.
원도표 조건·주석const: 재할당 시도는 TypeError를 던진다. catch 뒤에도 기존 binding과 값은 그대로 readable이다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
TDZbinding: uninitialized / 읽기: ReferenceError블록 이름은 있지만 선언 초기화 전이다같은 이름의 바깥 값을 대신 검색하지 않는다
초기화binding: 초기화됨 / 읽기: 현재 값선언을 실행해 값을 저장한다선언 실행 뒤에만 해당 binding을 사용할 수 있다
재할당 비교let: 새 값 / const: TypeError/기존 값 유지let은 새 값을 저장하고 const 재할당은 실패한다catch하는 확장 가정 없이는 오류 뒤 실행이 계속된다고 쓰지 않는다

let·const 바인딩 초기화와 재할당 — 상태 추적

같은 바인딩의 상태가 미초기화 TDZ→선언 초기화→재할당 시도에 따라 달라진다. let의 새 값과 const 오류 뒤 기존 값 유지, 바깥 동명 변수 미탐색을 추적한다.

let·const 바인딩 초기화와 재할당

STATE · WEB PLATFORM CONTRACT

let·const 바인딩 초기화와 재할당

선언 위치에서 초기화되기 전에는 읽을 수 없다. 초기화 이후 let은 재할당할 수 있고, const의 재할당 오류는 기존 값을 바꾸지 않는다.

이름 생성·초기화·두 재할당 경로스코프에 진입하면 이름이 생성되지만 아직 초기화되지 않습니다. 초기화 전 TDZ에서 읽으면 ReferenceError가 발생하며, 바깥의 동명 변수로 넘어가지 않습니다. 선언 평가 뒤에는 값을 읽을 수 있습니다. let의 재할당은 새 값을 저장한 뒤 읽기 상태로 돌아갑니다. const의 재할당은 TypeError를 던지지만 catch로 처리하면 기존 값을 읽을 수 있습니다. 모바일도 두 별도 경로와 반환 방향을 유지합니다. 객체 내부 변경은 이 바인딩 그림의 범위 밖입니다.스코프 진입이름 생성 · 아직 초기화 전TDZ읽으면 ReferenceError선언 평가 · 초기화let 초기값 없음: undefined현재 값 읽기 가능let 또는 constlet 재할당새 값 저장const 재할당 시도TypeError · 기존 값 유지오류를 catch하면 기존 값 읽기를 계속할 수 있음
이름 생성·초기화·두 재할당 경로스코프에 진입하면 이름이 생성되지만 아직 초기화되지 않습니다. 초기화 전 TDZ에서 읽으면 ReferenceError가 발생하며, 바깥의 동명 변수로 넘어가지 않습니다. 선언 평가 뒤에는 값을 읽을 수 있습니다. let의 재할당은 새 값을 저장한 뒤 읽기 상태로 돌아갑니다. const의 재할당은 TypeError를 던지지만 catch로 처리하면 기존 값을 읽을 수 있습니다. 모바일도 두 별도 경로와 반환 방향을 유지합니다. 객체 내부 변경은 이 바인딩 그림의 범위 밖입니다.스코프 진입이름 생성TDZ · 초기화 전ReferenceError선언 평가초기값 저장값 읽기 가능현재 값let 재할당새 값 저장letconst 재할당TypeError기존 값 유지const의 다른 경로catch 뒤 읽기 계속 가능
  1. scope 진입 → TDZ

    let·const binding은 생기지만 uninitialized라 declaration 전 읽기는 ReferenceError다.

  2. declaration initialization

    초기값을 평가해 저장하면 읽을 수 있다. 초기값 없는 let 선언은 undefined로 초기화한다.

  3. let

    재할당하면 새 값을 저장하고 같은 readable 상태로 돌아간다.

  4. const

    재할당 시도는 TypeError를 던진다. catch 뒤에도 기존 binding과 값은 그대로 readable이다.

초기화 완료 뒤에 읽기 가능

let은 재할당한 새 값을 읽는다. const의 재할당 시도는 오류를 던지지만 기존 값은 그대로 유지한다.

주황색 반환선은 let의 새 값 저장, 점선은 const 오류 이후 기존 값 유지를 뜻한다. TypeError는 catch로 처리할 수 있다. const가 가리키는 객체의 속성 변경은 별개다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석scope 진입 → TDZ: let·const binding은 생기지만 uninitialized라 declaration 전 읽기는 ReferenceError다.
원도표 조건·주석declaration initialization: 초기값을 평가해 저장하면 읽을 수 있다. 초기값 없는 let 선언은 undefined로 초기화한다.
원도표 조건·주석let: 재할당하면 새 값을 저장하고 같은 readable 상태로 돌아간다.
원도표 조건·주석const: 재할당 시도는 TypeError를 던진다. catch 뒤에도 기존 binding과 값은 그대로 readable이다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
TDZbinding: uninitialized / 읽기: ReferenceError블록 이름은 있지만 선언 초기화 전이다같은 이름의 바깥 값을 대신 검색하지 않는다
초기화binding: 초기화됨 / 읽기: 현재 값선언을 실행해 값을 저장한다선언 실행 뒤에만 해당 binding을 사용할 수 있다
재할당 비교let: 새 값 / const: TypeError/기존 값 유지let은 새 값을 저장하고 const 재할당은 실패한다catch하는 확장 가정 없이는 오류 뒤 실행이 계속된다고 쓰지 않는다

위 그림은 let과 const 바인딩이 거치는 상태를 보여 줍니다. 스코프에 들어가면 이름이 만들어지지만 아직 초기화되지 않은 TDZ 상태이고, 이때 읽으면 ReferenceError가 납니다. 바깥에 같은 이름의 변수가 있어도 그쪽으로 넘어가 읽지 않는다는 점이 중요합니다.

선언문이 평가되면 초기값이 저장되어 값을 읽을 수 있게 되며, 초기값 없이 let value;처럼 선언하면 undefined로 초기화됩니다. 그 뒤 let은 다른 값을 대입하면 새 값을 저장하고 다시 읽을 수 있는 상태로 돌아갑니다. const에 다른 값을 대입하려 하면 TypeError가 나지만 기존 값은 그대로이므로, 오류를 catch로 처리하면 원래 값을 계속 읽을 수 있습니다. 바인딩이 가리키는 객체의 속성을 바꾸는 일은 이 그림의 범위 밖이며, 아래 const 절에서 다룹니다.


const 키워드

const는 let처럼 블록 스코프를 따르고 TDZ도 같지만, 상수(constant)를 선언한다는 점이 다릅니다. 한 번 초기화한 바인딩에는 다른 값을 다시 대입할 수 없습니다.

재할당 불가

const로 선언한 변수는 선언할 때 반드시 값을 넣어야 하고, 그 뒤에는 다른 값을 대입할 수 없습니다. 다음 코드에서 주석 처리된 두 줄이 각각 이 두 규칙을 어겼을 때의 오류를 보여 줍니다. PI, TAX_RATE처럼 바뀌지 않는 값의 이름을 대문자와 밑줄로 짓는 것은 문법이 아니라 관례입니다.

const PI = 3.14159;
console.log(PI); // 결과: 3.14159

// PI = 3.14; // TypeError: Assignment to constant variable. (재할당 불가능)

// const TAX_RATE; // SyntaxError: Missing initializer in const declaration (선언과 동시에 할당 필수)

PI에 다른 값을 대입하는 줄은 실행될 때 TypeError를 내고, 초기값 없는 const TAX_RATE;는 실행 전에 SyntaxError가 납니다. let은 초기값 없이 선언하면 undefined로 초기화되지만, 일반 const 선언에는 초기값이 꼭 필요합니다. for (const value of values)처럼 반복할 때마다 문법이 값을 넣어 주는 경우는 반복마다 새 바인딩이 생기는 것이라 예외가 아닙니다.

객체와 배열을 const로 선언할 때

const가 막는 것은 같은 이름을 다른 값에 다시 연결하는 일입니다. 그래서 객체나 배열을 const로 선언해도, 그 객체의 속성이나 배열의 요소는 바꿀 수 있습니다.

const user = {
    name: "Jane",
    age: 25
};

user.age = 26; // 객체 내부의 프로퍼티 변경은 가능
console.log(user.age); // 결과: 26

// user = { name: "Mike", age: 30 }; // 하지만 객체 자체를 재할당하는 것은 불가능
// TypeError: Assignment to constant variable.

const colors = ["red", "green"];
colors.push("blue"); // 배열에 요소 추가는 가능
console.log(colors); // 결과: ["red", "green", "blue"]

// colors = ["yellow"]; // 배열 자체를 재할당하는 것은 불가능
// TypeError: Assignment to constant variable.

user.age를 바꾸거나 colors에 요소를 추가하는 것은 같은 객체의 내부를 바꾸는 일이라 허용됩니다. 막히는 것은 user나 colors에 새 객체나 새 배열을 대입하는 일뿐입니다.

객체의 내용까지 바뀌지 않게 하려면 Object.freeze()를 쓸 수 있습니다. 다만 Object.freeze()는 그 객체의 자체 속성만 고정하므로, 속성 값으로 들어 있는 중첩 객체까지 고정하려면 따로 처리해야 합니다. 깊은 복사도 원본과 복사본을 떼어 놓을 뿐, 복사본을 바꾸지 못하게 막지는 않습니다.


var, let, const 고르기

재할당 필요 여부에 따라 const와 let을 선택하고 var는 기존 코드 해석에 한정하는 흐름

DECISION TREE

새 코드의 선언 키워드 선택

다른 값을 다시 대입해야 하는지 확인한다. 재할당이 없으면 const, 있으면 let을 선택한다.

바인딩 재할당 여부에 따른 선택새 코드에서 바인딩에 다른 값을 대입해야 하는지 판단합니다. 예 경로는 let, 아니오 경로는 const에서 끝납니다. 모바일은 결과를 세로로 배치하지만 let을 거쳐 const로 진행하는 선은 없습니다. 객체 프로퍼티 변경은 바인딩 재할당과 별개입니다. var는 기존 코드 해석에 연결합니다. 새 코드의 선언객체 내부 변경과 구별다른 값을 다시 대입?바인딩 재할당 여부let재할당이 필요한 경우const재할당이 없는 경우예아니오var는 기존 코드의 스코프·초기화를 해석할 때 확인
바인딩 재할당 여부에 따른 선택새 코드에서 바인딩에 다른 값을 대입해야 하는지 판단합니다. 예 경로는 let, 아니오 경로는 const에서 끝납니다. 모바일은 결과를 세로로 배치하지만 let을 거쳐 const로 진행하는 선은 없습니다. 객체 프로퍼티 변경은 바인딩 재할당과 별개입니다. var는 기존 코드 해석에 연결합니다.새 코드의 선언내부 변경과 구별다시 대입해야하는가?let재할당이 필요const재할당 없음예아니오: 다른 경로var: 기존 코드 해석
  • 재할당이 없다

    기본 선택은 const다.

  • 재할당이 필요하다

    let을 사용하고 변경 지점을 좁게 유지한다.

var는 새 코드의 선택지가 아니라 기존 코드의 스코프와 호이스팅을 해석할 때 확인한다. 객체의 속성 변경은 바인딩 재할당과 다르므로 const로도 가능하다.

위 그림은 새 코드를 쓸 때 선언 키워드를 고르는 순서입니다. 질문은 하나로, 이 바인딩에 나중에 다른 값을 다시 대입해야 하는지를 묻습니다. 다시 대입하지 않는다면 const를, 대입해야 한다면 let을 쓰고 값이 바뀌는 곳을 좁게 유지합니다. 객체의 속성을 바꾸는 것은 바인딩을 다시 대입하는 것과 다르므로 const로도 할 수 있습니다. var는 새 코드의 선택지가 아니라, 기존 코드의 스코프와 호이스팅을 읽을 때 확인하는 대상입니다.

const, let, var의 스코프, 재선언, 재할당, 선언 전 접근을 비교한 표

COMPARISON

const·let·var의 선언 경계

스코프, 재선언, 재할당, 선언 전 접근을 같은 축으로 비교한다. const의 제한은 객체 내부가 아닌 바인딩에 적용된다.

새 코드는 const를 기본으로 하고 재할당이 필요한 경우에만 let을 선택한다.
키워드 스코프 같은 스코프 재선언 재할당 선언 전 접근
const 기본 블록 불가 다른 값으로 바인딩 변경 불가 TDZ에서 ReferenceError
let 블록 불가 가능 TDZ에서 ReferenceError
var 함수 · 최상위는 전역/모듈 가능 가능 undefined로 읽힐 수 있음
const 기본
스코프 블록
같은 스코프 재선언 불가
재할당 다른 값으로 바인딩 변경 불가
선언 전 접근 TDZ에서 ReferenceError
let
스코프 블록
같은 스코프 재선언 불가
재할당 가능
선언 전 접근 TDZ에서 ReferenceError
var
스코프 함수 · 최상위는 전역/모듈
같은 스코프 재선언 가능
재할당 가능
선언 전 접근 undefined로 읽힐 수 있음
바인딩 고정 ≠ 객체 불변

const는 객체 내부를 불변으로 만들지 않는다. 바인딩을 다른 값으로 재할당하지 못하게 할 뿐이다.

위 표는 세 키워드를 스코프, 같은 스코프 안의 재선언, 재할당, 선언 전 접근이라는 같은 기준으로 비교합니다. const와 let은 블록 스코프이고 같은 스코프에서 다시 선언할 수 없으며, 선언 전에 읽으면 TDZ 때문에 ReferenceError가 납니다. 둘의 차이는 재할당뿐으로, const는 다른 값을 다시 대입할 수 없고 let은 할 수 있습니다.

var는 함수 스코프이고, 함수 밖의 최상위에서 선언하면 classic script에서는 전역, 모듈에서는 그 모듈의 스코프에 속합니다. 같은 스코프에서 다시 선언할 수 있고, 선언 전에 읽으면 undefined가 나올 수 있습니다. 표 아래의 설명처럼 const는 바인딩을 고정할 뿐 객체를 바꾸지 못하게 만드는 것은 아닙니다.

정리하면 지금의 자바스크립트에서는 다음 원칙을 따르는 것이 일반적입니다. 기본으로 const를 써서 값이 바뀌지 않는다는 의도를 드러내고, 반복문의 카운터처럼 다시 대입해야 할 때만 let을 씁니다. var는 오래된 코드를 유지보수하는 경우가 아니라면 새 코드에서 쓰지 않습니다.

// 전역 설정값, 절대 변하지 않는 값
const API_URL = "http://localhost:4000";
const MAX_ITEMS_PER_PAGE = 20;

// 한 번 할당되면 변하지 않는 참조형 값 (내부 변경은 가능)
const config = {
    theme: "dark",
    version: "1.0.0"
};
config.theme = "light"; // 가능

let counter = 0; // 값이 증가해야 하므로 let
for (let i = 0; i < 5; i++) { // 반복문 카운터는 let
    counter += i;
}

const someCondition = true; // 예시용 조건
if (someCondition) {
    let result = "Success"; // if 블록 내에서만 필요한 변수는 let
    console.log(result);
}
// console.log(result); // 에러

API_URL은 따로 실행한 로컬 목(mock) 서버의 주소를 가정한 값입니다. config 객체는 const로 선언했지만 속성은 바꿀 수 있고, counter처럼 값이 계속 바뀌어야 하는 변수만 let으로 선언했습니다. if 블록 안에서만 필요한 result는 블록 안에서 선언해 블록 밖으로 새어 나가지 않게 합니다.

더 깊이: for 머리의 let은 회차마다 어떻게 새로 만들어질까

for (let i = 0; i < 3; i++)의 i는 하나의 변수처럼 보이지만, 명세는 for의 초기화 부분에 let이 있으면 회차마다 새 변수를 만들도록 정합니다. 본문을 한 번 실행할 때마다 새 범위를 만들고, 직전 회차의 값으로 새 i를 초기화한 뒤, 그 새 범위에서 증감 식 i++를 실행합니다. 그래서 증감 식이 바꾸는 것은 새로 만든 변수이고, 직전 회차의 본문에서 만든 클로저가 붙잡은 변수는 그 값 그대로 남습니다.

이 복사 규칙은 본문에만 적용됩니다. for (let i = 0, getI = () => i; i < 3; i++)처럼 초기화 부분에서 만든 함수는 처음 만든 i 하나만 붙잡으므로, 본문에서 getI()를 세 번 호출해도 0, 0, 0이 나옵니다. 초기화 부분은 처음에 한 번만 실행되기 때문입니다.

const를 쓸 수 있는지도 이 규칙으로 갈립니다. for...of는 회차마다 새 변수에 다음 값을 넣을 뿐 같은 변수에 다시 대입하지 않으므로, 본문에서 바꾸지 않는다면 for (const item of items)처럼 써도 됩니다. 반면 일반 for에서 for (const i = 0; i < 3; i++)라고 쓰면, 첫 회차의 본문은 실행되지만 i++가 상수에 다시 대입하는 순간 TypeError가 납니다. 자세한 동작은 MDN의 for 문 설명에 있습니다.


바깥 이름을 가리는 블록

같은 이름이 바깥에 있어도, 블록 안에서 선언한 이름이 그 블록에서는 바깥 이름을 가립니다. 다음 코드는 새 파일이나 독립된 실행 범위에서 실행하세요. catch는 예상한 오류가 난 뒤에도 다음 줄을 확인하기 위한 것입니다.

const limit = 10;
{
    try {
        console.log(limit);
    } catch (error) {
        console.log(error.name);
    }
    let limit = 3;
    console.log(limit);
}
console.log(limit);

출력은 ReferenceError, 3, 10 순서입니다. 블록에 들어가는 순간 안쪽의 limit 바인딩이 만들어지지만 아직 초기화되지 않았으므로, 첫 console.log(limit)는 바깥의 10으로 넘어가 읽지 않고 TDZ 오류를 냅니다. let limit = 3을 지난 뒤에는 안쪽 값 3을 읽고, 블록을 나오면 다시 바깥의 10을 읽습니다. 그림에서 본 것처럼 TDZ는 바깥 이름으로 넘어가는 대신 오류를 내는 구간입니다.


정리

  • var는 함수 스코프라 블록 밖으로 새어 나가고, 선언 전에 undefined로 읽히며, 같은 이름을 다시 선언해도 오류가 없습니다.
  • let과 const는 선언한 블록 안에서만 유효하고, 같은 스코프에서 다시 선언할 수 없습니다.
  • let과 const의 이름은 스코프에 들어갈 때 만들어지지만 초기화 전까지는 TDZ라서, 읽으면 바깥 이름 대신 ReferenceError가 납니다.
  • const는 선언할 때 초기값이 필요하고 다른 값을 다시 대입할 수 없지만, 가리키는 객체의 속성이나 배열의 요소는 바꿀 수 있습니다.
  • Object.freeze()는 객체의 자체 속성만 고정하므로 중첩 객체는 따로 처리해야 합니다.
  • 새 코드에서는 const를 기본으로 쓰고, 다시 대입해야 할 때만 let을 쓰며, var는 기존 코드를 읽을 때만 다룹니다.

연습 문제

1. 선언 위치를 옮기면

앞 절의 마지막 예제에서 let limit = 3;을 블록 안의 try 앞으로 옮기고 나머지는 그대로 둡니다. 세 출력은 무엇일까요? 오류가 사라진 이유가 스코프가 바뀌어서인지, 초기화 시점이 바뀌어서인지 골라 설명해 보세요.

해설 보기

3, 3, 10입니다. 블록의 범위는 그대로지만, 첫 읽기보다 먼저 안쪽 limit이 초기화되었으므로 TDZ 오류가 나지 않습니다. 즉 바뀐 것은 초기화 시점입니다.

블록 안의 두 출력은 모두 안쪽 limit의 3이고, 블록을 나온 뒤에는 바깥 limit의 10이 출력됩니다. 바깥의 limit에는 다른 값이 대입된 적이 없습니다.

2. 반복문과 타이머

다음 두 반복문은 각각 무엇을 출력할까요? 결과가 다른 이유를 스코프로 설명해 보세요.

for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log("var", i), 0);
}

for (let j = 0; j < 3; j++) {
    setTimeout(() => console.log("let", j), 0);
}
해설 보기

var 3이 세 번, 이어서 let 0, let 1, let 2가 출력됩니다. 타이머 콜백은 두 반복문이 모두 끝난 뒤에 실행됩니다.

var i는 반복문 전체가 함께 쓰는 하나의 변수라, 세 콜백이 모두 같은 i를 기억합니다. 콜백이 실행될 때는 반복이 끝나 i가 3이므로 세 번 모두 3입니다. 반면 for의 let j는 반복할 때마다 새 바인딩이 만들어지므로, 각 콜백은 그 회차의 j를 클로저로 기억해 0, 1, 2를 출력합니다.

3. const와 Object.freeze

다음 코드를 브라우저의 비엄격 classic script에서 실행했습니다. 두 출력은 무엇일까요?

const settings = Object.freeze({ theme: "dark", layout: { cols: 2 } });
settings.theme = "light";
settings.layout.cols = 3;
console.log(settings.theme, settings.layout.cols);
해설 보기

dark 3이 출력됩니다. Object.freeze()로 고정한 settings의 자체 속성 theme은 바꿀 수 없어, 비엄격 모드에서는 대입이 오류 없이 무시되고 "dark"로 남습니다. 엄격 모드였다면 이 대입에서 TypeError가 납니다.

반면 layout 속성이 가리키는 { cols: 2 }는 고정되지 않은 별도의 객체이므로, 그 안의 cols는 3으로 바뀝니다. Object.freeze()는 한 단계만 고정하고, const는 그와 별개로 settings에 다른 값을 대입하는 것만 막습니다.

선언과 초기화의 규칙은 MDN의 let과 MDN의 const 문서에서 확인할 수 있습니다.

다음 글 화살표 함수와 this에서는 화살표 함수의 문법과, 화살표 함수가 this를 다루는 방식이 일반 함수와 어떻게 다른지 살펴봅니다.