ES6 In Depth: let 과 const

ES6 In Depth 시리즈는 ECMAScript 표준 6번째 에디션(줄여서 ES6)을 통해 JavaScript 에 새로 추가된 요소들을 살펴보는 시리즈입니다.

제가 오늘 이야기하려고 하는 주제는 처음 보기에는 별것 아닌 것 같지만, 아주 중요한 것입니다.

Brendan Eich 는 1995년 JavaScript 첫 버전을 설계할 때 많은 잘못을 저질렀습니다. 그가 저지른 잘못은 그 이후로 랭귀지의 일부로 계속 이어져 오고 있습니다. 예를 들어 Date 객체와 어떤 객체들을 우연히 곱했을 때 그 결과가 자동으로 NaN 이 되는 특성은 그의 잘못이라고 생각합니다. 하지만, 중요한 사안들에 관해서 그는 올바른 결정을 내렸습니다. 돌이켜 봤을 때, 객체, 프로토타입, 1급 객체로 취급되는 함수(first-class functions)와 렉시컬 스코핑(lexical scoping), 본질적으로 동적인 특성(mutability by default)은 그의 올바른 결정이었습니다. JS 랭귀지는 아주 좋은 체계를 갖고 있습니다. JS 랭귀지는 쓰면 쓸수록 그 진가를 알게 됩니다.

Brendan 이 만든 설계 상의 결정이 또 하나 있습니다. 오늘 글에서 다룰 내용입니다. 이 결정은 제가 생각하기에 명백한 실수입니다. 이것은 작다면 작은 일이고, 사소하다면 사소한 일입니다. 아마도 JS 랭귀지를 수년간 쓰면서 이 문제를 느끼지 못했을 수도 있습니다. 하지만 이것을 문제라고 말하는 이유는, 이 문제가 “랭귀지의 장점”이라고 분류되는 특징에 포함되어 있기 때문입니다.

이 문제는 변수와 관련된 것입니다.

문제점 #1: 블럭이 스코프를 결정하지 않습니다

규칙은 아주 단순합니다: JS 함수 안에서 선언한 var스코프는 해당 함수 전체입니다. 하지만 이 규칙 때문에 발생하는 골치 아픈 상황이 2개 있습니다.

하나는 어떤 블럭 안에 선언한 변수의 스코프가 해당 블럭이 아니라는 점입니다. 변수의 스코프는 함수입니다.

아마 지금까지 이런 사실을 알지 못했을 수도 있습니다. 하지만 이것은 그냥 넘어갈 수 있는 사소한 문제가 아닙니다. 이 문제로 인해 발생하는 난감한 상황을 생각해 봅시다.

먼저 작업 코드가 t 라는 이름의 변수를 사용한다고 가정해 봅시다.

function runTowerExperiment(tower, startTime) {
  var t = startTime;

  tower.on("tick", function () {
    ... code that uses t ...
  });
  ... more code ...
}

여기까지는 모든 것이 훌륭하게 동작합니다. 이제 우리는 볼링 공의 속도를 재는 코드를 추가해야 합니다. 이를 위해 간단한 if-구문을 안쪽의 콜백 함수에 추가합시다.

function runTowerExperiment(tower, startTime) {
  var t = startTime;

  tower.on("tick", function () {
    ... code that uses t ...
    if (bowlingBall.altitude() <= 0) {
      var t = readTachymeter();
      ...
    }
  });
  ... more code ...
}

앗, 우리는 무심코 t 라는 이름의 변수를 또 만들 었습니다. 이제, 지금까지 정상적으로 동작하던 “code that uses t” 구문의 변수 t 는 안쪽에 새로 선언한 변수 t 를 참조합니다. 기존에 존재하던 바깥쪽의 변수를 참조하지 않습니다.

JavaScript 에서 var 의 스코프가 결정되는 방식은 포토샵의 페인트-칠하기(bucket-of-paint) 동작 방식과 비슷합니다. var 의 스코프는 변수가 선언된 곳으로부터 양쪽으로 퍼져 나갑니다. 윗쪽과 아랫쪽으로요. 그래서 함수 경계를 만날 때까지 계속 퍼져 갑니다. t 변수의 스코프가 윗쪽으로 끝까지 퍼져 나가기 때문에, 결국 함수 시작 시점에 변수 t 가 생성됩니다. 이것을 호이스팅(hoisting)이라고 부릅니다. 저는 JS 엔진이 작은 크레인으로 var 구문과 function 구문을 함수의 최상단으로 끌어 올리는 이미지를 떠올리곤 합니다.

