👩‍💻 Programming/JavaScript

함수의 달인이 되자_1. 클로저(closure)와 스코프(scope) from Secrets of the JavaScript Ninja

codingBear 2022. 4. 11. 16:20
728x90
반응형

이번 글은 'Secrets of the JavaScript Ninja'의 'Chapter 5. Functions for the master: closures and scopes'를 바탕으로 작성하였습니다.


핵심 Concepts

  • 개발 과정을 간소화하기 위한 클로저 사용법
  • 실행 컨텍스트(execution context)를 통한 JavaScript 프로그램의 실행 절차 추적
  • 렉시컬 환경(lexical environments)를 통한 변수 스코프(variable scopes) 추적
  • 변수 종류에 대한 이해
  • 클로저 작동 방법에 대한 탐구

사전 지식 체크!

  • 스코프는 각기 다른 변수나 메소드를 몇 개나 가질 수 있으며 종류는 무엇인가?
  • 식별자(indentifier)는 어떻게 값을 가지며 추적되는가?
  • 수정 가능한 변수는 무엇이며 어떻게 JavaScript에서 정의하는가?

들어가며

 클로저는 JavaScript의 핵심적인 특징이다. 이를 활용하면 복잡한 코드를 간략하게 작성할 수 있고 다양한 추가 기능을 실현 가능하다. 예를 들어 클로저 없이 이벤트 핸들러 같은 콜백과 관련한 작업을 수행하면 굉장히 복잡할 것이다. 그 외에도 클로저 없이는 프라이빗 객체 변수(private objexts variables)를 활용하기는 불가능하다.

 클로저는 스코프가 JavaScript 내에서 동작하다 발생하는 '부수 효과(side effect)'이다. 따라서 클로저의 개념을 이해하려면 스코프에 대한 이해 역시 필요할 것이다.


Understanding closures

 클로저는 해당 함수(funtion)의 바깥에 있는 변수를 조작하거나 다룰 수 있게 해준다. 또한 클로저는 스코프 안에 있는 다른 함수(function)가 정의될 때 모든 변수에 접근 가능하게도 해준다. 

 

스코프는 프로그램의 특정 부분에서 식별자가 보이는 부분을 참조한다. 스코프는 특정한 이름이 특정한 변수에 바운딩된 프로그램의 한 부분이다.

 

 한번 선언된 함수는 선언된 스코프가 사라진 후에라도 언제든지 호출할 수 있다. 구체적인 예를 살펴보기에 앞서 아래 간단한 예제부터 살펴보자.

 

 

 위 예제를 보면 변수 outerValue와 함수 outerFunction을 같은 전역 스코프(global scope)에 선언해놨다. 그러고 나서 outerFunction을 호출한다.

 보면 알다시피 test는 정상적으로 통과된다. 우리는 이러한 코드를 무의식적으로 작성해왔을 것이다. 위 outerValue와 outerFunction은 전역 스코프에 선언되었고, 해당 스코프(클로저)는 애플리케이션이 동작되는 한 사라지지 않는다. 

 위 예제만 보아서는 클로저를 사용하는 이점에 대해 알기 어렵다. 아래 예제를 통해 더 깊이 알아보자.

 

 

 위 예제 코드를 하나하나 뜯어 보면서 무슨 일이 일어날지 짐작해보자.

 

  • 첫 번째 assert는 확실히 pass된다. outerValue는 전역 스코프 안에 있어서 코드 내 어디서든 볼 수 있다. 그렇다면 두 번째 assert는 pass될까?
  • 전역 변수 later에다 함수에 대한 참조를 복사함으로써 innerFuntion을 outerFuntion에 이어서 실행할 것이다. 
  • innerFunction이 실행되면 later를 통해 함수를 호출한 시점에 외부 함수 안의 스코프는 사라질 것이다.
  • 따라서 innerValue는 'undefined'이 되어 두 번째 assert는 fail이 뜰 것을 예상할 수 있다.

 

 

 하지만 예상과 달리 모든 assert는 통과된다. 내부 함수가 생성되었던 스코프가 사라진 후에 내부 함수를 실행했는데도 어떻게 변수 innerValue는 존재하는 것일까. 답은 바로 클로저이다.

 함수 innerFuntion을 외부 함수 내에 선언할 때 함수 선언(function declaration)만이 정의되는 것이 아니라 함수가 정의되는 시점의 스코프 안에 든 모든 변수들을 비롯해 함수 정의를 포함하는 클로저 역시 생성된다. 함수 innerFunction이 선언된 스코프가 사라진 후에 innerFuntion이 실행되더라도 클로저를 통해 선언된 원래 스코프에 접근할 수 있다. 

 

 

 

 위 그림을 보면 클로저의 개념을 이해할 수 있다. 클로저는 함수가 정의되는 시점에 스코프 안에 든 모든 변수와 함수를 위한 '안전한 거품(safety bubble)'을 마련함으로써 해당 함수는 실행되는 데 필요한 모든 것을 갖출 수 있다. 함수와 모든 변수를 포함한 이 거품은 함수가 실행되는 동안 사라지지 않는다.

 이러한 구조는 눈에 띄진 않지만 이 방법으로 정보를 저장하고 참조하려면 대가가 필요하다. 클로저를 통해 정보에 접근하는 각 함수는 이 정보를 포함하는 '볼(ball)과 체인(chain)'을 가진다. 모든 정보는 JavaScript 엔진이 해당 정보를 전혀 원하지 않을 때까지 혹은 페이지가 사라질 때까지 메모리를 차지한다.

 앞으로 클로저의 실질적인 쓰임새에 대해 살펴보자.

 


