5. let vs. const


let과 const의 차이점

차이점 1. const는 선언과 할당이 동시에 이루어져야만 한다.

const로 변수를 선언할 때에는 반드시 할당도 함께 해야만 합니다. 가만 생각해 보면 논리적으로 마땅합니다. let의 경우는 변할 수 있음을 전제로 하고 있으니, 값이 할당되지 않은 상태라 해도 의미가 있을 수 있습니다. 값의 할당 여부에 따라 다르게 동작하는 함수 등 다양한 쓰임새가 있을 것입니다. 반면 값이 할당되지 않은 변경 불가능한 변수는 과연 어디에 쓸 수 있을까요? 실로 존재할 가치가 없이 메모리만 차지하는 부담스러운 존재가 될 뿐입니다.

1
2
3
4
5
let a;   // OK.
const b;
// chrome | Error: Missing initializer in const declaration
// safari | Error: const declared variable 'b' must have an initializer.
// firefox | Error: missing = in const declaration

세 브라우저에서의 에러메시지가 표현은 다르지만 결국 같은 얘기를 하고 있습니다. 크롬과 사파리는 ‘initializer’가 반드시 있어야 하는데 없으니 에러가 났다고 합니다. initializer는 초기값을 지정하는 행위를 말합니다. 즉 const 선언시에는 초기값을 지정이 반드시 필요한데 그러한 행위가 이루어지지 않았음을 알려주는 것입니다. 파이어폭스는 const 선언에 = (할당)이 빠졌다고 알려주네요. 변경 불가능한 변수로써 존재 가치를 지니기 위한 당연한 요구입니다.

차이점 2. const로 선언한 변수는 재할당이 불가능하다.

정의 자체가 let은 ‘변경 가능한 변수’이고 const는 ‘변경 불가능한 변수 선언’이니 더이상 설명할 것이 없는 차이점입니다.

1
2
3
4
5
const c = 1 // OK.
c = 2
// chrome | Error: Assignment to constant variable.
// safari | Error: Attempted to assign to readonly property.
// firefox | Error: invalid assignment to const 'c'

역시 브라우저들이 모두 같은 얘기를 하고 있습니다. 크롬은 ‘constant variable’에 assign한 자체가 문제라고 하고, 사파리는 ‘readonly property’에 assign하려는 시도가 문제라고 합니다. 파이어폭스는 ‘유효하지 않은 할당’이라고 하네요.

개인적으로 사파리의 단어 사용은 좀 아쉽네요. ‘readonly property’라는 단어는 문자 그대로 읽기 전용 속성을 부여한 객체의 프로퍼티에 대해서만 쓰는 것이 옳다고 생각합니다. 변수는 변수일 뿐 어떤 객체의 프로퍼티가 아닙니다. 전역공간에서의 ‘var’가 전역객체의 프로퍼티와 동일한 것으로 간주하는 이상한 시스템이 존재하긴 하지만, 이는 오직 ‘var’로 선언하였거나, ‘var’로 선언한 것으로 간주할 수 있는 상황에서만 성립합니다. 오히려 TC39 위원회는 let과 const에 대해서, ‘var’에 관한 이 이상한 시스템으로 인한 문제를 해소하기 위해 전역공간에서도 전역객체의 프로퍼티와 동일시하지 않고 독립적으로 동작하도록 하였습니다. 또한 ECMAScript 명세상으로는 let, const, var로 선언한 변수 모두 LexicalEnvironment의 environmentRecord에 기록된다고 정의되어 있습니다. 그러나 이는 어디까지나 ‘이런 논리 흐름대로 동작하면 된다’는 이론일 뿐입니다. 실제 자바스크립트 엔진들은 이 정의를 저마다 다양한 방식으로 구현하고 최적화하고 있지만, 그 결과물은 결국 엔진 내부 로직일 뿐, 외부에 노출된 코드 상에서까지 객체의 프로퍼티로 간주되거나 그러한 성질을 지닌다고 볼 여지는 없습니다.

