함수는 어디까지 접근 가능한가? - Closure와 this 이해하기

함수는 여러가지 변수를 참조하며 그 역할을 수행합니다. 그런데 함수는 함수 밖의 변수를 어디까지 참조할 수 있을까요? 이 글에서는 스코프와 실행 컨텍스트의 관점에서 Closure와 this에 대해 알아봅니다.

2024-10-13에 씀

스코프란?

변수는 프로그램의 값을 저장하기 위해 사용된다. 변수에 담긴 값을 사용하려면 변수를 참조해야 하는데, 변수를 참조하려면 변수를 참조하는 코드가 작성된 위치에서 그 변수에 접근할 수 있어야 한다. 특정 위치에서 변수를 저장하고 찾기 위한 규칙을 스코프(Scope)라고 부른다. MDN에서는 값과 표현식이 표현되거나 참조될 수 있는 현재 실행되는 컨텍스트를 의미한다고 정의한다.

예를 들어, 아래와 같은 코드가 있다고 해보자.

1var bar = '안녕';
2
3function hello() {
4 var foo = '하세요';
5 console.log(bar);
6 console.log(foo);
7}
8
9hello(); // '안녕', '하세요'
10console.log(bar); // '안녕'
11console.log(foo); // Uncaught ReferenceError: foo is not defined

이 코드가 형성하는 스코프를 그림으로 나타내면 아래와 같다.

변수 bar는 전역에서 선언되었으므로, 전역 스코프와 연결된다. 함수 hello도 전역에서 선언되었으므로, 전역 스코프에 속해 있다. 변수 foo는 함수 hello 안에서 선언되었으므로, 함수 hello가 만드는 스코프와 연결된다.

변수를 참조할 때는 자신이 속한 스코프 혹은 그보다 상위의 스코프의 변수에만 접근할 수 있다. 전역 스코프는 최상위 스코프이므로, 전역 스코프에서 실행되는 코드는 전역 스코프와 연결된 변수만 참조할 수 있다. 전역 스코프에서 함수 스코프에 있는 foo를 참조하려고 하면 참조 에러가 발생한다.

함수 hello의 스코프 내에서 실행되는 코드는 함수 hello의 스코프와 연결된 변수에 참조할 수 있다. 만약 함수 hello의 스코프에 없는 변수를 참조하려고 했다면, 함수 hello의 상위 스코프인 전역 스코프에서 해당 변수를 찾는다. 함수 hello 안에서 참조한 변수 bar는 전역 스코프의 변수이고, 함수 hello는 전역 스코프의 변수를 참조할 수 있으므로 정상적으로 참조할 수 있다.

이러한 특성에서, 스코프는 함수의 선언 위치에 기반하여 형성되는 정적인 개념임을 알 수 있다.

렉시컬 스코프 VS 동적 스코프 - 스코프가 생성되는 시점에 따라

스코프가 생성되는 시점에 따라, 렉시컬(정적) 스코프와 동적 스코프로 구분할 수 있다. 자바스크립트에서는 렉시컬 스코프만을 사용한다.

아래 코드를 보자. a를 출력하면 어떤 값이 나올까?

1var a = 1;
2
3function foo() {
4 a = 2;
5}
6
7function bar() {
8 var a;
9 foo();
10}
11
12bar(); // ?
13console.log(a); // ?

정답은 2이다.

이 코드에서 형성되는 스코프를 그림으로 나타내면 아래와 같다.

전역에서 bar를 호출하면, bar의 함수 스코프가 생성되고, 함수 bar의 스코프 안에 변수 a가 선언되어 연결된다. bar 안에서 foo를 호출하면, foo의 함수 스코프가 생성된다.

함수 foo 안에서 참조하는 변수 a는, foo 안에서 선언되지 않았지만 전역 스코프에서 찾을 수 있는 변수이다. 함수 foo는 a = 2 코드를 실행하며 전역 스코프의 변수 a에 값을 할당한다. 그 결과, 전역 스코프 변수 a의 값은 2가 되고, 함수 bar의 스코프 변수 a의 값은 undefined이다. 따라서 전역 스코프의 변수 a를 참조하면 그 값은 2이다.

이는 렉시컬 스코프의 특성이다. 자바스크립트는 코드를 실행하기 전에 컴파일 과정을 거치는데, 자바스크립트 코드를 토큰으로 파싱하여 그 의미를 분석하는 과정을 렉싱이라고 부른다. 렉싱은 함수가 정의된 위치에 기반해서 스코프를 형성한다. 컴파일이 끝난 후 자바스크립트 엔진이 코드를 실행하는 시점에 특정 변수를 찾을 때는, 코드가 속한 스코프에서 시작하여 렉시컬 스코프를 차례로 상위로 탐색한다.

(참고) 동적 스코프

1var a = 1;
2
3function foo() {
4 a = 2;
5}
6
7function bar() {
8 var a;
9 foo();
10}
11
12bar(); // ?
13console.log(a); // ?

