본문 바로가기

Client/Front-end

<JS> 클로저에 대한 고찰 (소개 / 활용 / 단점 / 메모리)

1. 소개

* 배경

 - 최근 기업 면접을 다니며 가장 자주 받은 질문 중 하나가 클로저에 대한 질문이었다. 간단하게만 묻고 넘어가는 기업도 있었지만 내가 어디까지 생각해봤는지 물어보는 기업도 있었다. 그래서 클로저가 무엇인지부터 시작해서 깊이 있게 고찰해보려 한다.

* 클로저란

 - 클로저는 외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미한다. 자바스크립트의 렉시컬 환경은 외부 렉시컬 환경을 가리키는 outer 가 존재한다.

 

 - 알다시피 자바스크립트는 렉시컬 스코프를 따르므로 식별자가 현재 스코프에 존재하지 않으면 선언된 위치를 기준으로 외부 환경에서 해당 변수를 찾는다. 

 

 - 결과적으로 자바스크립트의 함수는 모두 클로저이다.

 

function func() {
  const x = 10;
  return function() {
    console.log("x값은 :", x)
  }
}
const myFunc = func();

myFunc(); // x값은 : 10

 

 - 위 코드에서 myFunc 라는 변수에 func의 반환값인 새로운 함수를 담았다. 그 후 호출했더니 x값이 없음에도 외부 변수였던 x를 기억하고 10을 보여준다.

 

2. 활용

* 콜백 함수

 - 타이머 함수와 같은 콜백 함수를 받는 형태의 함수에서 인자를 넘기고 싶었던 경우가 있을 것이다. 이 때 클로저를 이용하여 함수를 넘기면 인자를 넘겨줄 수 있다.

 

function sum(a, b) {
  console.log(a + b);
}

function getClosure(func, ...rest) {
  return function fn() {
    func(...rest);
  };
}

const myFunc = getClosure(sum, 10, 20);
setTimeout(myFunc, 1000);

// 30

 

 - getCloure을 이용하여 fn이라는 클로저를 만들 수 있도록 하였다. 인자를 함수 뒤에 받아서 인자를 기억하도록 하였다.

 

 - myFunc에 원하는 함수와 인자를 넣어 할당하였다. 이제 myFunc는 인자를 필요로 하지않고 setTimeout에 인자로 넣어 활용할 수 있게 되었다.

 

* 정보 은닉

 - 클로저를 통해 객체 지향에서의 private 속성을 만들 수 있다. private속성은 외부에서 임의로 해당 속성의 값을 변경하지 못하도록 하는 것이다. 객체를 통해 먼저 필요성을 알아보자.

 

const myObj = {
  count: 0,
  up() {
    this.count++;
    console.log(this.count);
  },
};

 

 - 위는 count라는 속성을 갖고 있고, up 메서드를 통해 카운트를 올리는 객체이다. 

 

myObj.up(); // 1
myObj.up(); // 2
myObj.up(); // 3
myObj.count = 0;
myObj.up(); // 1

 

 - up을 호출할 때마다 숫자가 올라가는 것을 확인할 수 있다. 하지만 객체 내부 프로퍼티인 count에 직접 접근하여 숫자를 임의로 변경시킬 수 있다. 만약 악의적인 코드나 실수로 외부에서 내부 프로퍼티를 조작하면 프로젝트에 손상을 줄 수도 있는 것이다.

 

 - 이 때 필요한 것이 정보 은닉이다. 클래스에서 Private 속성처럼 클로저를 이용하여 이를 구현해보자.

 

function counter() {
  let count = 0;
  return {
    up() {
      count++;
      console.log(count);
    },
  };
}

const myCount1 = counter();
const myCount2 = counter();

 

 - 위 코드를 보면 counter 내부에 count라는 값이 정의되어 있다. 반환하는 up이 클로저가 된다. 물론 객체 프로퍼티이므로 up외에 다른 함수도 추가로 작성할 수 있다.

 

 - myCount 에 클로저를 할당하고 위에서 만들었던 객체처럼 사용할 수 있다.

 

