JavaScript 클로저(Closure) 짚고 넘어가기 😾

혜혜·2024년 3월 9일
1

JavaScript

목록 보기
2/9
post-thumbnail

💡 JS 기술 면접 단골질문인 "클로저"... 항상 면접 준비할 때만 바짝 공부하고 제대로 이해하지 못하는 기분이라 이참에 벨로그에 확실하게 정리해서 이해해 보고자 한다! "모던 리액트 Deep Dive" 서적을 많이 참고했음을 미리 밝힙니다 ☺️

🎯 클로저(Closure)란?

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

  • React에서는 16.8 버전 이후부터 이 클로저가 적극적으로 사용되기 시작
    → 클로저를 이해하지 않고서는 리액트가 어떤 식으로 동작하는지 명확하게 이해하기 어려워짐!

"선언된 어휘적 환경(Lexical Scope)"란?

변수코드 내부에서 어디서 선언됐는지!

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

add()
  • 위 코드에서 a 변수의 유효 범위는 add 전체이고, b 변수의 유효 범위는 innerAdd의 전체
  • innerAddadd 내부에서 선언돼 있어 a 사용 가능
  • 어휘적 환경은 코드가 작성된 순간에 정적으로 결정

→ "클로저"는 이러한 어휘적 환경을 조합해서 코딩하는 기법!

🎯 스코프(Scope)란?

변수의 유효 범위!

전역 스코프

전역 레벨에서 선언하는 것

  • 전역 스코프에서 변수를 선언하면 어디서든 호출 가능
  • 브라우저 환경에서 전역 객체는 window,
    Node.js 환경에서는 global
    → 이 객체에 전역 레벨에서 선언한 스코프가 바인딩됨!
var global = 'gloabl scope'

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

console.log(global) // global scope
hello() // global scope
console.log(global === window.global) // true
  • 전역 스코프hello 스코프 모두에서 global 변수에 접근 가능

함수 스코프

  • JavaScript는 기본적으로 함수 레벨 스코프
  • 즉, {} 블록이 스코프 범위를 결정하지 않는다!
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

→ 함수 레벨 스코프이기 때문에, 이러한 경우에는 ReferenceError

만약 스코프가 중첩되어 있다면?

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()
  • JavaScript에서 스코프는, 일단 가장 가까운 스코프에서 변수가 존재하는지를 먼저 확인! (동적으로 결정)
  • 즉, 실행 결과는 10, 100, 1000 순으로 출력됨

🎯 클로저의 활용

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

const innerFunction = outerFunction()
innerFunction() // "hello"
  • 여기서 outerFunctioninnerFunction을 return하며 실행 종료됨
  • 여기서 반환된 함수에는 x라는 변수가 존재하지 않지만,
    해당 함수가 선언된 어휘적 환경, 즉 outerFunction에는 x라는 변수가 존재하며 접근할 수도 있다!

→ 같은 환경에서 선언 · 반환된 innerFunction에서는 x라는 변수가 존재하던 환경을 기억하기 때문에, 정상적으로 "hello"가 출력되는 것!!

클로저의 활용

  • 어디서든 접근 및 수정이 가능하다는 게 전역 스코프의 장점이자 단점
var counter = 0

function handleClick() {
	counter++
}
  • counter 변수의 문제점

→ 전역 레벨에 선언되어 있어서 누구나 수정 가능
(window.counter를 활용하면 쉽게 해당 변수에 접근 가능)

  • React가 관리하는 내부 상태 값은, React가 별도로 관리하는 클로저 내부에서만 접근 가능!

  • 위 코드를 클로저를 활용한 코드로 변경해 보자! ↓↓↓

