자바스크립트 톺아보기 9

이지영·2025년 1월 11일

변수의 유효범위와 클로저

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

함수 내부에서 함수 외부에 있는 변수에 접근할 수 있다
그런데 함수가 생성된 이후에 외부 변수가 변경되면 어떤 일이 발생할까?
함수는 호출되는 곳을 기준으로 외부 변수에 접근할까?

코드블록

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

{
   let message = "안녕";
   alert(message);
};
{
   let message = "잘가";
   alert(message);
}

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

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

중첩함수

함수 내부에서 선언한 함수는 중첩(nested)함수라고 부른다.
자바스크립트에선 손쉽게 중첩 함수를 만들 수 있다.
중첩 함수는 아래와 같이 코드를 정돈하는데 사용할 수 있다.

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

위 예시에서 외부 변수에 접근해 이름 전체를 반환해주는 중첩함수 getFullName()은 편의상 만든 함수이다
이렇게 자바스크립트에서는 중첩 함수가 흔히 사용된다.
중첩 함수는 새로운 객체의 프로퍼티 형태나 중첩 함수 그 자체로 반환될 수 있다는 점에서 흥미롭다.
이렇게 반환된 중첩함수는 어디서든 호출해 사용할 수 있다.
물론 이때도 외부 변수에 접근할 수 있다는 사실은 변함없다.
아래 함수 makeCounter는 호출될 떄마다 다음 숫자를 반환해주는 카운터 함수를 만든다.

