본문으로 건너뛰기

JavaScript 값·스코프·클로저 정리

객체를 복사한 뒤 이름을 수정했는데 원본까지 바뀌는 경우가 있습니다. 카운터를 만드는 함수는 호출이 끝나도, 반환한 함수로 계속 값을 올릴 수 있고요.

수량 0을 처리하는 코드부터 시작해서, 객체 복사와 함수의 변수 접근을 차례로 살펴보겠습니다.

전체 개념 지도​

값·바인딩·스코프·클로저 사이의 관계를 표시한 개념도입니다.

개념 연결도

값에서 클로저까지

같은 객체를 두 이름으로 쓰는 경우와, 같은 내용의 객체를 따로 만드는 경우를 비교합니다.

  • Value — 실행 중 다루는 값. 원시값과 객체를 구분하고, 객체라면 같은 객체인지까지 확인합니다.
  • Object identity — 같은 내용 ≠ 같은 객체. 객체는 내용이 같아 보여도 생성된 대상이 다르면 다른 정체성을 가집니다.
  • Mutation / Copy — 공유할지 분리할지. 변경과 얕은 복사는 어떤 객체를 함께 가리키는지에 따라 결과가 달라집니다.

같은 객체를 두 이름으로 쓰는 경우와, 같은 내용의 객체를 따로 만드는 경우를 비교합니다.

  • Value — 실행 중 다루는 값. 원시값과 객체를 구분하고, 객체라면 같은 객체인지까지 확인합니다.
  • Object identity — 같은 내용 ≠ 같은 객체. 객체는 내용이 같아 보여도 생성된 대상이 다르면 다른 정체성을 가집니다.
  • Mutation / Copy — 공유할지 분리할지. 변경과 얕은 복사는 어떤 객체를 함께 가리키는지에 따라 결과가 달라집니다.

1. 값과 비교​

수량 입력이 '0'일 때, 숫자로 바꾼 뒤 어떤 기본값 연산자를 쓰느냐에 따라 결과가 달라집니다.

const raw = '0';
const count = raw.trim() === '' ? null : Number(raw);
console.log(count || 10); // 10
console.log(count ?? 10); // 0

||는 0도 falsy로 처리해서 10을 반환합니다. ??는 null과 undefined에만 기본값을 적용하므로 수량 0을 유지합니다. 빈 입력은 앞에서 null로 바꿔두었습니다.

2. 객체 참조와 얕은 복사​

const b = a에서 두 이름은 같은 객체를 가리킵니다. spread로 복사하면 바깥 객체가 새로 생깁니다. 아래 코드의 profile처럼 중첩된 객체는 그대로 공유합니다.

const original = { profile: { name: 'A' } };
const copied = { ...original };
copied.profile.name = 'B';
console.log(original.profile.name); // 'B'
console.log(original === copied); // false
console.log(original.profile === copied.profile); // true

바깥 객체는 둘인데 profile 객체는 하나입니다. copied를 통해 그 객체를 수정했으므로 original에서도 B가 보입니다.

객체 복사

두 객체가 공유하는 profile

copied의 바깥 객체는 새로 만들어졌지만 profile 참조는 그대로 복사됐습니다.

  • original — const binding. 이 이름은 계속 같은 바깥 객체 A를 가리킵니다.
  • 객체 A — profile →. original에 연결된 바깥 객체입니다.
  • profile 객체 — name: 'A'. 중첩 객체입니다. 얕은 복사에서는 이 객체를 공유합니다.
  • copied — const binding. 새 바깥 객체를 가리키는 별도의 바인딩입니다.
  • 객체 B — profile →. spread로 새로 만든 바깥 객체입니다. 중첩 객체까지 새로 만들지는 않습니다.

copied의 바깥 객체는 새로 만들어졌지만 profile 참조는 그대로 복사됐습니다.

  • original — const binding. 이 이름은 계속 같은 바깥 객체 A를 가리킵니다.
  • 객체 A — profile →. original에 연결된 바깥 객체입니다.
  • profile 객체 — name: 'A'. 중첩 객체입니다. 얕은 복사에서는 이 객체를 공유합니다.
  • copied — const binding. 새 바깥 객체를 가리키는 별도의 바인딩입니다.
  • 객체 B — profile →. spread로 새로 만든 바깥 객체입니다. 중첩 객체까지 새로 만들지는 않습니다.

