관리 메뉴

bright jazz music

클로저(closure) 본문

Language/Javascript

클로저(closure)

bright jazz music 2024. 1. 3. 09:05

리액트의 클래스형 컴포넌트에 대한 이해가 JS의 클래스, 프로토타입, this에 달려있다면, 함수형 컴포넌트에 대한 이해는 클로저에 달려 잇다. 함수형 컴포넌트의 구조와 작동 방식, 훅의 원리, 의존성 배열 등 함수형 컴포넌트의 대부분의 기술이 모두 클로저에 의존하고 있다.

 

1. 클로저의 정의

MDN에서는 '클로저는 함수와 함수가 선언된 어휘적 환경(Lexical Scope)의 조합"이라고 돼 있다. 

 

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

 

위 예제코드에서는 add 함수 내부에 innerAdd가 있다. innerAdd 함수는 내부에서 b 변수를 선언한 뒤 자신의 함수 외부에 있는 a와 b를 더해 30을 출력한다. 이 때, a 변수의 유효 범위는 add 함수 전체이고, b의 유효범위는 innerAdd 함수 전체다. innerAdd는 add 함수 내부에 선언돼 있어 a를 사용할 수 있다.  즉, "선언된 어휘적 환경"이라는 것은 변수가 코드 내부에서 어디서 선언됐는지를 말하는 것이다. 이는 호출되는 방식에 따라 결정되는 this와는 다르게 코드가 작성된 순간에 정적으로 결정된다. 클로저는 이러한 어휘적 환경을 조합해 코딩하는 기법이다.

 

2. 변수의 유효 범위. 스코프

2.1. 전역 스코프

전역 레벨에 선언하는 것을 전역 스코프(global 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 변수에 접근할 수 있는 것을 확인할 수 있다.

 

2.2. 함수 스코프

다른 언어와 달리 JS는 기본적으로 함수 레벨 스코프(function level scope)를 따른다. 즉, {}블록이 스코프 범위(block level scope)를 결정하지 않는다.

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

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

 

var global은 {} 내부에 선언돼 있는데 {}밖에서도 접근이 가능하다. 이는 앞서 언급했던 것처럼 기본적으로 JS가 함수레벨 스코프를 가지고 있기 때문이다. 자바스크립트는 기본적으로 함수 레벨 스코프(function-level scope)를 따르는데, 이는 변수가 선언된 함수 내에서만 접근 가능함을 의미한다.

 

블록 레벨 스코프를 가진 언어에서는, {}로 둘러싸인 블록(예: if, for, while 블록) 내에서 선언된 변수가 그 블록 내부에서만 접근 가능하다. 그러나 자바스크립트에서는 var로 선언된 변수가 블록 내부에 있더라도 그것을 포함하는 함수 전체 또는 전역 스코프에서 접근할 수 있다.

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() // 100 1000

 

JS에서 스코프는 일단 가장 가까운 스코프에서 변수가 존재하는지를 먼저 확인한다.

 

2.3. let과 const의 도입(참고)

ES6(ES2015)에서는 let 과 const가 도입되어 블록 레벨 스코프를 지원하게 되었다. 이들은 {} 블록 내에서 선언될 때 그 블록에 대한 지역 스코프를 가진다.

function myFunction() {
  if (true) {
    let blockScoped = '접근 불가능';
    const anotherBlockScoped = '또한 접근 불가능';
  }
  console.log(blockScoped); // ReferenceError: blockScoped is not defined
  console.log(anotherBlockScoped); // ReferenceError: anotherBlockScoped is not defined
}

myFunction();

 

 

3. 클로저의 활용

클로저의 정의인 "함수와 함수가 선언된 어휘적 환경의 조합"이 무엇인지 살펴보았다. JS는 함수 레벨 스코프를 가지고 있으므로, 이렇게 선언된 함수 레벨 스코프를 활용해 어떤 작업을 할 수 있다는 개념이 발로 클로저다.

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

    return innerFunction
}

const innerFunction = outerFunction()
innerFunction() // "hello"

 

 

JS는 함수 레벨 스코프를 가지고 있으며, 이러한 스코프는 동적으로 결정되기 때문에 위 예제에서는 "hello"가 출력된다. outerFunction은 innerFunction을 반환하며 실행이 종료된다. 반환된 innerFunction에는 x라는 변수가 존재하지 않지만 해당 함수가 선언된 어휘적 환경, 즉 클로저인 outerFunction에는 x라는 변수가 존재하며 접근도 가능하다.

 

