JavaScript

You Don't Know JS Yet - Scope & Closures 요약 및 감상 (2)클로저

한땀코딩 2021. 1. 24. 21:09

요약 1편 '변수'는 여기를 확인하세요.

 들어가며 

자바스크립트를 공부해보면 클로저가 등장합니다. this와 더불어 처음 접하는 입장에서 정확한 개념을 이해하기가 굉장히 난해한 축에 속한다고 생각합니다. 개념을 대충 알더라도, 또 의식적으로 활용하기가 어렵지 않나 싶습니다 (생각보다 우리는 모듈에서도 그렇고 클로저라고 모르고 사용하는 클로저가 많습니다). 특히 private, public 변수가 기본적으로 명시되지 않은 자바스크립트에선 클로저가 encapsulation을 위해선 필수로 등장합니다. You Don't Know JS Yet 2권은 전반에 걸쳐 클로저에 대한 정의, 흔히 하는 오해, 활용법 등을 설명하고 있는데, 사족을 몇 가지 곁들여가며 요약해보았습니다.

 클로저? 

외부 함수의 변수를 참조하고, 캡슐화해서 관리하고... 그 형태는 종종 봐와서 잘 알고 있지만 그래서 결론적으로 클로저는 어떻게 정의내릴 수 있을까요? 저자는 Observational, Implementational, 이 두 가지의 시각으로 아래와 같이 설명하고 있습니다.

  • Observational: 클로저는 다른 스코프에서 실행되었음에도 외부 변수를 기억하는 함수 객체이다. (closure is a function instance remembering its outer variables even as that function is passed to and invoked in other scopes)
  • Implementational: 클로저는 다른 스코프에서 참조되거나 실행될 때도 그대로 유지되는 함수 객체와 스코프 환경이다. (closure is a function instance and its scope environment preserved in-place while any references to it are passed around and invoked from other scopes)

이 정의를 굳이 나누는 이유는, 이후 클로저가 기억하는 범위가 어디까지인지에서 두 가지 시각이 등장할 수 있기 때문입니다. 우리 눈에 관측되는 부분만을 클로저의 정의로 볼 것인지, 엔진이 실제로 동작하기 위해 어디까지 관리하는지를 정의로 볼지에 대한 고찰이라고 볼 수 있겠습니다.

 클로저가 되려면 

YDKJSY에서는 클로저의 특징을 몇 가지 제시하고 있는데, 대략 다음과 같은 것들이 있습니다.

  1. Must be a function involved
    • 클로저는 반드시 함수와 연관이 있다. 단순 객체나 클래스 등은 클로저라 지칭할 수 없다.
  2. Must reference at least one variable from an outer scope
    • 외부 스코프의 변수를 적어도 하나는 참조해야한다.
  3. Must be invoked in a different branch of the scope chain from the variable
    • 클로저는 함수가 실행되어야만 관찰이 되는 개념이다.
    • 참조되는 변수와 다른 스코프에서 실행되어야 한다.

한 줄로 정의해본다면?

개인적으로 클로저를 어떻게든 정의해본다면, 현재 실행 환경에서 도달할 수 없는 스코프의 변수를 스코프 체인을 이용하여 참조하는 함수(혹은 그 행위)라고 말할 수 있을 거 같습니다. 클로저 챕터 전반에 걸쳐서도 계속 언급되겠지만, 어찌 되었든 클로저의 핵심은 현재 위치에선 접근할 수 없는 변수를 실제로 사용하는 것에 중점을 둔다고 이해하면 좋은 거 같습니다.

 클로저는 객체마다! 

함수를 반환하는 외부 함수가 두 번 실행되어 각각의 함수를 생성한다면, 둘은 클로저로 접근하는 그 외부 변수를 공유하는 것이 아니라 각자 별도로 생성하여 접근하게 됩니다. 예시 코드는 이렇습니다.

function adder() {
  var num = 0;
  return function addTo() {
    num++;
    console.log(num);
  };
}

let instance1 = adder();
let instance2 = adder();

instance1(); // 1
instance1(); // 2
instance1(); // 3
instance2(); // 1 -> 참조하는 num이 다름

instance1과 instance2는 작성된 위치로는 동일하게 adder 함수의 num을 참조하는 것처럼 보이지만, 둘은 다른 함수 객체이기에 각각 새로운 클로저가 됩니다.

 클로저는 스냅샷이 아니라 링크! 

클로저는 primitive 변수라도 참조될 때 그 값을 복사하여 가져오는 것이 아니라, 그 변수 자체를 참조하게 됩니다. 즉, 변경을 가하면 다음에 또 참조할 때 변한 값으로 불러와진다는 것입니다. 위의 예시와 굉장히 비슷한 코드를 예시로 보여드리겠습니다.

var hits;
{
  let count = 0;
  hits = function getCurrent() {
    count++;
    return count;
  };
}
console.log(hits()); // 1
console.log(hits()); // 2
console.log(hits()); // 3

이렇게 클로저 함수를 호출하여 참조하는 변수에 변경을 가한다면, 해당 변수는 변경 사항을 기억합니다. 아래는 클로저를 스냅샷이라고 생각했을 때 헷갈릴 수 있는 코드입니다.

var studentName = "Grace";
var greeting = function hello() {
  // Grace 라는 값이 아닌, studentName을 클로저가 참조합니다.
  console.log(`Hello, ${studentName}!`);
};

studentName = "Suzy";

greeting(); // Hello, Grace! 가 아니라 Hello, Suzy!가 출력됩니다.

 Ajax 도 클로저! 

function lookupStudentRecord(studentID) {
  ajax(`https://some.api/student/${studentID}`, function onRecord(record) {
    console.log(`${record.name} (${studentID})`);
  });
}
lookupStudentRecord(114); // Frank (114)