```jsx
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. 변수

자바스크립트에서는 실행 중인 함수, 코드 블록 {...}, 스크립트 전체는 렉시컬 환경이라 불리는 재부 숨김 연관 객체를 갖는다.
렉시컬 환경 객체는 두 부분으로 구성된다.
1. 환경 레코드: 모든 지역 변수를 프로퍼티로 저장하고 있는 객체 this값과 같은 기타 정보도 여기에 저장된다.
2. 외부 렉시컬 환경에 대한 참조: 외부 코드와 연관됨

변수는 특수 내부 객체인 환경 레코드의 프로퍼티일 뿐이다. 변수를 가져오거나 변경하는 것은 환경 레코드으 ㅣ프로퍼티를 가져오거나 변경함을 의미힌다.

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

스크린샷 2025-01-11 오후 8 52 18

이렇게 스크립트 전체와 관련된 렉시컬 환경은 전역 렉시컬 환경이라고 한다.
위 그림에서 네모상자는 변수가 저장되는 환경 레코드를 나타내고 네모 상자 옆, 오른쪽을 향하는 화살표는 외부 렉시컬 환경에 대한 참조를 나타낸다.
전역 렉시컬 환경은 외부 참조를 갖지 않기 때문에 화살표가 null을 가리킨다.

코드가 실행되고 실행 흐름이 이어져 나가면서 렉시컬 환경은 변화한다.
좀 더 긴 코드를 살펴보자

스크린샷 2025-01-11 오후 8 54 55

우측의 네모 상자들은 코드가 한 줄, 한 줄 실행될 때마다 전역 렉시컬 환경이 어떻게 변화하는지 보여준다.
1. 스크립트가 시작되면 스크립트 내에서 선언한 변수 전체가 렉시컬 환경에 올라간다.

  • 이때 변수의 상태는 특수내부상태인 uninitialized가 된다. 자바스크립트 엔진은 uninitialized상태의 변수를 인지하긴 하지만, let을 만나기 전까진 이 변수를 참조할 수 없다.
  1. let phrase가 나타난 후, 아직 값을 할당하기 전이기 때문에 프로퍼티의 값은 undefined이다. phrase는 이 시점 이후부터 사용할 수 있따.
  2. Pharse에 값이 할당되었다.
  3. Pharse의 값이 변경되었다.

지금까지 배운 내용을 요약해보면

  • 변수는 특수 내부 객체인 환경 레코드의 프로퍼티이다. 환경 레코드는 현재 실행 중인 함수와 코드 블록, 스크립트와 연관되어 있다.
  • 변수를 변경하면 환경 레코드의 프로퍼티가 변경된다.
    또한 변수는 이론상의 객체이다 따라서 코드를 사용해 직접 렉시컬 환경을 얻거나 조작하는 것은 불가능하다.

단계 2. 함수 선언문

함수는 변수와 마찬가지로 값이다.
다만 함수 선언문으로 선언한 함수는 일반 변수와는 달리 바로 초기화된다는 점에서 차이가 있다.
변수는 let을 만나 선언이 될 때까지 사용할 수 없지만, 함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있다.
선언되기 전에도 함수를 사용할 수 있는 것은 바로 이 때문이다.
아래 그림은 스크립트에 함수를 추가했을 때 전역 렉시컬 환경 초기 상태가 어떻게 변하는지 보여준다.

스크린샷 2025-01-11 오후 9 05 04

이런 동작 방식은 함수 선언문으로 정의한 함수에만 적용된다.
함수를 변수에 할당한 함수 표현식은 해당하지 않는다.

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

함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어진다.
이 렉시컬 환경엔 함수 호출 시 넘겨받은 매개변수와 함수의 지역 변수가 저장된다.
say("john")을 호출하면 아래와 같은 내부 변화가 일어난다(현재 실행 흐름은 함수 내부, 화살표로 표시한 줄(alert문)에 멈춰있는 상황이다.

스크린샷 2025-01-11 오후 9 09 12

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

  • 예시의 내부 렉시컬 환경은 ㅎ녀재 실행 중인 함수인 say에 상응하고, 내부 렉시컬 환경엔 함수의 인자인 name으로부터 유래한 프로퍼티 하나만 있다.
    say ("John")을 호출했기 때문에, name의 값을 John이 된다.
  • 예시의 외부 렉시컬 환경은 전역 렉시컬 환경이다. 전역 렉시컬 환경은 pharse와 함수 say를 프로퍼티로 갖는다.
    그리고 내부 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 갖는다.

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

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

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

  • 함수 say내부의 alert에서 변수 name에 접근할땐, 먼저 내부 렉시컬 환경을 살펴본다. 내부 렉시컬 환경에서 변수 name을 찾았다.
  • alert에서 변수 pharse에 접근하려는데 pharse에 상응하는 프로퍼티가 내부 렉시컬 환경엔 없다. 따라서 검색 범위는 외부 렉시컬 환경으로 확장된다.
    외부 렉시컬 환경에서 pharse를 찾았다.
스크린샷 2025-01-11 오후 11 02 07

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

makeCounter 예시로 돌아가보자

function makeCounter() {
   let count = 0;
   return function() {
      return count ++;
   }
}
let counter = makeCounter();

makeCounter() 를 호출하면 호출할 때마다 새로운 렉시컬 환경 객체가 만들어지고 여기에 makeCounter를 실행하는데 필요한 변수들이 저장된다.
위쪽에서 살펴본 say("John")예시와 마찬가지로 makeCounter()를 호출할 떄도 두 개의 렉시컬 환경이 만들어진다.

스크린샷 2025-01-11 오후 11 04 13

그런데 위쪽에서 살표번 say함수 예시와 makeCounter()함수 예시에는 차이점이 있다.
makeCOunter()가 실행되는 도중엔 본문이 한줄 짜리인 중첩 함수가 만들어진다는 점이다
현재는 중첩함수가 생성되기만 하고 실행은 되지 않은 상태이다.
여기서 중요한 사실은 모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다는 점이다. 함수는 [[Environment]]라 불리는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장된다.

스크린샷 2025-01-11 오후 11 07 56

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

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

스크린샷 2025-01-11 오후 11 10 49

실행 흐름이 중첩 함수의 본문으로 넘어오면 count 변수가 필요한데 먼저 자케 렉시컬 환경에서 변수를 찾는다.
익명 중첩 함수엔 지역변수가 없기 떄문에 이 렉시컬 환경은 비어있는 상황이다. 이제 counter()의 렉시컬 환경이 참조하는 외부 렉시컬 환경에서 count를 찾는다.
이제 count++가 실행되면서 count값이 1증가해야하는데, 변숫값 갱신은 변수가 저장된 렉시컬 환경에서 이뤄진다.

따라서 실행이 종료된 후의 상태는 아래와 같다.

스크린샷 2025-01-11 오후 11 14 06

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

클로저

클로저(closure)는 개발자라면 알고 있어야 할 프로그래밍 용어이다.
클로저는 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미한다.
자바스크립트에서는 모든 함수가 자연스럽게 클로저가 된다.

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

가비지 컬렉션

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

그런데 호출이 끝난 후에도 여전히 도달 가능한 중첩 함수가 있을 수 있다.
이때는 중첩함수의 [[Environment]]프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장된다. 도달 가능한 상태가 되는 것이다.
함수 호출은 끝났지만 렉시컬 환경이 메모리에 유지되는 이유는 바로 이 때문이다.

예시

function f() {
   let value = 123;
   return function () {
      alert(value);
   }
}
leg g = f(); // g.[[Environment]]에 f() 호출 시 만들어지는 렉시컬 환경 정보가 저장된다.

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


```jsx
function f() {
   let value = Math.randon();
   return function () { alert(value) };
}
// 배열 안의 세 함수는 각각 ()를 호출할 때 생성된 렉시컬 환경과 연관 관계를 맺는다. 
let arr = [f(),f(),f()];

