Javascript

[코어 자바스크립트] 클로저

minjaem 2024. 3. 2. 15:15

클로저?

클로저를 설명하는 단어는 여럿 있지만, 저자는 클로저를 다음과 같은 정의로 설명한다.

클로저란 어떤 함수에서 선언한 변수를 참조하는 내부함수를 외부함수로 전달할 경우, 함수의 실행 컨텍스트가 종료된 후에도 해당 변수가 사라지지 않는 현상이다.

내부함수를 외부로 전달하는 방법에는 함수를 return하는 경우뿐 아니라 콜백으로 전달하는 경우도 포함된다.

 

이런 정의로, 정확하게 이해되지 않는다. 예시를 보자.

예제 1.

var outer = function () {
    var a = 1;
    var inner = function () {
    	console.log(++a);
    }
    inner();
}
outer(); // 2
outer(); // 2

inner 함수 내부에서는 a를 선언하지 않았기 때문에 environmentRecord에서 값을 찾지 못하므로 outerEnvironmentReference에 지정된 상위 컨텍스트인 outer의 LexicalEnvironment에 접근해서 다시 a를 찾는다. 이후, outer 함수의 실행 컨텍스트가 실행되고 종료되면 LexicalEnvironment에 저장된 식별자들(a, inner)에 대한 참조를 지운다. 그러면 각 주소에 저장돼 있떤 값들은 자신을 참조하는 변수가 하나도 없게 되므로 가비지 컬렉터의 수집 대상이 된다.

예제 2.

var outer = function () {
    var a = 1;
    var inner = function () {
    	return a++;
    }
    return inner;
}

var outer2 = outer();
outer2() // 2
outer2() // 3

이번엔 결과 값이 달라졌다. outer 함수의 실행 컨텍스트가 실행되고 종료됐음에도 불구하고 outer2 변수를 실행 시킬때마다 outer 함수의 실행 결과인 내부 inner 함수를 참조한다.

inner 함수의 실행 시점에는 outer 함수는 이미 실행이 종료된 상태인데, outer 함수의 LexicalEnvironment에 어떻게 접근 할 수 있을까?

가비지 컬렉터의 동작 방식에 그 이유가 있다.

가비지 컬렉터는 어떤 값을 참조하는 변수가 하나라도 있다면 그 값은 수집 대상에 포함시키지 않는다.

예제 2를 보면 outer 함수는 실행 종료 시점에 inner 함수 자체를 반환한다.(함수를 실행시키지 않는다.) 외부 함수인 outer의 실행이 종료되더라도 내부 함수인 inner 함수는 언젠가 outer2를 실행함으로써 호출될 가능성이 열렸기 떄문에, 언젠가 inner 함수의 실행 컨텍스트가 활성화되면 outerEnvironmentReference가 outer 함수의 LexicalEnvironment를 필요로 할 것이므로 수집 대상에서 제외된다. 그 덕에 inner 함수가 이 변수에 접근에 가능한 것이다.

 

클로저와 메모리 관리

클로저의 본질적인 특성은 의도된 메모리 소모이기 때문에 '메모리 누수'라는 표현은 맞지 않다.

그렇다면, '메모리 소모' 관리 방법은 어떻게 해야할까?

클로저는 어떤 필요에 의해 의도적으로 함수의 지역변수를 메모리를 소모하도록 함으로써 발생한다. 그렇다면 그 필요성이 사라진 시점에는 더는 메모리를 소모하지 않게 해주면 된다.

참조 카운트를 0으로 만들면 언젠가 GC가 수거해갈 것이고, 이때 소모됐던 메모리가 회수될 것이다. 참조 카운트를 0으로 만드는 방법은?

식별자에 참조형이 아닌 기본형 데이터(보통 null이나 undefined)를 할당하면 된다.

// 예시

var outer = function () {
    var a = 1;
    var inner = function () {
    	return a++;
    }
    return inner;
}

outer = null;

 

클로저의 접근 권한 제어(정보 은닉)

정보 은닉은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서 모듈간의 결합도를 낮추고 유연성을 높이고자 하는 개념이다.

흔히 접근 권한에는 public, private, protected의 세 종류가 있습니다.

각 단어의 의미 그대로 public은 외부에서 접근 가능한 것이고, private은 내부에서만 사용하며 외부에 노출되지 않는 것을 의미한다.