호이스팅에는 장점도 있습니다. 호이스팅이 없다면, 글로벌 스코프에서 적절하게 동작하는 많은 수의 그럴듯한 기법들이 IIFE 안에서 동작하지 않게 될 것입니다. 하지만 지금은 호이스팅 때문에 버그가 발생한 상황입니다. 변수 t 를 사용하는 모든 연산 결과가 NaN 이 될 것입니다. 작업 코드가 예제 코드보다 훨씬 크다면 문제의 원인을 찾아내기가 더욱 힘들 것입니다.

새로 추가한 코드로 인해 그 코드보다 윗쪽에 있는 코드에서 에러가 발생했습니다. 이것이 이상하게 느껴지는 것은 저뿐인가요? 우리는 결과가 원인보다 선행하는 것을 상상하지 못합니다.

하지만 var두번째 문제에 비하면 이것은 약과입니다.

문제점 #2: 루프 안의 변수가 과도하게 공유됩니다

아래 코드를 실행하면 어떤 결과가 생길지 생각해 보세요. 아주 자명한 코드입니다.

var messages = ["Hi!", "I'm a web page!", "alert() is fun!"];

for (var i = 0; i < messages.length; i++) {
  alert(messages[i]);
}

만약 당신이 이 시리즈를 계속 지켜봤다면, 제가 예제 코드에 alert() 을 즐겨 사용한다는 것을 아실 겁니다. 그리고 아마도 alert() 이 피해야 할 API 라는 것도 아실 겁니다. alert() 은 동기적 API 입니다. 그래서 alert 경고창이 보여지는 동안에는 입력장치 이벤트가 전달되지 않습니다. 그래서 사용자가 OK 버튼을 클릭하기 전까지 당신의 JS 코드는 물론 UI 전체가 멈춥니다.

이런 이유 때문에 alert() 을 웹 페이지에 사용하는 것은 잘못된 선택입니다. 하지만 같은 이유 때문에 alert() 을 교육 용도로 사용하는 것은 훌륭한 선택입니다. 그래서 저는 alert() 을 즐겨 사용했습니다.

하지만, 이번에는 그런 투박하고 나쁜 코딩 습관을 중단하라는 조언을 수용해야 할 것 같습니다. 말하는 고양이 코드를 만들어야겠습니다…

var messages = ["Meow!", "I'm a talking cat!", "Callbacks are fun!"];

for (var i = 0; i < messages.length; i++) {
  setTimeout(function () {
    cat.say(messages[i]);
  }, i * 1500);
}

이 코드가 제대로 동작하지 않는 것을 확인하세요!

무언가 잘못되었습니다. 고양이가 3개의 메시지를 순서대로 말하지 않고, “undefined” 만 세번 말합니다.

무엇이 버그인지 찾을 수 있나요?

(Photo of a caterpillar well camouflaged on the bark of a tree. Gujarat, India.)

Photo credit: nevil saveri

문제는 이 코드에 변수 i 가 오직 하나뿐이라는 것입니다. 변수 i 는 루프에서 공유될 뿐 아니라 세번의 콜백 호출에서도 공유됩니다. 루프가 종료될 때, 변수 i 의 값은 3 입니다 (왜냐하면 messages.length 의 값이 3 이기 때문입니다). 그리고 콜백은 아직 한번도 호출되지 않았습니다.

그래서 처음으로 타임아웃이 만료되어 콜백 함수가 호출될 때, cat.say(messages[i]) 코드는 messages[3] 을 참조합니다. 분명히 그 값은 undefined 입니다.

이 문제를 해결하는 다양한 방법이 있습니다 (여기 한가지 방법이 있습니다). 이것이 var 스코프 규칙 때문에 발생하는 두번째 문제입니다. 어쨌든 이런 문제들은 문제들 자체를 겪지 않는 것이 최선입니다.

