자바스크립트/개념

자바스크립트 클로저 (Closure) 란 무엇인가?

Jake Seo 2022. 7. 13. 17:39

개요

Closure 란, Lexical Scope 개념에서 스코핑 개념을 조금 더 확장시킨 것이다.

외부 함수가 값을 반환했음에도 Lexical Scope 에 의해 내부 함수에서 외부 함수 스코프에 존재하는 변수에 접근 가능하다는 것이 핵심이다.

클로저 예제 코드 1: 기본 개념

function outerFunction() {
  const outerVariable = "Mozilla";

  function innerFunction() {
    console.log(outerVariable);
  }

  return innerFunction;
}

const myFunc = outerFunction();

myFunc();
  • innerFunction() 에서는 outerFunction() 스코프에 존재하는 outerVariable 을 출력(console.log())하려 한다.
  • Lexical Scope 에 의해 내부 함수에서 외부 함수의 변수 값에 접근하는 것은 아무런 문제가 없다.
  • 위 코드에서 눈여겨봐야 할 것은 외부 함수가 반환된 이후에도 외부 함수에 선언되었던 변수에 여전히 접근 가능하다는 것이다.
  • outerFunction() 은 소위 말하는 Function Factory 형태로 구현된 함수이다.
  • 코드를 직접 실행시켜보면 결과를 알 수 있다.

클로저 예제 코드 2: 파라미터를 이용한 클로저

function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

파라미터도 Function Scope 에 있는 변수처럼 접근 가능하기 때문에 변수 선언을 하지 않고 이를 이용해서도 클로저 코드를 만들 수 있다.

function makeSizer(size) {
  return function () {
    document.body.style.fontSize = `${size}px`;
  };
}

const size12 = makeSizer(12);
const size14 = makeSizer(14);
const size16 = makeSizer(16);

/*
document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;

<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>
*/

콜백 함수의 함수 팩토리 형태로 만들어보았다.

클로저 예제 코드 3: 자바의 private 멤버 따라하기

const makeCounter = function () {
  let privateCounter = 0;

  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment() {
      changeBy(1);
    },

    decrement() {
      changeBy(-1);
    },

    value() {
      return privateCounter;
    },
  };
};

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1.value()); // 0.

counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2.

counter1.decrement();
console.log(counter1.value()); // 1.
console.log(counter2.value()); // 0.
  • 위의 makeCounter() 함수는 3가지의 메서드를 가진 오브젝트를 반환하는데, 3개의 메서드 모두 Lexical Scoping 에 의해 privateCounter 변수 값에 접근이 가능하다.
  • 오직 반환된 오브젝트 내부에 있는 3개의 메서드에 의해서만 접근이 가능하다.
  • 별 것 아닌 것 같은 일이어도 변수를 private 화 할 수 있다는 것은 설계상 매우 엄청난 일이다.
    • 누군가가 privateCounter 변수값을 직접 바꿀 수 있다는 위협 없이 안정적으로 코드를 작성해나갈 수 있다.

여태까지의 예제 코드로 본 클로저의 장점 정리

  • 사실 대부분 객체지향 프로그래밍에 관련된 장점이다.
  • 데이터 은닉과 캡슐화가 용이해진다.

클로저 스코프 체인

클로저는 3개의 스코프가 있다.

  • 로컬 스코프
  • 인클로징(Enclosing) 스코프 (ex. 블록, 함수, 모듈 스코프 등)
  • 글로벌 스코프
// 글로벌 스코프 (global scope)
const e = 10;
function sum(a) {
  return function (b) {
    return function (c) {
      // 인클로징 스코프, 외부 함수 스코프 (outer functions scope)
      return function (d) {
        // 로컬 스코프 (local scope)
        return a + b + c + d + e;
      };
    };
  };
}

console.log(sum(1)(2)(3)(4)); // log 20

익명 함수를 빼면 아래와 같다.

// global scope
const e = 10;
function sum(a) {
  return function sum2(b) {
    return function sum3(c) {
      // outer functions scope
      return function sum4(d) {
        // local scope
        return a + b + c + d + e;
      };
    };
  };
}

const sum2 = sum(1);
const sum3 = sum2(2);
const sum4 = sum3(3);
const result = sum4(4);
console.log(result); //log 20
function outer() {
  const x = 5;
  if (Math.random() > 0.5) {
    const y = 6;
    return () => console.log(x, y);
  }
}

outer()(); // logs 5 6

모듈 예제 코드

// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
  x = val;
};
import { getX, setX } from "./myModule.js";

console.log(getX()); // 5
setX(6);
console.log(getX()); // 6

이렇게 모듈을 이용해도, 같은 영역을 공유하기 때문에 모듈 내부에 있는 전역 변수의 레퍼런스를 유지할 수 있다.

