JavaScript 언어에서 가장 큰 화두 중 하나가 바로 클로저(Closure)입니다. FAANG으로 불리는 빅테크 기업들의 코딩 면접에서도 단골로 출제되는 주제죠. 이 글에서는 클로저와 스코프(Scope)가 무엇인지 살펴보고, 간단한 예제를 통해 개념을 하나씩 짚어본 뒤, 마지막에 대형 테크 기업 면접에서 나올 법한 예시 문제까지 다뤄보겠습니다.
스코프(Scope)란 무엇일까?
누군가 "그건 프로젝트 범위(스코프) 안이 아니야"라고 말할 때, 그 의미는 무엇일까요?
이 질문에 답할 때 저는 잠망경이나 망원경을 떠올립니다. 이 도구들은 렌즈 안에 담긴 것들만 보여줍니다. 렌즈 시야 안에 있는 것은 '스코프 내'에 있다고 할 수 있고, 시야 밖에 있는 것은 아무리 빛을 비춰도 볼 수 없습니다. JavaScript에는 서로 다른 세 가지 중요한 스코프, 즉 지역(local), 전역(global), 렉시컬(lexical) 스코프가 있습니다.
지역 스코프(Local Scope)
지역 스코프는 오늘 다룰 세 가지 스코프 중 가장 작은 단위입니다. 함수를 선언하면 중괄호({}) 안의 모든 것은 해당 함수에 '지역적(local)'이라고 간주됩니다. JavaScript 엔진이 함수를 읽으면 변수를 선언하고, 함수 실행이 끝나면 그 변수들을 소멸시킵니다.
function greeting() {
var websiteName = 'Career Karma';
return `Hello ${websiteName}`;
}
console.log(greeting()); // Hello Career Karma
console.log(websiteName); // ReferenceError: websiteName is not defined
실행된 greeting 함수의 결과를 console.log()로 출력하면, 함수 실행 후에도 websiteName 값에 접근해 'Hello Career Karma'라는 원하는 문자열을 얻을 수 있습니다. 반면 함수 내부에서 선언된 변수를 직접 console.log()로 출력하려 하면 에러가 발생하는데, 그 이유는 해당 변수가 정의되지 않았기 때문입니다.
앞서 말했듯이 websiteName이 undefined인 이유는, 변수들이 함수가 호출될 때 생성되고 마지막 구문이 실행되면 소멸하기 때문입니다. 특별한 설정이 없다면 함수 외부에서는 함수 내부의 요소에 접근할 수 없습니다.
전역 스코프(Global Scope)
전역 스코프는 이름 그대로의 의미를 지닙니다. 함수 바깥에서 선언된 요소들을, 모든 스크립트와 메서드, 함수가 접근해 자신의 로직에 활용할 수 있는 공간에 저장합니다.
let counter = 0; // global -- declared outside function
const add = () => { // function declaration
let counter = 0; // local -- declared inside function
counter += 1;
// counter increased by 1 -- which counter variable increased?
return counter;
}
add(); // invoke
add(); // three
add(); // times
console.log(counter) // is this 3 or 0? Why?
위 코드의 마지막에서 counter를 console.log()로 출력하면 어떻게 될까요? 결과를 미리 예상해 보세요.
코드를 한 줄씩 따라가 보겠습니다:
- counter 변수가 전역 환경에서 선언 및 초기화됩니다.
- add 함수가 전역 환경에서 선언됩니다.
- add가 호출됩니다.
- counter 변수가 지역 환경에서 선언 및 초기화됩니다.
- 지역 counter가 1 증가합니다 ← 왜 전역이 아니라 지역일까요?
- counter가 반환되고 함수가 종료됩니다.
- add가 다시 호출됩니다.
- 4~6번 과정을 다시 거칩니다.
- 3~6번 과정을 반복합니다.
console.log(counter); ← 무엇이 출력될까요?
함수가 매번 counter가 1일 때 종료되기 때문에, 지역 counter 변수는 함수가 실행될 때마다 0으로 다시 선언되고 초기화됩니다. 어떤 일이 있어도 지역 수준에서 counter는 항상 1에서 멈춥니다.
함수가 자신의 스코프 안에서 변수를 찾으면, 더 이상 전역 스코프를 찾지 않습니다. 따라서 전역 변수는 결코 변하지 않으며, console.log()는 0을 출력합니다. 해당 문장의 환경에서 가장 가까운 정의된 변수가 전역 환경에 있기 때문입니다.
렉시컬 스코프(Lexical Scope)
렉시컬 스코프는 JavaScript의 가장 근본적인 개념 중 하나입니다. 함수나 변수의 생성 위치에 따라 코드의 특정 부분에서는 접근 가능하고, 다른 부분에서는 접근할 수 없다는 아이디어입니다. 전부 각 변수와 함수의 선언 위치에 달려 있습니다.
다음 코드 블록을 살펴보겠습니다:
const init = () => { // <== This is our outer function
const var1 = 'Career'; // outer scope
const second = () => { // <== This is our inner function
const var2 = 'Karma'; // inner scope
console.log(var1); // Career
console.log(var2); // Karma
return var1 + " " + var2;
};
// console.log(var2); // undefined
return second();
};
init();
여기에는 중첩된 함수들이 있습니다. init() 함수는 var1이라는 변수를 선언하고, second라는 함수를 선언한 뒤 second()를 호출합니다.
컴파일러가 이 코드를 처음 통과할 때는 전체적인 윤곽만 봅니다:
init()함수init()호출
이 시점에서는 init() 함수 내부의 다른 것들은 보이지 않습니다. 함수가 존재한다는 사실만 알 뿐입니다. init() 함수가 호출되면 컴파일러는 함수 내부를 다시 한번 훑어봅니다:
var1second()함수second()호출
init() 함수는 second() 블록 내부에서 무슨 일이 일어나는지 전혀 알지 못합니다. 자신의 렉시컬 환경(lexical environment), 즉 주변 상태에 있는 것만 볼 수 있습니다.
각각의 중첩 함수는 마트료시카 인형처럼 점점 작은 컨테이너 안에 들어 있습니다. 인형들은 자신의 컨테이너 안에서 일어나는 일과 부모에게서 이미 일어난 일 또는 선언·읽힌 것만 알고 있습니다. 가장 큰 인형은 예컨대 자신 안의 다음 인형이 존재한다는 것만 알 뿐, 나머지 인형들에 대해서는 알지 못합니다. 즉, 자신의 렉시컬 환경(상태)과 이미 일어난 일(외부 스코프)만 알고 있을 뿐입니다.
결국 우리는 두 가지 사실을 알 수 있습니다:
- 외부 스코프는 내부 스코프를 볼 수 없다.
- 내부 스코프는 외부 스코프에 접근할 수 있다.
외부 스코프는 내부 스코프에서 일어나는 일을 볼 수 없으므로, 이것이 일방향 관계라고 안전하게 말할 수 있습니다. 내부는 외부 스코프의 변수를 보고 사용할 수 있지만, 외부는 내부를 볼 수 없습니다. 이것이 바로 렉시컬 스코프입니다.
렉시컬 스코핑의 아름다움은 변수의 값이 코드상의 배치에 의해 결정된다는 점입니다. 함수는 먼저 자신의 지역 환경에서 변수의 의미를 찾고, 찾지 못하면 자신을 정의한 함수로 이동합니다. 거기서도 찾지 못하면 체인을 따라 다음 정의된 함수로 올라갑니다.
이 개념은 JavaScript 프레임워크와 그 동작 방식을 학습하면서 계속해서 등장하는 매우 중요한 개념입니다. 외부에서 내부로 전달할 수는 있지만, 반대 방향으로 "위로" 전달할 수는 없습니다. 이제 핵심 주제인 클로저(closure)로 넘어가기 전에 꼭 기억해야 할 부분입니다.
클로저(Closure)
클로저의 정의는 렉시컬 스코프와 매우 유사합니다. 두 개념의 주요 차이점은 클로저는 고차 함수(higher order function)라는 점이고, 렉시컬 스코핑은 그렇지 않다는 것입니다. 고차 함수의 기본 특징은 하나입니다: 함수를 반환하거나 함수를 매개변수로 사용합니다.
클로저는 나중에 호출되더라도 자신의 렉시컬 스코프에 접근할 수 있는 함수입니다.
클로저와 렉시컬 스코프는 모두 자체 변수를 가지고, 부모 함수의 변수와 매개변수에 접근할 수 있으며, 전역 변수를 사용할 수 있습니다. 다음 코드를 살펴보겠습니다:
function greeting() { //outer scope (parent function)
const userName = "CrrKrma1952"; // parent variable
function welcomeGreeting() { // inner function
console.log("Hello, " + userName); // accesses parent var
return "Hello, " + userName; // terminal statement
}
return welcomeGreeting; // returns a function (which makes it HOF)
} // end of greeting()
const greetUser = greeting(); //
greetUser(); // Hello, CrrKrma1952
greeting()함수가 존재하지만, 아직 내용은 알 수 없습니다.greetUser가 존재하지만, 아직 내용은 알 수 없습니다.greetUser()– 앞 줄을 호출하며, 이는 다시 greeting() 함수를 호출합니다.- userName이 선언됩니다.
welcomeGreeting()이 존재하지만, 아직 내용은 알 수 없습니다.welcomeGreeting()블록 아래의 return 문이 바로 그 함수 자체를 반환합니다.console.log('Hello, ' + userName); 여기서 console.log는 부모 스코프에 접근해 userName 값을 가져옵니다.- 마지막 구문이 함수를 끝내고 코드 블록 내부 변수들의 의미를 소멸시킵니다.
이 코드에서는 함수들을 서로 중첩시켜 정보를 주고받음으로써, 부모 스코프를 나중에 접근할 수 있도록 만듭니다.
마무리
이번 글에서는 JavaScript의 꽤 방대한 주제인 스코프와 클로저에 대해 다뤘습니다. 여러 관점에서 이 주제를 설명하는 다양한 글을 추가로 읽어보시길 권합니다. 가르치는 방식이 다양하다는 것은 곧 배우는 방법도 다양하다는 뜻이니까요. 이 입문서가 도움이 되었기를 바랍니다. 클로저 학습을 계속 이어가시길 응원합니다!