2024년 7월 7일

변수의 유효범위와 클로저

자바스크립트는 함수 지향 언어입니다. 이런 특징은 개발자에게 많은 자유를 줍니다. 함수를 동적으로 생성할 수 있고, 생성한 함수를 다른 함수에 인수로 넘길 수 있으며, 생성된 곳이 아닌 곳에서 함수를 호출할 수도 있기 때문입니다.

함수 내부에서 함수 외부에 있는 변수에 접근할 수 있다는 사실은 앞서 학습해서 알고 계실 겁니다.

그런데 함수가 생성된 이후에 외부 변수가 변경되면 어떤 일이 발생할까요? 함수는 새로운 값을 가져올까요? 아니면 생성 시점 이전의 값을 가져올까요?

매개변수를 통해 함수를 넘기고 이 함수를 저 멀리 떨어진 코드에서 호출할 땐 어떤 일이 발생할까요? 함수는 호출되는 곳을 기준으로 외부 변수에 접근할까요?

이젠 이런 간단한 시나리오부터 시작해 좀 더 복잡한 시나리오를 다룰 수 있도록 지식을 확장해 봅시다.

여기선 let, const로 선언한 변수만 다룹니다.

자바스크립트에서는 3개의 키워드를 사용해 변수를 선언할 수 있습니다. 모던한 방식인 let, const가 있고, 과거의 잔재인 var도 있습니다.

  • 이번 주제의 모든 예시에선 let으로 선언한 변수를 사용할 예정입니다.
  • const로 선언한 변수 역시 let 변수와 동일하게 동작합니다. 따라서 예시의 letconst로 바꿔도 동일한 동작을 기대할 수 있습니다.
  • varletconst로 선언한 변수와 몇 가지 중요한 차이가 있습니다. 자세한 내용은 오래된 var에서 다루겠습니다.

코드 블록

코드 블록 {...} 안에서 선언한 변수는 블록 안에서만 사용할 수 있습니다.

예시:

{
  // 지역 변수를 선언하고 몇 가지 조작을 했지만 그 결과를 밖에서 볼 수 없습니다.

  let message = "안녕하세요."; // 블록 내에서만 변숫값을 얻을 수 있습니다.

  alert(message); // 안녕하세요.
}

alert(message); // ReferenceError: message is not defined

이런 블록의 특징은 특정 작업을 수행하는 코드를 한데 묶어두는 용도로 활용할 수 있습니다. 블록 안엔 작업 수행에만 필요한 변수가 들어갑니다.

{
  // 메시지 출력
  let message = "안녕하세요.";
  alert(message);
}

{
  // 또 다른 메시지 출력
  let message = "안녕히 가세요.";
  alert(message);
}
블록이 없으면 에러가 발생합니다.

이미 선언된 변수와 동일한 이름을 가진 변수를 별도의 블록 없이 let으로 선언하면 에러가 발생합니다.

// 메시지 출력
let message = "안녕하세요.";
alert(message);

// 또 다른 메시지 출력
let message = "안녕히 가세요."; // SyntaxError: Identifier 'message' has already been declared
alert(message);

if, for, while 등에서도 마찬가지로 {...} 안에서 선언한 변수는 오직 블록 안에서만 접근 가능합니다.

if (true) {
  let phrase = "안녕하세요!";

  alert(phrase); // 안녕하세요!
}

alert(phrase); // ReferenceError: phrase is not defined

if 블록 밖에 있는 alertphrase에 접근할 수 없기 때문에 위 예시를 실행하면 에러가 발생합니다.

이런 특징은 변수의 유효 범위를 블록 범위, 특히 if 분기문 범위로 한정시킬 수 있어서 아주 유용합니다.

if 뿐만 아니라 for, while 반복문에서도 동일한 특징이 적용됩니다.

for (let i = 0; i < 3; i++) {
  // 변수 i는 for 안에서만 사용할 수 있습니다.
  alert(i); // 0, 1, 2가 출력
}

alert(i); // ReferenceError: i is not defined

참고로 for문에서 for 옆 괄호 안에서 선언한 변수(예시에서 let i)는 {...} 밖에 있긴 하지만 블록 {...}에 속하는 코드로 취급됩니다.

중첩 함수

함수 내부에서 선언한 함수는 ‘중첩(nested)’ 함수라고 부릅니다.