Putting closures to work

Mimicking private variables

 대다수 언어들은 프라이빗 변수(private variables)를 사용한다.

 

프라이빗 변수: 외부 요소로부터 숨어 있는 객체의 프로퍼티(property)

 

 허나 JavaScript에서는 프라이빗 변수를 지원하지 않는다. 하지만 클로저를 사용하여 다음 예제에서 보듯 비슷하게 구현 가능하다.

 

 

 위 예제 코드를 윗줄부터 살펴보면 다음과 같다.

 

  • 생성자(constructor) Ninja를 생성
  • this는 새롭게 생성된 객체를 가리킴
  • 생성자 안에 상태를 저장하는 변수 feints 선언
  • JavaScript의 스코프 법칙에 따라 변수 feints에 대한 접근은 생성자 내로 한정되기에 접근용 메소드(accessor method) getFeints 선언(프라이빗 변수를 읽을 수 있음, 접근용 메소드는 게터(getter)라고도 함)
  • feint 메소드를 실행하여 변수 feints의 값 증가

 

 위 예제 코드로써 알 수 있는 사실은 접근용 메소드를 통해 프라이빗 변수의 값을 직접 얻을 수는 없지만 우회적으로 얻어낼 수 있다는 점이다. 이를 통해 개발자는 진짜 프라이빗 변수를 사용할 때처럼 제어 불가능한 변수값의 변경을 막을 수 있다.

 클로저를 사용함으로써 메소드 사용자가 ninja의 상태에 직접적으로 접근하는 일 없이 메소드 내 ninja의 상태는 유지될 수 있다. 왜냐하면 변수는 클로저를 통해 내부 메소드에 접근 가능하지만 외부에 있는 코드에는 접근 불가능하기 때문이다.