const myCount1 = counter();
const myCount2 = counter();
myCount1.up(); // 1
myCount1.up(); // 2
myCount1.up(); // 3
myCount2.up(); // 1
myCount2.up(); // 2

 

 - myCount1 과 myCount2가 별개의 객체처럼 동작하며 up메서드를 호출할 수 있다는 것을 확인할 수 있다. 여기서 먼저 드는 궁금증은 왜 같은 count를 공유하지 않을까. counter라는 클로저를 반환하는 함수를 호출할 때마다 새로운 유효범위 체인과 새로운 내부변수 count가 생성되기 때문이다.

 

 - 그렇다면 다시 처음으로 돌아와서 count에 접근한다면 어떻게 될까

 

const myCount1 = counter();

myCount1.up(); // 1
myCount1.up(); // 2
myCount1.up(); // 3
myCount1.count = 0;
myCount1.up(); // 4

 

 - myCount1.count = 0; 으로 새로운 프로퍼티를 추가하였다. 하지만 up메서드는 기존에 참조하던 외부환경의 count를 여전히 사용하여 원하는 결과를 출력하는 것을 볼 수 있다. 이와 같은 정보은닉을 통해 목표했던대로 외부로부터의 오염을 방지할 수 있다.

 

* 커링 함수

 - 클로저를 사용한 대표적인 예로 커링함수를 들 수 있다. 여러 개의 인자를 받아야하는 함수의 경우 인자를 필요에 따라 하나씩 받아서 호출할 수 있도록 한다.

 

const curry = (func) => (x) => (y) => func(x, y);

const myCurry = curry(sum);
const plusTen = myCurry(10);
plusTen(5); // 15
plusTen(10); // 20

 

 - 위에서 봤었던 sum함수를 예로 인자를 하나씩 받아서 활용한 예시코드이다. 10이라는 인자만 받아서 plusTen에 할당하였고, 필요에 따라 5와 10을 호출하여 10에 더할 수 있도록 하였다.

 

 - 커링함수는 주로 지연 실행에 사용된다. 가장 대표적인 예시가 미들웨어이다.

 

// redux-thunk
function thunkMiddleware({ dispatch, getState }) {
  return (next) => (action) => 
    typeof action === 'function' ? action(dispatch, getState) : next(action);
}

 

 - 위는 redux-thunk의 코드이다. (현재는 타입스크립트가 적용되면서 코드가 조금 변경되었다.)  action이 들어오기 전까지 함수의 호출을 지연시킨 후 action을 받을 때마다 처리하도록 구현하였다.

 

3. 단점

 - 위 글을 읽으며 드는 생각이 있을 것이다. 단점이 무엇일까? 먼저 가볍게 드는 생각은 주입받기 때문에 가독성이 떨어지지 않을까 하는 문제이다. 하지만 이 부분은 설계에서 조금 더 고민하고 일관된 규칙으로 코드를 작성하고 추상화한다면 일부 해결될 것으로 보인다.

 

 - 그렇다면 다른 문제는 무엇이 있을까. 필자는 총 두 가지라고 생각한다. 자바스크립트의 영원한 난제 this와 생각하기 싫은 메모리 누수이다.

 

 - 먼저 this에 대해 생각해보자. 사실 this는 단점이라기 보다는 주의점에 가깝다. 아래의 코드를 먼저 읽어보자.

 

const myObj = {
  outer() {
    console.log(this);
    return function () {
      console.log('this :', this);
    };
  },
};