자바스크립트에선 손쉽게 중첩 함수를 만들 수 있습니다.

중첩 함수는 아래와 같이 코드를 정돈하는데 사용할 수 있습니다.

function sayHiBye(firstName, lastName) {

  // 헬퍼(helper) 중첩 함수
  function getFullName() {
    return firstName + " " + lastName;
  }

  alert( "Hello, " + getFullName() );
  alert( "Bye, " + getFullName() );

}

위 예시에서 외부 변수에 접근해 이름 전체를 반환해주는 중첩 함수 getFullName()은 편의상 만든 함수입니다. 이렇게 자바스크립트에선 중첩 함수가 흔히 사용됩니다.

중첩 함수는 새로운 객체의 프로퍼티 형태나 중첩 함수 그 자체로 반환될 수 있다는 점에서 흥미롭습니다. 이렇게 반환된 중첩 함수는 어디서든 호출해 사용할 수 있습니다. 물론 이때도 외부 변수에 접근할 수 있다는 사실은 변함없습니다.

아래 함수 makeCounter는 호출될 때마다 다음 숫자를 반환해주는 ‘카운터’ 함수를 만듭니다.

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1
alert( counter() ); // 2

예시의 makeCounter는 아주 단순하지만, 여기에 약간의 변형을 가하면 난수 생성기 같은 실용성 있는 함수를 만들 수 있습니다.

그런데 makeCounter를 살펴보다 보면 “counter를 여러 개 만들었을 때, 이 함수들은 서로 독립적일까? 함수와 중첩 함수 내 count 변수엔 어떤 값이 할당될까?” 같은 의문이 들기 마련입니다.

이런 의문을 해결할 수 있게 되면 자바스크립트 숙련도가 크게 올라갑니다. 좀 더 복잡한 시나리오도 다룰 수 있게 되죠. 자, 긴말할 필요 없이 바로 시작해봅시다.

렉시컬 환경

호랑이 굴에 들어가야 합니다!

앞으로 다룰 내용은 어렵습니다.

지금까진 로우 레벨 영역은 최대한 피하는 방식으로 튜토리얼을 전개했습니다. 하지만 여기서 다루는 내용을 알지 못하면 자바스크립트를 완전히 이해할 수 없습니다. 호랑이 굴에 들어가 호랑이를 잡아봅시다!

명확한 이해를 돕기 위해 설명을 몇 개의 단계로 나눠서 진행하겠습니다.

단계 1. 변수

자바스크립트에선 실행 중인 함수, 코드 블록 {...}, 스크립트 전체는 렉시컬 환경(Lexical Environment) 이라 불리는 내부 숨김 연관 객체(internal hidden associated object)를 갖습니다.

렉시컬 환경 객체는 두 부분으로 구성됩니다.

  1. 환경 레코드(Environment Record) – 모든 지역 변수를 프로퍼티로 저장하고 있는 객체입니다. this 값과 같은 기타 정보도 여기에 저장됩니다.
  2. 외부 렉시컬 환경(Outer Lexical Environment) 에 대한 참조 – 외부 코드와 연관됨

'변수’는 특수 내부 객체인 환경 레코드의 프로퍼티일 뿐입니다. '변수를 가져오거나 변경’하는 것은 '환경 레코드의 프로퍼티를 가져오거나 변경’함을 의미합니다.

아래 두 줄짜리 코드엔 렉시컬 환경이 하나만 존재합니다.

이렇게 스크립트 전체와 관련된 렉시컬 환경은 전역 렉시컬 환경(global Lexical Environment)이라고 합니다.

위 그림에서 네모 상자는 변수가 저장되는 환경 레코드(1)를 나타내고 네모 상자 옆, 오른쪽을 향하는 화살표는 외부 렉시컬 환경에 대한 참조(2)를 나타냅니다. 전역 렉시컬 환경은 외부 참조를 갖지 않기 때문에 화살표가 null을 가리키는 걸 확인할 수 있습니다.

코드가 실행되고 실행 흐름이 이어져 나가면서 렉시컬 환경은 변화합니다.

좀 더 긴 코드를 살펴봅시다.

