Published on

렉시컬 환경은 변수와 함수를 어떻게 기억할까?

Authors
  • avatar
    Name
    GW

개요

왜 JavaScript는 변수와 함수를 따로 저장하는 공간을 만들었을까요?

이번 글에서는 렉시컬 환경이 무엇인지부터 시작해 다음 내용을 살펴보겠습니다.

  • 렉시컬 환경이 무엇이고, 언제 만들어지는지
  • JavaScript가 변수와 함수를 어떻게 기억하는지
  • 호이스팅은 렉시컬 환경과 어떤 관계가 있는지
  • 클로저는 왜 외부 변수를 기억할 수 있는지

렉시컬 환경이란 무엇일까?

렉시컬 환경(Lexical Environment)은 현재 스코프에서 사용할 수 있는 변수와 함수 정보를 저장하는 공간입니다.

조금 쉽게 표현하면

현재 코드에서 사용할 수 있는 식별자들을 기억하는 공간

이라고 생각하면 됩니다.

예를 들어 다음 코드를 살펴보겠습니다.

const name = '철수';
const age = 20;

const sayHello = () => {
  console.log(name);
};

JavaScript는 코드를 실행하기 전에 현재 스코프에서 사용할 수 있는 식별자를 렉시컬 환경에 저장합니다.

단순하게 표현하면 다음과 같습니다.

렉시컬 환경

name → '철수'
age → 20
sayHello → 함수

즉, 렉시컬 환경은 식별자의 이름과 실제 값을 연결해서 기억하는 공간입니다.

식별자란 무엇일까?

여기서 말하는 식별자(Identifier)는 변수나 함수처럼 이름이 붙은 대상을 의미합니다.

const name = '철수';
const age = 20;

const sayHello = () => {};

예를 들어 위 코드에서 name, age, sayHello는 모두 식별자입니다.

렉시컬 환경은 이러한 식별자들을 저장하고, JavaScript는 코드를 실행하면서 필요한 식별자들을 렉시컬 환경에서 찾게 됩니다.

렉시컬 환경은 언제 만들어질까?

렉시컬 환경은 실행 컨텍스트가 생성될 때 함께 생성됩니다.

전역 코드 진입
1. 전역 실행 컨텍스트 생성
2. 전역 렉시컬 환경 생성
3. 전역 코드 한 줄씩 실행

함수 호출
1. 함수 실행 컨텍스트 생성
2. 함수 렉시컬 환경 생성
3. 함수 내부 코드 실행

즉, 프로그램이 시작되면 전역 실행 컨텍스트와 함께 전역 렉시컬 환경이 만들어지고,

함수가 호출되면 새로운 함수 실행 컨텍스트와 함께 새로운 함수 렉시컬 환경이 생성됩니다.

예를 들어 다음 코드를 살펴보겠습니다.

const name = '철수';

const sayHello = () => {
  const age = 20;

  console.log(name, age);
};

sayHello();

실행 과정은 다음과 같습니다.

1. 전역 코드 평가
전역 실행 컨텍스트 생성
↓
콜 스택에 추가
↓
전역 렉시컬 환경 생성
├─ name → 바인딩 생성
└─ sayHello → 함수 바인딩 생성

2. 전역 코드 실행
name = '철수'
↓
sayHello() 호출

3. sayHello() 호출
sayHello 함수 실행 컨텍스트 생성
↓
콜 스택에 추가
↓
함수 렉시컬 환경 생성
├─ age → 바인딩 생성
└─ 외부 렉시컬 환경 참조
   └─ 전역 렉시컬 환경
↓
함수 코드 실행
↓
age = 20

4. console.log(name, age) 실행
현재 렉시컬 환경에서 age 탐색
↓
age 발견 → 20

현재 렉시컬 환경에서 name 탐색
↓
name 없음
↓
외부 렉시컬 환경 참조
↓
전역 렉시컬 환경
↓
name 발견 → '철수'

즉, 함수가 호출될 때마다 새로운 렉시컬 환경이 만들어지고, 그 안에는 해당 함수에서 사용할 수 있는 지역 변수와 매개변수 등이 저장됩니다.

렉시컬 환경은 어떻게 변수를 찾을까?

그렇다면 JavaScript는 식별자가 필요할 때 어떻게 값을 찾을까요?

예를 들어 다음 코드를 살펴보겠습니다.

const name = '철수';

const sayHello = () => {
  console.log(name);
};

sayHello();

sayHello() 안에는 name이 선언되어 있지 않습니다.

그런데도 "철수"가 출력됩니다.

왜 그럴까요?

JavaScript는 먼저 현재 렉시컬 환경에서 name을 찾습니다.

함수 렉시컬 환경에서 name 찾기
↓
없음

