JavaScript의 Execution Context를 예제로 이해해보기
execution context 글을 읽고 나면 보통 이런 생각이 듭니다.
- 개념은 알겠는데 실제 코드에서 어떻게 읽어야 하지?
var,let,const차이를 실행 컨텍스트로 설명하면 뭐가 달라지지?closure는 왜 바깥 값이 계속 살아 있는 것처럼 보이지?this는 왜 같은 함수인데 호출 방식에 따라 달라지지?
이 질문은 자연스럽습니다. execution context는 정의만 보면 추상적으로 느껴지기 쉽기 때문입니다.
하지만 이 개념은 예제로 볼 때 훨씬 강해집니다. 중요한 것은 용어를 외우는 것이 아니라, 실행 전에 무엇이 준비되고, 실행 중 어떤 식별자를 볼 수 있으며, 함수 호출 시 어떤 컨텍스트가 새로 생기고, this는 어떤 방식으로 결정되는지를 실제 코드에 대입해보는 것입니다.
이 글에서는 execution context와 관련된 대표 예제들을 아래 흐름으로 풀어보겠습니다.
let,var,const는 왜 다르게 보이는지closure는 왜 바깥 값을 계속 참조할 수 있는지this는 왜 선언 위치가 아니라 호출 방식에 따라 달라지는지- 예제를 읽을 때 어떤 순서로 해석하면 좋은지
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
var,let,const차이는 execution context의 준비 단계에서 다르게 드러납니다.closure는 함수가 바깥 값을 복사하는 것이 아니라, 바깥 렉시컬 환경과 연결된 결과로 이해하는 편이 더 정확합니다.this는 일반 변수처럼 lexical scope로 결정되지 않고, 함수가 실행될 때의 호출 방식과 연결됩니다.- 예제를 읽을 때는 "지금 어떤 context가 만들어졌는가"를 먼저 보면 대부분의 혼란이 줄어듭니다.
즉, 예제를 잘 푸는 핵심은 문법 암기보다 실행 문맥을 먼저 세우는 습관입니다.
예제 1. var는 왜 undefined가 보이고 let, const는 에러가 날까?
먼저 가장 기본적인 예제입니다.
console.log(a);
var a = 1;이 코드는 많은 경우 undefined를 출력합니다.
반면:
console.log(b);
let b = 1;또는:
console.log(c);
const c = 1;는 에러가 납니다.
겉으로만 보면:
var는 "호이스팅된다"let,const는 "호이스팅되지 않는다"
처럼 외우기 쉽습니다. 하지만 execution context 관점에서는 이렇게 읽는 편이 더 정확합니다.
전역 코드가 실행되기 전, 전역 execution context의 준비 단계에서:
var a는 이름이 등록되고undefined로 초기화될 수 있고let b,const c는 이름은 등록되지만 초기화 전 접근이 막힙니다
즉, 차이는 "위로 끌어올려졌느냐"보다 준비 단계에서 어떤 상태로 들어오느냐에 있습니다.
그래서 실행 단계에서 첫 줄 console.log(a)를 만났을 때는 이미 a라는 이름이 존재하고, 값은 아직 undefined입니다.
반면 b, c는 이름은 준비돼 있어도 초기화 전 구간이기 때문에 접근 자체가 막힙니다. 이것이 흔히 말하는 TDZ입니다.
즉, 이 예제는 var, let, const의 차이를 문법 차이로만 보기보다, 같은 execution context 안에서도 준비 방식이 다르다는 점을 보여줍니다.
예제 2. 함수 안으로 들어가면 무엇이 달라질까?
이번엔 함수 컨텍스트를 같이 봐야 합니다.
var value = 10;
function printValue() {
console.log(value);
var value = 20;
}
printValue();처음 보는 사람은 10을 예상할 때가 많습니다. 바깥의 전역 변수 value가 있으니 그 값을 읽을 것처럼 보이기 때문입니다.
하지만 실제로는 함수 안의 var value 때문에 undefined가 나옵니다.
이걸 execution context로 풀어보면:
- 전역 컨텍스트가 만들어지고
value = 10,printValue가 준비됩니다. printValue()호출 시 함수 execution context가 새로 만들어집니다.- 이 함수 컨텍스트의 준비 단계에서
var value가 지역 이름으로 먼저 등록되고undefined로 초기화됩니다. - 실행 단계에서
console.log(value)는 전역이 아니라, 먼저 함수 자신의 지역value를 찾습니다.
즉, 바깥 값을 보기 전에 현재 컨텍스트의 식별자 테이블부터 본다는 감각이 중요합니다.
이 예제를 이해하면:
- 왜 지역 변수가 전역 변수를 가리는지
- 왜
var가 있을 때 예상과 다른undefined가 나오는지
를 execution context 안에서 자연스럽게 설명할 수 있습니다.
예제 3. let이 반복문에서 덜 헷갈리는 이유
아래 예제는 closure와 let/var를 함께 볼 때 자주 나옵니다.
for (var i = 0; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}많은 경우 결과는 아래처럼 나옵니다.
3
3
3반면:
for (let i = 0; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}는 보통:
0
1
2처럼 보입니다.
이 차이를 단순히 "클로저 때문이다"라고만 설명하면 반만 맞습니다. 실제로는 closure와 execution context, 그리고 반복문에서의 바인딩 방식이 같이 작동합니다.
var 버전에서는:
- 하나의 함수/전역 스코프에 있는 같은
i바인딩을 계속 공유하고 - 타이머 콜백들이 나중에 실행될 때 그 하나의
i를 봅니다
그래서 반복이 끝난 뒤의 최종값 3을 모두 읽게 됩니다.
반면 let 버전에서는:
- 각 반복이 더 독립적인 바인딩처럼 다뤄지고
- 콜백이 각 시점의
i에 연결되기 쉬워집니다
즉, 이 예제는 클로저가 "값을 복사했다"가 아니라, 어떤 바인딩을 참조하느냐가 다르다는 점을 보여줍니다.
실무에서도 이 감각이 중요합니다. 값이 이상하게 모두 마지막 값으로 찍힌다면, 대부분은 비동기 문제 자체보다 공유된 바인딩을 여러 콜백이 함께 보고 있는 문제일 때가 많습니다.
예제 4. closure는 왜 바깥 값이 살아 있는 것처럼 보일까?
가장 대표적인 클로저 예제입니다.
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2직관적으로는 이상합니다.
createCounter()는 이미 끝났는데- 왜 그 안의
count가 계속 남아 있을까?
execution context 관점에서는 이렇게 볼 수 있습니다.
createCounter()가 호출되면 함수 execution context가 생깁니다.- 그 안에서
count라는 지역 바인딩이 만들어집니다. - 반환되는
increment함수는 자신이 선언될 때 연결된 바깥 렉시컬 환경을 참조할 수 있습니다. - 그래서
createCounter()호출이 끝난 뒤에도increment가 그 바인딩을 계속 볼 수 있습니다.
즉, increment가 count 값을 복사해서 들고 있는 것이 아닙니다. 더 정확히는 바깥 환경의 바인딩에 접근 가능한 연결이 유지되는 것입니다.
이 차이를 이해하면 아래 같은 코드도 덜 헷갈립니다.
function createCounter() {
let count = 0;
return {
increment() {
count += 1;
return count;
},
decrement() {
count -= 1;
return count;
},
};
}여기서 increment와 decrement는 서로 다른 함수지만, 같은 count 바인딩을 공유할 수 있습니다.
즉, 클로저의 핵심은 "함수가 기억력이 좋다"가 아니라, 여러 함수가 같은 외부 환경과 연결될 수 있다는 점입니다.
예제 5. closure는 값 복사가 아니라 참조 유지라는 점
아래 예제를 보면 이 차이가 더 선명해집니다.
function outer() {
let value = 1;
function increase() {
value += 1;
}
function print() {
console.log(value);
}
return { increase, print };
}
const result = outer();
result.print(); // 1
result.increase();
result.print(); // 2만약 클로저가 값을 복사하는 것이라면 print()는 항상 1을 출력해야 할 것처럼 느껴질 수 있습니다.
하지만 실제로는 increase()가 바꾼 결과를 print()도 봅니다.
즉, 두 함수가 보고 있는 것은 값 복사본이 아니라 같은 렉시컬 환경 안의 같은 바인딩입니다.
이 감각은 실무에서 매우 중요합니다. 특히:
- 상태 캡슐화
- 팩토리 함수
- 훅 내부 상태 해석
- 오래된 값 캡처 버그 분석
에서 거의 직접적으로 이어집니다.
예제 6. this는 왜 객체를 가리킬 때도 있고 아닐 때도 있을까?
이번엔 this입니다.
const user = {
name: 'Marco',
printName() {
console.log(this.name);
},
};
user.printName();이 코드는 보통 Marco를 출력합니다.
그런데 아래처럼 바꾸면 결과가 달라질 수 있습니다.
const user = {
name: 'Marco',
printName() {
console.log(this.name);
},
};
const fn = user.printName;
fn();왜 이런 일이 생길까요?
많은 사람이 함수가 user 안에 선언됐으니 계속 user를 가리킬 것처럼 생각합니다. 하지만 this는 그렇게 움직이지 않습니다.
execution context 관점에서 보면 함수가 실행될 때는:
- 지역 변수만 필요한 것이 아니라
- 현재 함수의
this가 무엇인지도 같이 정해져야 합니다
즉, this는 lexical scope처럼 "선언 위치"로 고정되는 값이 아니라, 호출 시점의 실행 문맥에 따라 정해지는 현재 바인딩에 가깝습니다.
그래서:
user.printName()처럼 메서드 호출 문맥이면this가user처럼 보이고- 함수 참조만 꺼내
fn()처럼 호출하면 원래의 객체 문맥이 유지되지 않을 수 있습니다
즉, this 예제는 "함수가 어디에 들어 있었는가"보다 어떻게 호출됐는가를 먼저 봐야 풀립니다.
예제 7. this와 closure를 섞어 보면 왜 더 헷갈릴까?
아래 코드는 처음 보면 자주 착각을 만듭니다.
const user = {
name: 'Marco',
printLater() {
setTimeout(function () {
console.log(this.name);
}, 100);
},
};
user.printLater();많은 경우 "printLater가 user의 메서드니까 안쪽 함수도 user를 볼 것"이라고 생각합니다.
하지만 여기에는 서로 다른 두 규칙이 섞여 있습니다.
- 안쪽 함수가 바깥 스코프 변수에 접근하는 문제는
closure - 안쪽 함수 실행 시
this가 무엇이 되는지는 호출 방식 문제
즉, 바깥 함수 안에 있다고 해서 안쪽 일반 함수의 this가 자동으로 바깥 this를 따라가는 것은 아닙니다.
이럴 때 아래처럼 arrow function을 쓰면 체감이 달라집니다.
const user = {
name: 'Marco',
printLater() {
setTimeout(() => {
console.log(this.name);
}, 100);
},
};왜냐하면 arrow function은 일반 함수처럼 자기만의 this 바인딩을 새로 만들기보다, 바깥 문맥의 this를 그대로 활용하기 때문입니다.
즉, 이 예제는 closure와 this를 섞어 보지 말고:
- 식별자 접근은 어떤 렉시컬 환경을 따르는가
this는 어떤 호출 문맥에서 결정되는가
를 분리해서 봐야 잘 풀립니다.
예제 8. this를 execution context로 읽는 습관
아래 두 코드는 비슷해 보여도 다르게 읽어야 합니다.
const obj = {
value: 1,
method() {
return this.value;
},
};
obj.method();const obj = {
value: 1,
method() {
return this.value;
},
};
const method = obj.method;
method();둘 다 같은 함수 본문을 실행하지만, 함수 execution context가 만들어질 때의 this 바인딩 조건은 다릅니다.
즉, this를 읽을 때는 함수 본문보다 먼저:
- 이 함수가 지금 메서드 호출인가
- 그냥 함수 호출인가
bind,call,apply로 강제된 호출인가
를 먼저 보는 습관이 중요합니다.
예제 9. 아래 코드는 무엇을 출력할까?
이런 문제는 짧지만 execution context를 정확히 보지 않으면 자주 틀립니다.
var x = 1;
function test() {
console.log(x);
var x = 2;
console.log(x);
}
test();
console.log(x);결과는 보통 이렇게 됩니다.
undefined
2
1이유를 순서대로 보면:
- 전역 컨텍스트 준비 단계에서
x,test가 등록됩니다. - 전역 실행 단계에서
x = 1이 됩니다. test()호출 시 함수 execution context가 새로 생깁니다.- 이 함수 컨텍스트 준비 단계에서 지역
var x가 먼저 등록되고undefined로 초기화됩니다. - 그래서 첫 번째
console.log(x)는 전역x가 아니라 지역x를 읽어undefined를 출력합니다. - 이후
x = 2가 대입되고, 두 번째 출력은2가 됩니다. - 함수가 끝나면 지역 컨텍스트는 빠지고, 마지막 전역
x는 여전히1입니다.
이 문제의 핵심은 "전역 변수가 있으니 1이 나오겠지"가 아니라, 현재 함수 컨텍스트 안에 같은 이름이 이미 준비돼 있는가를 먼저 보는 것입니다.
예제 10. 클로저는 상태를 공유할까, 분리할까?
이 문제도 다양한 형태로 자주 변형됩니다.
function createCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter1 = createCounter();
const counter2 = createCounter();
console.log(counter1());
console.log(counter1());
console.log(counter2());결과는 보통:
1
2
1입니다.
많이 헷갈리는 지점은 "createCounter라는 같은 함수를 썼으니 상태도 공유되지 않나?" 하는 부분입니다. 하지만 execution context 관점에서 보면, createCounter()가 호출될 때마다 새 함수 컨텍스트와 새 렉시컬 환경이 만들어집니다.
즉:
- 첫 번째 호출은
counter1만 보는count바인딩을 만들고 - 두 번째 호출은
counter2만 보는 별도의count바인딩을 만듭니다
그래서 counter1이 두 번 증가해도 counter2는 자기 쪽 count를 처음부터 1로 시작합니다.
이 문제는 클로저를 "함수에 붙는 기능"으로 보면 헷갈리고, 호출마다 다른 외부 환경이 생길 수 있다고 보면 자연스럽게 풀립니다.
예제 11. 반복문과 클로저를 함수로 감싸면 왜 달라질까?
이건 var 반복문 문제의 고전적인 변형입니다.
for (var i = 0; i < 3; i += 1) {
(function (j) {
setTimeout(() => {
console.log(j);
}, 0);
})(i);
}결과는 보통:
0
1
2입니다.
var를 썼는데도 왜 이번엔 괜찮을까요?
핵심은 즉시 실행 함수가 매 반복마다 새로운 함수 execution context를 만든다는 점입니다. 그리고 그 컨텍스트의 매개변수 j는 각 호출 시점의 i 값을 받습니다.
즉:
- 바깥
i는 여전히 하나의 공유된 바인딩이지만 - 안쪽
j는 호출마다 새로 만들어지는 지역 바인딩입니다
그래서 타이머 콜백은 나중에 실행되더라도 공유된 i가 아니라, 각자 자기 호출의 j를 읽게 됩니다.
이 예제는 실무에서 오래된 코드나 라이브러리 코드를 읽을 때도 중요합니다. let이 없던 시절에는 이런 패턴으로 반복문 클로저 문제를 자주 우회했습니다.
예제 12. this는 어디서 깨질까?
아래 코드는 무엇을 출력할까요?
const user = {
name: 'Marco',
getName() {
return this.name;
},
};
const getName = user.getName;
console.log(user.getName());
console.log(getName());
console.log(getName.call({ name: 'Lee' }));핵심 포인트만 보면:
user.getName()은 메서드 호출이라this가user와 연결되기 쉽고getName()은 분리된 함수 호출이라 원래 객체 문맥이 유지되지 않을 수 있으며call(...)은 호출 시점의this를 명시적으로 바꿉니다
즉, 기대 결과는 실행 환경에 따라 두 번째 줄이 달라질 수 있지만, 일반적으로는:
Marco
undefined
Lee처럼 이해하면 됩니다.
이 문제에서 중요한 것은 함수 본문이 아니라 호출 표현식 자체가 함수 execution context의 this를 결정한다는 점입니다.
예제 13. bind를 하면 왜 달라질까?
이건 위 문제에서 한 단계만 더 가면 자주 나옵니다.
const user = {
name: 'Marco',
getName() {
return this.name;
},
};
const boundGetName = user.getName.bind(user);
console.log(boundGetName());
console.log(boundGetName.call({ name: 'Lee' }));이 경우는 보통:
Marco
Marco처럼 봅니다.
이유는 bind가 단순히 "지금 한 번 실행"하는 것이 아니라, 특정 this가 고정된 새 함수를 만들어내는 효과에 가깝기 때문입니다.
즉, 나중에 call로 다시 실행하더라도 이미 바인딩된 this 규칙이 우선합니다.
이런 문제는 call, apply, bind를 문법으로만 외우면 헷갈리고, 함수가 실행될 때 어떤 this 정보가 컨텍스트에 들어가도록 미리 결정됐는가로 보면 훨씬 잘 풀립니다.
예제를 풀 때 가장 먼저 봐야 할 순서
실제로 이런 문제를 만나면 아래 순서로 보면 좋습니다.
- 지금 전역 코드인가, 함수 호출로 새 execution context가 생겼는가?
- 준비 단계에서 어떤 식별자가 등록되고, 어떤 값은 아직 초기화되지 않았는가?
- 현재 식별자를 찾을 때 지역 -> 바깥 함수 -> 전역 순서로 어떻게 올라갈 수 있는가?
- 이 함수는 바깥 렉시컬 환경을 참조하는가?
- 현재 함수의
this는 선언 위치가 아니라 어떤 호출 방식으로 결정되는가?
이 순서로 보면:
var,let,const는 준비 단계 문제closure는 외부 환경 연결 문제this는 호출 문맥 문제
로 정리되기 시작합니다.
즉, execution context는 모든 개념을 하나로 뭉뚱그리는 말이 아니라, 각 개념이 어느 지점에서 갈리는지 구분해주는 프레임이라고 보는 편이 좋습니다.
실무에서 특히 도움이 되는 순간
이 관점은 아래 상황에서 특히 강합니다.
1. 예상과 다른 undefined가 나올 때
대부분은 값이 없는 문제가 아니라, 현재 컨텍스트 안에 이미 같은 이름이 준비돼 있는 문제일 수 있습니다.
2. 비동기 콜백에서 값이 전부 마지막 값으로 보일 때
공유된 바인딩을 여러 함수가 같이 보고 있는지 확인하면 원인이 빨리 드러납니다.
3. 메서드를 분리했더니 this가 깨질 때
함수 본문보다 호출 방식을 먼저 보면 훨씬 빠르게 원인을 찾을 수 있습니다.
4. 훅, 팩토리 함수, 상태 캡슐화 코드를 읽을 때
클로저가 어떤 바인딩을 붙잡고 있는지 이해하면 코드가 훨씬 덜 마법처럼 보입니다.
5. 출력 결과 예제를 빠르게 해석할 때
전역/함수 컨텍스트, 준비 단계, 렉시컬 환경, this 바인딩 순서대로 체크하면 감으로 찍는 문제를 구조적으로 풀 수 있습니다.
같이 보면 좋은 글
- JavaScript의 Execution Context란 무엇이고 왜 중요한가
- JavaScript의 Hoisting이란 무엇이고 왜 그렇게 동작할까
- JavaScript의 Closure란 무엇이고 왜 그렇게 동작할까
- JavaScript의 this란 무엇이고 왜 이렇게 헷갈릴까
결론
execution context는 추상 개념처럼 보이지만, 실제로는 예제를 해석할 때 가장 실용적인 기준입니다.
짧게 정리하면:
var,let,const는 준비 단계에서 다르게 처리되고closure는 바깥 렉시컬 환경 연결이 유지되는 결과이며this는 호출 시점의 실행 문맥에서 결정됩니다
결국 execution context를 기준으로 보면 let, var, const, closure, this는 서로 따로 노는 개념이 아니라, 함수가 실행될 때 어떤 정보가 준비되고 어떤 규칙으로 참조되는가를 다른 각도에서 보여주는 개념으로 정리됩니다.
