D5ng

JavaScript

구조적 측면으로 클로저 이해하기

클로저는 왜 어려울까? 개념만으로는 쉽게 이해되지 않는 경우가 많다. 이를 더 잘 이해하기 위해, 구조적인 측면으로 분석해보자.

DH
DongHyun Lee·FE Developer
14 min read·January 05, 2025
thumnnail

자바스크립트를 공부하다 보면 클로저라는 개념을 마주하게 된다. 클로저는 자바스크립트를 학습하는 많은 사람들에게 머리를 아프게한다. 그 이유는 클로저를 제대로 이해하기 위해 필요한 선행 지식들이 상당히 어렵고, 이를 충분히 숙지하지 못하면 개념 자체를 이해하기 어려워지기 때문이다.

클로저를 이해하기 위한 선행지식

클로저를 제대로 이해하기 위해 반드시 알아야 할 선행 지식들이 필요하다.

  • 렉시컬 스코프
  • 실행 컨텍스트
  • 일급 객체
  • 생명 주기
  • 함수형 프로그래밍

클로저란

클로저란 함수가 선언된 렉시컬 환경을 기억하며, 외부 함수의 실행이 종료된 이후에도 외부 변수에 접근할 수 있는 함수를 말한다. MDN 문서에서는 클로저를 다음과 같이 설명한다.

A closure is the combination of a function and the lexical environment within which that function was declared.
클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다.

이 문장은 이해하기 어려울 수 있지만, 밑의 내용을 읽고 나서도 이해가 되지 않는다면, 그때는 관련된 선행 지식들을 다시 공부하는 것이 좋다. 위 문장을 구조적인 측면으로 쪼개어 설명하면 다음과 같다.

함수가 선언된 렉시컬 환경이란?

자바스크립트는 렉시컬 스코프(Lexical Scope)를 따르며, 함수가 어디에서 정의되었는지에 따라 상위 스코프를 기억한다. 렉시컬 환경은 함수가 선언될 당시의 식별자와 스코프, 그리고 외부 렉시컬 환경에 대한 참조(상위 스코프)가 들어있는 환경이다. 따라서, "함수가 선언된 렉시컬 환경"이란 함수가 정의된 위치를 기준으로 구성된 변수와 스코프 정보를 의미한다고 이해할 수 있다.

외부 함수의 실행이 종료된 이후에도 변수에 접근할 수 있는 함수란?

우선 위 문장을 이해하려면 생명 주기에 대해 알아야 한다. 생명 주기란 식별자(변수, 함수 등)가 메모리에 생성되어 사용되고, 더 이상 참조되지 않을 때 가비지 컬렉터에 의해 제거되는 과정을 말한다. 일반적으로, 함수가 실행을 마치면 그 함수의 실행 컨텍스트와 함께 해당 함수 내의 변수는 메모리에서 제거된다. 하지만 어떤 함수가 해당 변수를 참조하고 있다면, 해당 변수는 참조가 유지되기 때문에 메모리에 남아있게 된다.

스코프 체인과 렉시컬 환경 덕분에 함수는 선언된 위치의 상위 스코프에 있는 식별자에 계속 접근할 수 있다. 이러한 특성 덕분에 함수는 외부 함수의 실행이 종료된 이후에도 상위 스코프의 식별자에 접근할 수 있다.

즉, 외부 함수의 실행이 종료된 이후에도 외부 변수에 접근할 수 있는 함수란, 함수가 선언된 렉시컬 환경을 기억하고 있어, 외부 함수 실행이 종료된 이후에도 해당 변수를 참조할 수 있는 함수를 말한다. 이러한 함수는 상위 스코프의 렉시컬 환경을 참조하기 때문에, 관련된 변수는 메모리에 유지된다.

이러한 동작이 가능한 이유는 JavaScript에서 함수가 일급 객체로 취급되기 때문이다. 즉, 함수는 다른 값처럼 변수에 저장되거나 반환값으로 사용할 수 있어, 상위 스코프의 렉시컬 환경을 함께 담아둘 수 있다.

함수형 프로그래밍과 어떤 연관이 있을까?

함수형 프로그래밍은 상태와 부작용을 최소화하고, 코드를 선언적으로 작성하는 프로그래밍 패러다임이다. 이 패러다임의 핵심은 동일한 입력에 대해 항상 동일한 결과를 반환하는 순수 함수와 변경할 수 없는 값, 즉 불변성을 사용하는 것이다.

클로저는 외부 상태를 직접 변경하지 않고, 부작용 없이 안전하게 상태를 관리할 수 있는 기능을 제공한다. 아래의 코드는 순수 함수로 작성된 예시는 아니지만, 상태 관리 측면에서 함수형 프로그래밍의 핵심 원칙을 지키고 있다. count 변수는 함수 내부에 은닉되어 있으며, setCount를 사용하지 않고는 외부에서 변경할 수 없다. 이처럼 클로저는 외부 상태를 안전하게 캡슐화하여 함수형 프로그래밍의 원칙과 잘 맞아떨어진다.

클로저는 자바스크립트 고유의 개념이 아니라 함수를 일급 객체로 취급하는 함수형 프로그래밍 언어(Functional Programming language: 얼랭(Erlnag), 스칼라(Scala), 하스켈(Haskell), 리스프(Lisp)…)에서 사용되는 중요한 특성이다.

클로저 사용 예시

function counterState(initialValue?: number) {
  let count = initialValue ?? 1
 
  const getCount = () => {
    return count
  }
 
  const setCount = (value: number) => {
    count = value
  }
 
  return [getCount, setCount] as const
}
 
