클로저에 대하여 공식 문서에서는 다음과 같이 정의한다.
클로저는 외부 스코프에 대하여 접근할 수 있는 함수를 제공한다.
클로저를 이해하기 위해서 먼저 아래 예시를 살펴보자!
function init() {
var name = "Mozilla";
function displayName() {
console.log(name);
}
displayName();
}
init();
자바스크립트에서는 스코프 체인을 통해 내부에서 외부 스코프 방향으로 변수를 검색한다.
따라서 init
함수를 실행하면 name
을 출력하는 것은 아주 정상적이다.
displayName
함수 내부에는 name
이 정의되어 있지 않지만
그 외부 함수인 init
에 name
이 정의되어 있기 때문에
스코프 체인을 따라 name
을 검색하여 출력하였다.
그러나 다음의 예제를 살펴보자.
function makeFunc() {
const name = "Mozilla";
function displayName() {
console.log(name);
}
return displayName;
}
const myFunc = makeFunc();
myFunc();
첫 번째 예제와의 차이점은,
외부 함수에서 displayName
함수를 실행하는 것이 아니라
displayName
함수 자체를 반환하고 있다.
그렇다면 위 예제를 실행하면 어떤 결과가 출력될까?
"Mozilla" 가 출력된다. 하지만 이러한 결과는 부자연스럽다.
그 이유는,
myFunc
은 실질적으로 displayName
이라는 함수이고
displayName
은 name
을 출력하고 있다.
makeFunc
함수 내부에 정의된 로컬 변수인 name
은
makeFunc
함수가 실행 중일 때만 유효하게 접근할 수 있을 것이다.
그러나 makeFunc
함수는 실행 중이 아니므로 name
에 유효하게 접근할 수 없을 것이다.
하지만 실제로는 "Mozilla" 가 출력된다.
외부 스코프에 name
이 살아서 존재한다는 의미이다!
이처럼 클로저는 외부 스코프에 대하여 접근할 수 있는 함수를 제공한다!
아래의 예제에서는 클로저에 대하여 더욱 흥미로운 걸 발견할 수 있다.
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
makeAdder
은 함수를 만들어내는 팩토리이다.
다시 말해, 특정한 값을 인자로 가질 수 있는 함수를 리턴한다.
위 예제에서 함수 팩토리는 전달된 인자에
2 혹은 10을 더하는 두 개의 새로운 함수를 만들어낸다.
즉 add5
와 add10
두 개의 클로저가 생성된 것이다.
흥미로운 부분은 두 함수 모두 x + y
을 반환하는 동일한 내용으로 구성되어 있지만
실제로 출력하는 값은 다르다는 것이다.
이는 두 함수가 각자 서로 다른 스코프(값이 저장된 공간)를 가지고 있다는 것을 의미한다.
add5
에서의 x
는 5지만, add10
에서의 x
는 10이다.
이처럼 동일한 팩토리 함수에서 생성된 클로저여도
서로 다른 스코프(값이 저장된 공간)를 가질 수 있다!
클로저는 자바스크립트를 포함하여 여러 함수형 프로그래밍 언어에서 관찰할 수 있다.
그렇다면 클로저는 도대체 어디에 활용되고 있는 걸까?
클로저를 통해 우리는 무엇을 얻을 수 있을까?
정보 은닉은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서
모듈 간의 결합도를 낮추고 유연성을 높이고자 하는 개념이다.
접근 권한에는 흔히 다음과 같은 세 가지가 존재한다.
자바스크립트에서 클래스를 지원하기 전에는 메소드를 private으로 선언할 수 없었다.
그러나 클로저를 사용하면 private(같은) 메소드를 구현할 수 있다.
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
, decrement
, value
3개의 함수를 반환한다.
외부에서는 이렇게 반환된 3가지의 함수만 사용할 수 있으며
changeBy
함수에는 접근할 수 없다.
이처럼 외부에 제공하고자 하는 정보들을 모아서
return
하고,
내부에서만 사용할 정보들은return
하지 않는 것으로 접근 권한 제어가 가능하다!
useState
는 어떻게 상태를 기억하고 관리하는 것일까?
useState
의 동작 원리를 살펴보면 클로저가 사용되었다!
배경 지식인 useState
에 먼저 설명하도록 하겠다!
다음 코드는 버튼을 클릭하면 number
가 1씩 증가하는 컴포넌트이다.
이때는 useState을 사용하지 않고 number
을 관리하였다.
function App() {
let number = 0;
const handleButtonClick = () => {
number += number;
};
return (
<>
<div>{number}</div>
<button onClick={handleButtonClick}>1 증가</button>
</>
);
}
export default App;
이때 값이 변할 number
라는 상태는 let
으로 선언하였다.
이를 실행하고 버튼을 클릭하면 어떤 결과가 나타날까?
버튼을 눌러도 number
의 값은 0으로 변하지 않는다.
🤔 왜 그럴까?
먼저 지역 변수는 값이 변경되어도 자동으로 리렌더링이 되지 않는다.
따라서 변경된 number
의 값이 화면에 반영되지 않는다.
만약 리렌더링을 강제로 시킨다면 어떨까?
그렇다 하더라도 number
의 값은 화면에 0으로 출력될 것이다.
이유는 다음과 같다.
함수형 컴포넌트는 렌더링될 때마다 전체 함수가 다시 실행되고 JSX를 반환한다.
그 결과, number
는 함수 내의 지역 변수이기 때문에,
컴포넌트가 다시 렌더링될 때마다 항상 0으로 초기화된다.
이 문제를 해결하려면 useState
, useReducer
와 같은
State Hooks을 사용하여 상태를 관리해야 한다!
State Hooks을 사용하면
컴포넌트가 다시 렌더링되더라도 상태 값이 유지되며,
상태가 변경될 때마다 컴포넌트는 자동으로 리렌더링되어
변경된 값을 화면에 반영할 수 있다!
useState
을 사용한 예제는 다음과 같다.
import { useState } from 'react';
function App() {
const [number, setNumber] = useState(0);
const handleButtonClick = () => {
setNumber(number + 1);
};
return (
<>
<div>{number}</div>
<button onClick={handleButtonClick}>1 증가</button>
</>
);
}
export default App;
이처럼 useState
을 사용하면 버튼을 클릭할 때마다 정상적으로 number
가 증가하며 화면에도 바로 반영된다.
🤔 그런데 코드를 찬찬히 살펴보면 이상한 점이 있다.
첫 번째로, number
가 const
로 선언되었다는 점이다.
const
은 재할당이 불가능하므로 값이 수정될 수 없다.
두 번째로, 리렌더링될 때마다 App 컴포넌트가 다시 실행되기 때문에
그 때마다 number
가 0으로 초기화될 것이다.
이런 점들이 의문을 불러일으키지만
버튼을 클릭하면 number
가 정상적으로 증가하는 것을 확인할 수 있다.
그러면 어떻게 상태를 유지할 수 있는 것일까?
다시 App 이 실행되면, useState
는 다시 처음부터 실행되어 0을 넣는 것은 아닐까?
대체 값을 어떻게 기억하고 있는걸까 ❓
이러한 의문을 따라가보면 클로저를 발견할 수 있다.
useState
의 내부 코드를 단순화하면 아래 코드와 같다.
let _val
useState(initialValue) {
if (_val === undefined) {
_val = initialValue
}
const setState = (newVal) => {
_val = newVal
}
return [_val, setState]
}
_val
은 외부 변수지만 useState
내에서 참조하고 있다.
그리고 이 _val
을 return문을 통해 반환하고 있다. (클로저)
_val
은 처음에는 undefined
가 할당되어 있을 수밖에 없는데,
undefined
일 경우에는 initialValue
를 할당한다.
useState
가 두 번째로 불릴 때부터는,
_val
에 이미 값이 할당되었을 것이므로 기존 값을 그대로 사용한다.
useState
메서드 안에도 setState
메서드가 있다.
컴포넌트가 useState
를 사용하면 반환 받는 setter로,
컴포넌트에서 값을 업데이트할 때는 이 setState
를 이용하게 된다.
이 setState
는 외부 scope에 정의되어 있는 _val
을 변경한다.
결론 😎
state은 클로저를 통해 참조되고 있는 외부 변수임을 알 수 있다!
이러한 원리를 잘 이해했다면 아까의 코드를 다시 살펴보자!
import { useState } from 'react';
function App() {
const [number, setNumber] = useState(0);
const handleButtonClick = () => {
setNumber(number + 1);
};
return (
<>
<div>{number}</div>
<button onClick={handleButtonClick}>1 증가</button>
</>
);
}
export default App;
처음으로 App 컴포넌트를 호출하면 useState
가 실행된다.
이때 _val
에는 아무것도 없기 때문에 초기값인 0이 _val
에 할당된다.
useState
은 반환값으로 number
을 0으로, setNumber
는 _val
에 접근하는 setter을 돌려준다.
이후 App 컴포넌트는 버튼 컴포넌트를 포함한 JSX를 반환한다.
화면에 나타난 버튼을 클릭하면 setNumber(number + 1)
을 실행한다.
이때 외부 scope에 정의되어 있는 _val
에 접근하여 값을 변경한다 ❗️
이때 setter을 실행하면 리렌더링이 된다. (위 코드엔 없지만 내부적으로 구현되어 있음)
리렌더링을 한다는 것은 다시 App 컴포넌트가 호출된다는 것이다.
그러므로 또 다시 useState
가 실행된다.
이때 _val
에는 1이라는 값이 존재한다.
따라서 initialValue
을 할당하지 않으며 1의 값이 그대로 유지된다!
useState
은 반환값으로 number
을 1으로, setNumber
는 _val
에 접근하는 setter을 돌려준다.
즉, App 컴포넌트 내부에서 1의 값을 갖는 number
가 const로 선언된다.
이런 식으로 클로저의 특징을 이용해 상태를 유지시킬 수 있다!
📎 참고 자료
- Closure 공식 문서
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures- useState의 동작 원리와 함정
https://medium.com/hcleedev/web-usestate의-동작-원리와-함정-7b4825c16b9- React useState의 동작 원리와 클로저
https://seokzin.tistory.com/entry/React-useState의-동작-원리와-클로저