1. **클로저의 정의**: 클로저는 단순히 함수 자체가 아니라, 함수와 그 함수가 선언된 어휘적 환경의 조합을 의미한다. 

2. **`outerFunction`과 `innerFunction`의 관계**: `outerFunction` 자체는 클로저가 아니다. 마찬가지로, `innerFunction` 자체도 클로저가 아니다. 그러나 `innerFunction`은 `outerFunction`의 어휘적 환경에 접근할 수 있으며, 이 접근성이 클로저를 형성한다.

3. **클로저의 형성**: `innerFunction`이 `outerFunction`의 내부에서 선언되었기 때문에, `innerFunction`은 `outerFunction`의 변수와 함수에 접근할 수 있다. `innerFunction`이 `outerFunction`의 범위 밖에서 호출될 때, `outerFunction`의 변수에 여전히 접근할 수 있으며, 이 때 클로저가 형성된다.

즉, 클로저는 특정 함수가 선언된 시점의 변수들에 대한 접근을 유지하는 능력이다. 이는 함수가 자신이 생성될 때의 어휘적 환경을 '기억'하고, 그 환경 밖에서 실행될 때도 해당 환경의 변수에 접근할 수 있게 한다. 이러한 특성은 자바스크립트에서 중요한 프로그래밍 패턴을 가능하게 한다.

 

3.1. 클로저의 활용

전역 스코프는 어디서든 원하는 값을 꺼내올 수 있다는 장점이 있다. 그러나 이는 반대로 누구든 접근할 수 있고 수정할 수 있다는 뜻이기도 하다.

var counter = 0

function handleClick() {
	counter++
}

 

위 counter 변수는 전역 레벨에 선언돼 있어서 누구나 수정할 수 있따. window.counter를 활용하면 쉽게 해당 변수에 접근할 수 있다. 만약 리액트의 useState 변수가 전역 레벨에 저장돼 있으면 누구나 해당 변수를 건드릴 수 있다는 뜻이 된다. 따라서 리액트가 관리하는 내부 상태 값은 리액트가 별도로 관리하는 클로저 내부에서만 접근할 수 있다. 위 코드는 아래처럼 클로저를 이용한 코드로 변경할 수 있다.

 

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.decreasee()) // 2
console.log(c.counter) // 2

 

https://ui.dev/javascript-visualizer

 

위와 같이 코드를 변경하면 아래와 같은 이점이 있다.

 

1. counter 변수를 직접적으로 노출하지 않음으로써 사용자가 직접 수정하는 것을 막았음은 물론, 접근하는 경우를 제한해 로그를 남기는 등의 부차적인 작업도 수행할 수 있다. 

 

2. counter변수의 업데이트를 increase와 decrease로 제한해 무분별하게 변경되는 것을 막았다. 

 

이처럼 클로저를 활용하면 전역 스코프의 사용을 막고 개발자가 원하는 정보만 개발자가 원하는 방향으로 노출시킬 수 있다는 장점이 있다.

리액트에서의 사용을 예로 들면, useState의 변수를 저장해 두고, useState의 변수 접근 및 수정 또한 클로저 내부에서 확인이 가능해 값이 변하면 렌더링 함수를 호출하는 등의 작업이 이루어지는 것이다.

 

3.2. 리액트에서의 클로저