렉시컬 환경 객체는 다른 객체와 마찬가지로 도달할 수 없을 때 메모리에서 삭제된다. 해당 렉시컬 환경 객체를 참조하는 중첩 함수가 하나라도 있으면 사라지지 않는다.
아래 예시 같이 중첩 함수가 메모리에서 삭제되고 난 후에야, 이를 감싸는 렉시컬 환경(그리고 그 안에 변수인 value)도 메모리에서 제거된다.

function f() {
   let value = 123;
   return function() {
      alert(value);
   }
}
let g = f(); // g가 살아있는 동안엔 연관 렉시컬 환경도 메모리에 살아있다.
g = null; // 도달할 수 없는 상태가 되었으므로 메모리에서 삭제된다.

최적화 프로세스

앞에서 보았듯이, 함수가 살아있는 동안엔 이론상으론 모든 외부 변수 역시 메모리에 유지된다.
그러나 실제로는 자바스크립트 엔진이 이를 지속해서 최적화한다.
자바스크립트 엔진은 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 제거한다.
디버깅 시 최적화 과정에서 제거된 변수를 사용할 수 없다는 점은 V8엔진의 주요 부작용이다.

과제

함수가 최신 변경사항을 반영할까?

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

let name = "보라";
function sayHi() {
   alert(name + "님, 안녕하세요.");
}
name = "지민";
sayHi(); // 보라, 지민 중 무엇이 출력될까?

정답은 지민이다.
함수는 외부 변수의 현재 값 즉, 가장 최신의 값을 사용한다.
이전 값은 어디에도 저장되지 않는다. 함수에서 변숫값을 사용할 땐 해당 함수의 렉시컬 환경 또는 외부 렉시컬 환경에서 해당 변숫값을 찾는다.

클로저를 이용하여 합 구하기

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

정답:

function sum (a) {
   return function (b) {
      return a+b
   }
}

변수가 보일까요?

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

let x = 1;
function func() {
   console.log(x);
   let x = 2;
}
func();

정답: 에러가 발생한다.
코드 블록 실행에 들어가는 순간 그 안의 변수는 초기화되지 않은 상태가 된다. 그리고 let을 만날 때까지 해당 상태가 유지된다.
엔진은 함수가 시작될 때 로컬 변수 x의 존재를 알고있지만 let 문이 실행될 때까지 x는 초기화되지 않은 상태(dead zone)이기 떄문에
에러가 발생한다.

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

함수를 이용해 원하는 값만 걸러내기

배열에 사용할 수 있는 내장 메서드 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

정답

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

필드를 기준으로 정렬하기

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

스크린샷 2025-01-12 오전 12 15 22

정답

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

여기서 a.fieldName b.fieldName 형태로 써도 될까?

답은 아니다.

이유는 fieldName은 문자열로 "name"이나 "age"등 다양한 값이 될 수 있다. 따라서 a[fieldName]과 같은 형태로 동적으로 속성을 접근해야한다.

오래된 var

var을 let으로 바꿀 때 var에 대해 제대로 알아두지 않으면 예상치 못한 에러는 만날 수 있다.

var은 블록 스코프가 없다.

var로 선언한 변수의 스코프는 함수 스코프이거나 전역 스코프이다.
블록 기준으로 스코프가 생기지 않기 때문에 블록 밖에서 접근이 가능하다.

코드 블록이 함수 안에 있다면 var은 레벨 변수가 된다.

function sayHi() {
  if (true) {
    var phrase = "Hello";
  }

  alert(phrase); // 제대로 출력됩니다.
}

sayHi();
alert(phrase); // Error: phrase is not defined

위와 같이 var는 if, for등의 코드 블록을 관통한다.
또한 var은 변수의 중복 선언을 허용한다.

선언하기 전 사용할 수 있는 var

var선언은 함수가 시작될 떄 처리된다. 전역에서 선언한 변수라면 스크립트가 시작될 떄 처리된다.
함수 본문 내에서 var로 선언한 변수는 선언 위치와 상관없이 함수 본문이 시작되는 지점에서 정의된다. (단 변수가 중첩 함수 내에서 정의되지 않아야 이 규칙이 적용된다.)

function sayHi() {
  phrase = "Hello";

  alert(phrase);

  var phrase;
}
sayHi();

