클로저

쏘뽀끼·2025년 1월 9일
0

react

목록 보기
25/25

함수 컴포넌트의 구조와 작동 방식, 훅의 원리, 의존성 배열 등 함수 컴포넌트의 대부분의 기술이 모두 클로저에 의존하고 있다.

클로저의 정의

함수와 함수가 선언된 어휘적 환경(Lexical Scope)의 조합

여기서 '어휘적 환경'이라는 말은 꽤 난해하게 받아들여진다.

function add(){
 const a =10
 function innerAdd(){
  const b = 20
  console.log(a+b)
  }
 innerAdd()//30
}

add()

add함수 내부에 innerAdd가 있고, innerAdd함수는 내부에서 b변수를 선언한 뒤, 내부에 있는 ab를 더해서 정상적으로 30을 출력한 것을 볼 수 있다.

a변수의 유효한 범위는 add전체이고, b의 유효범위는 innerAdd전체이다.
innerAddadd내부에서 선언돼 있어 a를 사용할 수 있게 된 것이다.

즉 어휘적 환경이란, 변수가 코드 내부에서 어디서 선언됐는지를 뜻한다.
호출되는 방식에 따라 동적으로 결정되는 this와는 다르게 코드가 작성된 순간에 정적으로 결정된다.




변수의 유효 범위, 스코프

변수의 유효 범위를 스코프(scope)라고 한다.
자바스크립트에는 다양한 스코프가 있다.

전역 스코프

전역 스코프는 변수를 선언하면 어디서든 호출할 수 있게 된다.
브라우저 환경에서 전역 객체는 window, Node.js 환경에서는 global이 있는데, 바로 이 객체에 전역 레벨에서 선언한 스코프가 바인딩된다.

var global = 'global scope'

function hello(){
console.log(global)
}

console.log(global) //global scope

hello() // global scope
console.log(global === window.global) //true

위 코드에서 global이라는 변수를 var과 함께 선언했더니 전역 스코프와 hello스코프 모두에서 global변수에 접근할 수 있는 걸 알 수 있다.




함수 스코프

자바스크립트는 기본적으로 함수 레벨 스코프를 따른다.
{}블록이 스코프 범위를 결정하지 안흔다.

if(true){
 var global = 'global scope'
}

console.log(global)//'global scope'
console.log(global === window.global) //true

var global은 분면 {}내부에서 선언돼 있는데 {}밖에서도 접근이 가능하다.
이는 기본적으로 자바스크립트는 함수 레벨 스코프를 갖고 있기 때문이다.


function hello(){
 var local = 'local variable'
 console.log(local)//local variable
}

hello()
console.log(local)// Uncaught ReferenceError: local is not defined

함수 블록 내부에서는 일반적으로 예측하는 것과 같이 스코프가 결정되는 것을 볼 수 있다.

var x = 10

function foo(){
 var x=100
 console.log(x)//100


function bar(){
 var x = 1000
 console.log(x)//1000
}

bar()
}

console.log(x)//10
foo()

만약 이렇게 스코프가 중첩되어 있다면?
스코프는 일단 가장 가까운 스코프에서 변수가 존재하는 지를 먼저 확인한다.




클로저의 활용

function outerFunction(){
 var x = 'hello'
 function innerFunction(){
  console.log(x)
  }
  
  return innerFunction
 }
 
 const innerFunction = outerFunction()
innerFunction()//"hello"

outerFunctioninnerFunction을 반환하며 실행이 종료됐다.
반환한 함수에는 x라는 변수가 존재하지 않지만, 해당 함수가 선언된 어휘적 환경, 즉 outerFunction에는 x라는 변수가 존재하며 접근이 가능하다.
따라서 같은 환경에서 선언되고 반환된 innerFunctin에서는 x라는 변수가 존재하던 환경을 기억하기 때문에 정상적으로 "hello"를 출력할 수 있는 것이다.





클로저의 활용

전역 스코프는 어디서든 원하는 값을 꺼내올 수 있다.
즉 누구든 접근할 수 있고, 수정이 가능하다는 뜻도 된다.

var counter = 0 

function handleClick(){
 counter++
}

counter변수는 큰 문제를 가지고 있다.
전역에 선언돼 있어 누구나 수정이 가능하다.

만약 useState의 변수가 전역 레벨에 저장돼 있으면 어떻게 될까?
자바스크립트를 조금만 아는 사람이라면, 누구나 리액트 애플리케이션을 쉽게 망가뜨릴 것이다.
리액트가 관리하는 내부 상태 값은 리액트가 별도로 관리하는 클로저 내부에서만 접근할 수 있다.

function Counter(){
 var coutner = 0
 
 return { 
  increase : function (){
   return ++ counter
   },
  decrease : function () {
   return --counter
  },
 counter: function()
  console.log('counter에 접근')
  return counter
  },
 }
}


var c= Counter()