자바스크립트는 기본적으로 변수 자체에 이러한 접근 권한을 직접 부여하도록 설계되어있지 않으나, 클로저를 이용해서 public한 값과 private한 값을 구분하는 것이 가능하다.

 

다시 위로 올라가서, 클로저 예제 2번을 확인해보자.

outer 함수를 종료할 때 inner 함수를 반환함으로써 outer 함수의 지역 변수인 a의 값을 외부에서도 읽을 수 있게 되었다. 이처럼 클로저를 활용하면 외부 스코프에서 함수 내부의 변수들 중 선택적으로 일부의 변수에 대한 접근 권한을 부여할 수 있다.

바로, return을 활용해서

return을 이용해서 접근 권한을 부여할 수 있다?

외부에서는 오직 outer 함수가 return한 정보에만 접근할 수 있다. 즉, return 값이 외부에 정보를 제공하는 유일한 수단인 것이다.

그러니까 정리하자면, 외부에 제공하고자 하는 정보들을 모아서 return하고, 내부에서만 사용할 정보들은 return하지 않는 것으로 접근 권한 제어가 가능해지는 것이다.

즉, return한 변수들은 공개 멤버(public)가 되고, 그렇지 않은 변수들은 비공개 멤버(private)가 되는 것이다.

 

커링 함수

커링 함수란 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수로 나눠서 순차적으로 호출될 수 있게 체인 형태로 구성한 것.

그러니까, 커링은 한 번에 하나의 인자만 전달하는 것을 원칙으로 중간 과정상의 함수를 실행한 결과는 그다음 인자를 받기 위해 대기만 한다. 또한, 마지막 인자가 전달되기 전까지는 원본 함수가 실행되지 않는다.

예제 1.

var curry3 = function (func) {
    return function (a) {
        return function (b) {
            return func(a,b)
        }
    }
}

var getMaxWith10 = curry3(Math.max)(10);
console.log(getMaxWith10(8)); // 10
console.log(getMaxWith10(12)); // 12

커링함수가 헷갈린다면 다음과 같이 쉽게 이해해보자,

curry5로 선언한 함수는 총 3개의 인자를 받는다. func로 이루어진 함수 인자 1개와 이 함수의 인자로 사용될 a,b 라는 인자 2개.

호출할 때는 인자를 1개 1개씩 넘기는 것이다.

즉, getMaxWith10은 이미 func에 Math.max 이라는 함수 인자를, a에는 10이라는 숫자 타입의 인자를 넘긴 것이다. 그러면 이제 b라는 인자를 기다리고 있기만 하면 된다.

그러니 getMaxWith10(8)으로 함수 인자 b에 해당하는 값을 8을 넘겨 호출함으로써, 커링함수가 실행된다.

그러니 아래와 같이 작성해도 결과 값은 같다.

var curry3 = function (func) {
    return function (a) {
        return function (b) {
            return func(a,b)
        }
    }
}

var getMaxWith10 = curry3(Math.max);
console.log(getMaxWith10(10)(8)); // 10
console.log(getMaxWith10(10)(12)); // 12

예제 2.

var curry5 = function (func) {
    return function (a) {
        return function (b) {
            return function (c) {
                return function (d) {
                    return function (e) {
                        return func(a,b,c,d,e);
                    }
                }
            }
        }
    }
}

var getMax = curry5(Math.max);
console.log(getMax(1)(2)(3)(4)(5)); // 5

이번에는 함수를 5개 받아 처리했는데, 너무 길어 가독성이 좋지 않다. ES6에서 화살표 함수를 써서 단 한줄로 변경할 수 있다.

var curry5 = func => a => b => c => d => e => func(a,b,c,d,e);

 

커링 함수는 각 단계에서 받은 인자들을 모두 마지막 단계에서 참조할 것이므로 GC되지 않고 메모리에 차곡차곡 쌓였다가, 마지막 호출로 실행 컨텍스트가 종료된 후에야 비로소 한꺼번에 GC의 수거 대상이 된다.

이 커링함수는 지연 실행(lazy execution)을 할 때 매우 유용하다.

지연 실행이란, 당장 필요한 정보만 받아서 전달하고 또 필요한 정보가 들어오면 전달하는 식으로 하면 결국 마지막 인자가 넘어갈 때까지 함수 실행을 미루는 셈이 되는데, 결국 원하는 시점까지 지연시켰다가 실행을 시키는 것이 필요한 상황에서 유용한 것이다.

