렉시컬 스코프 - 코드가 어디에 쓰여있는지가 전부다
JavaScript를 배우면서 이런 코드를 보고 혼란스러웠던 적 있나요?
const name = 'Global';
function outer() {
const name = 'Outer';
function inner() {
console.log(name); // 'Outer'가 나올까? 'Global'이 나올까?
}
return inner;
}
const fn = outer();
fn(); // ?
“함수가 어디서 호출되는지에 따라 달라지는 거 아니야?” 처음에는 이렇게 생각했습니다. 하지만 JavaScript는 다릅니다. JavaScript는 렉시컬 스코프(Lexical Scope)를 사용하기 때문에, 함수가 어디서 정의되었는지가 중요합니다.
이 개념을 이해하지 못하면 클로저(Closure), 화살표 함수의 this, 호이스팅(Hoisting) 같은 JavaScript의 핵심 동작을 이해할 수 없습니다.
이 문서에서는 렉시컬 스코프가 무엇인지, 왜 중요한지, 그리고 실전에서 어떻게 활용하는지를 자세히 설명하겠습니다.
목차
- 왜 렉시컬 스코프를 이해해야 할까요?
- 먼저, 스코프가 무엇인지 알아봅시다
- 렉시컬 스코프란 무엇인가?
- 렉시컬 스코프 vs 동적 스코프
- 렉시컬 환경(Lexical Environment)의 동작 원리
- 렉시컬 스코프와 클로저
- 화살표 함수와 렉시컬 this
- 실전에서 렉시컬 스코프 활용하기
- 렉시컬 스코프의 함정과 주의사항
- 결론: 코드를 쓴 위치가 모든 것을 결정한다
- 참고 자료
왜 렉시컬 스코프를 이해해야 할까요?
1. JavaScript의 모든 동작이 렉시컬 스코프 기반입니다
// 클로저 - 렉시컬 스코프 덕분에 작동
function createCounter() {
let count = 0;
return () => ++count;
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
// 화살표 함수의 this - 렉시컬 스코프
const obj = {
name: 'MyObject',
regularFunc: function() {
setTimeout(function() {
console.log(this.name); // undefined (동적 this)
}, 100);
},
arrowFunc: function() {
setTimeout(() => {
console.log(this.name); // 'MyObject' (렉시컬 this)
}, 100);
}
};
렉시컬 스코프를 이해하지 못하면 이런 코드들이 왜 작동하는지 알 수 없습니다.
2. 디버깅할 때 필수입니다
let x = 10;
function outer() {
let x = 20;
function inner() {
console.log(x); // 왜 20일까? 10이 아니라? ㅋ
}
inner();
}
outer();
변수가 어떤 값을 참조하는지 이해하려면, 렉시컬 스코프를 알아야 합니다.
3. 성능 최적화를 이해하는 데 도움이 됩니다
// ❌ 나쁜 예: 불필요한 클로저 생성
function createHandlers() {
const handlers = [];
for (var i = 0; i < 10; i++) {
handlers.push(function() {
console.log(i); // 모두 10을 출력!
});
}
return handlers;
}
// ✅ 좋은 예: 렉시컬 스코프 이해하고 해결
function createHandlers() {
const handlers = [];
for (let i = 0; i < 10; i++) { // let으로 블록 스코프 생성
handlers.push(function() {
console.log(i); // 각각 0, 1, 2, ... 9 출력
});
}
return handlers;
}
먼저, 스코프가 무엇인지 알아봅시다
스코프의 정의
스코프(Scope)는 변수와 함수에 접근할 수 있는 유효 범위입니다.
const globalVar = 'I am global';
function myFunction() {
const localVar = 'I am local';
console.log(globalVar); // ✅ 접근 가능
console.log(localVar); // ✅ 접근 가능
}
myFunction();
console.log(globalVar); // ✅ 접근 가능
console.log(localVar); // ❌ ReferenceError: localVar is not defined
JavaScript의 스코프 종류
// 1. 전역 스코프 (Global Scope)
const global = 'global';
function test() {
// 2. 함수 스코프 (Function Scope)
const func = 'function';
if (true) {
// 3. 블록 스코프 (Block Scope) - let, const만 해당
const block = 'block';
var notBlock = 'not block'; // var는 블록 스코프 무시
console.log(global); // ✅ 접근 가능
console.log(func); // ✅ 접근 가능
console.log(block); // ✅ 접근 가능
}
console.log(global); // ✅ 접근 가능
console.log(func); // ✅ 접근 가능
console.log(notBlock); // ✅ var는 함수 스코프
console.log(block); // ❌ ReferenceError
}
test();
스코프 체인 (Scope Chain)
변수를 찾을 때, JavaScript는 현재 스코프에서 시작해서 바깥 스코프로 차례대로 찾아갑니다.
const level1 = 'Level 1';
function outer() {
const level2 = 'Level 2';
function middle() {
const level3 = 'Level 3';
function inner() {
const level4 = 'Level 4';
console.log(level4); // 1단계: inner 스코프에서 찾음
console.log(level3); // 2단계: middle 스코프에서 찾음
console.log(level2); // 3단계: outer 스코프에서 찾음
console.log(level1); // 4단계: 전역 스코프에서 찾음
}
inner();
}
middle();
}
outer();
시각화:
스코프 체인:
inner 스코프 (level4)
↑
middle 스코프 (level3)
↑
outer 스코프 (level2)
↑
전역 스코프 (level1)
렉시컬 스코프란 무엇인가?
기본 개념
렉시컬 스코프(Lexical Scope)는 함수가 정의된 위치에 따라 스코프가 결정되는 것을 말합니다.
“렉시컬(Lexical)”은 “어휘적”, “정적” 이라는 뜻으로, 코드를 작성하는 시점에 스코프가 결정된다는 의미입니다.
const name = 'Global';
function outer() {
const name = 'Outer';
function inner() {
// inner는 outer 안에서 "정의"되었음
// 따라서 outer의 name을 참조
console.log(name); // 'Outer'
}
return inner;
}
// inner를 전역에서 호출해도...
const innerFunc = outer();
innerFunc(); // 'Outer' (정의된 위치의 스코프 사용!)
핵심: 함수가 어디서 호출되는지는 중요하지 않습니다. 어디서 정의되었는지만 중요합니다.
코드로 이해하기
let x = 1;
function first() {
let x = 10;
second(); // second를 여기서 호출
}
function second() {
console.log(x); // ?
}
first();
질문: second()는 first() 안에서 호출되었으니 x는 10일까요?
답: 아닙니다! 1입니다.
이유: second()는 전역에서 정의되었으므로, 전역의 x를 참조합니다.
코드 작성 위치:
전역 스코프
├─ let x = 1
├─ function first() { let x = 10; ... }
└─ function second() { console.log(x); } ← 여기서 정의됨!
비유로 이해하기
렉시컬 스코프는 태어난 곳(정의된 곳)이 당신의 고향을 결정하는 것과 같습니다.
// 서울에서 태어난 사람
function seoul() {
const hometown = '서울';
function person() {
console.log('나의 고향:', hometown);
}
return person;
}
// 부산에서 실행
function busan() {
const hometown = '부산';
const seoulPerson = seoul();
seoulPerson(); // '나의 고향: 서울'
// 부산에서 실행되어도, 서울이 고향!
}
busan();
렉시컬 스코프 vs 동적 스코프
일부 언어(Bash, Perl 등)는 동적 스코프(Dynamic Scope)를 사용합니다. JavaScript와 비교해봅시다.
동적 스코프 (JavaScript가 아님!)
동적 스코프에서는 함수가 호출되는 위치에 따라 스코프가 결정됩니다.
// 만약 JavaScript가 동적 스코프를 사용한다면...
let x = 1;
function outer() {
let x = 10;
inner(); // 여기서 호출
}
function inner() {
console.log(x); // 동적 스코프라면 10 (호출 위치의 x)
}
outer();
렉시컬 스코프 (JavaScript!)
JavaScript는 함수가 정의된 위치를 따릅니다.
let x = 1;
function outer() {
let x = 10;
inner(); // 여기서 호출해도...
}
function inner() {
console.log(x); // 1 (정의 위치의 x)
}
outer(); // 1
실전 예제로 비교
const value = 'global';
function makeFunction() {
const value = 'makeFunction';
return function() {
console.log(value);
};
}
function caller() {
const value = 'caller';
const fn = makeFunction();
fn(); // ?
}
caller();
// JavaScript (렉시컬 스코프): 'makeFunction'
// 만약 동적 스코프였다면: 'caller'
왜 렉시컬 스코프가 더 나을까?
- 예측 가능: 코드만 보면 어떤 값을 참조하는지 알 수 있음
- 디버깅 쉬움: 실행 흐름을 추적할 필요 없음
- 최적화 가능: 컴파일 시점에 스코프 결정 가능
렉시컬 환경(Lexical Environment)의 동작 원리
JavaScript 엔진이 렉시컬 스코프를 구현하는 방법을 이해해봅시다.
렉시컬 환경의 구조
모든 함수는 렉시컬 환경(Lexical Environment)이라는 내부 객체를 가집니다.
function outer() {
const x = 10;
function inner() {
const y = 20;
console.log(x + y);
}
return inner;
}
const fn = outer();
fn();
렉시컬 환경 구조:
outer의 렉시컬 환경:
{
environmentRecord: { x: 10 },
outer: 전역 렉시컬 환경
}
inner의 렉시컬 환경:
{
environmentRecord: { y: 20 },
outer: outer의 렉시컬 환경 ← 정의된 위치!
}
변수 조회 과정
function a() {
const x = 1;
function b() {
const y = 2;
function c() {
const z = 3;
console.log(x + y + z); // 6
}
c();
}
b();
}
a();
변수 x 조회 과정:
1. c의 환경에서 x 찾기 → 없음
2. c.outer (b의 환경)에서 x 찾기 → 없음
3. b.outer (a의 환경)에서 x 찾기 → 발견! (1)
함수가 정의될 때 무슨 일이 일어날까?
const globalVar = 'global';
function createFunction() {
const localVar = 'local';
// 이 시점에 inner의 [[Environment]]가 설정됨
function inner() {
console.log(globalVar);
console.log(localVar);
}
return inner;
}
const fn = createFunction();
// fn은 자신의 렉시컬 환경을 "기억"함
fn(); // 'global', 'local'
내부 동작:
inner함수가 생성될 때- 현재 렉시컬 환경(createFunction의 환경)을
[[Environment]]에 저장 - 나중에
fn()이 호출될 때, 저장된 환경을 사용
이것이 바로 클로저의 원리입니다!
렉시컬 스코프와 클로저
클로저는 렉시컬 스코프의 가장 중요한 활용 사례입니다.
클로저란?
클로저(Closure)는 함수가 자신이 정의된 렉시컬 환경을 기억하는 것입니다.
function makeCounter() {
let count = 0;
return function() {
return ++count;
};
}
const counter1 = makeCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter1()); // 3
const counter2 = makeCounter();
console.log(counter2()); // 1
console.log(counter2()); // 2
왜 작동할까?
counter1의 렉시컬 환경 체인:
익명 함수의 환경
↑ outer
makeCounter의 환경 { count: 0 }
↑ outer
전역 환경
반환된 함수는 makeCounter의 렉시컬 환경을 계속 참조합니다.
실전 예제: Private 변수
function createBankAccount(initialBalance) {
// Private 변수 (외부에서 접근 불가)
let balance = initialBalance;
// Public 메소드들 (렉시컬 스코프로 balance 접근)
return {
deposit(amount) {
if (amount > 0) {
balance += amount;
return balance;
}
},
withdraw(amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return balance;
}
return null;
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(1000);
console.log(account.getBalance()); // 1000
account.deposit(500);
console.log(account.getBalance()); // 1500
account.withdraw(200);
console.log(account.getBalance()); // 1300
// ❌ 직접 접근 불가능
console.log(account.balance); // undefined
실전 예제: 이벤트 핸들러
function setupButtons() {
const buttons = document.querySelectorAll('button');
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
// 클로저: i를 기억함
console.log(`Button ${i} clicked`);
});
}
}
// 주의: var를 사용하면 문제 발생
function setupButtonsBroken() {
const buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
// 모든 버튼이 같은 i를 참조 (마지막 값)
console.log(`Button ${i} clicked`); // 항상 buttons.length
});
}
}
화살표 함수와 렉시컬 this
화살표 함수는 this도 렉시컬하게 바인딩합니다.
일반 함수의 동적 this
const obj = {
name: 'MyObject',
regularMethod: function() {
console.log('1. this:', this.name); // 'MyObject'
setTimeout(function() {
console.log('2. this:', this.name); // undefined
// setTimeout의 this는 전역 객체 (strict mode에서 undefined)
}, 100);
}
};
obj.regularMethod();
화살표 함수의 렉시컬 this
const obj = {
name: 'MyObject',
arrowMethod: function() {
console.log('1. this:', this.name); // 'MyObject'
setTimeout(() => {
console.log('2. this:', this.name); // 'MyObject'
// 화살표 함수는 정의된 위치의 this를 사용 (렉시컬)
}, 100);
}
};
obj.arrowMethod();
왜 렉시컬 this가 필요했을까?
// ES5 이전: this를 변수에 저장
function Counter() {
this.count = 0;
const self = this; // this를 변수에 저장
setInterval(function() {
self.count++; // self 사용
console.log(self.count);
}, 1000);
}
// ES6: 화살표 함수로 간결하게
function Counter() {
this.count = 0;
setInterval(() => {
this.count++; // 렉시컬 this
console.log(this.count);
}, 1000);
}
React 컴포넌트에서의 활용
class Timer extends React.Component {
state = { seconds: 0 };
componentDidMount() {
// ❌ 일반 함수: this가 undefined
setInterval(function() {
this.setState({ seconds: this.state.seconds + 1 }); // 에러!
}, 1000);
}
}
class Timer extends React.Component {
state = { seconds: 0 };
componentDidMount() {
// ✅ 화살표 함수: 렉시컬 this
setInterval(() => {
this.setState({ seconds: this.state.seconds + 1 }); // 작동!
}, 1000);
}
}
실전에서 렉시컬 스코프 활용하기
패턴 1: 모듈 패턴
const TodoModule = (function() {
// Private 변수
let todos = [];
let nextId = 1;
// Private 함수
function findById(id) {
return todos.find(todo => todo.id === id);
}
// Public API (렉시컬 스코프로 private에 접근)
return {
add(text) {
const todo = { id: nextId++, text, completed: false };
todos.push(todo);
return todo;
},
remove(id) {
const index = todos.findIndex(todo => todo.id === id);
if (index !== -1) {
todos.splice(index, 1);
return true;
}
return false;
},
toggle(id) {
const todo = findById(id);
if (todo) {
todo.completed = !todo.completed;
return todo;
}
return null;
},
getAll() {
return [...todos]; // 복사본 반환
}
};
})();
// 사용
TodoModule.add('Learn Lexical Scope');
TodoModule.add('Master Closures');
console.log(TodoModule.getAll());
// ❌ Private에는 접근 불가
console.log(TodoModule.todos); // undefined
패턴 2: 팩토리 함수
function createValidator(rules) {
// 렉시컬 스코프: rules를 기억
return function(value) {
for (const rule of rules) {
if (!rule.test(value)) {
return {
valid: false,
error: rule.message
};
}
}
return { valid: true };
};
}
// 이메일 검증기 생성
const emailValidator = createValidator([
{
test: (val) => val.length > 0,
message: '이메일을 입력하세요'
},
{
test: (val) => val.includes('@'),
message: '올바른 이메일 형식이 아닙니다'
}
]);
// 비밀번호 검증기 생성
const passwordValidator = createValidator([
{
test: (val) => val.length >= 8,
message: '비밀번호는 8자 이상이어야 합니다'
},
{
test: (val) => /[A-Z]/.test(val),
message: '대문자를 포함해야 합니다'
}
]);
console.log(emailValidator('test@example.com')); // { valid: true }
console.log(passwordValidator('weak')); // { valid: false, error: ... }
패턴 3: 부분 적용 (Partial Application)
function multiply(a, b) {
return a * b;
}
// 첫 번째 인자를 고정하는 함수
function partial(fn, firstArg) {
// 렉시컬 스코프: fn과 firstArg를 기억
return function(secondArg) {
return fn(firstArg, secondArg);
};
}
const double = partial(multiply, 2);
const triple = partial(multiply, 3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// 🎯 실전 예제: API 호출
function apiCall(baseUrl, endpoint) {
return fetch(`${baseUrl}${endpoint}`);
}
const apiV1 = partial(apiCall, 'https://api.example.com/v1');
const apiV2 = partial(apiCall, 'https://api.example.com/v2');
apiV1('/users'); // https://api.example.com/v1/users
apiV2('/users'); // https://api.example.com/v2/users
패턴 4: 메모이제이션 (Memoization)
function memoize(fn) {
const cache = {}; // 렉시컬 스코프: 각 함수마다 독립적인 캐시
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log('Cache hit!');
return cache[key];
}
console.log('Computing...');
const result = fn(...args);
cache[key] = result;
return result;
};
}
// 사용 예: 피보나치
const fibonacci = memoize(function(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(10)); // Computing 여러 번
console.log(fibonacci(10)); // Cache hit!
console.log(fibonacci(11)); // 일부만 Computing
렉시컬 스코프의 함정과 주의사항
함정 1: 루프 안의 클로저
// ❌ var 사용 시 문제
function createFunctions() {
const functions = [];
for (var i = 0; i < 3; i++) {
functions.push(function() {
console.log(i);
});
}
return functions;
}
const fns = createFunctions();
fns[0](); // 3
fns[1](); // 3
fns[2](); // 3 (모두 3!)
// ✅ let 사용으로 해결
function createFunctions() {
const functions = [];
for (let i = 0; i < 3; i++) { // let은 블록 스코프
functions.push(function() {
console.log(i);
});
}
return functions;
}
const fns = createFunctions();
fns[0](); // 0
fns[1](); // 1
fns[2](); // 2
왜 그럴까?
var 사용 시:
모든 함수가 같은 i를 참조 (함수 스코프)
for 루프 종료 후 i = 3
→ 모든 함수가 3을 출력
let 사용 시:
각 반복마다 새로운 i 생성 (블록 스코프)
→ 각 함수가 다른 i를 참조
함정 2: 메모리 누수
// ❌ 불필요한 클로저 유지
function heavyOperation() {
const hugeData = new Array(1000000).fill('data');
return function() {
// hugeData를 사용하지 않아도 참조 유지!
console.log('Done');
};
}
const fn = heavyOperation();
// hugeData가 메모리에서 해제되지 않음
// ✅ 필요한 것만 참조
function heavyOperation() {
const hugeData = new Array(1000000).fill('data');
const summary = hugeData.length; // 필요한 정보만 추출
return function() {
console.log('Size was:', summary);
// hugeData는 가비지 컬렉션됨
};
}
함정 3: this와 렉시컬 스코프 혼동
const obj = {
name: 'MyObject',
// ❌ 화살표 함수를 메소드로 사용
greet: () => {
// 렉시컬 this = 전역 this
console.log(this.name); // undefined
},
// ✅ 일반 함수 사용
greet2: function() {
console.log(this.name); // 'MyObject'
},
// ✅ 메소드 축약 문법
greet3() {
console.log(this.name); // 'MyObject'
}
};
함정 4: 의도하지 않은 전역 변수
function test() {
// ❌ 선언 없이 할당 = 전역 변수!
count = 10;
return function() {
console.log(count);
};
}
const fn = test();
fn(); // 10
console.log(window.count); // 10 (전역 오염!)
// ✅ strict mode로 방지
'use strict';
function test() {
count = 10; // ReferenceError: count is not defined
}
함정 5: 변수 shadowing
let name = 'Global';
function outer() {
let name = 'Outer';
function inner() {
let name = 'Inner'; // 외부 name을 가림
console.log(name); // 'Inner'
}
inner();
console.log(name); // 'Outer'
}
outer();
console.log(name); // 'Global'
// 주의: 의도하지 않은 shadowing
function calculate(price) {
const tax = 0.1;
function applyDiscount(discount) {
const tax = 0.05; // 실수로 외부 tax를 가림!
return price * (1 - discount) * (1 + tax);
}
return applyDiscount(0.1);
}
결론: 코드를 쓴 위치가 모든 것을 결정한다
핵심 요약
- 렉시컬 스코프의 정의
- 함수가 정의된 위치에 따라 스코프 결정
- 호출 위치는 중요하지 않음
- 코드 작성 시점에 스코프 결정 (정적)
- 렉시컬 스코프가 중요한 이유
- 클로저의 동작 원리
- 화살표 함수의 렉시컬 this
- 변수 조회 메커니즘
- 코드 예측 가능성
- 실전 활용
- Private 변수 (모듈 패턴)
- 팩토리 함수
- 부분 적용
- 메모이제이션
- 주의사항
- 루프 안의 클로저 (var vs let)
- 메모리 누수 가능성
- this와의 차이점
- 변수 shadowing
빠른 결정 가이드
변수가 어떤 값을 참조할까?
└─ 함수가 정의된 위치의 스코프를 따라 올라가며 찾기
클로저가 작동하는 이유는?
└─ 함수가 정의된 렉시컬 환경을 "기억"하기 때문
화살표 함수의 this는?
└─ 정의된 위치의 this를 렉시컬하게 사용
var vs let in 루프?
└─ var는 함수 스코프, let은 블록 스코프 (반복마다 새 환경)
실전 체크리스트
코드 작성 시 확인할 항목:
- 변수가 어느 스코프에서 정의되었는지 명확한가?
- 클로저가 의도대로 동작하는가?
- 불필요한 클로저로 메모리 낭비는 없는가?
- 루프 안에서 함수를 생성할 때 let을 사용했는가?
- 화살표 함수를 메소드로 사용하지 않았는가?
- 변수 shadowing이 의도적인가?
마지막 조언
렉시컬 스코프는 JavaScript의 가장 기본적이면서도 강력한 특징입니다.
처음에는 “함수가 정의된 위치”라는 개념이 낯설 수 있습니다. 하지만 이를 이해하면:
- 클로저가 어떻게 동작하는지 명확히 알게 됩니다
- 화살표 함수의 this 동작이 자연스러워집니다
- 디버깅이 훨씬 쉬워집니다
- 코드 예측이 가능해집니다
기억하세요: JavaScript에서는 코드를 어디에 쓰느냐가 모든 것을 결정합니다! 🎯
참고 자료
MDN 공식 문서
심화 학습
- You Don’t Know JS: Scope & Closures - Kyle Simpson
- JavaScript: The Definitive Guide - David Flanagan
- Understanding ECMAScript 6 - Nicholas C. Zakas
관련 문서
- closures.md - 클로저 상세 설명
- this.md - this 바인딩의 이해
- function-vs-arrow-function.md - 화살표 함수의 특징
- hoisting.md - 호이스팅과 스코프
- execution-context.md - 실행 컨텍스트
댓글