클로저는 자바스크립트의 핵심 개념 중 하나이며, 모르는 사이에 많이 사용하고 있는 클로저의 다양한 활용에 대해서 살펴보기 위해 이번 블로그를 작성하기로 결심했습니다.
이번 블로그의 목적은 클로저의 정의가 아니라 알게 모르게 사용하고 있는 다양한 클로저에 관련한 로직들과 메모이제이션에 대해 알아보는 내용입니다.
이어지는 다음글 : 클로저에서 자바스크립트 메모리 관리까지 토끼굴 파기
POLE(최소 노출의 원칙) 원칙은 변수나 함수 스코프를 지정할 때 최소한의 것만 노출하고 나머지는 가능한 비공개로 유지하는 원칙을 말합니다.
클로저에서 POLE 원칙을 알아야 하는 이유를 살펴보겠습니다.
즉, POLE 원칙이 클로저를 사용할 때 안정적이고 효과적인 코드를 작성하기 위한 지침을 제공하기 때문에 이러한 원칙들을 따름으로 유지보수가 좋고 재사용할 수 있으며 안정적인 개발을 구축할 수 있기 때문입니다.
그렇다면 POLE 원칙을 지키는 코드는 무엇인지 살펴보겠습니다.
let number = 1
function add(num){
return number+num
}
console.log(add(2))
위 코드는 전역변수 number
를 add
함수가 스코프 체인에 의해서 참조하여 계산하고 있습니다.
위 코드는 POLE 원칙(최소 노출의 원칙)에 부합하지 않습니다.
전역변수를 사용하고 있기 때문이며 이 전역변수는 어디서든 참조할 수 있기 때문에 공개 변수이며 의도치 않은 값의 변경 등이 일어날 수 있어 버그를 발생시킬 수 있습니다.
function add(num2){
let num1 = 1; // num1의 값은 항상 1로 고정됩니다.
return num1+num2
}
console.log(add(4))
이번에는 전역변수를 add()
함수 내부에 선언해서 지역변수로 은닉시켰습니다.
전역변수를 지역변수로 변경함으로써 외부에서는 접근할 수 없지만, 지역변수 num1
은 1
로 값이 할당되었기 때문에 매번 num1
은 1
로 값이 고정됩니다.
하지만 num1
의 값도 변경할 수 있으면 좋을 것 같습니다.
function outer(num1) {
function inner(num2) {
return num1 + num2;
}
return inner;
}
const add1 = outer(1) // num1의 값도 자유롭게 설정 가능
add1(2)
console.log(num1, num2) // 외부에서는 num1, num2 접근 불가
const add2 = outer(10) // num1의 값도 변경할 수 있습니다.
add2(20)
// 화살표 함수로 적용
function add(num1){
return (num2) => num1 + num2
}
이렇게 코드를 수정하니 num1
의 값도 원하는 값으로 변경하여 사용할 수 있게 되었습니다.
이제 POLE 원칙에 부합하는 코드가 되었습니다.
outer
함수는 매개변수 num1
을 받고 inner
함수를 반환합니다.
inner
함수는 외부함수인 outer
함수의 매개변수 num1
과 자신의 매개변수 num2
를 더하여 그 결과를 반환하는 클로저입니다.
클로저란 함수와 그 함수가 선언된 렉시컬 환경의 조합이다 - MDN
function outer(num1) {
function inner(num2) {
return num1 + num2;
}
return inner;
}
const add1 = outer(1) // num1의 값도 자유롭게 설정 가능
add1(2)
console.log(num1, num2) // 외부에서는 num1, num2 접근 불가
const add2 = outer(10) // num1의 값도 변경할 수 있습니다.
add2(20)
위 코드를 다시 살펴보겠습니다.
outer
함수를 실행하면, inner
함수가 반환됩니다.
inner
함수는 outer
함수의 매개변수 num1
을 포함하는 렉시컬 환경에서 생성됩니다.
따라서, inner
함수는 outer
함수의 실행 컨텍스트가 종료된 이후에도 num1
에 접근할 수 있습니다. 이것이 바로 클로저가 작동하는 방식입니다.
즉 클로저란, 내부 함수가 외부 함수의 식별자를 참조할 수 있고 외부 함수가 콜 스택에서 사라져도 내부 함수가 외부 함수의 식별자를 기억하고 참조할 수 있는 함수를 말합니다.
이 블로그의 목적은 클로저를 다양하게 활용하는 방법에 대한 내용으로 클로저에 대한 자세한 내용은 여기를 참고해주세요.
function user(name){
return {
get hello(){
console.log(`안녕하세요 ${name}님!`);
},
set hello(newName){
name = newName
}
}
}
const user1 = user("차은우")
user1.hello
user1.hello = "카리나"
위 코드는 getter/setter
접근자를 사용한 클로저입니다.
getter/setter 접근자에 대해서는 MDN를 확인해주세요
내부함수인 get hello
와 set hello
는 자신이 선언되었을 때의 환경을 기억하고 그 환경의 변수 name
에 접근할 수 있습니다.
클로저 형성 과정에 대해 살펴보겠습니다.
user
함수 호출
user(”차은우”)
가 호출될 때, name
매개변수는 “차은우” 값을 가지게 되고 이 시점에 name
변수는 user
함수의 지역변수가 됩니다.
객체 반환
user
함수는 getter hello
와 setter hello
를 포함하는 객체를 반환합니다. 이 getter
와 setter
는 name
변수를 사용합니다.
클로저 생성
getter
와 setter
는 user
함수의 지역변수 name
을 참조합니다.
user
함수의 실행이 끝난 후에도 이 getter
와 setter
는 name
변수에 접근할 수 있는 상태가 됩니다.
즉 user
함수의 실행 컨텍스트가 사라진 후에도 name
변수는 사라지지 않고 getter
와 setter
에서 계속 접근할 수 있는 상태로 남게 됩니다.
getter / setter 호출
user1.hello
를 호출할 때마다, console.log
를 통해 현재 name
변수의 값을 출력합니다. 이때 name
은 user
함수 호출 시점의 값인 "차은우"
로 초기화되어 있습니다.
user1.hello = "새이름"
처럼 할당할 때마다 name
변수의 값을 새로운 값으로 업데이트하고 getter
를 호출하면 업데이트된 name
의 값이 출력됩니다.
async/await
를 사용한 클로저비동기 코드가 클로저와 함께 사용될 때 async/await
는 비동기 작업을 수행하는 동안 클로저가 생성될 때의 렉시컬 환경을 기억하고, 그 환경 내의 변수들에 안전하게 접근할 수 있게 해줍니다.
이런 클로저의 특징 덕분에 비동기 코드의 실행이 완료될 때까지 함수의 상태를 유지할 수 있게 됩니다.
// 비동기적으로 데이터를 가져오는 함수
async function fetchData(url){
const response = await fetch(url)
const data = await response.json()
return data
}
// 클로저를 사용하여 비동기 작업 중 변수 'url'을 기억하게 합니다.
function createDataFetcher(url) {
return async function() {
try {
const data = await fetchData(url);
console.log(data);
} catch (error) {
console.error("Error fetching data:", error);
}
};
}
const fetchMyData = createDataFetcher('https://api.example.com/data');
fetchMyData();
위 코드에서 createDataFetcher
함수는 클로저를 생성합니다.
createDataFetcher
함수는 매개변수 url
을 받아서 비동기 함수(async function
)를 반환합니다.
반환된 비동기 함수는 fetchMyData()
를 호출해서 await
를 사용하여 fetchData(url)
의 함수의 결과를 기다리며 이 과정에서 url
매개변수는 비동기 처리가 완료될 때까지 유효하며 접근 가능합니다. 그 이유는 매개변수 url
은 내부 함수(클로저)가 생성될 때의 렉시컬 환경을 기억하기 때문에 url
매개변수를 기억하고 접근할 수 있습니다.
클로저의 다른 활용 예로는 대표적으로 React의 Hook를 예로 들 수 있습니다.
그 중 useState
를 간단하게 구현해보겠습니다.
function useState(initialState) {
let value = initialState;
function state() {
return value;
}
function setState(newValue) {
value = newValue;
return value;
}
return [state, setState];
}
const [state, setState] = useState(0);
// +) getter/settter로 구현해보기
function useState(initialState) {
let value = initialState;
return {
get state() {
return value;
},
set state(newValue) {
value = newValue;
return newValue;
},
};
}
const add = useState(0);
이렇게 클로저를 활용하여 간단하게 React의 useState
를 구현할 수 있습니다.
물론 실제 useState
구현은 그리 간단하지 않습니다. 자세한 내용은 facebook의 react를 살펴보세요!
앞에서 충분히 클로저에 대해 설명했으니 위 코드는 설명없이 넘어가겠습니다.
사실 클로저에 대한 블로그를 작성하게 된 이유는 메모이제이션(memoization)에 관해 공부하면서입니다.
메모이제이션은 값비싼 함수 호출의 결과를 캐싱하고 같은 입력이 다시 발생할 때 불필요하게 다시 계산하는 대신 캐싱 된 결과를 반환하는 프로그래밍 기술입니다.
즉, 동일한 입력으로 여러 번 호출되는 함수 또는 컴포넌트가 있을 때 유용하게 사용됩니다.
React에서는 useCallback, useMemo 와 같은 메모이제이션 훅을 통해 성능을 향상하고 코드의 복잡성을 줄일 수 있습니다.
하지만 메모이제이션은 메모리에 특정한 값을 저장하는 것이기 때문에, 정말 필요하지 않은 경우에도 남용하면 오히려 성능을 저하시킬 수 있다는 단점이 존재하기 때문에 꼭 필요한 경우에만 사용하는 것이 좋습니다.
메모이제이션에 대해 더 자세히 알아보실 분들은 네이버의 성능 하면 빠질 수 없는 메모이제이션, 네가 궁금해를 확인해주세요.
그럼 클로저로 메모이제이션 코드를 구현하는 방법에 대해 살펴보겠습니다.
function memoization() {
let cache = {}; // 캐시 내용을 저장합니다.
return (value) => {
if (value in cache) {
console.log("캐시된 데이터를 사용합니다. ", { cache });
return cache[value];
}
console.log("캐시된 내용이 없습니다. 계산을 시작합니다.");
let result = value * 2;
cache[value] = result;
return result;
};
}
const multiply = memoization();
multiply(2)
memoization
함수는 cache
객체를 생성하고, 이 cache
객체를 사용하여 계산 결과를 저장합니다.
반환된 함수(화살표함수)는 memoization
함수의 실행 컨텍스트가 종료된 후에도 클로저로 인해 cache
객체에 접근할 수 있습니다.
클로저를 통해 생성된 cache
객체는 여러 호출에 걸쳐 유지되며, 이를 통해 동일한 입력값에 대한 반복 계산을 방지합니다. 입력값이 cache
에 이미 존재한다면, 계산을 건너뛰고 캐시 된 값을 즉시 반환합니다. 이는 계산 비용이 큰 작업에서 성능을 크게 향상 시킬 수 있습니다.
또한 cache
객체는 외부에서 접근할 수 없고 오직 내부에서만 상태를 변경시킬 수 있기 때문에 안전합니다.
지금까지 다양한 클로저의 활용예제를 살펴봤습니다.
하지만 클로저가 무조건 좋은 것은 아닙니다. 모든 개발에는 트레이드 오프(trade-offs)가 발생하기 때문입니다.
클로저는 데이터를 캐시 하거나 상태를 유지하는 등의 작업에는 이렇듯 유용하게 사용되지만 메모리 관리 측면에서는 주의해야 합니다.
클로저가 큰 데이터를 참조하고 있을 때, 그 데이터는 클로저가 메모리에서 해제될 때까지 가비지컬렉션에 의해 수거되지 않기 때문에 메모리 누수의 원인이 될 수 있습니다.
위 메모이제이션 코드에서 클로저의 메모리를 해제하는 코드를 추가해보겠습니다.
function memoization() {
let cache = {}; // 캐시 내용을 저장합니다.
return {
createCache: (value) => {
if (value in cache) {
console.log("캐시된 데이터를 사용합니다. ", { cache });
return cache[value];
}
console.log("캐시된 내용이 없습니다. 계산을 시작합니다.");
let result = value * 2;
cache[value] = result;
return result;
},
clearCache: () => {
cache = {}; // 캐시를 비워 메모리를 해제합니다.
console.log("저장된 캐시를 모두 해제합니다.", { cache });
},
};
}
const multiply = memoization();
multiply.createCache(2);
multiply.createCache(2);
multiply.createCache(4);
multiply.createCache(4);
multiply.clearCache()
clearCache()
함수를 호출하면 cache
객체에 대한 참조가 제거되어 가비지 컬렉션의 대상이 됩니다.
이렇게 클로저가 참조하는 외부 변수의 메모리를 해제할 수 있습니다.
여기까지 클로저의 다양한 활용에 대해 살펴봤습니다.
사실 이제까지는 클로저에 대해 개념을 공부하면서도 막상 코드를 짜고 읽을 때는 클로저의 동작 원리를 잊고 그냥 사용할 때가 많았습니다.
이렇게 다양한 곳에서 사용되고 있는 클로저를 보면서 의도치 않은 메모리 누수가 많이 일어났을 수도 있었겠다는 경각심이 생겼습니다.
앞으로는 코드를 작성할 때 메모리 누수의 원인이 될 수 있을지 성능상 이점은 없을지를 고민하는 습관을 지녀야 할 것 같습니다.
클로저를 이용하여 메모이제이션으로 어떻게 활용하는지와 메모리 누수를 잡는 방법까지!
좋은 글 읽고 갑니다👍