let과 const의 공통점

차이점보다는 공통점이 더 많고 더 중요합니다. 하나하나 살펴봅시다.

공통점 1. 재선언 불가

재선언은 ‘재할당’과는 다른 개념입니다. 재할당은 이미 선언된 변수에 다른 값을 다시 할당하는 것이고, 재선언은 동일한 변수명에 대해 let, const 등으로 다시 한 번 선언하는 것을 말합니다.

1
2
3
4
5
let a = 1
let a = 2 // 재선언. Error

let b = 3
b = 4 // 재할당. OK

let과 const는 모두 한 번 선언한 변수를 다시 선언할 수 없습니다. 다시 선언하려고 하면 에러가 발생합니다.

1
2
3
4
5
let a = 1
let a = 2
// chrome | Error: Identifier 'a' has already been declared
// safari | Error: Cannot declare a let variable twice: 'a'.
// firefox | Error: redeclaration of let a
1
2
3
4
5
const b = 1
const b = 2
// chrome | Error: Identifier 'b' has already been declared
// safari | Error: Cannot declare a const variable twice: 'b'.
// firefox | Error: redeclaration of const b

크롬은 ‘식별자 a/b가 이미 선언되었다’고 하고, 사파리는 ‘let 변수를 두 번 선언할 수 없다’고 합니다. 파이어폭스는 ‘let/const 변수의 재선언’이라고만 합니다. 사파리의 표현이 가장 직관적이네요.

공통점 2. TDZ: 접근불가구역

let과 const는 선언이 이뤄지기 전까지는 해당 변수에 접근할 수 없습니다. 당연한 말인 것 같지만, 기존 var에 대해서는 그렇게 동작하지 않았습니다.

자바스크립트 창시자인 Brendan Eich가 의도했는지 여부와 무관하게, 결과적으로 자바스크립트는 ‘개발하기 쉽고 유연한’ 프로그래밍 언어로 어필할 수 있는 특징들을 다수 지닌 채 탄생하였습니다. 함수 및 변수 선언 위치와 무관하게 어디서든 실행할 수 있고(호이스팅), 정수형과 부동소수점형이 별도로 존재하지 않은 채 ‘숫자형’ 하나만 존재하며, 숫자형과 문자형 등의 형변환을 명시적으로 하지 않고도 자동으로 형변환이 이뤄지기도 하고, 0, ‘’, null, undefined 등은 조건문 등에서 false로 동작하는 등이 그렇습니다.

그러나 마냥 쉽고 유연한 언어를 지향한다고만 할 수는 없게 만드는 예상치 못한 부작용도 상당히 많이 존재했습니다. 각종 버그성 특징은 차치하더라도, 전역스코프를 제외하면 일반적인 스코프가 함수스코프만 존재했다는 점, 산술연산 결과의 오차가 생각보다 크다는 점, 전역변수가 전역객체의 프로퍼티와 동일시되는 점 등이 그렇습니다.

다른 프로그래밍 언어에 익숙한 사람들이 자바스크립트를 처음 접한 경우 생각보다 손쉽게 프로그램이 의도한 대로 동작하는 것을 경험하곤 합니다. 그러다가 앞서 기술한 특이한 성질들을 접했을 때, 이를 ‘자바스크립트의 고유한 특징’으로 이해하려는 노력을 기울이기보다 ‘이상한 언어’로 취급하려는 경향을 보이는 경우가 많았습니다.

호이스팅은 개발자로 하여금 자바스크립트가 쉽고 유연하다고 생각하게 하는 측면도 있고, 이상하다거나 어렵다고 느끼게 만들기도 하는 양면성을 보이는 단적인 예입니다. 코드상에서 함수선언문이나 var로 선언한 변수를 선언한 위치보다 더 위에서 접근해도 자바스크립트는 에러 없이 조용히 넘어갑니다. 심지어 함수는 많은 경우 아무런 문제 없이 잘 동작하기도 합니다. 물론 이 때 변수의 경우에는 값이 undefined인 상태여서 문제가 될 소지가 있긴 합니다. 그런데 이런 경우에도 해당 변수에 접근한 자체가 아닌, 해당 변수의 자료형을 undefined 외의 다른 형태로 간주하여 별도의 연산을 처리할 때에 비로소 문제를 야기하곤 합니다. 함수의 경우에도 중복선언이 이뤄진 경우에는 나중에 선언된 함수만 동작하게 되므로 이 역시 문제이죠.