찾지 못하면 바로 에러를 발생시키는 것이 아니라, 외부 렉시컬 환경으로 이동합니다.

함수 렉시컬 환경에서 name 찾기
↓
없음
↓
외부 렉시컬 환경으로 이동
↓
전역 렉시컬 환경에서 name 찾기
↓
'철수'

이렇게 현재 렉시컬 환경에서 시작해 외부 렉시컬 환경을 차례대로 탐색하는 과정을 스코프 체인(Scope Chain) 이라고 합니다.

즉, JavaScript는 변수를 찾을 때

  1. 현재 렉시컬 환경에서 찾고
  2. 없으면 외부 렉시컬 환경으로 이동하고
  3. 끝까지 찾지 못하면 ReferenceError를 발생시킵니다.

호이스팅은 렉시컬 환경과 어떤 관계가 있을까?

호이스팅은 선언문이 실제로 위로 올라가는 것이 아니라, JavaScript 엔진이 코드를 실행하기 전에 선언을 먼저 처리하는 과정이라고 했습니다.

그렇다면 선언은 어디에 저장되는 걸까요?

바로 렉시컬 환경입니다.

예를 들어 다음 코드를 살펴보겠습니다.

console.log(name);

const name = '철수';

JavaScript는 코드를 실행하기 전에 먼저 전역 실행 컨텍스트와 렉시컬 환경을 생성합니다.

이때 name이라는 식별자도 함께 렉시컬 환경에 등록됩니다.

코드 실행 전

전역 렉시컬 환경
name → <초기화되지 않음>

아직 const name = '철수'가 실행되지 않았기 때문에 값은 초기화되지 않았습니다.

그 상태에서 console.log(name)을 실행하면 JavaScript는 전역 렉시컬 환경에서 name을 찾지만, 아직 초기화되지 않았다는 사실을 확인하고 ReferenceError를 발생시킵니다.

즉, 호이스팅은 렉시컬 환경에 식별자가 먼저 등록되기 때문에 발생하는 현상입니다.

클로저는 왜 외부 변수를 기억할 수 있을까?

클로저 역시 렉시컬 환경과 깊은 관련이 있습니다.

예를 들어 다음 코드를 살펴보겠습니다.

const createCounter = () => {
  let count = 0;

  return () => {
    count += 1;

    return count;
  };
};

const counter = createCounter();

console.log(counter());
console.log(counter());

실행 결과는 다음과 같습니다.

1
2

분명 createCounter()의 실행은 이미 끝났는데, count는 계속 증가합니다.

왜 그럴까요? 그 이유는 반환된 함수가 createCounter의 렉시컬 환경을 계속 참조하고 있기 때문입니다.

중요한 점은 값을 복사하는 것이 아니라 렉시컬 환경 자체를 참조한다는 것입니다.

그래서 count를 변경하면 다음 호출에서도 변경된 값이 그대로 유지됩니다.

왜 JavaScript는 렉시컬 환경을 만들었을까?

JavaScript는 실행 중에 필요한 변수와 함수를 빠르게 찾을 수 있어야 합니다.

만약 모든 변수를 코드 전체에서 매번 처음부터 찾는다면 실행 속도가 크게 느려질 것입니다.

그래서 JavaScript는 실행 컨텍스트가 생성될 때 렉시컬 환경을 만들고, 그 안에 현재 스코프의 식별자들을 초기화 전 상태로 미리 등록해 둡니다.

덕분에 JavaScript는

  • 현재 스코프에서 변수를 빠르게 찾을 수 있고
  • 외부 스코프로 자연스럽게 탐색을 이어 갈 수 있으며
  • 함수가 종료된 이후에도 필요한 렉시컬 환경을 유지해 클로저를 구현할 수 있습니다.

즉, 렉시컬 환경은 단순히 변수를 저장하는 공간이 아니라 JavaScript의 스코프와 클로저를 가능하게 하는 핵심 구조라고 할 수 있습니다.

실행 컨텍스트와 렉시컬 환경은 어떤 관계일까?

실행 컨텍스트와 렉시컬 환경은 자주 함께 등장하기 때문에 같은 개념으로 생각하기 쉽습니다.

하지만 둘은 역할이 다릅니다.

실행 컨텍스트는 코드를 실행하기 위한 전체 실행 환경이고, 렉시컬 환경은 그 안에서 변수와 함수를 관리하는 구성 요소입니다.

조금 단순하게 표현하면 다음과 같습니다.

실행 컨텍스트
├── 렉시컬 환경
│   ├── 변수
│   ├── 함수
│   └── 외부 렉시컬 환경 참조
├── 변수 환경
└── this
└── 기타 실행에 필요한 정보

즉, 실행 컨텍스트 안에 렉시컬 환경이 포함되어 있다고 이해하면 됩니다.