우측의 네모 상자들은 코드가 한 줄, 한 줄 실행될 때마다 전역 렉시컬 환경이 어떻게 변화하는지 보여줍니다.

  1. 스크립트가 시작되면(execution start) 스크립트 내에서 선언한 변수 전체가 렉시컬 환경에 올라갑니다(pre-populated).
    • 이때 변수의 상태는 특수 내부 상태(special internal state)인 'uninitialized’가 됩니다. 자바스크립트 엔진은 uninitialized 상태의 변수를 인지하긴 하지만, let을 만나기 전까진 이 변수를 참조할 수 없습니다.
  2. let phrase가 나타났네요. 아직 값을 할당하기 전이기 때문에 프로퍼티 값은 undefined입니다. phrase는 이 시점 이후부터 사용할 수 있습니다.
  3. phrase에 값이 할당되었습니다.
  4. phrase의 값이 변경되었습니다.

아직까진 어려운 게 없어 보이네요. 지금까지 배운 내용을 요약해 봅시다.

  • 변수는 특수 내부 객체인 환경 레코드의 프로퍼티입니다. 환경 레코드는 현재 실행 중인 함수와 코드 블록, 스크립트와 연관되어 있습니다.
  • 변수를 변경하면 환경 레코드의 프로퍼티가 변경됩니다.
렉시컬 환경은 명세서에만 존재합니다.

'렉시컬 환경’은 명세서에서 자바스크립트가 어떻게 동작하는지 설명하는 데 쓰이는 ‘이론상의’ 객체입니다. 따라서 코드를 사용해 직접 렉시컬 환경을 얻거나 조작하는 것은 불가능합니다.

자바스크립트 엔진들은 명세서에 언급된 사항을 준수하면서 엔진 고유의 방법을 사용해 렉시컬 환경을 최적화합니다. 사용하지 않는 변수를 버려 메모리를 절약하거나 다양한 내부 트릭을 써서 말이죠.

단계 2. 함수 선언문

함수는 변수와 마찬가지로 값입니다.

다만 함수 선언문(function declaration)으로 선언한 함수는 일반 변수와는 달리 바로 초기화된다는 점에서 차이가 있습니다.

함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있습니다. 변수는 let을 만나 선언이 될 때까지 사용할 수 없지만 말이죠.

선언되기 전에도 함수를 사용할 수 있는 것은 바로 이 때문입니다.

아래 그림은 스크립트에 함수를 추가했을 때 전역 렉시컬 환경 초기 상태가 어떻게 변하는지 보여줍니다.

이런 동작 방식은 함수 선언문으로 정의한 함수에만 적용됩니다. let say = function(name)...같이 함수를 변수에 할당한 함수 표현식(function expression)은 해당하지 않습니다.

단계 3. 내부와 외부 렉시컬 환경

함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어집니다. 이 렉시컬 환경엔 함수 호출 시 넘겨받은 매개변수와 함수의 지역 변수가 저장됩니다.

say("John")을 호출하면 아래와 같은 내부 변화가 일어납니다(현재 실행 흐름은 함수 내부, 화살표로 표시한 줄(alert 문)에 멈춰있는 상황입니다).

함수 호출 중엔 호출 중인 함수를 위한 내부 렉시컬 환경과 내부 렉시컬 환경이 가리키는 외부 렉시컬 환경, 총 2개의 렉시컬 환경을 갖게 됩니다.

  • 예시의 내부 렉시컬 환경은 현재 실행 중인 함수인 say에 상응합니다. 내부 렉시컬 환경엔 함수의 인자인 name으로부터 유래한 프로퍼티 하나만 있네요. say("John")을 호출했기 때문에, name의 값은 "John"이 됩니다.
  • 예시의 외부 렉시컬 환경은 전역 렉시컬 환경입니다. 전역 렉시컬 환경은 phrase와 함수 say를 프로퍼티로 갖습니다.

그리고 내부 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 갖습니다.

코드에서 변수에 접근할 땐, 먼저 내부 렉시컬 환경을 검색 범위로 잡습니다. 내부 렉시컬 환경에서 원하는 변수를 찾지 못하면 검색 범위를 내부 렉시컬 환경이 참조하는 외부 렉시컬 환경으로 확장합니다. 이 과정은 검색 범위가 전역 렉시컬 환경으로 확장될 때까지 반복됩니다.