function Counter() {
	var counter = 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(c.counter()) // counter에 접근! 2
  • javascript-visualizer 에서 ES5 JavaScript 코드를 입력해 실행하면 코드가 어떤 식으로 실행되고 있는지, 현재 어떤 클로저가 존재하는지 등등 확인 가능!!

↑ 사이트에서 실행시켜 본 모습

  • 클로저를 활용한 코드로 변경했을 때의 이점
    • counter 변수를 직접적으로 노출하지 않음으로써 사용자가 직접 수정하는 것을 막음
    • 접근하는 경우를 제한해 로그를 남기는 등의 부차적인 작업도 수행
      - counter 변수의 업데이트를 increasedecrease로 제한해 무분별하게 변경되는 것을 막음
  • 이를 통해 유추해 볼 수 있는 React에서의 클로저 활용
    useState의 변수를 저장해 두고, useState의 변수 접근 및 수정을 클로저 내부에서 확인 가능해, 값이 변하면 렌더링 함수를 호출하는 등의 작업이 이루어질 것!

리액트에서의 클로저

function Component() {
	const [state, setState] = useState()
    
    function handleClick() {
    	// useState 호출은 위에서 끝났지만,
      	// setState는 계속 내부의 최신값(prev)을 알고 있다.
      	// 이는 클로저를 활용했기 때문에 가능하다.
      	setState((prev) => prev + 1)
    }
  	
  	// ...
}
  • 외부 함수(useState)가 반환한 내부 함수(setState)는 외부 함수(useState)의 호출이 끝났음에도 자신이 선언된 외부 함수가 선언된 환경(state가 저장되어 있는 어딘가)를 기억하기 때문에,
    계속해서 state 값 사용 가능!
    → 클로저가 useState 내부에서 활용됐기 때문!

🎯 주의할 점

for (var i = 0; i < 5; i++) {
	setTimeout(function () {
    	console.log(i)
    }, i * 1000)
}
  • 의도 : 0부터 시작해 1초 간격으로 console.log로 0, 1, 2, 3, 4를 차례대로 출력하는 것
  • But, 실제로 코드를 실행하면 0, 1, 2, 3, 4초 뒤에 '5'만 출력됨

i가 전역 변수로 작동하기 때문에!

  • JavaScript는 함수 레벨 스코프이기 때문에, varfor 문의 존재와 상관없이 해당 구문이 선언된 함수 레벨 스코프를 바라보고 있으므로, 함수 내부 실행이 아니라면 전역 스코프에 var i가 등록돼 있을 것
  • for문을 다 순회한 후, 태스크 큐에 있는 setTimeout을 실행하려고 했을 때, 이미 전역 레벨에 있는 i는 5로 업데이트 되어 있음

1. let으로 수정하는 법

for (let i = 0; i < 5; i++) {
	setTimeout(function () {
    	console.log(i)
    }, i * 1000)
}
  • 블록 레벨 스코프를 갖는 let으로 수정

2. 클로저를 활용해 수정하는 법

for (var i = 0; i < 5; i++) {
	setTimeout(
    	(function (sec) {
        	return function () {
            	console.log(sec)
            }
        })(i),
      	i * 1000,
    )
}
  • for문 내부에 즉시 실행 익명 함수 선언
  • 이 즉시 실행 함수는 i를 인수로 받는데,
    이 함수 내부에서는 이를 sec라고 하는 인수에 저장해 두었다가 setTimeout의 콜백 함수로 넘기게 됨
  • setTimeout의 콜백 함수가 바라보는 클로저는 즉시 실행 익명 함수가 되는데,
    이 즉시 실행 익명 함수는 각 for문마다 생성 · 실행되기를 반복
  • 각각의 함수는 고유한 스코프, 즉 고유한 sec를 가지게 되므로, 올바르게 실행된다!

클로저를 사용할 때 주의할 점

클로저를 사용하는 데는 비용이 든다! (성능 이슈)

  • 클로저는 생성될 때마다 그 선언적 환경을 기억해야 하므로 추가적인 비용이 발생
  • 엄청나게 긴 작업을 동일하게 처리하는 두 함수를 예시로 들어보자

1. 긴 작업을 일반적인 함수로 그냥 처리

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

function heavyJob() {
	const longArr = Array.from({ length: 10000000 }, (_, i) => i + 1)
    console.log(longArr.length)
}

aButton.addEventListener('click', heavyJob)

2. 긴 작업을 클로저로 처리

// 클로저
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()
})

→ 개발자 도구에서 살펴보면, 클로저를 활용하는 쪽이 압도적으로 부정적인 영향을 미친다는 것을 확인할 수 있음

  • 이미 스크립트를 실행하는 시점부터 아주 큰 배열을 메모리에 돌려두고 시작
    → 클로저의 기본 원리에 따라, 클로저가 선언된 순간 내부 함수는 외부 함수의 선언적인 환경을 기억하고 있어야 하므로, 이를 어디에서 사용하는지 여부에 관계없이 저장해 둠

🙇 참고자료

모던 리액트 Deep Dive
클로저 - MDN

profile
쉽게만살아가면재미없어빙고

0개의 댓글

관련 채용 정보