[JS] 클로저(Closure)

클로저는 자바스크립트에서 중요한 개념이면서도 입문할 때 많이 어려워하는 개념 중 하나입니다. 클로저는 자바스크립트만 사용되는 개념이 아니고, 함수를 일급 객체로 취급되는 많은 함수형 프로그래밍 언어에서 사용되는 중요한 특성입니다.
이 글은 클로저의 개념 설명과 예제 코드를 활용하여 정리한 글입니다.

읽기전

이 글은 자바스크립트의 스코프에 대한 이해가 필요합니다. 만약 스코프에 대해 이해가 없으시다면 [JS]호이스팅과 스코프를 추천합니다.

클로저(Closure)

클로저는 자바스크립트를 좀 더 고급 적으로 사용하기 위한 테크닉으로 단순히 브라우저에서 돔을 핸들링하기 위한 자바스크립트를 넘어서 자바스크립트를 좀 더 잘 쓰기 위해서는 꼭 알아야 하는 개념이라고 생각합니다.

클로저는 내부함수가 외부의 컨텍스트를 외부함수가 소멸하여도 가지고 있는 것입니다.

클로저는 세 가지의 스코프가 있습니다. 전역 스코프(Global Scope) > 외부 함수 스코프(Outer Functions Scope) > 지역 스코프(Local Scope)로 구성돼 있습니다.

클로저를 이해하기 전 스코프 체인을 먼저 이해하는 것이 중요합니다.

스코프 체인(Scope Chain)

스코프 체인은 함수가 실행될 때 가장 좁은 범위인 지역 스코프부터 시작해 사용되는 리소스를 외부 스코프로 이동하며 단계적으로 찾는 것을 말합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const x = 1;          // 전역 스코프

function foo() {
  const y = 2;        // 외부 함수 스코프(외부함수)
  const z = 2;

  return function() {
    const z = 3;      // 지역 스코프(내부함수)
    console.log(`x = ${x}`);
    console.log(`y = ${y}`);
    console.log(`z = ${z}`);
  }
}

const f = foo();
f();

/**
 * OUTPUT:
 * x = 1
 * y = 2
 * z = 3
 */

위 코드에서 보시면 스코프 별로 변수 x, y, z가 선언되어 있습니다. 그리고 foo 함수가 호출될 때 각 영역에 선언된 변수를 사용하고 있는 내부함수를 리턴합니다.

그리고 리턴된 함수를 호출하게 되면 변수에 할당된 값을 모두 출력하는 걸 확인할 수 있습니다. 이런 동작이 가능한 이유는 스코프 체인이 발생하기 때문입니다.

f() 함수가 실행될 때 먼저 지역 스코프에서 x, y, z를 찾습니다. z를 발견했으니 z의 값을 결정합니다. 나머지 변수들은 지역 스코프에 존재하지 않으니 다음은 외부 함수 스코프에서 찾습니다.

외부 함수 스코프에서 x와 y를 찾습니다. y를 발견했으니 y의 출력값을 결정합니다. 근데 여기서 z를 또 발견합니다. 이때 자바스크립트는 z변수를 무시합니다. 이렇게 좁은 범위에서 선언된 변수가 넓은 범위에서 선언된 변수를 가리는 것을 이를 섀도윙(Shadowing)이라고 합니다.

자, 외부 함수 스코프에서 x를 못 찾았으니 전역 스코프에서 x를 찾습니다. x를 발견했으니 x의 출력값을 결정합니다.

이렇게 함수가 실행될 때 좁은 범위의 스코프부터 넓은 범위로 이동하면서 체인의 형태를 띠며 리소스를 찾아가는 형태를 스코프 체인(Scope Chain)이라 합니다.

그리고 자연스럽게 이 예제에서 클로저를 확인할 수 있습니다. foo가 호출되고 소멸되었지만 함수 f는 foo의 지역변수 y를 그대로 사용하고 있습니다.

이렇게 외부 함수가 소멸하였지만 지역 함수가 외부함수의 리소스를 사용하고 있으면, 외부함수의 리소스를 내부함수가 할당될 때 메모리에 할당하여 이것을 사용할 수 있도록 하는 것이 클로저(Closure)입니다.

클로저 활용 예제

은닉화

다른 객체지향 프로그래밍 언어에서는 은닉화를 코드 문법적으로 지원하지만, 자바스크립트에서는 지원하지 않습니다. 대신 자바스크립트는 클로저를 통해 외부의 접근을 막을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let i = 0;

function inc() {
  i += 1;
  console.log(i);
}

function dec() {
  i -= 1;
  console.log(i);
}

inc();
inc();
dec();
dec();
/**
 * OUTPUT:
 * 1
 * 2
 * 1
 * 0
 */

위 코드는 변수 i의 값을 inc 함수와 dec 함수를 통해 증가 또는 감소시키는 걸 구현한 예제입니다. 우리의 의도대로 증가하고 감소한다면 정말 행복하겠지만 만약 누군가 inc를 다른 영역에서 호출하게 될 경우 내가 예상한 값보다 +1 된 값이 출력될 것입니다. 이런 상황은 프로그래머를 불행하게 만듭니다.

이런 불행을 막기 위해 아래의 코드와 같이 수정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function count() {
  let i = 0;

  return {
    inc: function() {
      i += 1;
      console.log(i);
    },
    dec: function() {
      i -= 1;
      console.log(i);
    }
  }
}

const f = count();
f.inc();
f.inc();
f.dec();
f.dec();
/**
 * OUTPUT:
 * 1
 * 2
 * 1
 * 0
 */

위 코드와 같이 수정할 경우 외부에서 count 함수 내의 지역 변수인 i에 접근할 수 없기 때문에 앞에서 언급한 불행한 상황은 발생하지 않습니다.

var 호이스팅 이슈

var는 호이스팅 되기 때문에 의도한 대로 동작하지 않을 때가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (var i = 1; i <= 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000 * i);
}

/**
 * OUTPUT:
 * 6
 * 6
 * 6
 * 6
 * 6
 */

위 코드는 호이스팅 개념을 모르는 개발자라면 1 2 3 4 5가 출력될 것을 예상할 수 있을 것입니다. 하지만 안타깝게도 6이 5번 출력됩니다.

이를 개선하기 위해 코드를 수정해보겠습니다.

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
  (function(index) {
    setTimeout(function() {
      console.log(index);
    }, 1000 * i);
  })(i);
}

위 코드는 for문 내에서 즉시 실행 함수(IIFE)를 사용하여 i를 인자 값으로 전달합니다. 이렇게 되면 즉시 실행 함수가 실행되는 시점에 생성된 클로저 덕분에 전역 변수 i를 사용하지 않고 인자값 index의 값을 사용하게 됩니다.

마무리하며

MDN에서는 프로세스에 꼭 필요하지 않은 이상 함수 내부에 함수를 선언하는 것을 권장하지 않습니다.

이 문제는 자바스크립트에서 객체를 생성할 때 내부에 선언된 함수의 메모리 할당과 관련이 있기 때문에 추후 자바스크립트의 객체지향 프로그래밍에 대해서 정리할 때 다루겠습니다.