function component() {
  const [state, setState] = useState();

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

 

useState 함수의 호출은  Component 내부 첫 줄에서 종료되었다. setState는 useState 내부의 최신값을 어떻게 계속해서 확인할 수 있을까? 그것은 closure가 useState 내부에서 활용됐기 때문이다. 외부 함수(useState)가 반환한 내부 함수 (setState)는 외부 함수(useState)의 호출이 끝났음에도 자신이 선언된 외부 함수가 선언된 환경(state가 저장돼 있는 어딘가)을 기억하기 때문에 계속해서 state 값을 사용할 수 있는 것이다.

 

4. 주의점

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

 

위 코드의 의도는 1초 간격으로 0,1,2,3,4를 출력하는 것이다. 그러나 실제로는 0,1,2,3,4초 뒤에 5만 출력된다. 이유는 i가 전역 변수로 작동하기 때문이다. var는 for문의 존재와 상관 없이 해당 구문이 선언된 함수 레벨 스코프만 바라보고 있으므로 함수 내부 실행이 아니라면 전역 스코프에 var i 가 등록돼 있을 것이다. for문을 순회한 이후, 태스크 큐에 있는 setTimeout을 실행하려고 하면, 이미 전역 레벨에 있는 i는 5로 업데이트가 완료돼 있다.

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

 

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

 

위처럼 let이나 (불변할 경우)const를 사용할 수도 있지만 closure를 제대로 활용하는 방법도 있다. 

 

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을 가지게 되므로 의도대로 작동하게 된다.

 

 

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

 

아래는 각각 긴 작업을 일반적인 함수로 처리하는 코드와 클로저로 처리하는 코드이다.

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

// heavyJob 함수 정의
function heavyJob() {
    const longArr = Array.from({length: 10000000}, (_, i) => i + 1)
    console.log(longArr.length)
}
// heavyJob 함수 할당
aButton.addEventListener('click', heavyJob)

버튼이 클릭될 때  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 함수 내부에서 정의된 배열을 정의하고, 이 배열을 사용하는 내부 함수를 반환한다. 

 

클로저를 활용하는 함수를 크롬 개발자 도구에서 확인해 보면 클로저를 활용하는 쪽이 압도적으로 부정적인 영향을 미친다. 일반함수를 사용하는 경우보다 메모리를 더 많이 차지하는 것이다. 

 

heavyJobWithClosure()로 분리해 실행하고 이를 onClick에서 실행하는 방식인데, 이미 이 스크립트를 실행하는 시점부터 아주 큰 배열을 메모리에 올려두고 시작한다. 약 40MB. 클로저의 기본 원리에 따라 클로저가 선언된 순간 내부 함수는 외부 함수의 선언적 환경을 기억하고 있어야 하므로 이를 어디에서 사용하는지 여부와 관련 없이 저장해 둔다.

 

실제로는 onClick 내부에서만 사용하고 있지만 이를 알 수 있는 방법이 없기 때문에 긴 배열을 저장해 두는 것이다. 반면 일반 함수의 경우에는 클릭 시 스크립트 실행이 조금 길지만 클릭과 동시에 선언, 그리고 길이를 구하는  작업이 모두 스코프 내부에서 끈나기 때문에 메모리 용량에 영향을 적게 미친다.

 

따라서 클로저에 꼭 필요한 작업만 남겨두지 않는다면 메모리를 불필요하게 잡아먹는 결과를 야기할 수 있다. 마찬가지로 클로저 사용을 적절한 스코프로 가둬두지 않는다면 성능에 악영향을 미친다.

 

5. 정리

 

클로저는 함수형 프로그래밍의 중요한 개념, 부수 효과가 없고 순수해야 한다는 목적을 달성하기 위해 적극적으로 사용되는 개념이다. 클로저는 비용을 수반하기 때문에 사용에 앞서 주의를 기울여야 한다.

 

 

 

 

참고. Array.from()

 

Array.from() 함수는 JavaScript에서 배열과 유사한 객체나 반복 가능한 객체(예: 문자열, Map, Set 등)를 새로운 배열로 변환하는 데 사용된다. 이 함수는 다양한 소스에서 배열을 생성할 수 있도록 해주며, 선택적으로 새 배열의 각 요소를 매핑하는 함수를 제공할 수도 있다.

Array.from(arrayLike, mapFn, thisArg)

// arrayLike: 배열로 변환될 유사 배열 객체 또는 반복 가능한 객체.
// mapFn (선택사항): 새 배열의 각 요소에 적용될 매핑 함수.
// thisArg (선택사항): mapFn을 실행할 때 this로 사용될 값.

 

매핑 함수는 일반적으로 두 개의 매개변수를 받는다: 

Array.from(someArray, (element, index) => ...)

 

첫 번째 매개변수는 현재 처리 중인 요소의 값이다.

두 번째 매개변수는 현재 요소의 인덱스다.

 

만약 매핑함수가 아래와 같다면

(_, i) => i + 1

첫 번째 매개변수(요소의 값)를 사용하지 않고 오직 인덱스만 사용한다는 뜻이다.

"_"는 해당 매개변수가 무시된다는 것을 표시하며, 해당 매개변수가 중요치 않다는 것을 나타낸다.

 

 

 

예시1: 문자열을 배열로 변환하기

문자열은 반복 가능한 객체이므로 Array.from()을 사용하여 각 문자를 배열 요소로 변환할 수 있다.

Array.from('hello')
// 결과: ['h', 'e', 'l', 'l', 'o']

 

예시2: set을 배열로 변환하기

set은 반복 가능한 객체이므로 Array.from()을 사용하여 각 문자를 배열 요소로 변환할 수 있다.

const mySet = new Set([1, 2, 3]);
Array.from(mySet);
// 결과: [1, 2, 3]

 

예시3: 매핑함수 사용하기

배열의 각 요소를 제곱하여 새로운 배열을 생성한다.

Array.from([1, 2, 3], x => x * x);
// 결과: [1, 4, 9]

 

예시4: 유사배열 객체를 배열로 변환하기

length 속성과 인덱스 속성을 가진 객체는 유사 배열로 간주되어 배열로 변환될 수 있다.

const arrayLike = { length: 3, 0: 'a', 1: 'b', 2: 'c' };
Array.from(arrayLike);
// 결과: ['a', 'b', 'c']

 

 

Array.from 함수의 사용상 주의점

 

Array.from()은 얕은 복사를 수행한다. 즉, 원본 객체가 참조형 데이터(예: 객체, 배열)를 포함하고 있다면, 새 배열의 해당 요소는 원본 객체의 참조를 공유한다. 이 함수는 유연하고 다재다능하게 배열을 생성할 수 있는 강력한 방법을 제공하지만, 복잡한 데이터 구조에서는 예상치 못한 결과를 초래할 수도 있다.

 

Array.from()은 JavaScript에서 배열을 다룰 때 매우 유용한 함수로, 다양한 종류의 데이터 소스로부터 새로운 배열을 생성하거나 기존 배열을 변형할 때 자주 사용된다.

 

 

 

Array.from의 세 번째 매개변수인 thisArgs에 대해서

 

`Array.from` 함수의 세 번째 매개변수인 `thisArg`는 선택적으로 사용되며, 매핑 함수(`mapFn`) 내부에서 `this` 값으로 사용될 객체를 지정한다. 매핑 함수가 특정 객체의 메서드 또는 `this` 키워드를 필요로 하는 경우, `thisArg`를 사용하여 `this`의 컨텍스트를 설정할 수 있다.

`thisArg` 사용 예시
아래 예시에서는 `thisArg`를 사용하여 매핑 함수 내부에서 특정 객체의 컨텍스트를 사용한다.

function multiply(n) {
    return n * this.multiplier;
}

const multiplierObject = {
    multiplier: 10
};

const originalArray = [1, 2, 3];
const newArray = Array.from(originalArray, multiply, multiplierObject);



위 코드에서:

1. `multiply` 함수는 `this.multiplier`를 사용하여 입력된 숫자를 곱한다. 이 경우 `this`는 호출 시점에 결정된다.

2. `Array.from`을 호출할 때, `multiply` 함수를 매핑 함수로 사용하고 `multiplierObject`를 `thisArg`로 제공한다.

3. `Array.from`이 `multiply` 함수를 호출할 때, `multiply` 내의 `this`는 `multiplierObject`로 설정된다. 따라서 `this.multiplier`는 `multiplierObject.multiplier`를 참조한다.

4. 결과적으로 `newArray`는 `[10, 20, 30]`이 된다 (`[1*10, 2*10, 3*10]`).

`thisArg`의 중요성

JavaScript에서 함수의 `this`는 기본적으로 실행 컨텍스트에 따라 달라진다. `thisArg`를 사용하면, 매핑 함수가 특정 객체의 메서드로 동작하는 것처럼 `this`의 값이나 컨텍스트를 명시적으로 설정할 수 있다. 이는 함수가 객체의 메서드가 아닐 때 특히 유용하며, 함수가 특정 객체의 상태나 메서드에 의존할 때 필요하다.

 

 

참고: 리액트 Deep Dive

'Language > Javascript' 카테고리의 다른 글

자바스크립트 기본 문법  (0) 2024.02.26
이벤트 루프와 비동기 통신  (0) 2024.01.03
자바스크립트 클래스  (0) 2024.01.01
함수 관련  (0) 2023.12.31
자바스크립트의 타입과 동등비교  (1) 2023.12.29
Comments