1
2
3
var b = a + 10
console.log(b > 0)
var a = 5

호이스팅에 의해 변수 a, b는 1행부터 접근이 가능합니다. 1행에서 a의 값은 아직 초기화가 이뤄지기 전 상태라서 undefined입니다. undefined와 10을 더하라는 연산은 자바스크립트가 자동으로 숫자형에 대한 연산으로 여겨, 숫자형이 아닌 값을 숫자형으로 형변환한 다음 실제 연산을 수행합니다. undefined를 숫자형으로 고치면 NaN이 됩니다. 이 상태에서 10을 더하면 여전히 NaN이죠. 따라서 변수 b에는 NaN이 할당됩니다. 2행에서는 b의 값이 0보다 크면 true, 그렇지 않으면 false를 출력하라고 합니다. NaN은 숫자형이긴 하지만 값의 비교에 대해 언제나 false를 반환합니다. 이후 3행에서 a에 5를 할당하고 코드 실행이 종료됩니다.

위 코드에서 개발자의 원래 의도대로 a에 5가 할당되어 true가 출력되려면 3행을 1행보다 위쪽으로 올렸어야 합니다. 그런데 개발자도 사람인지라 종종 실수를 할 수 있죠. 프로그래밍 세계에서 디버깅이 차지하는 비중은 코딩 자체보다 더 많을 수도 있습니다. 어디서 문제가 되었는지를 파악하는 데에만도 시간이 걸릴 수밖에 없기 때문입니다. 심지어 위 코드는 실행하고 결과를 받아본 후에도 문제가 있는지조차 파악하지 못할 수 있습니다. 어떠한 에러도 발생시키지 않고 조용히 처리하여 false가 ‘잘’ 출력되기 때문입니다. true를 예상했는데 false가 왜 나왔을지를 고민하며 코드 전반을 살펴보다가 a 변수가 3행에서 선언 및 할당된 것을 발견해야만 드디어 코드 수정을 할 수 있습니다. 예제 코드가 짧으니 망정이지, 긴 코드들로 이루어진 일반적인 업무 환경에서 이러한 오류를 찾아내는 데에는 생각보다 많은 시간이 필요할 수 있습니다.

디버깅 시간을 줄이기 위해서는 예상과 다른 결과의 원인이 무엇인지를 가급적 빨리 파악하는 것이 중요합니다. 그러기 위해서는 1행에서 a에 접근하려 할 때부터 에러 메시지가 노출된다면 개발자에겐 더없이 좋을 것입니다. let과 const가 바로 이렇게 동작합니다.