위 코드에서 a를 출력한 값이 1이라고 생각했다면, 동적 스코프의 관점에서 생각한 것이다. 함수 bar에서 함수 foo를 실행했으니, 함수 foo가 함수 bar의 스코프에 속한 변수에 접근할 수 있다는 관점이다. 이처럼 함수가 어디에서 호출되었는가에 따라 스코프가 달라지는 스코프를 동적 스코프라고 부른다.

동적 스코프는 Perl에서 옵셔널하게 제공되는 등 일부 언어에서 사용되는 개념이다. 호출 시점에 결정된다는 점이 자바스크립트 this와 유사해 보일 수 있지만, 어쨌든 자바스크립트의 스코프는 렉시컬 스코프를 따른다.

함수 스코프 VS 블록 스코프 - 스코프의 경계에 따라

스코프를 형성하는 경계에 따라, 함수 스코프와 블록 스코프로 나눌 수 있다. 여기서 말하는 블록은 중괄호로 묶이는 코드를 말한다. 자바스크립트는 기본적으로 함수 스코프를 따른다. 대표적으로 블록 스코프를 형성하는 표현문에는 현재는 deprecated된 with 문이나 try/catch 문에서의 catch 문이 있다.

변수 선언 키워드를 다르게 사용함으로써 그 변수의 스코프 범위를 지정해 줄 수 있다. var로 선언한 변수는 함수 스코프를 가진다.

1function foo(isTrue) {
2 if (isTrue) {
3 var a = 1;
4 }
5 console.log(a);
6}
7
8foo(true); // 1

다른 언어에서는 보통 if 문이 만드는 블록이 하나의 스코프를 이루기 때문에, console.log(a) 부분에서 참조 에러가 발생했을 것이다. 그러나 var 키워드로 선언된 변수 a는 함수 스코프에 속해 있으므로, if 문 블록 밖에서도 참조할 수 있다.

반면, let, const 키워드로 선언된 변수는 블록 스코프를 가진다.

1function bar(isTrue) {
2 if (isTrue) {
3 const a = 1;
4 let b = 2;
5 }
6 console.log(a);
7 console.log(b);
8}
9
10bar(true); // Uncaught ReferenceError: a is not defined

var로 선언한 변수와는 달리, if 문 안에서 let, const 키워드로 선언한 변수를 참조하려고 하면 참조 에러가 발생한다. 아래 그림처럼, 함수 foo의 스코프에서는 if 문이 만드는 블록 스코프에 접근할 수 없기 때문이다.

이런 이유로 인해, You don’t know JS의 저자 카일 심슨은 변수의 스코프에 따라 알맞은 변수 선언 키워드를 사용할 것을 제안하기도 했다. 변수가 함수 스코프라면 var을 사용하고, 블록 스코프에 한해야 한다면 let을 사용하는 것이다. 이렇게 하면 해당 변수의 스코프를 명확히 드러낼 수 있다.

클로저(closure)

클로저(closure)는 렉시컬 스코프의 특성 중 하나로, 함수가 정의된 스코프가 아닌 다른 스코프에서 함수가 실행되더라도, 스코프 밖에 있는 변수를 기억하고 이 외부 변수에 계속 접근할 수 있는 경우를 말한다.

아래 코드에서 foo()를 실행하면, add 함수를 호출하면서 console.log(count) 가 호출된다. 출력되는 값은 무엇일까?

1function counter() {
2 var count = 0;
3
4 function add() {
5 count = count + 1;
6 console.log(count);
7 }
8
9 return add;
10}
11
12function foo() {
13 const add = counter();
14 add();
15}
16
17foo(); // ?

정답은 1이다. 이는 함수 foo를 실행했을 때, 함수 counter의 스코프에 속한 count 변수를 참조할 수 있었다는 뜻이다. 위 예제에서는 분명 불가능했는데, 이번엔 왜 다른 함수의 스코프에 접근할 수 있었던 걸까? 스코프 구조를 그림으로 나타내면 아래와 같다.

함수 counter는 내부에 함수 add를 가지고 있는 중첩 함수다. 함수 foo가 counter() 를 호출하면 함수 add 인스턴스를 반환받을 수 있다. foo는 이 함수 인스턴스를 함수 foo 내부 변수인 add 에 할당했다. add() 를 호출하면, 함수 add는 함수 foo 안에서 실행되었지만 변수를 찾을 때는 렉시컬 스코프를 사용한다. 즉, 함수 add는 자신이 선언되었던 함수 counter의 스코프의 변수에 접근할 수 있다.

다시 생각해 보자. counter() 가 호출됐을 때, 함수 add의 인스턴스는 함수 counter가 가지고 있다. foo가 반환받은 값은 함수 add의 참조이다. 따라서, foo가 add()를 호출하는 것은 함수 counter 안에 있는 함수 add 인스턴스를 호출한 것이다. 따라서 함수 counter와의 스코프 체인을 그대로 유지한다.

즉, 클로저는 렉시컬 스코프로 인해 일어나는 자연스러운 현상이다.

실행 컨텍스트와 this