Using closures with callbacks

 클로저는 함수가 정의된 이후 정해지지 않은 시간에 호출되는 콜백(callback)을 다룰 때도 많이 쓰인다.

 

 

 그와 같은 함수 내에서는 주로 외부 정보에 접근할 필요성이 있다. 다음 예제를 통해 간단한 콜백 타이머를 살펴보겠다.

 

 

 위 예제 코드에서 특히 중요한 점은 목표 요소인 div에 애니메이션을 적용하기 위해 setInterval의 인수로서 익명 함수(anonymous function)를 하나 사용한다는 점이다. 애니메이션을 처리하기 위해 해당 함수는 클로저를 활용하여 세 가지 변수(elem, tick, timer)에 접근한다. 허나 해당 변수들을 함수 animateIt 바깥의 전역 스코프로 옮겨놓아도 함수가 잘 작동함을 볼 수 있는데 왜 그런 것일까?

 변수들을 전역 스코프에 옮겨 놓은 채 똑같은 div 요소를 하나 더 추가한다 해보자. 이와 같이 코드를 작성해보면 문제점이 명확히 드러날 것이다. 전역 스코프에다 변수들을 옮겨 놓았을 경우, 각 함수 animateIt마다 별도로 변수들을 지정해줘야 한다. 그렇지 않으면 다른 함수들이 같은 변수들을 참조하여 기능에 혼선이 생긴다. 

 함수 안에 변수들을 정의하고 클로저를 활용해 타이머 콜백 호출에 변수들을 사용가능케 함으로써 각 함수는 제가끔의 '변수 거품'을 아래 그림과 같이 가진다.

 

 

  클로저 없이 애니메이션이나 서버에 대한 호출 등 같은 작업을 여러 번 반복하는 코드를 짜기 매우 까다로울 것이다. 예제와 같이 기능 구현에 쓰이는 변수들을 클로저 버블 스코프 안에서 관리하면 외부 접근에 의해 오염될 일도 없고, 기능을 부여할 요소 선택 시 함수의 매개변수에 id만 바꿔 입력해주면 각기 다른 요소에다 같은 기능을 부여 가능해서 하드 코딩 없이 코드를 간략하게 작성 가능하다. 변수들을 함수 animateIt에 포함함으로써 별다른 코드 작성 없이 내재된 클로저를 생성할 수 있다. 

 여기서 또 다른 중요한 개념을 살펴보자. 개발자는 클로저가 생성될 시점에 변수들이 가진 값을 볼 수 있을 뿐만 아니라, 클로저 내의 함수가 실행되는 동안 클로저 내의 변수값을 변경할 수 있다. 클로저는 스코프가 생성될 당시의 상태에 머물러 있는 것이 아니라 클로저가 존재하는 한 언제든지 수정할 수 있는 상태들을 캡슐화(encapsulation)해놓은 것이다.

 

Chapter 4에서 살펴봤듯이 함수 컨텍스트(funtion context)는 함수가 호출되었을 때 this 키워드를 활용하여 접근 가능한 객체이다. 반면 실행 컨텍스트(execution context)는 이름만 비슷하고 기능은 전혀 다르다. 이는 JavaScript 엔진이 함수의 실행을 쫓는 데 활용하는 JavaScript의 내재적인 개념이다.

 

 Chapter 2에서 언급했다시피 JavaScript는 싱글 스레드 실행 모델(single-threaded execution model)이다. 함수가 호출될 때마다 현재 실행 컨텍스트는 중지되고, 함수 코드로 여겨질 새로운 실행 컨텍스트가 생성된다. 함수가 제 기능을 다하고 나면 해당 함수의 실행 컨텍스트는 삭제되고 호출 실행 컨텍스트(caller execution context)는 저장된다. 따라서 실행될 하나와 실행되기를 기다리는 하나 모두 추적할 필요가 있다. 이 같은 작업을 수행하는 가장 쉬운 방법은 실행 컨텍스트 스택(execution context stack 혹은 call stack)이라 일컫는 스택을 활용하는 것이다. 

 