실행 컨텍스트가 만들어질 때마다 새로운 렉시컬 환경도 함께 생성되고, JavaScript는 이를 이용해 현재 스코프의 변수와 함수를 관리합니다.

자주 하는 오해

함수가 종료되면 렉시컬 환경도 항상 사라질까?

아닙니다.

일반적으로 함수 실행이 끝나고 해당 렉시컬 환경을 참조하는 대상이 없다면 가비지 컬렉션의 대상이 될 수 있습니다.

하지만 클로저처럼 내부 함수가 외부 함수의 렉시컬 환경을 계속 참조하고 있다면 해당 환경은 유지됩니다.

const createCounter = () => {
  let count = 0;

  return () => {
    count += 1;

    return count;
  };
};

const counter = createCounter();

createCounter()의 실행은 끝났지만 반환된 함수가 count가 들어 있는 렉시컬 환경을 계속 참조하고 있습니다.

따라서 이 렉시컬 환경은 즉시 사라지지 않습니다.

반대로 더 이상 참조하지 않게 되면 해당 함수와 렉시컬 환경도 접근할 수 없는 상태가 되어 가비지 컬렉션의 대상이 될 수 있습니다.

렉시컬 환경은 함수를 호출할 때만 결정될까?

아닙니다.

함수가 어떤 외부 렉시컬 환경과 연결되는지는 함수가 선언된 위치를 기준으로 결정됩니다.

다음 코드를 살펴보겠습니다.

const name = '철수';

const printName = () => {
  console.log(name);
};

const run = () => {
  const name = '영희';

  printName();
};

run();

실행 결과는 다음과 같습니다.

철수

printName()은 run() 안에서 호출되었지만, run의 name을 사용하지 않습니다.

printName이 선언된 위치의 외부 환경은 전역 렉시컬 환경이기 때문입니다.

printName이 선언된 위치
↓
전역 렉시컬 환경
↓
name: '철수'

즉, 변수 탐색 경로는 함수를 어디에서 호출했는지가 아니라 어디에서 선언했는지에 따라 정해집니다.

이러한 방식을 렉시컬 스코프라고 합니다.

정리

렉시컬 환경은 JavaScript가 현재 스코프의 변수와 함수를 기억하고, 필요한 식별자를 찾기 위해 사용하는 내부 구조입니다.

렉시컬 환경은 크게 두 가지 정보를 관리한다고 생각할 수 있습니다.

렉시컬 환경
├── 현재 스코프의 식별자와 값
└── 외부 렉시컬 환경에 대한 참조

JavaScript는 식별자가 필요하면 현재 렉시컬 환경에서 먼저 찾습니다.

현재 환경에 없다면 외부 렉시컬 환경으로 이동하고, 이 과정을 전역 렉시컬 환경에 도달할 때까지 반복합니다.

현재 렉시컬 환경에서 식별자 탐색
↓
찾지 못함
↓
외부 렉시컬 환경으로 이동
↓
식별자 탐색
↓
찾지 못하면 다시 외부 렉시컬 환경으로 이동
↓
...
↓
전역 렉시컬 환경까지 탐색
↓
찾지 못하면 ReferenceError

핵심 내용을 정리하면 다음과 같습니다.

  • 렉시컬 환경은 현재 스코프에서 사용할 수 있는 식별자를 관리합니다.
  • 실행 컨텍스트가 생성될 때 해당 코드에 필요한 렉시컬 환경도 함께 구성됩니다.
  • 각각의 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 가질 수 있습니다.
  • JavaScript는 이 연결을 통해 식별자를 탐색하며, 이를 스코프 체인이라고 합니다.
  • 변수 탐색 경로는 함수가 호출된 위치가 아니라 선언된 위치를 기준으로 정해집니다.
  • 선언이 코드 실행 전에 렉시컬 환경에 등록되기 때문에 호이스팅 현상이 나타납니다.
  • 내부 함수가 외부 렉시컬 환경을 계속 참조하면 함수 실행이 끝난 뒤에도 변수를 사용할 수 있으며, 이것이 클로저의 기반이 됩니다.

지금까지 따로 배웠던 개념을 연결하면 다음과 같습니다.

1. 실행 컨텍스트 생성
2. 렉시컬 환경 생성
3. 변수와 함수 선언 등록 (호이스팅)
4. 외부 렉시컬 환경 연결 (스코프 체인)
5. 함수 실행 종료 후에도 외부 렉시컬 환경을 계속 유지·참조 (클로저)

렉시컬 환경을 이해하면 호이스팅, 스코프 체인, 클로저가 서로 독립된 문법이 아니라 JavaScript가 식별자를 저장하고 탐색하는 하나의 원리에서 나온 결과라는 것을 알 수 있습니다.

참고 자료