렉시컬 스코프와 클로저, 그리고 커링

JS (TIL & Remind)·2022년 4월 8일
8

클로저를 공부하기 이전에

MDN에 클로저의 정의는 ‘함수와 함수가 선언된 어휘적 환경의 조합이다’ 라고 나타나 있다.
그리고 클로저를 이해하려면 렉시컬 스코프(Lexical Scope)를 먼저 이해해야 한다고 나와있다.
따라서, 렉시컬 스코프가 무엇인지 먼저 알아보자.

렉시컬 스코프(Lexical Scope)

스코프(Scope)란?

스코프참조 대상 식별자(변수, 함수의 이름과 같이 어떤 대상을 다른 대상과 구분하여 식별할 수 있는 유일한 이름)를 찾아내기 위한 규칙이다.

간단한 예제로 설명하자면 다음과 같다.

/*
 전역에 변수를 선언하면 이 변수는 어디서든지 참조할 수 있는
 "전역 스코프"를 가지는 전역 변수가 된다.
*/
var global = 'global';

function foo() {
  // var 키워드로 선언한 변수는 "함수 레벨 스코프"를 가진다.
	var local = 'local';
	
	console.log(global);
}

foo();
console.log(local);

// 결과
// global
// Uncaught ReferenceError: local is not defined

위 예제에서 global 이름을 가진 변수는 전역 스코프를 가지기 때문에 foo 함수 내에서도 참조할 수 있지만, local 이름을 가진 변수는 함수 레벨 스코프를 가지므로 foo 함수 외부에서 참조하려고 한다면 참조에러가 발생한다. 즉, 이와 같은 개념을 스코프라 한다.

그래서 렉시컬 스코프(Lexical Scope)란?

렉시컬 스코프는 함수를 어디에 선언하였는지에 따라 상위 스코프가 결정되는 것을 말한다.
자바스크립트를 포함한 대부분의 프로그래밍 언어는 렉시컬 스코프를 따르며, 이를 정적 스코프(Static Scope)라고 부르기도 한다.

var x = 1;

function foo() {
	var x = 10;
	bar();
}

function bar() {
	console.log(x);
}

foo(); // 1
bar(); // 1

위와 같은 상황에서 bar 함수에서 참조하는 x 변수는 bar 함수의 상위 스코프가 무엇인지에 따라 결정된다.
따라서, 상위 스코프가 무엇인지 알려면 bar 함수가 어디에 선언되었는지 봐야되는데, 위 코드에서는 bar 함수가 전역에 선언되었으므로 상위 스코프는 전역 스코프가 된다.
그래서 bar 함수 내의 x 변수는 전역에 선언된 x 변수를 참조하게 된다.

  • 동적 스코프

    렉시컬 스코프와 다르게 함수를 어디서 호출하였는지에 따라 상위 스코프가 결정되는 것을 동적 스코프라고 한다.
    Bash Scripting, Common Lisp 등 일부 언어에서 동적 스코프를 따른다.

클로저(Closure)

클로저는 반환된 내부함수가 자신이 선언됐을 때의 환경(렉시컬 스코프)인 스코프를 기억하여 자신이 선언됐을 때의 환경(스코프) 밖에서 호출되어도 그 환경(스코프)에 접근할 수 있는 함수를 말한다.
즉, 자신이 생성될 때의 환경(렉시컬 스코프)을 기억하는 함수다.

예제 코드를 보며 이해해보자.

const outerFunc = () => {
		let x = 10; // '자유 변수' 라고 한다.

		// 클로저
		const innerFunc = (y) => {
				x = x + y;
				console.log(x);
		}

		return innerFunc;
}

const addFunc = outerFunc();
addFunc(5); // 15
addFunc(10); // 25

위 코드에서 innerFunc의 x는 렉시컬 스코프에 의해 outerFunc의 지역변수 x를 참조하고 있다.

그리고 outerFunc는 const addFunc = outerFunc(); 구문에서 호출된 후 콜스택에서 제거된다.

따라서, 이후에 addFunc를 호출 할 때 innerFunc의 x를 참조할 수 없어 보이는데, 외부함수 실행 컨텍스트 내의 *활성 객체가 내부함수에 의해 참조되는 한 유효하기 때문에 그렇지 않다.

즉, 내부함수가 유효한 상태에서 외부함수가 종료되어도, 외부함수의 x 변수를 참조할 수 있다. 여기서 내부함수를 클로저라고 한다.

  • 활성 객체란? 실행 컨텍스트가 생성될 때 자바스크립트 엔진이 해당 컨텍스트에서 실행에 필요한 여러가지 정보(변수, 함수 선언 등의 정보)를 담을 객체를 생성하는데, 이를 활성 객체라고 한다.

클로저의 활용

클로저는 다음과 같은 상황에서 유용하게 사용될 수 있다.

정보 은닉 (접근 권한 제어)

정보 은닉은 어떤 모듈의 내부 로직의 노출을 최소화 하는 것을 말한다.

클로저를 통해 자바스크립트에서도 클래스 기반 언어의 public과 private을 흉내내어 변수에 대한 접근 권한을 제어할 수 있다.