전역 렉시컬 환경에 도달할 때까지 변수를 찾지 못하면 엄격 모드에선 에러가 발생합니다. 참고로 비 엄격 모드에선 정의되지 않은 변수에 값을 할당하려고 하면 에러가 발생하는 대신 새로운 전역 변수가 만들어지는데, 이는 하위 호환성을 위해 남아있는 기능입니다.

예시와 그림을 보면서 변수 검색이 어떻게 진행되는지 다시 정리해 봅시다.

  • 함수 say 내부의 alert에서 변수 name에 접근할 땐, 먼저 내부 렉시컬 환경을 살펴봅니다. 내부 렉시컬 환경에서 변수 name을 찾았습니다.
  • alert에서 변수 phrase에 접근하려는데, phrase에 상응하는 프로퍼티가 내부 렉시컬 환경엔 없습니다. 따라서 검색 범위는 외부 렉시컬 환경으로 확장됩니다. 외부 렉시컬 환경에서 phrase를 찾았습니다.

단계 4. 함수를 반환하는 함수

makeCounter 예시로 돌아가 봅시다.

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

makeCounter()를 호출하면 호출할 때마다 새로운 렉시컬 환경 객체가 만들어지고 여기에 makeCounter를 실행하는데 필요한 변수들이 저장됩니다.

위쪽에서 살펴본 say("John") 예시와 마찬가지로 makeCounter()를 호출할 때도 두 개의 렉시컬 환경이 만들어집니다.

그런데 위쪽에서 살펴본 say("John") 예시와 makeCounter() 예시에는 차이점이 하나 있습니다. makeCounter()가 실행되는 도중엔 본문(return count++)이 한줄 짜리인 중첩 함수가 만들어진다는 점입니다. 현재는 중첩함수가 생성되기만 하고 실행은 되지 않은 상태입니다.

여기서 중요한 사실이 하나 있습니다. 모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다는 점입니다. 함수는 [[Environment]]라 불리는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장됩니다.

따라서 counter.[[Environment]]{count: 0}이 있는 렉시컬 환경에 대한 참조가 저장됩니다. 호출 장소와 상관없이 함수가 자신이 태어난 곳을 기억할 수 있는 건 바로 이 [[Environment]] 프로퍼티 덕분입니다. [[Environment]]는 함수가 생성될 때 딱 한 번 값이 세팅되고 영원히 변하지 않습니다.

counter()를 호출하면 각 호출마다 새로운 렉시컬 환경이 생성됩니다. 그리고 이 렉시컬 환경은 counter.[[Environment]]에 저장된 렉시컬 환경을 외부 렉시컬 환경으로서 참조합니다.

실행 흐름이 중첩 함수의 본문으로 넘어오면 count 변수가 필요한데, 먼저 자체 렉시컬 환경에서 변수를 찾습니다. 익명 중첩 함수엔 지역변수가 없기 때문에 이 렉시컬 환경은 비어있는 상황입니다(<empty>). 이제 counter()의 렉시컬 환경이 참조하는 외부 렉시컬 환경에서 count를 찾아봅시다. count를 찾았습니다!

이제 count++가 실행되면서 count 값이 1 증가해야하는데, 변숫값 갱신은 변수가 저장된 렉시컬 환경에서 이뤄집니다.

따라서 실행이 종료된 후의 상태는 다음과 같습니다.

counter()를 여러 번 호출하면 count 변수가 2, 3으로 증가하는 이유가 바로 여기에 있습니다.

클로저

'클로저(closure)'는 개발자라면 알고 있어야 할 프로그래밍 용어입니다.

클로저는 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미합니다. 몇몇 언어에선 클로저를 구현하는 게 불가능하거나 특수한 방식으로 함수를 작성해야 클로저를 만들 수 있습니다. 하지만 자바스크립트에선 모든 함수가 자연스럽게 클로저가 됩니다. 예외가 하나 있긴 한데 자세한 내용은 new Function 문법에서 다루도록 하겠습니다.

요점을 정리해 봅시다. 자바스크립트의 함수는 숨김 프로퍼티인 [[Environment]]를 이용해 자신이 어디서 만들어졌는지를 기억합니다. 함수 본문에선 [[Environment]]를 사용해 외부 변수에 접근합니다.