원본을 남기려면 변경할 경로의 객체도 새로 만들 수 있습니다. 도식의 마지막 단계는 처음 값 A에서 다시 시작해 profile까지 복사한 경우입니다. React의 중첩 state 업데이트에서도 이 방식을 사용합니다.

3. 바인딩과 스코프​

같은 이름의 변수가 여러 곳에 있으면 어느 값을 읽을까요? 아래 ES Module 예제에는 label이 두 개 있습니다. readLabel을 caller 안에서 호출한 결과는 module입니다.

const label = 'module';
function readLabel() { return label; }

function caller() {
const label = 'caller';
return readLabel();
}
console.log(caller()); // 'module'

readLabel은 호출한 곳이 아니라 정의된 위치의 바깥 범위에서 label을 찾습니다. caller의 label은 그 탐색 경로에 없는 별개의 지역 변수입니다.

호출은 caller → readLabel 순서지만, 바깥 변수는 readLabel → 모듈 경로로 찾습니다.

호출과 변수 탐색

readLabel이 찾는 label

호출 스택은 caller를 거쳤지만, 식별자는 readLabel의 바깥 환경인 모듈에서 찾습니다.

  • readLabel의 환경 — label 없음. readLabel 실행 중 label을 찾기 시작하는 환경입니다.
  • 모듈 환경 — label: 'module'. readLabel이 정의된 바깥 환경입니다. 식별자 탐색은 이 경로를 따릅니다.
  • caller의 환경 — label: 'caller'. caller의 지역 변수 label입니다. readLabel은 모듈의 label을 읽습니다.

호출 스택은 caller를 거쳤지만, 식별자는 readLabel의 바깥 환경인 모듈에서 찾습니다.

  • readLabel의 환경 — label 없음. readLabel 실행 중 label을 찾기 시작하는 환경입니다.
  • 모듈 환경 — label: 'module'. readLabel이 정의된 바깥 환경입니다. 식별자 탐색은 이 경로를 따릅니다.
  • caller의 환경 — label: 'caller'. caller의 지역 변수 label입니다. readLabel은 모듈의 label을 읽습니다.

TDZ: 이름은 있지만 아직 읽을 수 없는 상태​

const count = 10;
{
console.log(count); // ReferenceError
let count = 20;
}

현재 블록에 count가 있으므로 바깥의 10을 대신 읽지 않습니다. 하지만 안쪽 count는 초기화 전이라 읽을 수 없고, ReferenceError가 발생합니다.

선언과 TDZ

초기화 전의 count

안쪽 count를 찾았으므로 바깥의 10을 대신 읽지는 않습니다.

  • count 읽기 — console.log(count). 식별자 count의 바인딩을 현재 환경부터 찾습니다.
  • 현재 블록 — count: 미초기화. 바인딩은 있지만 아직 초기화되지 않았습니다. 읽으면 ReferenceError가 납니다.
  • 모듈 환경 — count: 10. 현재 블록에서 같은 이름을 찾았으므로 이 값으로 넘어가지 않습니다.

안쪽 count를 찾았으므로 바깥의 10을 대신 읽지는 않습니다.

  • count 읽기 — console.log(count). 식별자 count의 바인딩을 현재 환경부터 찾습니다.
  • 현재 블록 — count: 미초기화. 바인딩은 있지만 아직 초기화되지 않았습니다. 읽으면 ReferenceError가 납니다.
  • 모듈 환경 — count: 10. 현재 블록에서 같은 이름을 찾았으므로 이 값으로 넘어가지 않습니다.

도식의 마지막 단계에서는 코드를 바꿔 다시 실행합니다. 선언 다음에 count를 읽으면 20이 나옵니다.

4. 클로저와 상태 공유​

createCounter가 반환한 객체에는 inc와 read가 들어 있습니다. createCounter의 호출이 끝난 뒤에도 이 두 함수로 count를 읽고 바꿀 수 있습니다.

function createCounter() {
let count = 0;
return {
inc: () => ++count,
read: () => count,
};
}
const a = createCounter();
const b = createCounter();
a.inc();
console.log(a.read(), b.read()); // 1 0