const user = () => {
		let userName = ''; // private

		return {
				getUserName: function() {
						return userName;
				},
				setUserName: function(_userName) {
						userName = _userName;
				}
		};
}

const user1 = user();
user1.getUserName(); // ''

user1.setUserName('Chojs');
user1.getUserName(); // Chojs

부분 적용 함수

부분 적용 함수는 n개의 인자를 받는 함수에 미리 m개의 인자만 넘겨 기억 시켜둔 후, 나중에 나머지 인자를 넘겨 원래 함수의 실행 결과를 얻을 수 있도록 하는 함수이다.

대표적인 예로 *debounce가 있다.

  • debounce란? 여러 번 발생하는 이벤트에서, 가장 마지막 이벤트만을 실행되도록 만드는 개념. 예를 들어, mousemove 이벤트 핸들러는 mouse가 움직일 때 마다 실행되는데, 디바운스를 적용시켜 mouse의 움직임이 끝날 때 핸들러를 한번 호출하여 과도한 호출을 방지함으로써 퍼포먼스를 향상시킬 수 있다.
const debounce = (eventName, func, wait) => {
		let timerId = null;
		
		return function(event) {
				let self = this;
				clearTime(timerId);
				timerId = setTimeout(func.bind(self, event), wait);
		};
};

const mousemoveHandler = () => {
		console.log('wait 시간 내 많은 이벤트 발생 시 한번만 호출됨');
}

document.body.addEventListener('mousemove', debounce('mousemove', mousemoveHandler, 500));

커링

커링은 function(a, b, c) 처럼 단일 호출로 처리하는 함수를 function(a)(b)(c)와 같이 각각의 인수가 호출 가능한 프로세스로 호출된 후 병합되도록 변환하는 것이다.

예제 코드를 보면 커링을 구현할 때 클로저가 들어간다.

// 커링 함수
const curry = (func) => (a) => (b) => func(a, b);

const sum = (a, b) => (a + b);

let curriedSum = curry(sum);
console.log(curriedSum(1)(2)); // 3

커링의 활용

커링은 주로 지연 실행에 사용된다. 함수의 마지막 인자가 들어오기 전까지 지연시켰다가 마지막 인자가 들어왔을 때 실행된다.

대표적인 예는 미들웨어이다.

const reduxThunk = (store) => (next) => (dispatch) => {
		return typeof action === "function" ? action(dispatch, store.getState) : next(action);
}

또한, 정보를 출력하고 출력하는 로그(log) 함수를 구현할 때도 유용하다.

const log = (date) => (importance) => (message) => {
		return `[${date.getHours()}:${date.getMinutes()}] [${importance}] ${message}`;
}

// 기본
console.log(log(new Date())("WARNING")("Reference Error!!!")); // '[15:54] [WARNING] Reference Error!!!'

// 응용
const logNow = log(new Date());
console.log(logNow("INFO")("message")); // '[16:51] [INFO] message'

const debugNow = logNow("DEBUG");
console.log(debugNow("message")); // '[16:59] [DEBUG] message'

고급 커링

위의 예제를 보면 커링된 함수는 func(a)(b)(c)와 같이 단일 인자만 넘겨 호출할 수 있다.

그러나 고급 커링을 구현하여 func(a)(b, c)와 같이 다중 인자를 넘겨 호출할 수 있도록 구현도 가능하다.

function curry(func) {
  return function curried(...args) {
    if (args.length >= func.length) {
      return func.apply(this, args);
    } else {
      return function(...args2) {
        return curried.apply(this, args.concat(args2));
      }
    }
  };
}

const sum = (a, b, c) => a + b + c;

let curriedSum = curry(sum);
console.log(curriedSum(1, 2, 3)); // 기존 함수처럼도 사용가능
console.log(curriedSum(1)(2, 3)); // 첫 번째 인자만 커링
console.log(curriedSum(1)(2)(3)); // 모두 커링

혹은 간단하게 lodash를 이용하여 구현할 수도 있다.

import _ from 'lodash';

let sum = (a, b, c) => a + b + c;

let curriedSum = _.curry(sum);
console.log(curriedSum(1, 2, 3)); 
console.log(curriedSum(1)(2, 3)); 
console.log(curriedSum(1)(2)(3)); 

주의할 점

클로저를 사용하면 내부함수가 외부함수의 변수를 참조하고 있기 때문에 외부함수의 실행이 끝나더라도 가비지 콜렉터에 의해 메모리가 해제되지 않는다.

이 때, 클로저를 할당한 변수에 null을 할당해줌으로써 메모리를 해제시킬 수 있다.

const outerFunc = () => {
		let x = 10;

		// 클로저
		const innerFunc = (y) => {
				x = x + y;
				console.log(x);
		}

		return innerFunc;
}

let addFunc = outerFunc();
addFunc(5);
addFunc = null;
profile
노션에 더욱 깔끔하게 정리되어있습니다. (하단 좌측의 홈 모양 아이콘)

2개의 댓글

comment-user-thumbnail
2024년 1월 19일

클로저에 관해 궁금한 점이 많았는데 자세한 설명 감사드립니다. ^^

답글 달기
comment-user-thumbnail
2024년 5월 21일

이해가 너무 잘돼요! 기가막히네요

답글 달기