예제

var getInformation = function (baseUrl) {
	return function (path) {
    	return function (id) {
        	return fetch(baseUrl + path + '/' + id);
        }
    }
}

// ES6
var getInformation = baseUrl => path => id => fetch(baseUrl + path + '/' + id);

 

보통 REST API를 이요할 경우 baseUrl은 몇개로 고정되지만 나머지 path나 id 값은 매우 많은 경우가 많다. 이런 상황에서 서버에 정보를 요청할 필요가 있을 떄마다 매번 baseUrl부터 전부 기입해주기보다는 공통적인 요소는 먼저 기억시켜두고 특정한 값(id)만으로 서버 욫어을 수행하는 함수를 만들어두는 ㅍ녀이 개발 효율성이나 가독성 측면에서 더 좋을 것이다.

var imageUrl = 'https://imageAddress.com/';
var productUrl = 'https://productAddress.com/';

// 이미지 타입별 요청 함수 준비
var getEmoticon = getImage('emoticon'); // https://imageAddress.com/emoticon
var getIcon = getImage('icon'); // https://productAddress.com/icon

// 제품 타입별 요청 함수 준비
var emoticon10 = getEmoticon(10) // https://imageAddress.com/emoticon/10
var icon10 = getIcon(10) // https://productAddress.com/icon/10

 

 

*정리하며 생각해볼 질문

 

1. 클로저에 대해 설명해주세요.

  • 클로저는 렉시컬 스코프 밖에서 실행되더라도, 렉시컬 환경을 기억하고 참조할 수 있는 함수입니다.
  • 조금 더 자세히 설명하면, 함수 내부에서 사용하는 변수나 함수들은 함수가 선언된 시점의 스코프에서 결정되는데, 이걸 렉시컬 스코프라고 합니다.
  • 일반적인 함수는 실행이 종료되면 더이상 참조할 수 없는데, 클로저는 렉시컬 환경을 기억하고 참조할 수 있습니다.

2. 클로저의 장점을 설명해주세요.

  • 클로저를 사용하면, 클로저가 어떤 함수 내부의 어떤 값들을 참조하고 있다면, 해당 함수의 생명주기가 종료되더라도 렉시컬 환경을 통해 참조할 수 있습니다.
  • 이를 통해 은닉화와 캡슐화 효과를 얻을 수 있습니다. 함수 내부 변수를 접근하는 클로저를 생성하면, 그 함수는 클로저를 통해서가 아니면 더이상 내부에 접근할 수 없기 때문입니다.

3. 클로저는 단점이 없나요?

  • 일반적인 함수는 실행이 종료되면 가비지 컬렉션에 의해 메모리에서 해제됩니다.
  • 하지만 클로저를 사용하면 사용이 종료된 함수 내부에 렉시컬 환경에 의해 참조가 가능하므로 메모리에서 해제할 수 없기 때문에 메모리를 사용하게 됩니다.

4. 클로저를 예를 들어줄 수 있나요? (클로저를 사용해본 경험이 있나요?)

  • 학부 수업에서 배운 커링 기법을 자바스크립트로 구현할 때 클로저를 사용했습니다.
  • 그리고 자바스크립트 라이브러리로 리액트를 주로 사용하는데, 리액트의 setState와 같은 훅을 클로저를 통해 구현할 수 있습니다.
function sum(x,y) {
	return x+y;
}

// 변환 후
function sum(a) {
	return (b) => {
		return a+b;
	}
};
const add2 = sum(2);
const five = add2(3); // 2+3
  • sum 함수의 파라미터가 2개인 것을 커링 기법을 적용해서 파라미터를 분리했습니다.
  • 클로저의 특성으로 sum이 반환하는 함수는 a 값을 기억하게 되고, add2 함수와 같이 사용할 수 있습니다.
const MyReact = (function(){
	let _state;
	function useState(initialValue) {
		const state = _state || initialValue;
		const setState = (newState) => {
			_state = newState;
		}
		return [state, setState];
	}
	return { useState };
})();
  • 리액트의 state 는 초기값을 지정한 이후에 다시 렌더링이 되서 함수가 호출되더라도 초기값으로 초기화되지 않습니다. 그리고 useState로 반환된 state를 직접 변경해도 실제 상태는 변하지 않습니다.
  • 이걸 _state 값을 두고, useState 를 클로저로 선언해서 구현할 수 있습니다.