a.inc와 a.read는 같은 count를 사용합니다. inc가 1로 바꾸면 read도 1을 읽습니다. b는 두 번째 호출에서 만들어진 별도의 count를 읽으므로 0을 반환합니다.

호출별 상태

a와 b가 읽는 count

a.read는 변경된 count A를, b.read는 별도의 count B를 읽습니다.

  • a — 반환된 객체. 첫 createCounter() 호출이 반환한 객체입니다.
  • a.inc · a.read — 두 함수. 같은 호출에서 만들어져 count A 바인딩에 함께 접근합니다.
  • 호출 A의 환경 — count: 1. 첫 호출에서 만들어진 count입니다. 함수가 반환돼도 필요한 접근 관계는 유지됩니다.
  • b — 반환된 객체. 두 번째 createCounter() 호출이 반환한 객체입니다.
  • b.inc · b.read — 별도의 두 함수. b의 함수들은 호출 B의 count에 접근합니다.
  • 호출 B의 환경 — count: 0. count A와 이름만 같고 별개인 바인딩입니다.

a.read는 변경된 count A를, b.read는 별도의 count B를 읽습니다.

  • a — 반환된 객체. 첫 createCounter() 호출이 반환한 객체입니다.
  • a.inc · a.read — 두 함수. 같은 호출에서 만들어져 count A 바인딩에 함께 접근합니다.
  • 호출 A의 환경 — count: 1. 첫 호출에서 만들어진 count입니다. 함수가 반환돼도 필요한 접근 관계는 유지됩니다.
  • b — 반환된 객체. 두 번째 createCounter() 호출이 반환한 객체입니다.
  • b.inc · b.read — 별도의 두 함수. b의 함수들은 호출 B의 count에 접근합니다.
  • 호출 B의 환경 — count: 0. count A와 이름만 같고 별개인 바인딩입니다.

inc와 read는 만들어질 때 바깥의 count에 접근하는 연결을 갖습니다. createCounter가 실행을 마쳐도 이 연결은 남습니다. 이렇게 함수가 자신이 만들어진 환경의 변수를 사용하는 것을 클로저라고 합니다.

React의 이전 핸들러는 어떤 값을 읽을까요?​

앞에서는 같은 count의 값을 바꿨습니다. 이번에는 makeHandler를 0과 1로 각각 호출합니다. 호출마다 별도의 count가 만들어집니다.

function makeHandler(count) { return () => count; }
const first = makeHandler(0);
const second = makeHandler(1);
console.log(first(), second()); // 0 1
React 연결 · 일반 JS 예제

first와 second가 읽는 count

second는 두 번째 호출의 count에 연결되고, first는 첫 호출의 count에 연결돼 있습니다.

  • 첫 번째 핸들러 — first. 첫 호출에서 만들어진 함수입니다.
  • 첫 호출의 환경 — count: 0. 다음 호출이 생겨도 이 바인딩이 1로 바뀌지는 않습니다.
  • 두 번째 핸들러 — second. 새 호출에서 만든 다른 함수입니다.
  • 두 번째 호출의 환경 — count: 1. 첫 호출과 별개의 매개변수 바인딩입니다.

second는 두 번째 호출의 count에 연결되고, first는 첫 호출의 count에 연결돼 있습니다.

  • 첫 번째 핸들러 — first. 첫 호출에서 만들어진 함수입니다.
  • 첫 호출의 환경 — count: 0. 다음 호출이 생겨도 이 바인딩이 1로 바뀌지는 않습니다.
  • 두 번째 핸들러 — second. 새 호출에서 만든 다른 함수입니다.
  • 두 번째 호출의 환경 — count: 1. 첫 호출과 별개의 매개변수 바인딩입니다.

React에서도 각 렌더에서 만든 함수는 그 렌더의 지역 바인딩을 사용합니다. 새 렌더가 이전 핸들러의 바인딩을 최신 state로 덮어쓰지는 않습니다. state를 보관하고 렌더에 전달하는 것은 React의 규칙이고, 핸들러가 바깥 변수에 접근하는 것은 JavaScript의 클로저 동작입니다.

정리​

