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가 발생합니다.
초기화 전의 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
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.is | SameValueZero |
|---|---|---|---|---|
0, '0' | true | false | false | false |
NaN, NaN | false | false | true | true |
0, -0 | true | true | false | true |
별도로 만든 {} 두 개 | false | false | false | false |
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를 읽습니다. 타이머 없이도 같은 차이가 납니다.