const [getCount, setCount] = counterState(1)
 
console.log(getCount()) // 1
 
setCount(5)
 
console.log(getCount()) // 5

React의 useState는 클로저를 활용하고 있다.

useState는 React 컴포넌트에서 상태 관리를 위해 제공되는 훅으로, 내부적으로 클로저의 특성을 활용해 상태를 유지한다. React에서 리렌더링이 발생하면 컴포넌트 함수가 다시 호출되며, useState도 함께 호출된다. 하지만, useState는 항상 최신 상태를 반환한다.

즉, 렌더링이 반복되더라도 useState가 이전 렌더링에서 변경된 상태 값을 유지하며, 컴포넌트의 상태 일관성이 보장된다. 이를 근거로 useState는 클로저로 동작하고 있다는 것을 알 수 있다.

React useState 의사코드

const { render, useState } = (function () {
  const internals: Internals<any> = {
    states: [],
    rootComponent: null,
    currentStateIndex: 0,
  }
 
  function render<T>(component: Component<T>): ReturnType<Component<T>> {
    internals.rootComponent = component
    return _render()
  }
 
  function _render() {
    internals.currentStateIndex = 0
    const component = internals.rootComponent!()
    component.render()
    return component
  }
 
  function useState<T>(initialState: T) {
    const currentIndex = internals.currentStateIndex
    const state = internals.states[currentIndex] ?? initialState
 
    const setState = (newState: T | Partial<T>) => {
      internals.states[currentIndex] = newState
    }
 
    internals.currentStateIndex++
 
    return [state, setState]
  }
 
  return { render, useState }
})()
 
function Counter() {
  const [count, setCount] = useState(1)
 
  return {
    increase() {
      setCount(count + 1)
    },
    decrease() {
      setCount(count - 1)
    },
    render() {
      console.log("count", count)
    },
  }
}
 
let counterComponent: CounterComponent = render<CounterComponent>(Counter) // 1
counterComponent.increase()
counterComponent = render(Counter) // 2
counterComponent.increase()
counterComponent = render(Counter) // 3
counterComponent.decrease()
counterComponent = render(Counter) // 2

useState는 React의 상태 관리 훅을 모방한 구현으로, 내부적으로 클로저의 특성을 활용해 상태를 유지한다. 코드가 복잡해 보일 수 있지만, 동작 원리를 이해하려면 InternalsuseState의 구현을 중심으로 살펴보는 것이 핵심이다.

  • Internals

    • Internals객체는 컴포넌트의 상태와 훅의 인덱스를 저장하고 있다.
    • 이 객체는 외부에서 직접 접근할 수 없으며, 클로저로 캡슐화된 상태로, 반환된 함수(render, useState)를 통해서만 상태를 읽거나 수정할 수 있다.
  • useState

    • currentIndex는 useState 호출 순서를 추적하며, 각 훅의 상태를 고유하게 관리하기 위해 사용된다. (useState는 여러번 호출될 수 있다.)
    • internals.states, currentIndex를 참조하여 현재 상태가 있다면 반환하고 없다면 초기 상태를 설정하고 있다.
    • setState 함수는 internals.states[currentIndex]를 참조하여 상태를 변경한다.

이렇듯 클로저는 상태 관리의 핵심 도구로써 매우 유용하게 사용된다. 클로저를 활용하면 내부 상태를 외부로부터 안전하게 보호할 수 있을 뿐 아니라, 상태를 지속적으로 관리하고 유지하는 구조를 구현할 수 있다. React의 useState와 같은 상태 관리 훅은 클로저의 이러한 특성을 효과적으로 활용한 대표적인 예라 할 수 있다.

참고로 위 useState의 기능은 아직 부족한 점이 많다. 무엇이 부족한지 고민해보고 기능을 추가해보자!

클로저의 장점과 단점

장점

  • 상태 유지 - 클로저를 통해 외부 함수의 변수를 참조할 수 있다. 이는 함수가 실행된 이후에도 데이터를 유지할 수 있음을 의미하며, 데이터의 일관성을 보장한다.
  • 정보 은닉 - 클로저를 사용하면 외부 함수의 변수에 직접 접근할 수 없으며, 정의된 메서드를 통해서만 값을 읽거나 변경할 수 있다. 이를 통해 데이터 보호와 은닉이 가능하다.

단점

  • 메모리 누수 - 클로저를 남용할 경우, 참조가 끊기지 않은 변수가 메모리에 계속 유지되어 가비지 컬렉터가 메모리를 해제하지 못하는 상황이 발생할 수 있다.

마무리

클로저를 설명할 때, 정의와 장단점에 대해 얘기하면 간결할 수 있지만, 깊이를 전달하기에는 한계가 있다고 생각하고 너무 부족한 답변이라는 생각이 든다. 구조적인 측면에서 함께 설명하는 것이 프로그래밍 독해 능력을 보여주고, 자신이 깊이 있는 사고를 가진 사람임을 어필할 수 있는 좋은 방법이라고 생각한다.

추가적으로, 위 React.useState 의사코드의 핵심 로직들은 구글링을 통해 쉽게 얻을 수 있는 정보다. 하지만 구조적인 내용을 제대로 이해하지 못한다면, 이 코드를 여러 번 읽어도 스스로 구현해내는 것은 굉장히 어렵다. 코드가 짧지만, 그 안에는 상태 관리와 클로저를 활용한 많은 고민과 설계가 담겨 있다. 따라서 이 코드를 학습할 때는 단순히 암기하려는 태도보다는, "왜 이렇게 만들었을까?"라는 질문을 던지고 분석하려는 시도가 반드시 필요하다고 생각한다.