프런트엔드 개발자 채용 인터뷰에서 "클로저가 무엇입니까?"라는 질문을 받으면, 클로저의 정의를 말하고 자바스크립트에서 왜 모든 함수가 클로저인지에 관해 설명하면 될 것 같습니다. 이때 [[Environment]] 프로퍼티와 렉시컬 환경이 어떤 방식으로 동작하는지에 대한 설명을 덧붙이면 좋습니다.

가비지 컬렉션

함수 호출이 끝나면 함수에 대응하는 렉시컬 환경이 메모리에서 제거됩니다. 함수와 관련된 변수들은 이때 모두 사라지죠. 함수 호출이 끝나면 관련 변수를 참조할 수 없는 이유가 바로 여기에 있습니다. 자바스크립트에서 모든 객체는 도달 가능한 상태일 때만 메모리에 유지됩니다.

그런데 호출이 끝난 후에도 여전히 도달 가능한 중첩 함수가 있을 수 있습니다. 이때는 이 중첩함수의 [[Environment]] 프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장됩니다. 도달 가능한 상태가 되는 것이죠.

함수 호출은 끝났지만 렉시컬 환경이 메모리에 유지되는 이유는 바로 이 때문입니다.

예시:

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g.[[Environment]]에 f() 호출 시 만들어지는
// 렉시컬 환경 정보가 저장됩니다.

그런데 이렇게 중첩함수를 사용할 때는 주의할 점이 있습니다. f()를 여러 번 호출하고 그 결과를 어딘가에 저장하는 경우, 호출 시 만들어지는 각 렉시컬 환경 모두가 메모리에 유지된다는 점입니다. 아래 예시를 실행하면 3개의 렉시컬 환경이 만들어지는데, 각 렉시컬 환경은 메모리에서 삭제되지 않습니다.

function f() {
  let value = Math.random();

  return function() { alert(value); };
}

// 배열 안의 세 함수는 각각 f()를 호출할 때 생성된
// 렉시컬 환경과 연관 관계를 맺습니다.
let arr = [f(), f(), f()];

렉시컬 환경 객체는 다른 객체와 마찬가지로 도달할 수 없을 때 메모리에서 삭제됩니다. 해당 렉시컬 환경 객체를 참조하는 중첩 함수가 하나라도 있으면 사라지지 않죠.

아래 예시 같이 중첩 함수가 메모리에서 삭제되고 난 후에야, 이를 감싸는 렉시컬 환경(그리고 그 안의 변수인 value)도 메모리에서 제거됩니다.

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g가 살아있는 동안엔 연관 렉시컬 환경도 메모리에 살아있습니다.

g = null; // 도달할 수 없는 상태가 되었으므로 메모리에서 삭제됩니다.

최적화 프로세스

앞에서 보았듯이, 함수가 살아있는 동안엔 이론상으론 모든 외부 변수 역시 메모리에 유지됩니다.

그러나 실제로는 자바스크립트 엔진이 이를 지속해서 최적화합니다. 자바스크립트 엔진은 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 제거합니다.

디버깅 시, 최적화 과정에서 제거된 변수를 사용할 수 없다는 점은 V8 엔진(Chrome, Edge, Opera에서 쓰임)의 주요 부작용입니다.

Chrome 브라우저에서 개발자 도구를 열고 아래의 코드를 실행해봅시다.

그리고 실행이 일시 중지되었을 때 콘솔에 alert(value)를 입력해 보세요.

function f() {
  let value = Math.random();

  function g() {
    debugger; // Uncaught ReferenceError: value is not defined가 출력됩니다.
  }

  return g;
}

let g = f();
g();

정의되지 않은 변수라는 에러가 출력되네요! 이론상으로는 value에 접근할 수 있어야 하지만 최적화 대상이 되어서 이런 에러가 발생했습니다.

이런 외부 변수 최적화는 흥미로운 디버깅 이슈를 발생시키곤 합니다. 원인을 찾는 데 많은 시간을 허비하게 되면 흥미롭진 않겠지만 말이죠. 발생할 수 있는 상황 중 하나를 여기서 소개해 드리도록 하겠습니다. 예시를 실행해 의도한 변수 대신 같은 이름을 가진 다른 외부 변수가 출력되는 걸 확인해 봅시다.

let value = "이름이 같은 다른 변수";