copied와 original은 같은 profile을 공유해서 변경도 함께 보입니다. readLabel은 정의된 위치의 바깥에서 label을 찾습니다. a의 inc와 read는 같은 count를 읽고 바꾸지만, 별도로 만든 b의 count는 0으로 남습니다.

보충 노트 — 타입 비교·const·실행 컨텍스트·추가 예제

타입 검사와 비교 규칙​

typeof만으로는 배열과 null을 구분하기 어렵습니다. 배열은 Array.isArray(), null은 === null로 확인할 수 있습니다.

console.log(typeof null); // 'object'
console.log(Array.isArray([])); // true
console.log(typeof NaN); // 'number'
console.log(Number.isNaN(NaN)); // true
console.log(Boolean([])); // true

빈 배열도 truthy라서, 항목이 있는지 확인할 때는 length를 확인합니다.

두 값=====Object.isSameValueZero
0, '0'truefalsefalsefalse
NaN, NaNfalsefalsetruetrue
0, -0truetruefalsetrue
별도로 만든 {} 두 개falsefalsefalsefalse

Map과 Set은 SameValueZero를, React Effect의 dependency 비교는 Object.is를 사용합니다. 어느 쪽도 객체 내부를 재귀적으로 비교하지는 않습니다.

const로 선언했는데 프로퍼티는 왜 바뀔까요?​

const가 제한하는 것은 이름에 다른 값을 다시 대입하는 일입니다. 그 이름이 가리키는 객체의 모든 변경을 막지는 않습니다.

바인딩은 이렇게 이름과 값을 연결하는 관계입니다. let count = 0으로 초기화한 뒤 count = 1을 실행하면 같은 바인딩에 다른 값을 대입합니다.

구분제한하는 대상중첩 객체까지 보호하나요?
const바인딩의 재대입아니요. 객체 프로퍼티는 바뀔 수 있습니다.
TS readonly해당 타입을 통한 쓰기타입 검사 단계에 적용됩니다. 중첩 보호는 타입 정의에 따릅니다.
Object.freeze대상 객체 자신의 프로퍼티 변경 등중첩 객체는 자동으로 동결하지 않습니다.

함수에 객체를 넘길 때도 같은 구분을 적용할 수 있습니다. item.count를 바꾸면 호출자도 변경을 보지만, 매개변수 item에 새 객체를 대입한다고 호출자의 변수까지 바뀌지는 않습니다.

호출 스택과 변수 탐색 경로​

호출 스택은 실행과 복귀 순서를 나타냅니다. Environment Record는 바인딩과 바깥 환경의 연결을 설명하는 명세 장치입니다. 현재 환경에 이름이 없으면 [[OuterEnv]]를 따라 찾습니다.

Execution Context는 현재 실행에 필요한 상태를 설명합니다. 블록에 들어가 바인딩 범위가 바뀌는 것과, 함수를 호출해 실행 컨텍스트가 쌓이는 것은 구별됩니다.

var와 let의 초기화​

일반적인 var 바인딩은 해당 함수·모듈 등의 선언 처리에서 undefined로 초기화됩니다. let과 const는 초기화 전 접근이 제한되고, let count;도 선언문이 실행된 뒤에야 undefined가 됩니다. 호이스팅 설명에서 var와 let의 결과가 갈리는 이유도 이 초기화 시점에 있습니다.

같은 바인딩을 읽는지 확인하는 두 예제​

미리 계산한 문자열은 count가 바뀌어도 저절로 다시 만들어지지 않습니다.

function create() {
let count = 0;
const message = `count=${count}`;
return {
inc: () => ++count,
live: () => count,
snapshot: () => message,
};
}
const x = create();
x.inc();
console.log(x.live(), x.snapshot()); // 1 'count=0'

live는 변경된 count를 읽고, snapshot은 처음 만든 문자열이 들어 있는 message를 읽습니다.

반복문에서도 함수들이 하나의 바인딩을 공유하는지에 따라 결과가 달라집니다.

const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
console.log(withVar.map(read => read())); // [3, 3, 3]

const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
console.log(withLet.map(read => read())); // [0, 1, 2]

첫 번째 함수들은 하나의 i를 함께 읽고, 두 번째 함수들은 반복별 j를 읽습니다. 타이머 없이도 같은 차이가 납니다.

참고​