let 이 새로운 var 입니다

지금에 와서 JavaScript 의 설계 오류들을 수정할 수 있는 방법은 거의 없습니다 (다른 프로그래밍 랭귀지들도 마찬가지입니다. 하지만 JavaScript 는 특히 더합니다). 웹에 존재하는 기존의 수많은 JS 코드들을 생각하면, 하위 호환성을 고려할 때, JS 의 행동양식을 바꾸는 것은 불가능합니다. 표준화 위원회도 그럴 힘이 없습니다. 그래서 세미콜론 자동추가 같은 JavaScript 의 이상한 특성들이 바뀌지 않고 계속 존재하는 것입니다. 브라우저 개발회사들은 급진적인 변화를 만들지 않습니다. 급진적인 변화는 결국 자기 브라우저의 사용자들을 괴롭히는 결과가 되기 때문입니다.

그래서 십년쯤 전에, Brendan Eich 가 이 문제를 해소하려고 결심했을 때, 그에게는 방법이 하나밖에 없었습니다.

그는 새로운 키워드를 추가해야 했습니다. 바로 let 키워드입니다. let 키워드는 var 키워드처럼 변수를 선언할 때 사용합니다. 하지만 개선된 스코프 규칙이 적용됩니다.

let 키워드를 사용하는 코드는 다음과 같습니다.

let t = readTachymeter();

또는 다음과 같습니다.

for (let i = 0; i < messages.length; i++) {
  ...
}

letvar 와 다릅니다. 그래서 기존 코드를 단순하게 찾기-바꾸기 하면 코드가 제대로 동작하지 않을 것입니다. (의도치 않게) var 키워드의 특성에 의존하는 코드가 있을 수 있기 때문입니다. 하지만 대부분의 경우, 새롭게 ES6 코드를 작성한다면, 모든 경우에 var 대신 let 을 사용하세요. 이제부터 슬로건은 “let 이 새로운 var” 입니다.