스택은 새로운 요소를 저장 공간 맨 위에 쌓고 저장 공간 내 맨 위의 요소를 꺼내어 활용하는 가장 기초적인 자료 구조이다.

 

 다음 예제 코드를 통해 스택의 개념을 살펴보자.

 

 

 

 예제 코드를 실행하면 다음과 같이 작동한다.

 

  1. 실행 컨텍스트 스택은 JavaScript 프로그램당 한 번만 생성되는 전역 실행 컨텍스트로 시작한다. 전역 실행 컨텍스트는 전역 코드가 실행될 때 활성화되는 실행 컨텍스트이다.
  2. 전역 코드에서 프로그램은 skulk와 report라는 두 함수를 먼저 정의한다. 한 번에 하나의 작업만 수행 가능하기에 JavaScript 엔진은 전역 코드의 실행을 멈추고 함수 skulk를 'kuma'라는 인수와 함께 호출한다. 이는 새로운 함수 실행 컨텍스트를 스택의 맨 꼭대기에 넣음으로써 가능하다. 
  3. 함수 skulk 다음으로는 함수 report가 'kuma skulking'이라는 message와 함께 호출된다. 역시 한 번에 하나의 작업만 수행 가능하기에 실행 중이던 함수 skulk는 멈추고, 함수 report를 위한 새로운 함수 실행 컨텍스트가 생성되어 스택 맨 위에 쌓인다.
  4. 함수 report가 console.log를 통해 로그(log)를 남긴 뒤 작업을 멈추고 나서 함수 skulk를 실행하여야 한다. 이는 스택에서 함수 report의 함수 실행 컨텍스트를 떼어내고 나서 이루어진다. 함수 skulk의 실행 컨텍스트가 재활성화되어 다시 실행을 시작한다.
  5. 함수 skulk가 실행을 끝마치고 나면 비슷한 일이 일어난다. 함수 skulk의 함수 실행 컨텍스트가 스택에서 제거되고 그 동안 실행되기를 기다렸던 전역 실행 컨텍스트가 재활성화된다.

 

 위와 같은 작업이 다른 함수를 수행할 때도 이루어진다. 

 

 애플리케이션의 어디가 실행 중인지 추적하는 것 외에도 실행 컨텍스트는 식별자 확인(identifier resolution) 작업에도 중요하다. 이 작업은 어느 변수를 특정 식별자가 참조하는지 알아내는 일련의 작업이다. 실행 컨텍스트는 이 같은 작업을 렉시컬 환경을 통해 수행한다.


Keeping track of identifiers with lexical environments

 렉시컬 환경이란 식별자에서 특정 변수까지 추적하는 길을 그리는 데 쓰이는 JavaScript 엔진에 내재된 구조이다. 다음 예를 살펴보자.

 

 

 렉시컬 환경은 변수 ninja가 console.log에 의해 접근될 때 참고된다.

 

렉시컬 환경은 JavaScript에 내재된 스코핑 메커니즘(scoping mechanism)으로 스코프라고도 한다.

 

 렉시컬 환경은 함수, 코드 블록(a block of code), try-catch 구문의 catch 부분과 같은 특정한 JavaScript 코드 구조와 관련 있다. 이 같은 각 구조는 고유한 식별자 맵핑(mapping)을 가진다.


​Code nesting

 렉시컬 환경은 코드 중첩(code nesting)과 밀접한 관련이 있다. 아래 그림은 코드 중첩의 예제이다.

 

 

 스코프 안에서 각 코드 구조는 코드가 평가될 때마다 관련된 렉시컬 환경을 얻는다. 예를 들어 skulk 함수가 호출될 때마다 새로운 렉시컬 환경이 만들어진다. 

 덧붙여 내부 코드 구조는 외부 코드 구조에 정의된 변수에 접근할 수 있다는 점이 중요하다. 예를 들어 for 반복문에서는 report 함수, skulk 함수 그리고 전역 코드 내의 변수 모두 접근 가능하다. report 함수에서는 skulk 함수와 전역 코드에 정의된 변수, skulk 함수에서는 전역 코드에 정의된 변수에만 접근 가능하다.