const test = myObj.outer(); // myObj
test(); // window

 

 - this를 확인하기위해 myObj라는 임의의 객체를 만들었다. outer는 클로저 함수를 반환한다. 반환된 클로저 함수를 test에 반환하고 실행했더니 this가 윈도우를 가르킨다. 사실 이 부분은 this의 동작원리와 클로저에 대해 제대로 알고 있다면 당연할 수도 있다. 

 

 - 그렇다면 의도한대로 window가 아니라 myObj를 출력하려면 어떻게 해야할까. 이는 클로저의 정의를 다시 생각해보면 쉽게 해결할 수 있다. 클로저는 외부 변수를 기억한다고 했다. this는 애초에 외부변수가 아니다. 따라서 outer 에 새로운 지역변수를 만들어주기만 하면 된다.

 

const myObj = {
  outer() {
    console.log(this);
    const closureThis = this;
    return function () {
      console.log('this :', closureThis);
    };
  },
};

const test = myObj.outer(); // myObj
test(); // myObj

 

 - 위처럼 지역변수에 저장하여 클로저가 접근할 수 있도록 하면 의도대로 동작할 수 있다.

 

 - 다음은 메모리 문제이다. 내부에서 외부 변수를 참조하면서 가비지컬렉션이 일어나지 않게될 것이기 때문이다. 물론 이를 '메모리 누수'라고 하기엔 무리가 있다. 왜냐하면 클로저는 개발자가 의도적으로 참조를 만들어 냈기 때문이다. 하지만 그럼에도 불구하고 메모리를 해제할 필요가 있다면 어떻게 해야할까? 

 

4. 메모리 해제

* 가비지컬렉션

 - 위에서 가비지 컬렉션이 일어나지 않을 것이라고 했는데 왜 그럴까? 이는 가비지컬렉션의 동작방식때문이다.

 

 - 먼저 가비지 컬렉션에 대해 알아보자. 코드 실행시 객체가 생성되면 자동으로 힙 메모리를 할당하게 된다. 이것이 쓸모 없어졌을 때 자동으로 해제 시켜주는 기능을 가비지컬렉션이라고 한다.

 

 - 자바스크립트 엔진은 직간접적으로 참조되지 않은 객체들을 메모리에서 해제하여 메모리 공간을 확보하는 방식으로 동작한다. 만일 자세히 알고 싶다면 V8 엔진의 가비지컬렉션과 관련된 내용을 찾아보길 바란다.

* 메모리 해제 (외부)

 - 결과적으로 참조되고 있다면 메모리에 계속 남게되므로, 사용하지 않을 때 참조를 끊어준다면 메모리 해제가 가능하다. 

 

 - 가장 쉬운 방법은 쓰이지 않는 시점에 외부에서 null이나 undefined를 할당하는 방법이다.

 

function getClosure() {
  let count = 0;
  return function () {
    count++;
    console.log(count);
  };
}
let closure = getClosure();
closure(); // 1
closure(); // 2
closure = null;

 

 - closure 가 더 이상 필요없어졌을 때 위와 같이 메모리를 쓰지 않도록 변경한다.

 

* 메모리 해제 (내부)

 - 두 번째 방식은 클로저 함수 내부에서 메모리를 해제하는 방법이다. 먼저 타이머함수를 활용한 방식이다. 내부적으로 더 이상 쓰이지않을 경우를 대비하여 null이나 undefined를 할당하여 메모리를 해제한다.

 

function getClosure() {
  let count = 0;
  let interval;
  let inner = function () {
    count++;
    console.log(count);
    if (count >= 5) {
      clearInterval(interval);
      inner = null;
    }
  };
  interval = setInterval(inner, 1000);
}
getClosure();

 

 - setInterval로 실행되어 count라는 외부 변수를 참조하고 있으나 count가 5가 되었을 때 메모리를 해제하도록 하였다. 

 

 - 이와 같은 방식으로 이벤트 리스너에서도 활용할 수 있다.

 

function getClosure() {
  const btnElem = document.querySelector('.my-button');
  let count = 0;

  let onClick = function () {
    count++;
    console.log(count);
    if (count >= 5) {
      btnElem.removeEventListener('click', onClick);
      onClick = null;
    }
  };
  btnElem.addEventListener('click', onClick);
}
getClosure();

 

 - 타이머에서와 사실상 동일하다. 버튼에 클릭핸들러를 달아서 클릭마다 카운트가 오르도록 하였다. count가 5가 되었을 때, 버튼에서 핸들러를 제거하고 함수를 null로 바꾸면서 메모리를 해제하였다.

 

