클로저와 자바스크립트에서의 클로저 (feat: 일급객체, 렉시컬 스코프)

te-ing·2025년 2월 24일
0

클로저란?

클로저란 외부 함수가 종료된 후에도, 내부 함수가 외부 함수의 지역 변수에 접근할 수 있는 함수를 의미한다. 함수가 실행되어 종료된 이후에도 종료된 외부 함수의 변수를 사용할 수 있기 때문에 상태유지, 데이터 은닉, 비동기 처리 등을 목적으로 사용할 수 있어, 많은 프로그래밍 언어에서 사용하고 있다.


상태유지와 데이터 은닉

function createCounter() {
    let count = 0;
    return function() {
        count++;
        return count;
    };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

위 예제에서는 createCounter는 실행과 동시에 종료되었으며, 반환된 함수는 counter라는 변수명에 저장되었다. 때문에 count에는 임의로 접근할 수 없으며, counter 함수의 클로저를 통해서만 수정할 수 있기 때문에 데이터 수정을 제한할 수 있다.
또한, count라는 변수를 전역에서 선언하지 않고도 상태를 유지할 수 있는 장점이 있다.


비동기 처리에서의 클로저

for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
      console.log(`Counter: ${i}`);
  }, i * 1000);
}

자바스크립트의 비동기 처리를 묻기 위해 자주 사용되는 예제이다. 간단하게 코드의 진행방식을 살펴보면,

  1. for문이 동작하면서 3번 setTimeout 함수를 콜스택에서 실행하며, 비동기 함수인 setTimeout은 이벤트루프에 의해 Web API에서 처리된다.
  2. setTimeout이 비동기처리 되는 동안 i는 3번의 for문을 거쳐 4가 된다.
  3. Web API에서 처리된 setTimeout은 이벤트루프에 의해 매크로 테스크큐를 거쳐 콜스택에서 실행된다.
  4. 이때의 i는 var로 선언되어 함수레벨 스코프를 가지기 때문에 setTimeout의 콜백함수 내부에서 참조하는 i는 var로 선언한 i 이며, 이미 i가 4가 되었기 때문에 Counter: 4 가 출력된다.

뭔가 이상한 이 코드의 해결방법은 콜백함수 별로 개별적인 i를 가지도록 하여 해결할 수 있다. 첫번째 방법은 블록레벨 스코프를 사용하여 for문이 실행될 때 마다 개별적인 스코프를 가지는 i를 선언하는 방법,
그리고 두번째 방법은 즉시실행함수와 클로저를 이용하여 렉시컬 스코프에 개별적인 i 를 저장하는 방법이 있다.

본 포스팅은 클로저를 중심으로 다루기 때문에, 클로저를 통해 해결하는 방법을 알아보자면 다음과 같다.

for (var i = 1; i <= 3; i++) {
  ((argument) => {
      setTimeout(() => {
          console.log(`Counter: ${argument}`);
      }, argument * 1000);
  })(i);
}

앞서 설명한 비동기 처리 과정을 제외하고 이 코드의 진행방식을 살펴보면,

  1. for 루프가 실행될 때마다 즉시 실행 함수 (argument) => { ... }가 호출되며, i 값이 argument에 전달된다.
  2. 즉시 실행 함수가 실행되면서 setTimeout의 콜백함수가 생성되고, 이때 콜백함수는 상위함수인 즉시실행함수의 argument를 참조하는 렉시컬 환경을 가진다.
  3. 이후 setTimeout의 콜백함수가 실행되면서 argument를 참조하는데, 즉시실행함수의 변수 argmunet는 이미 종료되었지만 렉시컬 환경에서 참조하고 있기 때문에 접근할 수 있다. (클로저를 통한 접근)
  4. console.log()가 실행될 때 실행하는 argument는 4가 된 var = i를 참조하는 것이 아니라 콜백함수 (()=> console.log(...))의 렉시컬 환경인 argument 값을 참조하기 때문에 Counter: 1, 2, 3이 출력된다.

즉, setTimeout의 콜백함수가 실행될 때 참조하는 argument는 이미 종료된 즉시실행함수 ((argument) => { ...})(i)의 i를 참조하기 때문에 각각 개별적인 i를 갖는 것이고, 4가 된 i 변수와는 무관한 것이다.


리액트 setState에서의 클로저?

이 부분은 사실 잘 모르겠다. 블로그를 보다보면 흔히들 React에서 useState를 사용할 때 클로저를 통해 이전 값을 유지한다고 한다. 하지만 이 부분이 의심스러워서 리액트의 구현부를 찾아보았는데, 리액트의 컴포넌트가 생성될때 별도의 Fiber 노드를 가지고, 여기에 있는 memoizedState에 저장되는 것 같다.

클로저를 통해서 리액트의 useState 기능을 구현할 수 있다는 것은 동의하지만, React에서 클로저를 통해 상태값을 유지하는 것은 아직 잘 모르겠다. 대부분의 블로그에서 리액트의 useState가 클로저를 사용하여 상태를 유지한다고 말하기 때문에 확신은 못하지만, 내 생각에는 렉시컬 스코프가 아니라 리액트 내부에서 유지하는 것 같다. 이부분은 조금 더 공부한 뒤에 수정할 예정이며, 아시는 분이 있다면 의견을 남겨주시길 바란다.