정확히 letvar 와 무엇이 다른가요? 물어봐주셔서 감사합니다!

  • let 은 블럭을 기준으로 스코프를 결정합니다 (block-scoped). let 으로 선언한 변수의 스코프는 변수 선언을 둘러싸는 블럭입니다. 변수 선언을 둘러싸는 함수가 아닙니다.

    let 을 사용하더라도 호이스팅이 적용되지만, 그 효과가 무지막지하지 않습니다. runTowerExperiment 예제 코드의 문제는 varlet 으로 바꾸기만 하면 해결됩니다. 모든 곳에 let 을 사용하면, 그런 버그는 절대 겪지 않을 것입니다.

  • 글로벌 let 변수는 글로벌 객체의 속성이 아닙니다. 즉, 그렇게 정의한 변수를 window.variableName 코드로 접근할 수 없습니다. 그렇게 정의한 변수는 웹 페이지 안에서 실행되는 모든 JS 코드들을 둘러싸는 보이지 않는 개념적인 블럭 안에 존재합니다.

  • for (let x...) 형태의 루프는 루프를 반복할 때마다 매번 x 변수를 새로 바인딩합니다.

    이것은 아주 사소한 차이입니다. 하지만 이것은 만약 for (let...) 루프를 실행할 경우, 그리고 말하는 고양이 예제처럼 해당 루프가 클로져(closure)를 포함하고 있을 경우, 각 클로져가 서로 다른 루프 변수를 참조한다는 것을 의미합니다. 모든 클로져가 같은 변수를 참조하는 지금과는 다르게 말이죠.

    그래서 말하는 고양이 예제의 경우도 단지 varlet 으로 바꾸기만 하면 문제가 해결됩니다.

    이것은 3가지 종류의 for 루프 모두에 적용됩니다. forof, forin, 그리고 세미콜론을 함께 쓰는 C 언어 스타일의 for 루프 모두에 적용됩니다.

  • let 변수를 선언 전에 참조하는 것은 에러입니다. 제어흐름이 변수 선언에 다다르기 전까지의 변수 값은 uninitialized 입니다. 예를 들면 다음과 같습니다.

    function update() {
      console.log("current time:", t);  // ReferenceError
      ...
      let t = readTachymeter();
    }
    

    이 규칙 덕분에 버그의 원인을 쉽게 파악할 수 있습니다. 우리는 연산 결과로 NaN 을 얻는 대신, 문제가 발생하는 라인 위치에서 명시적인 예외 오류를 얻게 됩니다.

    변수가 존재하지만 초기화되지 않은 스코프 안의 영역을 TDZ(temporal dead zone) 라고 부릅니다. 저는 TDZ 라는 멋진 용어가 공상 과학 소설 분야에 커다란 영감을 주어서 멋진 소설이 나오기를 기대합니다. 아직은 없지만 말이죠.

    (성능에 관한 소소한 이야기: 거의 대부분의 경우, 코드를 그냥 보는 것만으로도 변수 선언문이 실행되었는지 판단할 수 있습니다. 그래서 JavaScript 엔진도 변수를 참조할 때마다 해당 변수가 초기화되었는지 일일이 확인하지 않습니다. 그런데, 클로져에서는 이 문제가 다소 모호해집니다. 그래서 JavaScript 엔진도 이 경우에는 런-타임에 변수의 초기화 여부를 체크합니다. 이는 letvar 보다 아주 약간 느릴 수 있다는 것을 의미합니다.)

    (있을 뻔했던 스코프 규칙: 몇몇 프로그래밍 랭귀지들은 변수의 스코프를 변수의 선언 지점부터 시작합니다. 변수 선언문을 둘러싸는 블럭의 시작 지점까지 거슬러 올라가지 않습니다. 표준화 위원회는 let 에 그런 스코프 규칙을 적용할지 고민했습니다. 그렇게 하면, ReferenceError 에러를 일으키는 예제 코드의 t 구문이 아랫쪽의 let t 스코프에 있지 않게 됩니다. 그래서 나중에 선언된 변수를 전혀 참조하지 않을 것입니다. 대신 외부 스코프의 t 를 참조할 것입니다. 하지만 이런 접근 방식은 클로져와 잘 맞지 않았습니다. 또 함수 호이스팅과도 잘 맞지 않았습니다. 그래서 이 방안은 폐기되었습니다.)

  • let 변수 선언을 반복하면 SyntaxError 에러입니다.

    이 규칙도, 역시나, 사소한 실수를 예방하기 위해 도입됐습니다. 그런데 이 규칙 때문에 기계적으로 varlet 으로 일괄 치환하면 문제가 일어날 가능성이 높습니다. 글로벌 let 변수들 때문입니다.

    만약에 여러개의 스크립트 파일들이 같은 이름의 글로벌 변수를 선언하고 있는 경우라면 var 를 계속 사용하는 것이 좋습니다. 만약 그런 경우 let 을 사용하도록 코드를 바꾸면, 두번째 이후로 로딩되는 스크립트 파일들이 에러를 낼 것입니다.

    이 에러를 피하려면 ES6 모듈(module)을 사용해야 합니다. ES6 모듈에 대해서는 별도의 글을 통해 따로 설명하겠습니다.

(문법에 관한 소소한 이야기: let 은 strict mode 를 위한 예약어입니다. non-strict-mode 에서는, 하위 호환성 때문에 let 이라는 이름으로 변수, 함수, 인자를 만들 수 있습니다. var let = 'q'; 같은 코드를 만드는 것이 가능합니다! 정말로 그런 코드를 만들지는 않겠지만요. 그러나 let let; 은 절대 허용되지 않습니다.)

이런 차이점에도 불구하고 letvar 와 무척 많이 닮아 있습니다. 둘 다 콤마를 구분자로 써서 여러 개의 변수를 한꺼번에 선언할 수 있습니다. 또 둘 다 디스트럭처링을 지원합니다.

참고로 class 선언은 var 보다 let 과 비슷한 방식으로 동작합니다. 만약 class 선언을 담고 있는 스크립트 파일들을 여러 개 로드하면 두번째 이후부터 로드되는 파일은 클래스 중복선언 에러를 낼 것입니다.

const

마지막으로, 하나만 더!

ES6 는 let 과 함께 사용할 수 있는 또하나의 키워드를 도입했습니다. const 키워드입니다.