ajax의 콜백으로 넘겨준 함수에서 studentID를 참조할 수 있는 것도 결국 클로저의 개념입니다.

 클로저 같지만 아닌 것들 

저자가 클로저를 설명하면서 끊임없이 강조하는 부분이 바로 'observability', 즉 관측이 되는가에 대한 부분입니다. 클로저처럼 보이지만 위에서 이야기한 클로저의 조건을 갖추지 않은 예시들은 아래와 같은 것들이 있습니다.

function say(myName) {
  var greeting = "Hello";
  output();

  function output() {
    console.log(`${greeting}, ${myName}!`);
  }
}

say("Kyle");
// Hello, Kyle!

얼핏 보면 함수 안에 함수가 있고, 외부 함수의 호출이 전역에서 일어나니 클로저처럼 보일 수도 있지만 문제는 output함수가 say 함수 내부에서 호출되고 끝난다는 점입니다. 이건 도달할 수 없는 스코프를 참조한 것이 아니라 그냥 nested scope가 자신의 상위 스코프의 변수를 참조했을 뿐입니다.

 클로저의 라이프사이클과 가비지 컬렉션(GC) 

단 하나의 함수라도 클로저로서 어떤 변수를 참고하고 있다면, 이 변수는 가비지 콜렉터가 처리하지 않고 무시합니다. 이 때문에 가끔 예기치 못하게 더 이상 참조하지 않음에도 가비지 컬렉션이 돌지 못하는 문제가 발생합니다. 대표적인 예시로 소개되는 것은 이벤트 리스너 부착 후 더 이상 필요로 하지 않음에도 해당 이벤트 리스너를 제거하지 않는 행위입니다. 더 이상 사용하지 않는 이벤트라면 효율성을 위해서 제거하는 것이 좋습니다.

 클로저는 어디까지 기억하는가 

만약 참조되는 변수가 외부 스코프에서 단 하나라면 다른 변수들은 과연 함께 어딘가에 저장되고 있을까요? 클로저 자체는 관측되는 현상만 놓고 보면 참조하는 변수 딱 하나만을 바라보기는 하지만, 어떻게 이것이 구현되는지에 대한 관점으로 보면 결국 클로저가 발생하면 참조되는 변수를 둘러싼 스코프 환경과 함수 객체까지 엔진이 기억하고 있습니다. 어떤 식으로 이해를 하더라도, 결론적으로 클로저는 결국 스코프가 직접적으로 관측이 되는 것은 참조하는 변수 그 하나에 대한 것이기에 좀 더 납득이 되는 방향으로 이해하면 좋을 거라고 저자는 이야기합니다.

 클로저는 언제 유용할까? 

이러한 html이 있다고 합시다.

<button data-kind="studentIDs">Register Students</button>

여기에 ajax 요청을 하는 이벤트 핸들러를 부착한다면 간단하게는 이런 방법이 있습니다.

function makeRequest(evt) {
  var btn = evt.target;
  var recordKind = btn.dataset.kind;
  ajax(APIendpoints[recordKind], data[recordKind]);
}

btn.addEventListener("click",makeRequest);

이벤트가 발생할 때마다 타깃 이벤트의 dataset.kindrecordKind에 넣어주고 요청을 넘깁니다. 하지만 해당 값은 사실상 여러 번 클릭이 발생한다고 해서 변화가 생기지 않습니다. 이 코드를 클로저를 활용하여 개선한다면 다음과 같이 작성할 수 있습니다.

function setupButtonHandler(btn) {
  var recordKind = btn.dataset.kind;
  btn.addEventListener("click", function makeRequest(evt) {
    ajax(APIendpoints[recordKind], data[recordKind]);
  });
}

setupButtonHandler(btn);

이렇게 이벤트 리스너가 클로저가 되어 접근할 수 있게 recordKind를 한 번만 선언하면 불필요한 반복 작업을 줄일 수 있습니다.

 클로저의 장점 

그래서 이렇게 많은 걸 설명하지만 결론적으로 클로저는 어떤 장점이 있을까요? 저자가 제시하는 것은 크게 아래 두 가지입니다.

  1. 한번만 계산하면 되는 값을 함수 객체가 기억할 수 있게 해 주기 때문에 효율성 증가
  2. 가독성이 향상. 함수 안에 캡슐화하여 관리가 가능하며 같은 값을 매번 인자로 받을 필요 없어 더욱 간결하고 기능에 충실한 함수가 작성이 가능

 마치며 

분명히 읽었던 챕터인데도 왜 이렇게 낯설까요... 다시 봐도 참 직관적으로 이해하기는 어려운 개념인 것 같습니다. lexical 하면서도 또 동시에 프로그램이 실행되어 나감에 따라 값이 변화하고 객체별로 새로이 클로저가 생기는 등, 여러 예시를 보지 않으면 그 특성을 파악하기가 힘든 것 같습니다. 그리고 찾아보는 자료마다 모두 정의가 아주 조금씩 다르다는 것이 흥미로우면서도 난감하지 않았나 싶습니다.

아직은 이렇게 책을 보며 거의 받아 적기 수준으로 정리하고 있습니다만, 언젠가는 의식적으로 클로저의 장점을 잘 살릴 수 있는 코드를 작성해야겠죠. 클로저의 매력은 다음 챕터였던 모듈에서도 더 현실적인 예시와 함께 많이 다뤄질 테니 해당 챕터 요약에서 또다시 한번 소개해보겠습니다.

참고자료

https://leehwarang.github.io/2019/10/07/scope.html

  • 클로저가 lexical 하다는 개념에 대해서 잘 소개되고 있습니다.