정리하자면 자바스크립트는 변수를 찾기 위해 렉시컬 스코프를 사용하고, 이 렉시컬 스코프는 선언 시점에 결정된다. 그런데 호출 시점에 정해지는 값은 어떻게 찾을 수 있을까? 아래 코드를 보자.

1var person = {
2 name: 'yejin',
3 sayHi() {
4 console.log(/* 여기에서 name을 참조하려면? */);
5 }
6}

위 코드의 sayHi 메서드가 person 객체의 name 프로퍼티 값에 접근하려면 어떻게 해야 할까? person.name을 참조해서 값을 가져올 수도 있지만, 문제는 이렇게 되면 sayHi 함수가 person 객체에 의존하게 되므로, 이 함수를 재활용할 수 없단 점이다.

좀더 유연하게 프로그램을 작성하기 위해, 함수 호출 시점의 맥락에 접근할 수 있는 방법이 필요하다. 어떤 함수를 호출하면, 함수 호출 시점의 맥락을 저장하는 실행 컨텍스트(Execution Context)가 만들어진다. 여기에 함수가 어느 경로로 호출됐는지를 담은 콜스택, 전달된 인자 등의 정보가 담겨 있다. 함수는 this 키워드를 사용해 실행 컨텍스트에 접근할 수 있다.

그래서 함수 호출 방식에 따라 this가 가리키는 객체가 동적으로 결정되는 것이다. 함수에 this를 지정하면 동적으로 컨텍스트를 지정할 수 있어 여러 객체에서 함수를 재사용할 수 있다.

화살표 함수

화살표 함수는 간결한 코드 작성을 위해 ES6에 도입된 함수 정의 방식이다. 주로 콜백 함수를 정의하거나 단순한 기능을 정의할 때 사용한다.

1// 원래 이렇게 쓰던 걸
2var add = function(a, b) {
3 return a + b;
4}
5
6// 이렇게 쓸 수 있게 됐다
7const add = (a, b) => a + b;

화살표 함수에서는 this가 다른 함수들과 다르게 동작한다. 일반적으로 함수의 this는 함수 호출 방식에 따라 결정되었다면, 화살표 함수의 this는 렉시컬 변수처럼 취급된다. 즉, 화살표 함수의 this는 화살표 함수가 정의된 위치에 따라 정의된다.

1const personA = {
2 nickname: 'yejin',
3 sayName: () => console.log(this.nickname)
4};
5
6const personB = {
7 nickname: 'robo',
8 sayName: function() {
9 const say = () => console.log(this.nickname);
10 say();
11 }
12};
13
14personA.sayName(); // undefined
15personB.sayName(); // robo

personA에서는 sayName을 화살표 함수로 정의했다. 화살표 함수의 this는 렉시컬 스코프를 따르는데, 화살표 함수 자신은 this를 가지지 않으므로 전역 스코프의 this를 참조하게 된다. globalThis.nickname은 할당되지 않은 변수이므로 undefined가 출력된다.

personB에서 sayName을 함수 표현식으로 생성한 다음 그 안에 화살표 함수를 정의한 다음 호출했다. personB 객체에 대하여 sayName 함수를 호출했기 때문에, sayName의 this는 personB 객체가 된다. sayName 메서드 함수 내부의 화살표 함수는 렉시컬 스코프를 가지므로, 화살표 함수의 this는 sayName의 this가 가리키는 personB 객체가 된다. 따라서 personB 객체의 nickname인 ‘robo’를 출력하게 된다.

이처럼 호출 방식과 관계 없이 this가 고정되는 특성 때문에, 화살표 함수는 콜백 함수나 비동기 처리를 하는 데 유용하게 사용될 수 있다.

1const personA = {
2 name: 'yejin',
3 sayName: function() {
4 setTimeout(() => {
5 console.log(`${this.name}`);
6 }, 1000);
7 }
8};
9
10const personB = {
11 name: 'robo',
12 sayName: function() {
13 var callback = function() {
14 console.log(`${this.name}`);
15 }
16 setTimeout(callback.bind(this), 1000);
17 }
18};
19
20personA.sayName(); // yejin
21personB.sayName(); // robo

personA.sayName과 personB.sayName 모두 정상적으로 동작하지만, personA의 sayName 메서드가 더 간결하다. personB의 경우 this를 유지하기 위해 함수를 따로 선언하고, this 바인딩을 해줘야 했다.

화살표 함수의 this가 다르게 동작한다는 특성 때문에 화살표 함수의 스코프도 일반 함수와 다를 것이라는 오해가 있지만, 스코프는 일반 함수와 동일하게 렉시컬 스코프를 가진다. 즉, 화살표 함수 또한 자신의 렉시컬 스코프를 만들고, 화살표 함수가 정의된 위치에 기반하여 스코프 체인을 형성한다.

참고 자료

프로필 사진

조예진

이전 포스트
⟦TypeScript〛DistributedOmit - 유틸리티 타입의 타입 분배
다음 포스트
Remix 서버 코드를 서버답게 관리하기