function f() {
  let value = "가장 가까운 변수";

  function g() {
    debugger; // 콘솔에 alert(value);를 입력하면 '이름이 같은 다른 변수'가 출력됩니다.
  }

  return g;
}

let g = f();
g();

이런 V8만의 부작용을 미리 알아 놓는 것이 좋습니다. Chrome이나 Edge, Opera에서 디버깅하는 경우라면 언젠간 이 이슈를 만나게 될 테니까요.

이 부작용은 버그라기보다는 V8만의 특별한 기능이라고 생각하시면 될 것 같습니다. 미래에 이 기능은 변경될 수 있습니다. 최적화 과정에서 외부 변수가 어떻게 처리되었는지 확인하고 싶다면 이 페이지로 돌아오셔서 예시를 실행해 보세요.

과제

중요도: 5

함수 sayHi는 외부 변수 name을 사용하고 있는데, 함수가 실행될 때 두 외부 변수 name 중 어떤 값이 사용될까요?

let name = "보라";

function sayHi() {
  alert(name + "님, 안녕하세요.");
}

name = "지민";

sayHi(); // "보라" 혹은 "지민" 중 무엇이 출력될까요?

이런 상황은 클라이언트, 서버 개발 모두에서 흔하게 발생합니다. 함수는 이미 생성되어 있는데, 사용자의 특정 작업 이후나 네트워크 요청 이후같이 특정 조건을 만족한 후에 함수 호출을 하게 되는 경우는 종종 발생합니다.

자, 그럼 다시 질문으로 돌아가 봅시다. 이 함수는 name 변경 사항을 반영할까요?

정답은 지민입니다.

함수는 외부 변수의 현재 값 즉, 가장 최신의 값을 사용합니다.

이전 값은 어디에도 저장되지 않습니다. 함수에서 변숫값을 사용할 땐 해당 함수의 렉시컬 환경 또는 외부 렉시컬 환경에서 해당 변숫값을 찾습니다.

중요도: 5

아래 makeWorker 함수는 내부에서 다른 함수를 만들고 해당 함수를 반환합니다. 반환된 새 함수는 다른 어딘가에서 호출할 수 있습니다.

새롭게 반환된 함수는 생성된 곳을 기억하고 그곳의 외부 변수에 접근할까요, 아니면 호출된 위치를 기준으로 외부 변수에 접근할까요? 아니면 둘 다일까요?

function makeWorker() {
  let name = "Pete";

  return function () {
    alert(name);
  };
}

let name = "John";

// 함수를 만듭니다.
let work = makeWorker();

// 함수를 호출합니다.
work(); // 무엇이 나올까요?

“Pete”, “John” 중 어떤 어떤 값이 출력될까요?

정답은 Pete입니다.

work() 함수는 만들어진 곳을 기준으로 외부 렉시컬 변수 참조를 통해 name을 가져옵니다.

그래서 결과는 "Pete"입니다.

만약 makeWorker() 함수 내부에 let name가 없었다면 바로 전역 스코프에 해당하는 외부에서 변수를 검색해서 "John"이 출력되었을 겁니다.

중요도: 5

makeCounter를 사용해 두 개의 conuter countercounter2를 만들었습니다.

두 counter는 독립적일까요? 두 번째 카운터는 0, 1이나 2, 3중 어떤 숫자를 얼럿창에 띄워줄까요? 다른 결과가 출력될까요?

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();
let counter2 = makeCounter();

alert( counter() ); // 0
alert( counter() ); // 1

alert( counter2() ); // ?
alert( counter2() ); // ?

얼럿창엔 0과 1 이 출력됩니다.

함수 countercounter2는 각각 다른 makeCounter 호출에 의해 만들어졌습니다.

두 함수는 독립적인 렉시컬 환경을 갖게 되므로 각 함수는 자신만의 count를 갖게 됩니다.

중요도: 5

생성자 함수를 이용해 counter 객체를 만들어보았습니다.

아래 예시는 잘 작동할까요? 결과는 어떨까요?

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };
  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // ?
alert( counter.up() ); // ?
alert( counter.down() ); // ?

잘 동작합니다.

생성자 함수의 두 중첩 함수는 동일한 외부 렉시컬 환경에서 만들어졌기 때문에 같은 count 변수를 공유합니다.

