클로저는 자바스크립트를 사용할 때 알아야 하는 개념 중 하나이다.
이해는 하고 있으나 설명하려고 하면 막막해지는 그런 개념이었다. 개념과 쓰임새를 다시 익히며 알아보려고 한다.
목차
1. 클로저란?
2. 렉시컬스코프
3. 클로저의 예시
- 클로저로 private methods 모방하기
4. 클로저 스코프체인
5. 과거 var 사용으로 인한 혼란
6. 성능관련 고려사항
1. 클로저란?
'A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment)' (MDN)
클로저는 함수와 선언된 함수의 Lexical environment의 조합이다. 우리는 클로저를 통해서 내부 함수에서 외부 함수의 스코프에 접근할 수 있다. 자바스크립트에서는 함수가 생성될 때마다 클로저가 생성된다.
이 개념을 잘 이해하려면 Lexical scoping, 어휘적 범위 지정이 어떤식으로 이루어지는지 알아야 한다.
2. Lexical Scoping
- Lexical Scoping with 'var'
function init() {
var name = "Mozilla"; // name is a local variable created by init
function displayName() {
// displayName() is the inner function, that forms the closure
console.log(name); // use variable declared in the parent function
}
displayName();
}
init();
위의 예시에서 init 함수는 name이라는 변수와 displayName이라는 함수를 만들고 호출한다. displayName 함수 내부에는 name이라는 변수가 선언되지 않았음에도 외부 함수인 init 에서 선언한 name이라는 변수를 사용할 수 있다. 이것은 lexical scoping의 한 예시이다. 함수가 중첩된 상황에서 parser가 변수를 어떤 식으로 처리하는지에 대한 것, lexcial이란 단어가 쓰인 이유는 그 변수가 소스코드 내 어디에서 선언 되었는지를 고려 한다는 것을 의미한다.
var의 경우 함수 스코프, 전역 스코프 둘로 나뉜다. { } 중괄호로 표시된 블럭 단위로 스코프가 생성되지 않는다.
if (Math.random() > 0.5) {
var x = 1;
} else {
var x = 2;
}
console.log(x);
보통 위와 같이 생긴 코드는 C나 JAVA에서 에러를 던진다. var로 선언된 변수 x는 블록 스코프에 포함되지 않기 때문에 전역변수로 생성이 되어버려 에러를 던지지 않는다.
if (Math.random() > 0.5) {
const x = 1;
} else {
const x = 2;
}
console.log(x); // ReferenceError: x is not defined
let, const로 생성하면 에러를 발생시키는 것을 볼 수 있다.
3. 클로저의 예시
function makeFunc() {
const name = "Mozilla";
function displayName() {
console.log(name);
}
return displayName;
}
const myFunc = makeFunc();
myFunc();
클로저의 예시이다. 이번에는 makeFunc() 함수안에서 displayName 함수를 리턴하고 있다. 함수는 정상적으로 작동하고 있다. 정상적으로 작동한다는 것이 어떤 프로그래밍 언어에서는 이상하게 보일 수 있다. 어떤 프로그래밍 언어에서는 함수 내의 로컬 변수가 함수 실행의 지속시간 동안만 존재하기 때문이다. makeFunc() 함수 실행이 끝나면 name 변수에 접근할 수 없을 것으로 예측하게 되는 것.
그러나 Javascript는 closure를 형성하기 때문에 위의 함수는 정상적으로 동작한다. 'displayName' 인스턴스는 변수 name이 존재하는 렉시컬 환경에 대한 참조를 유지해서 'myFunc'가 호출 될 때 변수 name을 여전히 사용할 수 있게 되는 것.
이 같은 클로저는 단일 method만 있는 객체를 사용할 수 있는 곳이라면 어디에서든 사용할 수 있다. 특히 웹에서 자주 사용된다. 프론트엔드 Javascript에서 작성된 코드 대부분은 사용자가 발생시키는 이벤트에 특정 동작을 수행하는 단일 콜백함수를 연결하는 이벤트 기반이기 때문이다.
예를 들어 페이지에 text size에 맞는 버튼을 추가한다고 가정하면,
우선 첫번째 방법으로 body 엘리먼트의 font사이즈를 설정하고 페이지의 다른 엘리먼트의 사이즈를 em 을 통해 설정하는 방법이 있다.
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.2em;
}
그리고 다른 방법으로 자바스크립트 클로저를 이용해서 구현할 수 있다.
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;
<button id="size-12">12</button>
<button id="size-14">14</button>
<button id="size-16">16</button>
- 클로저로 private methods 모방하기
Java와 같은 언어에는 private하게 method를 선언하는 방법이 있다. (오직 같은 클래스 안의 메소드에서만 호출될 수 있게 하는 것)
class가 나오기 이전의 Javascript에서는 이 같은 기능을 가진 method가 없었다. 하지만 클로저를 통해 비슷한 기능이 가능했다.
private method는 코드 접근 제한에 유용할 뿐 아니라 전역 namespace 관리하는 방법으로 제공한다.
const counter = (function () {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment() {
changeBy(1);
},
decrement() {
changeBy(-1);
},
value() {
return privateCounter;
},
};
})();
console.log(counter.value()); // 0.
counter.increment();
counter.increment();
console.log(counter.value()); // 2.
counter.decrement();
console.log(counter.value()); // 1.
이전 예시들에서는 각 클로저가 각각의 고유한 렉시컬 환경을 가졌다면, 이번 예시에서는 세개의 함수(counter.increment, counter.decrement, counter.value)가 하나의 렉시컬 환경을 공유하고 있다.
공유하고 있는 렉시컬환경은 즉시 실행함수로 작성된 익명함수에 의해 만들어졌다. 렉시컬 환경은 두가지 private 요소 : privateCounter, changeBy 를 가지고 있다. 이 익명 함수 밖에서는 이 요소들에 접근할 수 가 없다. 대신 익명 함수 안의 세가지 public함수를 통해 접근이 가능한 것 !
이런식으로 클로저를 사용하면 객체 지향 프로그래밍과 관련된 데이터 숨기기, 캡슐화와 같은 이점을 제공할 수 있다.
4. 클로저 스코프 체인
모든 클로저는 세개의 스코프를 가지고 있다.
- Local scope
- Enclosing scope (블럭 스코프나 함수 스코프 혹은 모듈 스코프)
- Global scope
종종 헷갈려하는 부분이 바깥 함수가 중첩되어 있는 경우, 바깥 함수의 스코프에 접근했을 때 바깥 함수가 포함하는 스코프도 같이 포함할 수 있다는 점을 인지하지 못하는 것이다. 이렇게 함수 스코프 체인이 생성된다.
// 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); // 20
위의 예시를 통해 중첩된 함수들이 있을 때 모두 바깥 함수의 스코프에 접근할 수 있다는 것을 알 수 있다.
물론 블럭스코프와 모듈 스코프에도 적용이 가능하다.
function outer() {
const x = 5;
if (Math.random() > 0.5) {
const y = 6;
return () => console.log(x, y);
}
}
outer()(); // Logs 5 6
if 문 안의 { } 블럭 스코프 클로저 예시이다. x변수가 { } 바깥에 선언되어 중첩되어 있지만 outer()() 호출 시 클로저를 통해 접근했다는 것을 알 수 있다.
// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
x = val;
};
// main.js
import { getX, setX } from "./myModule.js";
console.log(getX()); // 5
setX(6);
console.log(getX()); // 6
모듈별로 작성해서 import해오는 방식은, 평소에도 자주 이용하는 방식인데 클로저를 통한 것이였다니 새롭게 느껴진다.
특히 라이브 바인딩 부분이 새로웠다.
// 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
중간에 다른 모듈에서 값을 변경 했을 때, 변경한 부분을 반영하여 기존 모듈에서 값을 가져오는 것을 알 수 있다.
5. var의 사용으로 인한 혼란
let과 const가 생성되기 이전, var와 함께 루프를 사용할 때 생겼던 문제이다.
function showHelp(help) {
document.getElementById("help").textContent = help;
}
function setupHelp() {
var helpText = [
{ id: "email", help: "Your email 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();
위 코드는 제대로 작동하고 있지 않다. 어떠한 필드에 focus를 맞춰도 age에 대한 메시지가 표시된다.
값이 변경되는 변수 item이 var로 선언되어 함수 범위를 가지기 때문이다. 각각의 item이 생기는 것이 아니라, 루프를 벗어나서 계속해서 재할당 되는 것. 'item.help'의 값은 onfocus 콜백이 실행될 때 결정되고, 루프는 이미 실행되어 있으므로 item 변수는 helpText리스트 마지막 항목을 가리키게 된다.
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function makeHelpCallback(help) {
return function() {
showHelp(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 = makeHelpCallback(item.help);
}
}
setupHelp();
MDN에서 해결 가능할 수 있는 코드로 제공한 코드이다. makeHelpCallback 이라는 함수를 만들어 주었고, makeHelpCallback(item.help) 그것을 실행한 값을 부여해 주었다. 추가적인 클로저의 형성을 통해 해결하였다.
ES6의 let, const의 경우 중괄호 { } 블럭 단위로도 스코프가 생성되기 때문에 이제는 var대신 let, const를 이용하는 것으로도 문제를 해결할 수 있다.
6. 성능관련 고려사항
클로저가 필요하지 않은 상황에서 불 필요하게 함수내에서 다른 함수를 작성하는 것은 현명하지 못한 방법이다. 왜냐하면 모든 객체 생성시, 즉 생성자가 호출될 때마다 메소드가 재할당되기 때문이다. 처리속도 및 메모리 사용량 측면에서 부정적인 영향을 미칠 수 있다.
예를 들어 새로운 객체/클래스 생성시에는 메소드를 객체 생성자 내에 직접 정의하는 대신 객체 프로토타입과 연관시켜야 한다.
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}
위의 방식은 객체가 생성될 때마다 새로운 getName, getMessage 메소드가 생성되고 있다.
아래와 같이 prototype을 이용하면 하나의 getName, getMessage 메소드를 다른 인스턴스에서 가져다 쓸 수 있게 된다.
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;
};
참고사이트
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
'Frontend Study - 1 > Javascript' 카테고리의 다른 글
Javascript : Execution Context (0) | 2023.03.27 |
---|