const 를 써서 선언한 변수는 let 을 써서 선언한 변수와 한 가지만 빼고 똑같습니다. const 를 써서 선언한 변수에는 값을 할당할 수 없습니다. 오직 변수를 선언하는 시점에만 값을 할당할 수 있습니다. 그외의 경우는 모두 SyntaxError 에러입니다.

const MAX_CAT_SIZE_KG = 3000; // ?

MAX_CAT_SIZE_KG = 5000; // SyntaxError
MAX_CAT_SIZE_KG++; // nice try, but still a SyntaxError

합리적이게도, 값을 할당하지 않는 const 선언은 에러입니다.

const theFairest;  // SyntaxError, you troublemaker

네임스페이스는 비밀요원

“네임스페이스는 아주 멋진 아이디어입니다 — 자주 써먹읍시다!” —Tim Peters, “The Zen of Python”

무대 뒷편의 이야기지만, 중첩된 스코프는 프로그래밍 랭귀지의 핵심 개념들 중 하나입니다. 중첩된 스코프 개념이 처음 사용된 것은 아마도 ALGOL 일 것입니다. 57년 정도 된 개념입니다. 그러나 지금도 확실히 유용한 개념입니다.

ES3 이전에는, JavaScript 에 글로벌 스코프와 함수 스코프만 존재했습니다 (with 구문은 무시합시다). ES3 는 trycatch 구문을 도입했는데, 다시 말하면 이것은 새로운 종류의 스코프를 추가한 것입니다. catch 블럭에서 사용하는 예외 변수를 위한 스코프 말입니다. ES5 는 strict eval() 을 위한 스코프를 추가했습니다. ES6 는 for 루프를 위한 스코프, 새로운 글로벌 let 스코프, 모듈 스코프, 그리고 인자의 디폴트 값을 계산하기 위한 스코프를 추가했습니다.

ES3 이후 추가된 스코프들은 JavaScript 의 절차적이고 객체지향적인 특성이 클로져만큼 적절하고, 정확하고, 직관적으로 동작할 수 있도록 만들었습니다. 이들은 클로져와 매끄럽게 연동됩니다. 어쩌면 당신은 이런 스코프들의 존재를 지금까지 모르고 지냈을지도 모릅니다. 만약 그랬다면 JavaScript 랭귀지가 자기 역할을 잘 수행한 것입니다.

letconst 를 지금 당장 쓸 수 있나요?

그렇습니다. 웹에서 쓰려면, Babel, Traceur, TypeScript 같은 ES6 컴파일러를 사용해야 합니다 (Babel 과 Traceur 는 아직 TDZ 를 지원하지 않습니다).

io.js 는 letconst 를 지원합니다. 하지만 strict-mode 에서만 지원합니다. Node.js 도 io.js 와 같습니다. 하지만 --harmony 옵션이 필요합니다.

Brendan Eich 가 9년전 Firefox 에 let 을 처음 구현했습니다. 이 기능은 표준화 과정을 거치면서 완전히 새로 설계됐습니다. Shu-yu Guo 가 Firefox 의 구현을 표준에 맞게 업그레이드했습니다. Shu-yu Guo 의 업그레이드 작업은 Jeff Walden 과 다른 많은 사람들의 리뷰를 거쳤습니다.

이제 마칠 때가 되었습니다. ES6 의 특징을 살펴보는 우리의 긴 여행도 끝나 갑니다. 앞으로 2개의 글을 통해 정말 간절히 기다려왔던 ES6 의 기능을 설명할 계획입니다. 그전에, 다음 글에서 우리의 새로운(new) 지식을 확장해(extends) 보려고 합니다. 정말 놀라울(super) 것입니다. Eric Faust 가 다시 돌아와 설명할 ES6 서브클래싱 in-depth 를 함께 해주세요.

이 글은 Jason Orendorff 가 쓴 ES6 In Depth: let and const 의 한국어 번역본입니다.

작성자: ingeeKim

"누구에게나 평등하고 자유로운 웹"에 공감하는 직장인.

ingeeKim가 작성한 문서들…


댓글이 없습니다.

댓글 쓰기