Code nesting and lexical environments

 지역 변수, 함수 선언, 함수 매개변수(function parameters)를 추적하는 동안 각 렉시컬 환경은 자신의 외부(부모) 렉시컬 환경을 계속해서 추적한다. 이 같은 과정은 개발자가 작업 중에 외부 코드 구조에 정의된 변수에 접근해야 하기 때문에 필요하다. 현재 환경에서 식별자를 찾을 수 없다면 외부 환경을 탐색한다. 이 작업은 일치하는 변수를 찾거나 전역 환경까지 탐색해도 식별자가 없어 참조 에러를 띄우거나 했을 때 멈춘다.

 다음 예제를 통해 위와 같은 식별자 확인 과정을 자세히 살펴 보자.

 

 

 위 예제에서 report 함수는 skulk 함수에 의해 호출되고 skulk 함수는 전역 코드에 의해 호출된다. 각 실행 컨텍스트는 관련된 해당 컨텍스트에 직접 정의된 모든 식별자에 대한 맵핑을 포함한 렉시컬 환경을 가진다. 예를 들어 전역 환경은 식별자 ninja와 skulk에 대한 맵핑, skulk 환경은 식별자 action과 report에 대한 맵핑, report 환경은 식별자 intro에 대한 맵핑을 가진다.

 특정한 실행 컨텍스트 안에서 일치하는 렉시컬 환경에 직접 정의된 식별자에 대한 접근을 제외하고 프로그램은 외부 환경에 정의된 다른 변수들에 종종 접근하고는 한다. 예를 들어 report 함수의 내용 안에서는 외부에 있는 skulk 함수의 변수 action과 전역 변수인 ninja에 접근 가능하다. 이를 위해 외부 환경이 어떻게 동작하는지 추적할 필요가 있다. JavaScript는 일급 객체(first-class object)로서 함수의 이점을 취하면서 이 같은 작업을 수행한다.

 함수가 만들어 질 때마다 함수가 만들어진 렉시컬 환경에 대한 참조는 내부의 [[Environment]]라는 프로퍼티에 저장된다.(즉 외부에서 곧바로 접근하거나 수정 불가능하다) 위 예제의 경우 skulk 함수는 전역 환경에 대한 참조를 유지하고 report 함수는 skulk 함수에 대한 참조를 유지한다. 이 환경들은 각 함수가 생성된 환경이기 때문이다. 

 함수가 호출될 때마다 새로운 실행 컨텍스트가 생성되어 스택에 쌓인다. 새롭게 생성된 렉시컬 환경의 외부 환경에다 JavaScript 엔진은 지금 호출된 함수가 생성된 환경인 호출된 함수에 내재된 [[Environment]] 프로퍼티에 의해 참조되는 환경을 마련한다.

 위 예제의 경우 skulk 함수가 호출될 때 새롭게 생성된 skulk 환경의 외부 환경은 전역 환경이 된다.(왜냐하면 skulk 함수가 생성된 환경이기 때문이다.) 이와 같이 report 함수를 호출할 때 새롭게 생성된 report 환경의 외부 환경은 skulk 환경으로 설정된다.

 첫 assert가 평가될 때 intro 식별자를 확인해야 한다. 이를 위해 JavaScript 엔진은 현재 작동 중인 실행 컨텍스트인 report 환경을 확인함으로써 시작된다. report 환경은 확인된 식별자인 intro에 대한 참조를 포함하기 때문이다.

 다음으로 두 번째 assert에서 action 식별자를 확인해야 한다. 또 한 번 현재 작동 중인 실행 컨텍스트가 확인된다. 그러나 report 환경은 action 식별자에 대한 참조를 포함하고 있지 않으므로 JavaScript 엔진은 report 환경의 외부 환경을 확인해야 한다. skulk 환경이 바로 action 식별자에 대한 참조를 포함하고 있다. 비슷한 과정이 ninja 식별자를 확인할 때도 이루어진다.(해당 식별자는 전역 환경에서 찾을 수 있다.)


함께 보기

  • 클로저(closures)

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures

 

Closures - JavaScript | MDN

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function. In JavaScript, closur

developer.mozilla.org

https://www.w3schools.com/js/js_function_closures.asp

 

JavaScript Function Closures

W3Schools offers free online tutorials, references and exercises in all the major languages of the web. Covering popular subjects like HTML, CSS, JavaScript, Python, SQL, Java, and many, many more.

www.w3schools.com

  • 스코프(scopes)

https://developer.mozilla.org/en-US/docs/Glossary/Scope

 

Scope - MDN Web Docs Glossary: Definitions of Web-related terms | MDN

The current context of execution. The context in which values and expressions are "visible" or can be referenced. If a variable or other expression is not "in the current scope," then it is unavailable for use. Scopes can also be layered in a hierarchy, so

developer.mozilla.org

 

728x90
반응형