클로저의 정의와 활용

서로·2025년 2월 5일
2

JS

목록 보기
15/15
post-thumbnail

➊ 클로저의 정의

클로저에 대하여 공식 문서에서는 다음과 같이 정의한다.
클로저는 외부 스코프에 대하여 접근할 수 있는 함수를 제공한다.

클로저를 이해하기 위해서 먼저 아래 예시를 살펴보자!

function init() {
  var name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  displayName();
}
init();

자바스크립트에서는 스코프 체인을 통해 내부에서 외부 스코프 방향으로 변수를 검색한다.
따라서 init 함수를 실행하면 name을 출력하는 것은 아주 정상적이다.

displayName 함수 내부에는 name이 정의되어 있지 않지만
그 외부 함수인 initname이 정의되어 있기 때문에
스코프 체인을 따라 name을 검색하여 출력하였다.

그러나 다음의 예제를 살펴보자.

function makeFunc() {
  const name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

첫 번째 예제와의 차이점은,
외부 함수에서 displayName 함수를 실행하는 것이 아니라
displayName 함수 자체를 반환하고 있다.

그렇다면 위 예제를 실행하면 어떤 결과가 출력될까?

"Mozilla" 가 출력된다. 하지만 이러한 결과는 부자연스럽다.

그 이유는,
myFunc은 실질적으로 displayName이라는 함수이고
displayNamename을 출력하고 있다.

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을 더하는 두 개의 새로운 함수를 만들어낸다.

add5add10 두 개의 클로저가 생성된 것이다.

흥미로운 부분은 두 함수 모두 x + y을 반환하는 동일한 내용으로 구성되어 있지만
실제로 출력하는 값은 다르다는 것이다.

이는 두 함수가 각자 서로 다른 스코프(값이 저장된 공간)를 가지고 있다는 것을 의미한다.

add5에서의 x는 5지만, add10에서의 x는 10이다.

이처럼 동일한 팩토리 함수에서 생성된 클로저여도
서로 다른 스코프(값이 저장된 공간)를 가질 수 있다!

클로저는 자바스크립트를 포함하여 여러 함수형 프로그래밍 언어에서 관찰할 수 있다.

➋ 클로저 활용

그렇다면 클로저는 도대체 어디에 활용되고 있는 걸까?
클로저를 통해 우리는 무엇을 얻을 수 있을까?

① 메소드를 private으로 만들기(정보 은닉)

정보 은닉은 어떤 모듈의 내부 로직에 대해 외부로의 노출을 최소화해서
모듈 간의 결합도를 낮추고 유연성을 높이고자 하는 개념이다.

접근 권한에는 흔히 다음과 같은 세 가지가 존재한다.

  • public
  • protected
  • private

자바스크립트에서 클래스를 지원하기 전에는 메소드를 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의 동작 원리를 살펴보면 클로저가 사용되었다!

배경 지식인 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가 증가하며 화면에도 바로 반영된다.

🤔 그런데 코드를 찬찬히 살펴보면 이상한 점이 있다.

첫 번째로, numberconst로 선언되었다는 점이다.
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로 선언된다.

이런 식으로 클로저의 특징을 이용해 상태를 유지시킬 수 있다!

📎 참고 자료

profile
읽기 쉬운 코드와 글을 작성해요 📝

0개의 댓글