// myModule.js
export let x = 1;
export const setX = (val) => {
  x = val;
};
// closureCreator.js
import { x } from "./myModule.js";

export const getX = () => x; // Close over an imported live binding
import { getX } from "./closureCreator.js";
import { setX } from "./myModule.js";

console.log(getX()); // 1
setX(2);
console.log(getX()); // 2

이렇게 Getter 와 Setter 가 다른 모듈 안에 있어도 가능하다.

클로저로 인해 벌어질 수 있는 실수

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email" /></p>
<p>Name: <input type="text" id="name" name="name" /></p>
<p>Age: <input type="text" id="age" name="age" /></p>
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function () {
      showHelp(item.help);
    };
  }
}

setupHelp();

JSFiddle 에서 실행해보기

위 코드만 보고 힌트없이 코드에서 발생하는 문제점을 찾는다면, var 키워드를 이용해 코딩한 경험이 많은 사람일 것이다.

위 코드에서의 핵심적인 문제를 짚자면, onfocus 에 할당된 익명 함수에 들어있는 item.help 의 평가 시점이 콜백함수를 할당할 때가 아니라 실제로 포커스가 들어갔을 때이기 때문이다.

풀어서 설명하자면, onfocus 에 할당한 모든 콜백 메서드는 (원치 않았겠지만) 클로저(closure)이다. var 로 선언된 helpText 는 함수가 끝난 뒤에도 콜백함수 내부에서 참조하고 있기 때문에 스코프가 계속 기억된다.

그래서 콜백함수가 모두 할당된 이후 i 의 값이 증가하면서 다음 루프로 넘어갈 때 showHelp(item.help)item.help 는 스코프가 끝나지 않으므로 계속 같은 메모리 값을 참조하게 된다.

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function () {
      showHelp(item.help);
    };
  }

  console.log("item", item);
}

스코프가 지워지지 않으므로, 위의 코드에서의 console.log(item) 도 정상적으로 마지막으로 사용했던 item 을 출력하게 된다.

결국 item.help 는 모두 같은 메모리 주소를 참조하게 되므로 의도한대로 동작하지 않게 되는 것이다. 해결 방법은 그냥 item 을 선언할 때 const item 으로 선언하는 것 하나만으로 가능하다.

혹은 다른 스코프를 만들어 즉시 평가되도록 하여도 된다.

// ...

for (var i = 0; i < helpText.length; i++) {
  var item = helpText[i];
  document.getElementById(item.id).onfocus = ((txt) =>
    function () {
      showHelp(txt);
    })(item.help);
}

// ...

이렇게 코드를 작성하면, item.help 가 아닌 txt 라는 파라미터의 스코프를 따르기 때문에 파라미터의 스코프는 함수가 끝나면서 만료되어 기존의 item 처럼 계속 남아있지 않는다.

더 간단히 수정하면 아래와 같이 코드를 바꾸어도 스코프가 나뉜다.

function showHelp(help) {
  document.getElementById("help").innerHTML = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];

    setCallback(item.id, item.help);
  }
}

function setCallback(id, txt) {
  document.getElementById(id).onfocus = function () {
    showHelp(txt);
  };
}

결국 이 모든게 lexical scope & closure 라는 개념 때문에 생긴 버그이다.

성능을 위해 고려해봄직한 것

위에서도 잘 나와있다시피 함수는 자신의 스코프와 클로저까지 관리하기 때문에 이러한 클로저를 불필요하게 늘리는 것은 메모리 낭비이자 속도 저하의 원인이 될 수 있다.

새로운 오브젝트나 클래스를 만들 때는 생성자보다는 prototype 을 고려하자.

생성자에 메서드를 두게 되면 오브젝트나 클래스를 생성할 때마다 메서드가 재할당되어버린다.

나쁜 예제

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();

  this.getName = function () {
    return this.name;
  };
  this.getMessage = function () {
    return this.message;
  };
}

클로저 때문에 매 클래스 생성 시에 함수에서 namemessage 의 스코프를 저장한다. 클로저를 사용하지 않는 방향으로 개선하는 것이 좋다.

개선된 예제

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName() {
    return this.name;
  },
  getMessage() {
    return this.message;
  },
};

더이상 각 오브젝트/클래스의 namemessage 스코프를 기억하진 않지만, 프로토 타입을 재정의하는 것은 권장되지 않는다.

좋은 예제

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function () {
  return this.name;
};
MyObject.prototype.getMessage = function () {
  return this.message;
};
  • 모든 MyObject 에서 동일하게 사용된다.
  • 오브젝트 생성 시마다 스코프를 기억하지 않는다.
  • 프로토타입을 재정의하지 않는다.

레퍼런스

MDN - Closures

반응형