function Counter() {
  let count = 0;

  this.up = function() {
    return ++count;
  };

  this.down = function() {
    return --count;
  };
}

let counter = new Counter();

alert( counter.up() ); // 1
alert( counter.up() ); // 2
alert( counter.down() ); // 1

아래 예시의 실행 결과를 예측해보세요.

let phrase = "Hello";

if (true) {
  let user = "John";

  function sayHi() {
    alert(`${phrase}, ${user}`);
  }
}

sayHi();

에러가 발생합니다.

sayHiif문 안에서 정의했기 때문에, 오직 if문 안에서만 접근할 수 있습니다. if문 밖엔 sayHi가 없습니다.

중요도: 4

sum(a)(b) = a+b와 같은 연산을 해주는 함수 sum을 만들어보세요.

두 개의 괄호를 사용해서 말이죠.

예시:

sum(1)(2) = 3
sum(5)(-1) = 4

두 번째 괄호가 제대로 동작하려면 첫 번째 괄호는 반드시 함수를 반환해야 합니다.

아래와 같이 말이죠.

function sum(a) {

  return function(b) {
    return a + b; // 'a'는 외부 렉시컬 환경에서 가져옵니다.
  };

}

alert( sum(1)(2) ); // 3
alert( sum(5)(-1) ); // 4
중요도: 4

다음 코드를 실행했을 때 어떤 값이 출력될까요?

let x = 1;

function func() {
  console.log(x); // ?

  let x = 2;
}

func();

주의: 이 문제에는 함정이 있습니다. 뻔한 답을 떠올리지 마세요.

에러가 발생합니다.

실제 코드를 실행해 봅시다.

let x = 1;

function func() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 2;
}

func();

이 문제는 ‘존재하지 않는’ 변수와 ‘초기화되지 않은’ 변수의 미묘한 차이를 알아보기 위해 만들어졌습니다.

변수의 유효범위와 클로저에서 읽은 것처럼 코드 블록(또는 함수) 실행에 들어가는 순간에 그 안의 변수는 ‘초기화되지 않은’ 상태가 됩니다. 그리고 let을 만날 때까지 해당 상태가 유지됩니다.

‘초기화되지 않은’ 상태의 변수는 기술적으론 존재하지만 let 문을 만나기 전엔 사용할 수 없습니다.

문제의 코드는 이런 초기화되기 전의 변수에 접근할 때 무슨 일이 일어나는지를 잘 보여줍니다.

function func() {
  // 엔진은 함수가 시작될 때 로컬 변수 x의 존재를 알고 있지만
  // let 문이 실행될 때까지 x는 '초기화되지 않은' 상태(dead zone)이기 때문에
  // 에러가 발생합니다.

  console.log(x); // ReferenceError: Cannot access 'x' before initialization

  let x = 2;
}

실제 초기화가 일어나기 전 변수를 일시적 사용하지 못하는 구간(코드 블록의 시작부터 let이 나올 때까지)을 '데드 존(dead zone)'이라 부릅니다.

중요도: 5

배열에 사용할 수 있는 내장 메서드 arr.filter(f)는 함수 f의 반환 값을 true로 만드는 모든 요소를 배열로 반환해줍니다.

filter에 넘겨서 사용할 수 있는 함수 두 가지를 만들어봅시다.

  • inBetween(a, b)a 이상 b 이하
  • inArray([...]) – 배열 안에 있는 값인가

위 함수를 활용하면 다음과 같은 결과가 나와야 합니다.

  • arr.filter(inBetween(3,6)) – 3과 6 사이에 있는 값만 반환함
  • arr.filter(inArray([1,2,3]))[1,2,3] 안에 있는 값과 일치하는 값만 반환함

예시:

/* ... 여기에 두 함수 inBetween과 inArray을 만들어주세요 ...*/
let arr = [1, 2, 3, 4, 5, 6, 7];

alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

Filter inBetween

