모던 리액트 Deep Dive - Chapter 01-4. 클로저

작성일: 2025년 11월 25일 오전 08:58(마지막 수정: 2025년 12월 3일 오후 01:53)
조회수: 84

리액트에서 '클로저'가 중요한 이유?

'클래스 컴포넌트'에 대한 이해는 클래스, 프로토타입, this에 달려있다면,

함수 컴포넌트에 대한 이해는 클로저에 달려있다.(구조와 작동방식, 훅, 의존성 배열 등)


1. 클로저의 정의

  • 함수와 함수가 선언된 렉시컬(어휘적) 환경의 조합
  • 클로저는 '선언된 어휘적 환경'을 조합해 코딩하는 기법

2. 스코프: 전역 스코프와 함수 레벨 스코프

  • 전역 레벨 선언 -> 전역 스코프(window, global)

  • 자바스크립트는 기본적으로 '함수 레벨 스코프'를 따름(var -> 함수 레벨 스코프)

    • 이를 활용해 함수 내부적으로 상태를 관리할 수 있다.
      • 상태를 사용자가 직접 수정하는 것을 방지
      • 상태를 변경하는 방식도 개발자가 원하는 방식만 노출
      • React에서 useState가 이런 식의 클로저 활용이 이루어짐
    js
    // counter 변수를 직접 노출하지 않음으로써 사용자가 직접 수정하는 것을 방지
    // 변경 방식도 개발자가 원하는 두 가지 방식(increase, decrease)만 노출시킴
    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()); // 2

2-1. 전역 실행 컨텍스트

01-클로저-1-전역실행컨텍스트.png

2-2. Counter 함수 실행 컨텍스트

01-클로저-2-Counter함수실행컨텍스트.png

2-3. Counter 함수 실행 후 클로저

01-클로저-3-Counter함수실행후클로저.png

2-4. increase 함수 실행 컨텍스트

01-클로저-4-increase함수실행.png

2-5. increase 함수 실행 후 클로저

01-클로저-5-increase함수실행후클로저.png


3. React에서 javascript 클로저의 활용 예시: useState

다른 훅들도 많지만, 대표적으로 useState가 있다. (그 외 모든 훅을 파헤치는 것은 3장에서 다룬다)

jsx
function Component() {
    const [state, setState] = useState(); // 함수 호출로 클로저 생성

    function handleClick() {
        // useState 함수 호출은 끝났어도 클로저에 의해 최신값 prev를 알고 있음
        setState((prev) => prev + 1);
    }
    // ...
}

4. 클로저 사용 시 주의사항

클로저는 굉장히 어렵고 다루기 쉽지 않은 개념이므로, 클로저를 직접 사용할 때는 주의를 요한다.

4-1. 주의사항 1: var과 클로저

다음 코드의 동작을 예상해보자.

js
for (var i = 0; i < 5; i++) {
    setTimeout(function () {
        console.log(i);
    }, i * 1000);
}
  • 예상: 0초 뒤에 0, 1초 뒤 1... 4초 뒤에 4를 출력
  • 실제: 0초 뒤에 5, 1초 뒤에 5.. 5초 뒤에 5(n초는 정확하지 않을 수 있음)
    • var은 함수 레벨 스코프를 가지므로, for문 안에 작성되어 있지만 전역 스코프를 가진다.
    • setTimeout 호출 시 두 번째 인자인 i * 1000은 호출 시점에 연산되어 처리되지만, 첫 번째 인자인 콜백 함수 function () {console.log(i);}는 아직 호출되지 않았다. 콜백 함수는 Web API로 이동하고 이 상태로 setTimeout의 호출을 완료된다. 그 결과 i는 5가 된다.
    • Web API에서 타이머 (대략) i * 1000가 끝나면 해당된 콜백 함수가 태스크 큐로 이동했다가, 콜 스택이 비면 콜 스택으로 이동 후 호출된다.
    • 호출된 콜백함수는 렉시컬 환경을 기억하는데, 먼저 함수 내부에서는 i를 찾을 수 없으니 for문 block 스코프에서 i를 찾는데, 없으므로 마지막으로 전역 변수 i는 5이므로 5를 출력한다.

만약 스코프 내부에서 클로저를 제대로 활용해 i를 내부적으로 관리하고 싶었다면 잘못된 활용이라 볼 수 있다.

  • 해결책 1 - var가 아닌 let으로 for문 내에 블록 레벨 스코프의 i를 선언한다: 이러면 각 반복문 마다 별개(각각 다른 렉시컬 환경)의 i를 가진다.
    js
    for (let i = 0; i < 5; i++) {
        setTimeout(function () {
            console.log(i);
        }, i * 1000);
    }
  • 해결책 2 - 즉시 실행 함수로 선언: 위 함수를 return하는 즉시 실행 함수 형태를 상위에서 선언한다면, 매 반복마다 상위 함수(즉시 실행 함수)가 호출되며 그 순간 함수 인수(i)는 함수 레벨 스코프 에서 저장된 클로저를 생성한다. 이로 인해 내부 함수 호출 시에는 렉시컬 환경을 기억하여 상위 함수 i인 0, 1, 2, 3, 4를 올바르게 참조하게 된다.
    js
    for (var i = 0; i < 5; i++) {
        setTimeout(
            (function (i) {
                return function () {
                    console.log(i);
                };
            })(i),
            i * 1000
        );
    }

4-1. 주의사항 2: 클로저는 비용이 든다.

클로저는 생성될 때마다 그 렉시컬 환경을 기억해야 한다(=메모리 차지). 이는 곧 추가적인 비용이 발생함을 의미한다.

그러므로 꼭 필요한 것이 아니라면 클로저 사용은 지양하는 것이 좋다. 메모리를 불필요하게 잡아먹는 결과를 야기할 수 있기 때문이다.

js
// 일반 함수의 적절한 활용: 함수 실행 전후의 큰 메모리 크기 차이가 적다.(약 1.3MB)
const btn = document.getElementById("a");

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

btn.addEventListener("click".heavyJob);

// 클로저의 과도한 사용: 어디 사용하는 지와 상관없이 일단 메모리에 큰 배열을 올려둔다.(약 40.1MB)
const btn = document.getElementById("a");

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

const innerFunction = heavyJobWithClosure();

btn.addEventListener("click".innerFunction);
2개의 댓글
김영인
4개월 전
클로저는 참 어렵죠...! 포기하지 않고 힘내보자!
김영인
4개월 전
그래그래 힘내보자!