* 메모리 해제 (약한 참조)

 - 세번째 방법은 약한 참조를 활용한 방식이다. 약한 참조는 ES2021 에 새로 등장한 문법이다. ES2021에 관한 내용은 필자의 다른 글을 참고해주면 감사하겠다. 약한 참조는 가비지 컬렉터가 돌 때, 참조를 유지하지 않겠다는 의미로 사용한다.

 

function getClosure() {
  const obj = { count: 0 };
  const weakCount = new WeakRef(obj);
  return function () {
    console.log(weakCount.deref().count++);
  };
}
const closure = getClosure();
const interval = setInterval(closure, 100);

 

 - WeakRef는 객체타입만 받을 수 있으므로 count를 객체로 만들었다. 그후 약한 참조 객체를 새로 만들었다. 

 

 - 내부 함수는 obj를 참조하는 것이 아닌 weakCount를 갖고 있다는 점을 유의하자. 그렇다면 obj는 사라져야하지만 weakCount가 참조하고 있으므로 당장은 사라지지 않는다.

 

 - 참고로 deref메서드는 약한 참조를 읽는 메서드이다. 위를 실행하면 다음과 같이 동작한다.

 

 

 - count 프로퍼티를 가진 객체를 찾을 수 없다는 것을 보아 어느 시점에 가비지 컬렉션에 의해 정리되었음을 알 수 있다. 위에서 봤던 방식들에 비해 좀 더 세련된 방식이라고 여겨질 수 있지만 큰 단점이 있다. 가비지 컬렉션이 동작하는 시간이나 방식은 환경에 따라 천차만별이다. 즉 예측이 불가능하다. 당장 위 코드를 크롬 콘솔창에 다시 입력하더라도 66을 찍고 멈추지 않을 것이다.

 

 - 특별한 상황이 아니라면 약한 참조의 사용은 권장하지 않는다.

 

 - 이처럼 클로저는 의도적으로 제거하여 메모리를 해제할 수 있다. 하지만 V8엔진 성능이 매우 좋아졌고, 가비지컬렉션 성능 또한 덩달아 향상되면서 클로저의 메모리 누수가 심각하지 않다면 굳이 제거할 필요는 없다고 느껴진다. 오히려 불필요한 로직이 추가될 뿐만 아니라 의도적인 null, undefined의 삽입이 유지보수측면에서도 좋지 않을 것이기 때문이다.

 

 

 


참고

 

 

변수의 유효범위와 클로저

 

ko.javascript.info

 

 

javascript 클로저(closure)의 활용(2편)

클로저(closure)의 활용본 글은 독자가 자바스크립트 클로저의 개념에 대한 이해를 전제하에 작성된 글이다. 자바스크립트의 클로저의 개념에 대해서는 공부를 했지만, 막상 이 클로저라는 것을

www.hanumoka.net

 

 

[JS] 클로저와 클로저의 활용

본 포스팅은 자바스크립트 스터디 세미나를 위해 포스팅 하였고,"코어 자바스크립트" 책을 참고하여 작성되었습니다😊클로저는 쓰고 안쓰고의 개념이 아닌, 관계 를 이야기 합니다.결과를 예

velog.io

 

 

Javascript - Closure (클로저) 에 대해

- 클로저에 대한 이해 클로저는 여러 함수형 프로그래밍 언어에서 등장하는 보편적인 특성입니다. 바로 코드 예시를 보도록 하겠습니다. const outer = function () { let a = 1; const inner = function () { con..

grepper.tistory.com

 

 

[JS]클로저(Closure)와 메모리 관리

[JS]클로저(Closure)와 메모리 관리

velog.io