function inBetween(a, b) {
  return function(x) {
    return x >= a && x <= b;
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

Filter inArray

function inArray(arr) {
  return function(x) {
    return arr.includes(x);
  };
}

let arr = [1, 2, 3, 4, 5, 6, 7];
alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

중요도: 5

객체가 담긴 배열을 정렬해야 한다고 가정해봅시다.

let users = [
  { name: "John", age: 20, surname: "Johnson" },
  { name: "Pete", age: 18, surname: "Peterson" },
  { name: "Ann", age: 19, surname: "Hathaway" }
];

아래와 같은 방법을 사용해 정렬할 수 있을 겁니다.

// 이름을 기준으로 정렬(Ann, John, Pete)
users.sort((a, b) => a.name > b.name ? 1 : -1);

// 나이를 기준으로 정렬(Pete, Ann, John)
users.sort((a, b) => a.age > b.age ? 1 : -1);

그런데, 아래와 같이 함수를 하나 만들어서 정렬하면 더 깔끔해질 것 같네요.

users.sort(byField('name'));
users.sort(byField('age'));

함수를 직접 만들어 sort에 넘기는 것보다 byField(fieldName)를 넘기는 것처럼 말이죠.

필드를 기준으로 정렬을 도와주는 함수 byField를 만들어봅시다.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

function byField(fieldName){
  return (a, b) => a[fieldName] > b[fieldName] ? 1 : -1;
}

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

중요도: 5

아래 코드는 shooters가 요소인 배열을 만들어줍니다.

모든 함수는 몇 번째 shooter인지 출력해줘야 하는데 뭔가 잘못되었습니다.

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let shooter = function() { // shooter 함수
      alert( i ); // 몇 번째 shooter인지 출력해줘야 함
    };
    shooters.push(shooter);
    i++;
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0번째 shooter가 10을 출력함
army[5](); // 5번째 shooter 역시 10을 출력함
// 모든 shooter가 자신의 번호 대신 10을 출력하고 있음

왜 모든 shooter가 동일한 숫자를 출력하는 걸까요? 제대로 된 번호가 출력될 수 있게 코드를 수정해 보세요.

테스트 코드가 담긴 샌드박스를 열어 정답을 작성해보세요.

Let’s examine what’s done inside makeArmy, and the solution will become obvious.

  1. It creates an empty array shooters:

    let shooters = [];
  2. Fills it in the loop via shooters.push(function...).

    Every element is a function, so the resulting array looks like this:

    shooters = [
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); },
      function () { alert(i); }
    ];
  3. The array is returned from the function.

Then, later, the call to army[5]() will get the element army[5] from the array (it will be a function) and call it.

Now why all such functions show the same?

That’s because there’s no local variable i inside shooter functions. When such a function is called, it takes i from its outer lexical environment.

What will be the value of i?

If we look at the source:

function makeArmy() {
  ...
  let i = 0;
  while (i < 10) {
    let shooter = function() { // shooter function
      alert( i ); // should show its number
    };
    ...
  }
  ...
}

…We can see that it lives in the lexical environment associated with the current makeArmy() run. But when army[5]() is called, makeArmy has already finished its job, and i has the last value: 10 (the end of while).

As a result, all shooter functions get from the outer lexical envrironment the same, last value i=10.

We can fix it by moving the variable definition into the loop:

function makeArmy() {

  let shooters = [];

  for(let i = 0; i < 10; i++) {
    let shooter = function() { // shooter function
      alert( i ); // should show its number
    };
    shooters.push(shooter);
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0
army[5](); // 5

Now it works correctly, because every time the code block in for (let i=0...) {...} is executed, a new Lexical Environment is created for it, with the corresponding variable i.

So, the value of i now lives a little bit closer. Not in makeArmy() Lexical Environment, but in the Lexical Environment that corresponds the current loop iteration. That’s why now it works.

Here we rewrote while into for.

Another trick could be possible, let’s see it for better understanding of the subject:

function makeArmy() {
  let shooters = [];

  let i = 0;
  while (i < 10) {
    let j = i;
    let shooter = function() { // shooter function
      alert( j ); // should show its number
    };
    shooters.push(shooter);
    i++;
  }

  return shooters;
}

let army = makeArmy();

army[0](); // 0
army[5](); // 5

The while loop, just like for, makes a new Lexical Environment for each run. So here we make sure that it gets the right value for a shooter.

We copy let j = i. This makes a loop body local j and copies the value of i to it. Primitives are copied “by value”, so we actually get a complete independent copy of i, belonging to the current loop iteration.

테스트 코드가 담긴 샌드박스를 열어 정답을 확인해보세요.

튜토리얼 지도