1
2
3
4
5
6
let b = a + 10
// chrome | Error: a is not defined
// safari | Error: Cannot access uninitialized variable.
// firefox | Error: can't access lexical declaration 'a' before initialization
console.log(b > 0)
let a = 5
1
2
3
4
5
6
const b = a + 10;
// chrome | Error: Cannot access 'a' before initialization
// safari | Error: Cannot access uninitialized variable.
// firefox | Error: can't access lexical declaration 'a' before initialization
console.log(b > 10;
const a = 5;

역시 각 브라우저의 에러메시지가 표현 방식은 다르지만 에러의 원인이 무엇인지 충분히 설명하고 있습니다. 그 중 크롬만이 유일하게 let과 const를 구분하여 다르게 표현하고 있는데, 그 중에서도 let에 대한 표현이 가장 정확해 보입니다. a를 선언한 방식이 let이든 const이든, 1행에서의 상태는 변수 a가 ‘아직 선언되기 전’인 상태이므로, 선언을 전제로 한 ‘초기화’ 내지 ‘할당’에 대한 메시지를 표시할 이유는 없습니다. 즉 let과 const 모두에 대해 ‘not defined’ 라는 메시지를 출력하는 것이 타당합니다. 다만 const의 경우 선언과 동시에 할당(초기화)가 반드시 이루어져야 하니, 굳이 선언과 초기화를 구분할 이유가 없긴 합니다. 따라서 let에 대해서는 ‘아직 정의되지 않았음’을, const에 대해서는 ‘초기화 되기 전에는 접근할 수 없음’을 알려주는 크롬의 에러메시지가 가장 도움이 된다고 봅니다.

TDZ는 Temporal Dead Zone의 약자입니다. 직역하면 임시사망지역, 임시사각지대 정도가 되겠습니다. 그러나 이보다는 ‘접근불가구역’이라고 표현하는 것이 의미가 더 잘 와닿는 것 같습니다. let과 const로 선언한 변수는 선언이 실제로 이뤄지기 전까지 그 변수에 접근할 수 없습니다. 그리고 이렇게 접근할 수 없는 구역을 TDZ라고 칭합니다. 명세에 기재된 것은 아니지만 전세계 자바스크립트 개발자들 사이에서 널리 통용되는 명칭입니다.

TDZ와 스코프

TDZ와 스코프의 관계로부터 발생하는 재미 있는(?) 현상이 있습니다.

1
2
3
4
5
6
7
8
9
if (true) {
// 블록스코프 A
let a = 1
if (true) {
// 블록스코프 B
console.log(a) // (?)
let a = 2
}
}

1행의 조건문에 의해 7번줄까지의 블록스코프 A가 생성되었습니다. 2행에서 선언한 변수 a가 유효한 범위는 1행의 조건문에 의한 블록스코프 A 내부입니다. 3행의 다시 조건문에 의해 6번줄까지의 블록스코프 B가 생성되었습니다. 이제 4행에서 변수 a에 접근하고자 합니다. 그런데 5행에서는 2행의 변수와 동일한 식별자를 지닌 변수를 선언했습니다. 4행은 블록스코프 B에 속하면서, B의 변수 a가 선언되는 위치인 5행보다 코드상 위에 위치하고 있는, 블록스코프 B의 TDZ 영역에 속하는 위치입니다. 이 위치에서는 어떤 결과가 출력될까요? 만약 자바스크립트 엔진이 4행 위치에서 5행의 변수 선언보다 ‘먼저 선언된’ 외부 변수에 대한 접근을 우선시 한다면, 블록스코프 A에서 선언한 변수 a에 접근하여 1을 출력할 것입니다.

1
2
3
// chrome  | Error: Cannot access 'a' before initialization
// safari | Error: Cannot access uninitialized variable.
// firefox | Error: can't access lexical declaration 'a' before initialization

실제로는 TDZ 에러가 출력되었습니다. 그렇다면 자바스크립트 엔진은 스코프 내부에서 선언한 변수가 있는 한, 외부에 동일한 식별자가 존재하건 존재하지 않건 상관 없이 무조건 내부에서 선언한 변수에 먼저 접근하고자 한다는 것을 알 수 있습니다. 내부에서 선언한 변수가 존재하는 한, 설령 TDZ에 속한다 하더라도 상위 스코프에 대한 검색을 하지 않습니다.

사용자가 어떤 변수에 접근하고자 하면 자바스크립트 엔진은 해당 코드에서 가장 가까운 스코프에서 먼저 해당 변수를 검색하고, 없으면 보다 상위의 스코프에서 해당 변수를 검색합니다. 이런 순서로 계속 상위 스코프로 올라가다 보면 마지막에는 늘 전역 스코프까지 탐색하게 됩니다. 이런 검색 과정을 스코프 체이닝(scope chaining)이라 합니다. 스코프가 체인처럼 줄줄이 연결되어 있는 이미지를 떠올리시면 되겠습니다.