아래에 var phrase가 선언되었지만 끌어올려져서 사용할 수 있다.
이렇게 변수가 끌어올려 지는 현상을 호이스팅이라고 부른다.
var로 선언한 모든 변수는 함수의 최상위로 끌어올려지기 때문이다.

또한 변수 선언은 함수 실행이 시작될 떄 처리되지만(호이스팅) 할당은 호이스팅 되지 않기 떄문에 할당 관련 ㅗㅋ드에서 처리된다.
따라서 아래 예제는 undefined가 출력된다.

function sayHi() {
  var phrase; // 선언은 함수 시작 시 처리됩니다.
  alert(phrase); // undefined
  phrase = "Hello"; // 할당은 실행 흐름이 해당 코드에 도달했을 때 처리됩니다.
}
sayHi();

이처럼 모든 var선언은 함수 시작 시 처리되기 떄문에 var로 선언한 변수는 어디서든 참조할 수 있다. 하지만 변수에 무언가를 할당하기 전까진 값이 undefined이다.

전역 객체

전역 객체를 사용하면 어디서나 사용 가능한 변수나 함수를 만들 수 있다.
전역 객체는 언어 자체나 호스트 환경에 기본 내장되어 있는 경우가 많다.

브라우저 환경에서는 전역 객체를 window, Node.js환경에선 global이라고 부르는데 각 호스트 환경마다 부르는 이름은 다르다.
전역 객체의 이름을 globalThis로 표준화하자는 내용이 최근에 자바스크립트 명세에 추가되었기 떄문에 모든 호스트 환경이 이를 따라야한다.
Chrominum기반이 아닌 몇몇 브라우저는 아직 globalThis를 지원하지 않지만 이에 대한 폴리필을 쉽게 만들 수 있다.

브라우저에서 let 이나 const 가 아닌 var로 선언한 전역 함수나 전역 변수는 전역 객체의 프로퍼티가 된다

var gVar = 5;
alert(window.gVar); // 5 (var로 선언한 변수는 전역 객체 window의 프로퍼티가 된다)

하지만 let으로 선언된 변수는 window객체를 통해 변수에 접근할 수 없다.

중요한 변수라서 모든 곳에 사용할 수 있게 하려면, 아래와 같이 전역 객체에 직접 프로퍼티를 추가해주면 된다.

window.currentUser = {
   name: "John"
};
alert(currentUser.name); // John
alert(window.currentUser.name); // John

전역 변수는 되도록 사용하지 않는 것이 좋다. 함수를 만들 땐 외부 변수나 전역변수를 사용하는 것보다 인풋 변수를 받고 이를 이용해 아웃풋을 만들어내게 해야 테스트도 쉽고 에러도 덜 만들어낸다.

폴리필 사용하기

전역 객체를 이용해 현재 사용중인 브라우저가 최신 자바스크립트 기능을 지원하는지 여부를 확인할 수 있다.
내장 객체 Promise를 지원하는지 여부를 아래와 같이 테스트할 수 있다.
구식 브라우저는 Promise객체를 지원하지 않기 때문에 alert창이 뜬다.

if(!window.Promise) {
   alert("구식 브라우저 사용중");
}

명세에 있는 기능이지만 해당 기능을 지원하지 않는 오래된 브라우저를 사용하고 있다면 직접 함수를 만들어 전역 객체에 추가하는 방식으로
폴리필을 만들 수 있다.

if(!windowPromise) {
   window.Promise = ... // 모던 자바스크립트에서 지원하는 기능을 직접 구현
}

Summary

  • 전역 객체를 사용하면 어디서든 접근 가능한 변수를 만들 수 있다. 전역 객체엔 Array와 같은 내장객체, window.innerHeight같은 브라우저 환경 전용 변수 등이 저장되어 있다.
  • 전역 객체는 globalThis라는 보편적인 이름으로 불린다.
    하지만 브라우저에서는 window, Node.js에서는 global라는 이름으로 불릴때가 많다. 또한 globalTHis는 추가된지 얼마 안 된 기능이기 때문에 비 크로미움 기반 브라우저에선 지원하지 않는다.
  • 프로젝트 전체에서 꼭 필요한 변수만 전역 객체에 저장하고, 전역 변수는 가능한 한 최소한으로 사용하자
  • 브라우저에서 var로 선언한 전역 변수는 전역 객체의 프로퍼티가 된다.
  • 이해하기 쉽고 요구사항 변경에 쉽게 대응할 수 있는 코드를 구현하려면 window.x처럼 전역 객체의 프로퍼티에 직접 접근하자.

0개의 댓글