클로저는 자바스크립트만의 개념일까?

이처럼 활용도가 높은 클로저는 자바스크립트만의 고유 개념이 아니며, 현재 대부분의 언어에서 사용된다. 클로저를 사용하는 언어이자, 사용할 수 있는 언어는 다음과 같은 특징을 가진다.

  1. 렉시컬 스코프를 사용한다.
  2. 함수가 일급 객체이다.

렉시컬 스코프

렉시컬 스코프(정적 스코프)는 함수가 어디서 "선언"되었는지에 따라 변수의 유효 범위가 결정되는 방식을 말한다. 렉시컬 스코프와 반대되는 개념인, 다이나믹 스코프(동적 스코프)는 함수가 어디서 "호출"되었는지에 따라 변수의 유효범위가 결정된다. 그리고 현재 대부분의 언어는예측 가능성과 성능 최적화 등의 이유로 렉시컬 스코프를 채택하고 있다.

클로저와 렉시컬 스코프에 대해 이해했다면 왜 클로저를 사용하려면 렉시컬 스코프를 사용해야 하는지 알 수 있다. 더 쉽게 말하자면, 다이나믹 스코프에서는 클로저가 필요하지 않는 이유를 알 수 있다.

렉시컬 스코프는 선언된 위치에서 변수의 유효 범위가 결정되기 때문에 자신이 선언된 환경을 저장하며(렉시컬 환경), 이 개념을 기반으로 클로저가 사용된다. 반면에 다이나믹 스코프는 호출될 때 변수의 유효범위가 결정되므로 렉시컬 환경이 필요 없는 것이고, 자연스럽게 클로저도 사용할 필요가 없는 것이다.


일급 객체

프로그래밍 언어를 공부했다면 일급 객체라는 말을 무조건 들어봤을 것이다. 처음 일급 객체라는 단어를 봤을 때, 진짜 1급 객체를 말하는 것인가? 애니메이션 속 레벨을 지칭하는 것과 같은 유치한 네이밍은 어디서 왔을까 라는 의구심이 들었다.

결론으로 말하자면 일급 객체의 일은 1이 맞으며, 영문표현은 First-Class이다.
일급 객체는 1966년의 프로그래밍 언어의 설계에서 중요한 개념을 정의하며 사용된 단어로써, 특정 요소에 따라 1급, 2급, 3급으로 나누었다. 이때 할당, 전달, 반환이 모두 가능한 요소를 일급 객체라고 칭하였으며, 일부만 가능한 것은 이급, 매우 제한된 요소는 삼급으로 나누었다.

그럼 다시 클로저로 돌아가서, 왜 함수가 일급 객체여야 클로저를 사용할 수 있을까?

함수가 일급객체인 자바스크립트

function createCounter() {
    let count = 0;
    return function() {
        count++;
        return count;
    };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

앞서 소개한 전형적인 클로저의 예시이다. 클로저의 정의를 다시 살펴보면, 클로저란 외부 함수가 종료된 후에도, 내부 함수가 외부 함수의 지역 변수에 접근할 수 있는 함수를 의미한다.

위 예시에서는 createCounter 라는 함수를 counter 라는 변수에 저장했으며, createCounter(외부함수)는 종료되었지만 createCounter함수에서 반환된 함수(내부함수) function(){ ... return count; }가 createCounter의 변수인 count(외부함수의 지역 변수)를 참조하고 있기 때문에 count가 유지되며 접근할 수 있는 것이다.

이처럼 함수가 클로저가 되려면, 함수가 반환된 이후에도 렉시컬 환경을 유지할 수 있어야 한다. 이를 위해 함수를 변수에 저장하거나 반환할 수 있어야 하며, 이는 함수가 일급 객체일 때 가능하다. 따라서 함수가 일급 객체여야만 클로저가 성립할 수 있다.

함수가 일급객체가 아닌 C언어

#include <stdio.h>

void outer() {
    int count = 0;

    void inner() {  // C는 함수가 일급 객체가 아니므로 내부 함수를 반환할 수 없음
        count++;
        printf("%d\n", count);
    }

    inner();
}

int main() {
    outer();
    outer();  // 새로 호출될 때마다 count 값이 초기화됨
    return 0;
}

자바스크립트는 사용자 입력, 네트워크 요청과 같이 실행 중 동적으로 변화하는 환경(브라우저)에서 동작해야 했기 때문에, 함수를 런타임에서 생성하거나 반환할 수 있도록 설계되었으며, 함수의 메모리 저장 방식도 동적으로 변경될 수 있도록 힙(Heap) 메모리에 할당되도록 설계되었다.

반면, C언어는 하드웨어와 친화적인 정적환경에서 실행되도록 설계된 언어이기 때문에, 런타임에서의 동적 처리를 최소화하여 최적화하였다. 따라서, 실행 중에 새로운 함수를 동적으로 생성하거나 반환할 필요가 없었으며, 모든 함수는 컴파일 시점에 고정된 메모리 주소에 저장되었다.

때문에 C언어의 예시에서는 자바스크립트의 예시처럼 클로저를 사용한 상태유지가 불가능한 것이다.



참고: You Don’t Know JS Yet, https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

profile
프론트엔드 개발자

0개의 댓글

관련 채용 정보