JavaScript의 Scope란 무엇이고 왜 중요한가
JavaScript를 처음 공부할 때는 변수 선언 문법부터 배우게 됩니다.
varletconst
그리고 조금 지나면 자연스럽게 이런 질문이 따라옵니다.
- 왜 어떤 변수는 함수 밖에서도 읽히는지
- 왜 어떤 변수는 블록 밖에서 접근이 안 되는지
- 왜 안쪽 함수가 바깥 함수의 변수를 기억하는지
- 왜 같은 이름의 변수가 있어도 서로 다른 값처럼 동작하는지
이 질문들의 중심에는 scope가 있습니다.
겉보기에는 단순합니다. "변수에 접근할 수 있는 범위"라고 배웠을 가능성이 큽니다. 물론 맞는 설명입니다. 하지만 실무에서는 그것만으로는 부족합니다. 중요한 것은 어떤 식별자가 어디서 보이고, 어디서 가려지고, 어떤 규칙으로 탐색되는가를 이해하는 것입니다.
이 글에서는 JavaScript의 scope를 아래 흐름으로 정리해보겠습니다.
scope는 무엇인지- 전역 스코프, 함수 스코프, 블록 스코프는 무엇이 다른지
var,let,const는 왜 스코프 관점에서 다르게 느껴지는지lexical scope와scope chain은 무엇인지- 실무에서 자주 하는 실수는 무엇인지
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
scope는 식별자에 접근할 수 있는 유효 범위입니다.JavaScript에는 전역 스코프, 함수 스코프, 블록 스코프가 있습니다.var는 함수 스코프,let과const는 블록 스코프에 더 가깝습니다.- 함수는 자신이 선언된 위치를 기준으로 바깥 스코프를 기억합니다. 이것이
lexical scope입니다. - 변수를 찾을 때는 현재 스코프에서 시작해 바깥쪽으로 올라가는
scope chain이 동작합니다.
즉, scope의 핵심은 "변수가 어디 있나?"보다 이 코드가 지금 어떤 이름을 볼 수 있나? 에 가깝습니다.
scope는 정확히 무엇일까?
가장 단순하게 말하면 scope는 변수, 함수, 매개변수 같은 식별자에 접근할 수 있는 범위입니다.
예를 들어:
const name = 'Marco';
function printName() {
console.log(name);
}여기서 printName() 안에서는 name을 읽을 수 있습니다.
반대로:
function printName() {
const name = 'Marco';
}
console.log(name);이 코드는 바깥에서 name을 읽을 수 없습니다.
즉, 어떤 식별자가 "존재한다"는 것과, 지금 이 위치에서 접근 가능한가는 다른 문제입니다. scope는 바로 그 접근 가능 범위를 정합니다.
여기서 한 단계 더 정확히 말하면, scope는 값을 저장하는 상자 자체보다 식별자를 어떤 환경에서 해석할지 결정하는 규칙에 더 가깝습니다.
예를 들어 count라는 이름이 보였을 때 자바스크립트 엔진은 단순히 "메모리 어딘가에 값이 있겠지"라고 보지 않습니다. 더 정확히는:
- 지금 실행 중인 코드가 어떤 스코프 안에 있는지 보고
- 그 스코프에
count라는 바인딩이 있는지 확인하고 - 없으면 바깥 스코프로 올라가며 찾습니다
즉, scope는 값의 물리적 저장 위치보다 이름을 해석하는 규칙이라고 이해하는 편이 더 정확합니다.
왜 scope가 중요할까?
실무에서는 아래 문제들이 모두 scope와 연결됩니다.
- 변수 충돌 방지
- 함수 내부 구현 캡슐화
- 블록 단위 임시 변수 관리
- 비동기 코드 안에서 값이 꼬이지 않게 하기
- 클로저와 상태 유지 이해하기
즉, scope는 단순 문법 암기 문제가 아니라 코드가 안전하게 분리되고 예측 가능하게 동작하도록 만드는 기본 규칙입니다.
실제로 scope를 이해하지 못하면 아래 개념들도 같이 흐려집니다.
- 왜
closure가 가능한지 - 왜
hoisting이 스코프마다 다르게 보이는지 - 왜 같은 이름이 있어도 안쪽 값이 바깥 값을 가리는지
- 왜
for (let i ...)와for (var i ...)가 다르게 느껴지는지
즉, scope는 개별 문법을 설명하는 주변 개념이 아니라, 자바스크립트 실행 모델 전체를 이해하는 출발점에 더 가깝습니다.
전역 스코프는 무엇일까?
전역 스코프는 가장 바깥 범위입니다.
const appName = 'marco-log';
function printAppName() {
console.log(appName);
}여기서 appName은 전역 스코프에 있습니다.
전역에 선언된 값은 보통:
- 여러 함수에서 접근 가능하고
- 애플리케이션 전반에서 보일 수 있습니다
그래서 편해 보이기도 합니다. 하지만 너무 많이 쓰면 문제가 생깁니다.
- 이름 충돌 가능성이 커지고
- 어떤 코드가 값을 바꾸는지 추적이 어려워지고
- 의존성이 숨겨진 채 퍼질 수 있기 때문입니다
즉, 전역 스코프는 필요하지만 무분별하게 넓혀 쓰면 유지보수가 어려워지는 영역이기도 합니다.
여기서 한 가지 더 짚을 점이 있습니다. 전역 스코프도 항상 완전히 같은 방식으로 느껴지는 것은 아닙니다.
- 전통적인
script문맥 ES Module문맥
은 전역 취급이 조금 다르게 느껴질 수 있습니다.
예를 들어 모듈 환경에서는 파일 최상단 선언이 "브라우저 전체 전역"처럼 동작하지 않는 경우가 많습니다. 그래서 실무에서는 단순히 "파일 맨 위에 있으니 다 전역이다"보다는, 지금 코드가 어떤 실행 문맥에 들어가는지까지 같이 보는 편이 좋습니다.
즉, 전역 스코프는 생각보다 단순한 하나의 통 덩어리라기보다, 실행 환경에 따라 체감이 달라질 수 있는 범위입니다.
함수 스코프는 무엇일까?
함수 안에서 선언된 식별자는 함수 바깥에서 접근할 수 없습니다.
function printMessage() {
const message = 'hello';
console.log(message);
}
printMessage();
// console.log(message); // error이게 함수 스코프의 기본입니다.
함수 스코프의 의미는 꽤 큽니다.
- 함수 내부 구현을 숨길 수 있고
- 임시 변수를 바깥으로 노출하지 않을 수 있고
- 같은 이름을 다른 함수 안에서 반복해서 써도 충돌이 줄어듭니다
즉, 함수 스코프는 로직을 작은 독립 영역으로 나누는 기본 장치입니다.
조금 더 깊게 보면 함수는 단순히 코드 묶음이 아니라, 새로운 스코프를 만드는 단위이기도 합니다.
그래서 함수가 호출될 때마다:
- 매개변수가 새로운 바인딩으로 잡히고
- 함수 내부 선언이 별도 스코프에 들어가고
- 바깥 스코프와 연결된 탐색 구조가 형성됩니다
즉, 함수는 로직 실행 단위이면서 동시에 식별자 이름 공간을 분리하는 단위이기도 합니다.
블록 스코프는 무엇일까?
블록은 보통 {} 로 둘러싸인 범위를 말합니다.
예를 들어:
if (true) {
const message = 'inside block';
console.log(message);
}
// console.log(message); // error여기서 message는 if 블록 안에서만 유효합니다.
이 성질은 for, if, while, switch 같은 블록에서 특히 중요합니다.
for (let i = 0; i < 3; i += 1) {
console.log(i);
}
// console.log(i); // error즉, 블록 스코프는 짧게만 필요한 값을 아주 좁은 범위에 가둘 수 있게 해주는 규칙입니다.
이 차이는 단순히 "에러가 나느냐 아니냐" 수준보다 더 큽니다. 블록 스코프가 있으면:
- 변수의 생명 범위를 더 짧게 잡을 수 있고
- 의도를 더 명확하게 표현할 수 있고
- 값이 우연히 바깥 로직에 섞이는 일을 줄일 수 있습니다
즉, 블록 스코프는 코드 스타일 취향이 아니라 변수 노출 범위를 설계하는 도구입니다.
var, let, const는 왜 다르게 느껴질까?
이 부분이 scope에서 가장 많이 헷갈리는 지점입니다.
var
var는 기본적으로 함수 스코프를 가집니다.
function example() {
if (true) {
var count = 1;
}
console.log(count); // 1
}if 블록 안에서 선언했는데도 함수 안에서는 접근됩니다.
즉, var는 블록 스코프가 아니라 함수 단위로 묶인다고 보는 편이 맞습니다.
let
let은 블록 스코프를 가집니다.
function example() {
if (true) {
let count = 1;
console.log(count); // 1
}
// console.log(count); // error
}즉, 더 좁은 범위에 안전하게 변수를 가둘 수 있습니다.
const
const도 블록 스코프를 가집니다.
if (true) {
const message = 'hello';
}
// console.log(message); // errorconst는 블록 스코프이면서, 재할당이 불가능하다는 점이 추가됩니다.
즉, let과 const의 공통점은 블록 스코프이고, 차이는 재할당 가능 여부입니다.
여기서 자주 생기는 오해도 하나 있습니다. const는 "불변 객체"를 만드는 키워드가 아닙니다. 더 정확히는:
- 바인딩 자체를 다시 다른 값으로 연결하지 못하게 막는 것
에 가깝습니다.
즉, 스코프 관점에서 보면 const의 핵심은 let과 마찬가지로 블록 스코프를 가진다는 점이고, 그 위에 재할당 금지 규칙이 하나 더 붙는다고 보는 편이 맞습니다.
왜 요즘은 var보다 let, const를 더 많이 쓸까?
가장 큰 이유는 범위를 더 예측 가능하게 만들기 쉽기 때문입니다.
예를 들어 var는 이런 코드에서 실수를 만들기 쉽습니다.
for (var i = 0; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}많은 사람이 처음엔 0, 1, 2를 기대하지만, 실제로는 마지막 값이 반복되어 보일 수 있습니다.
반면 let을 쓰면:
for (let i = 0; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}블록 단위로 값이 더 자연스럽게 분리되어 기대에 가까운 결과를 얻기 쉽습니다.
즉, let과 const는 단순히 "최신 문법"이라기보다 스코프를 더 좁고 명확하게 가져가기 쉬운 도구에 가깝습니다.
특히 반복문에서는 이 차이가 더 중요합니다. let은 반복마다 별도의 바인딩처럼 동작해:
- 비동기 콜백
- 이벤트 핸들러
- 지연 실행 로직
에서 의도한 값을 더 자연스럽게 잡아주기 때문입니다.
즉, var와 let 차이는 문법 취향보다 반복마다 어떤 이름 공간을 만들 것인가의 차이로 보는 편이 더 정확합니다.
같은 이름의 변수가 있으면 어떻게 될까?
이때 중요한 개념이 shadowing입니다.
const name = 'global';
function printName() {
const name = 'local';
console.log(name);
}
printName(); // local안쪽 스코프에 같은 이름이 있으면, 바깥 이름을 가립니다.
즉:
- 현재 스코프에서 먼저 찾고
- 있으면 그 값을 쓰고
- 없으면 바깥으로 올라갑니다
이때 안쪽의 name이 바깥 name을 가리는 것을 보통 shadowing이라고 부릅니다.
이 개념이 중요한 이유는, 같은 이름을 재사용할 때 코드가 의도대로 읽히는지 항상 확인해야 하기 때문입니다.
shadowing 자체가 항상 나쁜 것은 아닙니다. 아주 좁은 블록 안에서 같은 이름을 다시 쓰는 것이 더 자연스러울 때도 있습니다. 하지만 범위가 커질수록:
- 지금 읽히는 값이 어느 스코프 것인지 헷갈리고
- 리팩터링 시 실수를 만들고
- 디버깅 시간을 늘릴 수 있습니다
즉, 중요한 것은 같은 이름 사용 자체보다 이 shadowing이 읽는 사람에게 의도를 명확하게 전달하는가입니다.
lexical scope는 무엇일까?
JavaScript의 스코프는 실행 시점보다 함수가 선언된 위치에 더 큰 영향을 받습니다. 이것을 lexical scope라고 봅니다.
예를 들어:
const message = 'global';
function outer() {
const message = 'outer';
function inner() {
console.log(message);
}
inner();
}
outer(); // outerinner()는 자신이 선언된 위치를 기준으로 message를 찾습니다.
즉, inner()는:
- 자기 스코프를 먼저 보고
- 없으면
outer()의 스코프를 보고 - 그다음 전역 스코프로 올라갑니다
중요한 것은 "어디서 호출됐는가"보다 어디서 선언됐는가입니다.
즉, JavaScript는 이름 탐색 기준이 선언 위치에 묶여 있는 언어라고 보는 편이 맞습니다.
이 점은 기본기에서 특히 중요합니다. 왜냐하면 많은 초보자는 함수가 "어디서 호출됐는가"를 더 중요하게 느끼기 때문입니다. 하지만 scope는 호출 위치보다 선언 위치에 더 강하게 묶입니다.
즉:
scope는 선언 위치 기준this는 호출 방식의 영향을 크게 받음
이라는 구분이 서 있어야 이후 개념도 덜 헷갈립니다.
scope chain은 무엇일까?
변수를 찾을 때는 현재 스코프에서 시작해서 바깥으로 점점 올라갑니다. 이 연결 구조를 보통 scope chain이라고 부릅니다.
예를 들어:
const a = 'global';
function outer() {
const b = 'outer';
function inner() {
const c = 'inner';
console.log(a, b, c);
}
inner();
}inner() 안에서:
c는 현재 스코프에서 찾고b는 바깥 함수 스코프에서 찾고a는 전역 스코프에서 찾습니다
즉, 스코프 체인은 현재 -> 바깥 함수 -> 더 바깥 -> 전역 순으로 이어지는 탐색 규칙입니다.
이 개념을 이해하면 "왜 이 함수가 바깥 변수를 읽을 수 있지?" 같은 질문이 훨씬 쉬워집니다.
조금 더 깊게 보면, 스코프 체인은 단순 재귀 탐색 비유가 아니라 현재 실행 중인 코드가 어떤 외부 환경 참조를 가지고 있는가와 연결됩니다. 즉, 각 스코프는 완전히 끊어진 섬이 아니라 바깥 환경을 참조하는 연결 구조를 갖고 있다고 보는 편이 더 정확합니다.
이 설명을 알아두면 나중에 closure, hoisting, execution context를 볼 때도 개념이 훨씬 자연스럽게 이어집니다.
스코프는 언제 결정될까?
이 질문도 기본기에서 중요합니다.
많은 경우 답은 "대체로 함수가 선언될 때"에 가깝습니다.
예를 들어:
const value = 'global';
function outer() {
const value = 'outer';
return function inner() {
return value;
};
}여기서 inner는 선언될 때 이미 어떤 바깥 스코프를 볼지 결정됩니다.
즉, scope는 실행 중 즉흥적으로 만들어지는 규칙이라기보다, 코드 구조를 기준으로 미리 결정되는 정적 성격이 강합니다. 그래서 lexical scope라는 표현이 중요합니다.
이 관점이 서면 "closure가 왜 특정 값을 기억하지?"도 훨씬 설명하기 쉬워집니다.
scope와 closure는 어떤 관계가 있을까?
클로저는 보통 바깥 스코프의 값을 기억하는 함수로 설명됩니다.
예를 들어:
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2여기서 increment()는 createCounter()가 끝난 뒤에도 count를 기억합니다.
이게 가능한 이유는 함수가 선언될 때 바깥 스코프를 함께 묶어두기 때문입니다.
즉, closure는 갑자기 생기는 별도 기능이라기보다 lexical scope가 실제로 동작하는 결과에 가깝습니다.
여기서 중요한 것은 "함수가 값을 복사해 저장한다"는 식으로 이해하면 곤란하다는 점입니다. 보통은 값 복사라기보다:
- 바깥 스코프의 바인딩에 계속 접근할 수 있는 상태
로 이해하는 편이 더 정확합니다.
그래서 클로저는 단순 암기 대상이 아니라, 스코프가 시간이 지나도 살아남아 보이는 이유를 설명하는 개념입니다.
스코프와 실행 컨텍스트는 어떤 관계가 있을까?
조금 더 깊게 들어가면 scope는 실행 컨텍스트와 함께 이해할 때 더 선명해집니다.
자바스크립트 엔진은 코드를 실행할 때 현재 어떤 문맥에서 실행 중인지 관리합니다. 이때 중요한 것 중 하나가:
- 지금 이 코드가 어떤 식별자를 볼 수 있는가
입니다.
즉, 실행 컨텍스트는 "지금 무엇을 실행 중인가"에 더 가깝고, 스코프는 "지금 어떤 이름을 볼 수 있는가"에 더 가깝습니다.
둘은 분리된 개념이지만, 실제 런타임에서는 강하게 연결됩니다. 기본기에서 이 감각을 가져가면:
hoistingclosurethisexecution context
같은 개념이 서로 덜 분리돼 보입니다.
실무에서는 어디서 특히 중요할까?
1. 반복문과 비동기 코드
var를 쓰면 반복문 안 비동기 콜백에서 값이 기대와 다르게 보일 수 있습니다.
이런 문제는 스코프를 이해하면 바로 보입니다.
2. 임시 변수 범위 축소
가능한 한 필요한 블록 안에만 const, let을 두면:
- 실수로 바깥에서 접근할 일이 줄고
- 코드 읽기가 쉬워집니다
3. 함수 내부 캡슐화
함수 내부 구현 디테일을 바깥에 노출하지 않고 숨길 수 있습니다.
4. 클로저 기반 상태
커스텀 훅, 이벤트 핸들러, 팩토리 함수 같은 패턴을 이해할 때 스코프 감각이 중요합니다.
자주 하는 실수
정리하면 아래 실수가 정말 자주 나옵니다.
var도 블록 스코프처럼 동작할 것이라 생각한다let과const차이를 스코프가 아니라 재할당 여부로만 본다- 같은 이름의 변수가 있을 때 어떤 값이 읽히는지 혼동한다
- 함수가 호출된 위치가 아니라 선언된 위치 기준으로 스코프가 결정된다는 점을 놓친다
- 반복문과 비동기 콜백에서
var때문에 값이 꼬이는 이유를 이해하지 못한다
즉, scope는 쉬운 개념처럼 보여도 식별자 탐색 규칙까지 이해하지 않으면 코드가 왜 그렇게 동작하는지 놓치기 쉽습니다.
실무 체크리스트
실제로 코드를 볼 때는 아래 질문으로 빠르게 점검하면 도움이 됩니다.
- 이 변수는 정말 이렇게 넓은 범위가 필요한가?
var대신let이나const로 더 좁게 가둘 수 없는가?- 지금 읽히는 값은 현재 스코프의 것인가, 바깥 스코프의 것인가?
- 같은 이름을 재사용하면서 shadowing이 가독성을 해치고 있지는 않은가?
- 비동기 콜백이나 이벤트 핸들러에서 스코프 때문에 값이 꼬일 가능성은 없는가?
이 기준으로 보면 단순 문법 선택이 아니라, 값의 생명주기와 가시 범위를 설계하는 문제로 스코프를 이해할 수 있습니다.
결론
JavaScript의 scope는 변수를 어디에 선언할까의 문제가 아니라, 어떤 식별자가 어느 범위에서 보이고 어떤 규칙으로 탐색되는가를 이해하는 문제입니다.
짧게 정리하면:
- 전역 스코프는 넓고
- 함수 스코프는 구현을 감추고
- 블록 스코프는 값을 더 안전하게 가두며
lexical scope와scope chain은 함수가 바깥 값을 어떻게 읽는지 설명해줍니다
결국 scope를 이해하면 변수 충돌, 비동기 버그, 클로저, 함수 캡슐화를 훨씬 더 자연스럽게 볼 수 있게 됩니다.