console.log(c.increase())//1
console.log(c.increase())//2 
console.log(c.increase()//3
console.log(c.decrease())//2
console.log(counter())//2

이렇게 코드를 짜면 counter 변수를 직접적으로 노출하지 않으므로써 사용자가 직접 수정하는 것을 막을 수 있다.
물론 접근도 제한해 로그를 남기는 등 부차적인 작업도 가능하다.
counter변수의 업데이트를 increasedecrease로 제한해 무분별하게 변경되는 것을 막았다.
이처럼 클로저를 활용하면 전역 크포르의 사용을 막고, 개발자가 원하는 정보만 개발자가 원하는 방향아로 노출시킬 수 있다는 장점이 있다.




리액트에서 클로저

리액트 함수 컴포넌트 훅에서 클로저는 어떻게 사용될까?
클로저의 원리를 사용하고 있는 대표적인 것중 하나가 바로 useState이다.

function Component()
 const[state,setState] = useState()
 
function handleClick(){
//useState호출은 위에서 끝났지만,
// setState는 계속 내부의 최신값(prev)을 알고 있다. 
//이는 클로저를 활용했기 때문에 가능하다. 
setState((prev)=> prev+1)
}
//...
}

useState함수의 호출은 Component 내부 첫 줄에서 종료됐다.
setStateuseState 내부의 최신 값을 어떻게 계속해서 확인할 수 있을까?
그것은 바로 클로저가 useState내부에서 활용됐기 때문이다.

외부함수(useState)가 반환한 내부 함수 (setState)는 외부함수(useState)의 호출이 끝났음에도 자신이 선언된 외부 함수가 선언된 환경(state가 저장돼 있는 어딘가)를 기억하기 때문에 계속해서 state값을 활용할 수 있는 것이다.




주의할 점

for(var i =0; i<5; i++){
 setTimeout(function(){
  console.log(i)
  }, i* 1000)
 }

위 코드의 의도는 0부터 시작해 1초 간격으로 console.log로 0,1,2,3,4를 차례대로 출력하는 것이다.
그러나 실제로 작동하면, 0,1,2,3,4초 뒤에 5만 출력된다.
setTimeout의 익명함수가 클로저로 i를 잘 따라가야 하는데 모두 5가 되는 이유는 뭘까?
그 이유는 i가 전역 변수로 작동하기 때문이다.
varfor문의 존재와 상관없이 해당 구문이 선언된 함수 레벨 스코프를 바라보고 있으므로 함수 내부 실행이 아니라면 전역 스코프에 i가 등록돼어 있을 것이다.
for문을 다 순회한 이후, 태스크 큐에 있는 setTimeout을 실행하려고 했을때, 이미 전역에 있는 i는 5로 업데이트가 완료되어 있다.




이를 수정하면
for (let i = 0; i<5; i++){
 setTimeout(function(){
  console.log(i)
  }, i*1000)
 }

let은 기본적으로 블록 레벨 스코프를 가지게 되므로 let ifor문을 순회하면서 각각의 스코프를 갖게 된다.
이는 setTimeout이 실행되는 시점에도 유효해서 각 콜백이 의도한 i값을 바라보게 할 수 있다.




그 다음으로는 클로저를 제대로 활용하는 것이다.

for (var i =0 ; i<5; i++){
 setTimeout(
 (funtion(sec){
  return function(){
   console.log(sec)
   }
  })(i),
 i*1000,
 }
}

for문 내부에 즉시 실행 익명 함수를 선언했다.
이 즉시 실행 함수는 i를 인수로 받는데, 이 함수 내부에서는 sec이라고 하는 인수에 저장해 두었다가 setTimeout의 콜백함수에 넘기게 된다.
이렇게 되면 setTimeout의 콜백 함수가 바라보는 클로저는 즉시 실행 익명 함수가 되는데, 이 즉시 실행 익명 함수는 각 for문마다 생성되고 생성되기를 반복한다.
각각의 함수는 고유한 스코프, 즉 고유한 sec을 가지게 된다.




클로저를 사용하는데는 비용이 든다.
클로저는 생성될 때 마다 그 선언적 환경을 기억해야 하므로 추가로 비용이 발생한다.

//일반적인 함수 
const aButton = document.getElementById('a')

function heavyJOB(){
 const longArr = Array.from({length:10000000}, (_,i)=>i+1)
 console.log(longArr.length)
 }
 
 aButton.addEventListener('click', heavyJob)
//클로저라면?
function heavyJobWithClosure(){
 const longArr = Array.from({length:10000000}, (_,i)=> i+1)
 return function(){
 console.log(longArr.length)
 }
}

const innerFunc = heavyJobWithClosure()
bButton.addEventListener('click', function(){
innerFunc()
})

클로저 heavyJobWithClosure로 분리해 실행하고, 이를 onClick에서 실행하는 방식이다.
이미 스크립트를 실행하는 시점부터 아주 큰 배열을 매모리에 올려두고 시작하는 것을 알 수 있다.

클로저의 기본 원리에 따라 클로저가 선언된 순간 내부 함수는 외부 함수의 선언적인 환경을 기억하고 있어야 하므로 이를 어디에서 하용하는지 여부에 관계없이 저장해 둔다.
실제로 onClick내부에서만 사용하고 있지만, 이를 알 수 있는 방법이 없기 때문에 긴 배열을 저장해 둔다.
반면 일반 함수는 클릭 시 스크립트 실해잉 조금 길지만 클리과 동시에 선언, 그리고 길이를 구하는 작업이 모드 스코프 내부에서 끝났기 때문에 메모리 용량에 영향을 미치지 않는다.

0개의 댓글