클로저, 진짜 알고 활용하고 있을까?

클로저, 진짜 알고 활용하고 있을까?

요즘 자바스크립트 공부를 다시 하면서 클로저라는 개념을 제대로 이해해보고 싶다는 생각이 들었다. 그동안 자바스크립트로 개발하면서, 나는 클로저를 따로 생각하지 않고도 매일 사용하고 있었다. 리액트 훅이나 이벤트 핸들러를 작성할 때도 클로저의 메커니즘이 작동하고 있었지만, 그 원리를 제대로 이해하지 못했던 것이다.

왜 클로저를 이해해야 하는가?

클로저는 자바스크립트의 핵심 메커니즘으로, 다음과 같은 실질적인 문제를 해결해준다

  • 상태 관리: 변수의 상태를 안전하게 유지하면서 관리할 수 있게 해준다
  • 정보 은닉: 외부에서 직접 접근할 수 없는 비공개 변수를 만들 수 있다
  • 모듈화: 관련 기능을 논리적으로 묶어 코드의 구조화가 가능하다
  • 콜백과 비동기 처리: 비동기 작업에서 컨텍스트를 유지할 수 있게 해준다

이러한 기능들은 웹 개발에서 필수적인 요소들로, 클로저를 제대로 이해하면 자바스크립트 코드의 동작 원리를 더 깊이 파악하는데 도움 될 수 있다.

클로저란 무엇인가?

클로저는 함수가 선언되었을 때의 렉시컬 환경(Lexical Environment)을 기억해서 나중에 호출될 때 그 환경의 변수들에 접근할 수 있는 특성을 가진 함수이다. 쉽게 말해, 함수가 생성될 당시의 주변 변수들을 '기억'하고 있다가, 나중에 함수가 호출될 때 그 변수들에 접근할 수 있게 해주는 메커니즘이다.

예를 들어 책갈피와 같다고 생각할 수 있다. 당신이 책(코드)을 읽다가 책갈피(클로저)를 꽂아두면, 나중에 다시 그 책을 펼쳤을 때 책갈피가 꽂힌 페이지와 그 주변 내용(렉시컬 환경)을 기억해서 바로 접근할 수 있는 것과 같다.

클로저의 동작 원리

자바스크립트에서 클로저의 동작 원리는 실행 컨텍스트(Execution Context)와 렉시컬 환경(Lexical Environment)이라는 개념과 깊이 연관되어 있다.

함수가 정의될 때, 그 함수는 자신이 생성된 렉시컬 환경에 대한 참조를 내부 속성 [[Environment]]로 저장한다. 이후에 함수가 어디서 호출되든 상관없이, 그 함수는 자신이 정의된 환경을 기억하고 있어 그 환경의 변수들에 접근할 수 있다.

실행 컨텍스트가 생성될 때 렉시컬 환경 객체가 만들어지는데, 이는 두 부분으로 구성된다:

  1. 환경 레코드(Environment Record): 변수와 함수 선언을 실제로 저장
  2. 외부 렉시컬 환경에 대한 참조(Outer Lexical Environment Reference): 바깥쪽 스코프를 가리킴

클로저가 동작하는 방식은 바로 이 외부 렉시컬 환경에 대한 참조를 통해 스코프 체인(Scope Chain)을 따라 변수를 찾아가는 과정이다.

function createCounter() {
  let count = 0 // 외부에서 직접 접근 불가능한 변수

  return function () {
    count++ // 클로저를 통해 count 변수에 접근
    return count
  }
}

const counter = createCounter()
console.log(counter()) // 출력: 1
console.log(counter()) // 출력: 2

위 예시를 단계별로 설명하면:

  1. createCounter 함수가 호출되면 실행 컨텍스트가 생성되고, 지역 변수 count가 0으로 초기화된다.
  2. 내부 함수가 정의되고, 이 함수는 자신이 생성된 렉시컬 환경(여기서는 createCounter의 환경)에 대한 참조를 저장한다.
  3. 내부 함수가 반환되어 counter 변수에 할당된다.
  4. createCounter 함수의 실행이 완료되지만, 반환된 내부 함수가 여전히 createCounter의 렉시컬 환경을 참조하고 있어 count 변수는 메모리에서 해제되지 않는다.
  5. counter 함수가 호출될 때마다, 클로저를 통해 count 변수에 접근하여 값을 증가시키고 반환한다.

클로저를 통한 데이터 은닉과 캡슐화

클로저는 자바스크립트에서 데이터 은닉과 캡슐화를 구현하는 자연스러운 방법이다.

클로저를 활용한 데이터 캡슐화 예시:

function createBankAccount(initialBalance) {
  let balance = initialBalance // 외부에서 직접 접근 불가능한 변수

  return {
    // 입금 메서드
    deposit(amount) {
      if (amount <= 0) {
        return '유효한 입금액이 아닙니다.'
      }
      balance += amount
      return `${amount}원이 입금되었습니다. 현재 잔액: ${balance}`
    },

    // 출금 메서드
    withdraw(amount) {
      if (amount <= 0) {
        return '유효한 출금액이 아닙니다.'
      }
      if (amount > balance) {
        return '잔액이 부족합니다.'
      }
      balance -= amount
      return `${amount}원이 출금되었습니다. 현재 잔액: ${balance}`
    },

    // 잔액 확인 메서드
    getBalance() {
      return `현재 잔액: ${balance}`
    },
  }
}

const myAccount = createBankAccount(10000)
console.log(myAccount.getBalance()) // 출력: 현재 잔액: 10000원
console.log(myAccount.deposit(5000)) // 출력: 5000원이 입금되었습니다. 현재 잔액: 15000원
console.log(myAccount.withdraw(3000)) // 출력: 3000원이 출금되었습니다. 현재 잔액: 12000원
console.log(myAccount.balance) // 출력: undefined (직접 접근 불가)

이 패턴의 특징:

  1. 데이터 은닉: balance 변수는 외부에서 직접 접근할 수 없으며, 오직 제공된 메서드를 통해서만 조작할 수 있다.
  2. 인터페이스 제공: 제한된 인터페이스(입금, 출금, 잔액 확인)만 노출하여 데이터 조작 방법을 제어한다.
  3. 유효성 검사: 각 메서드에서 데이터 조작 전에 유효성 검사를 수행하여 데이터 무결성을 보장한다.
  4. 컨텍스트 유지: 각 계좌마다 독립적인 상태(잔액)를 유지할 수 있다.

클로저는 단순히 데이터 은닉의 도구가 아니라, 자바스크립트의 함수형 특성을 강화하는 핵심 메커니즘이다.

우리가 의식하지 못했던 클로저의 활용

놀랍게도 우리는 이미 일상적인 자바스크립트 개발에서 클로저를 광범위하게 활용하고 있었다. 의식하지 못했을 뿐이다.

1. 리액트 훅 시스템

사람들이 아주 많이 쓰는 useState, useEffect 등등 훅들도 클로저를 활용하고 있다는걸 의식하고 있었는지 모르겠지만 리액트 개발의 핵심이 된 이 훅들은 클로저라는 메커니즘 없이는 작동할 수 없다. 특히 useState 훅이 어떻게 상태를 유지하는지 살펴보자:

function Counter() {
  const [count, setCount] = useState(0)

  return <button onClick={() => setCount(count + 1)}>클릭 수: {count}</button>
}

이 컴포넌트가 리렌더링될 때마다 Counter 함수는 새로 실행되지만, count 값은 초기화되지 않고 유지된다. 이게 가능한 이유가 바로 클로저다. setCount 함수는 클로저를 통해 어떤 상태를 업데이트해야 하는지 "기억"하고 있는 것이다.

useEffect도 마찬가지로 클로저를 활용한다:

useEffect(() => {
  // 이 콜백 함수는 클로저를 통해 count 변수를 캡처
  document.title = `클릭 수: ${count}`

  // 클린업 함수도 클로저를 통해 이전 count 값에 접근 가능
  return () => {
    console.log(`이전 클릭 수는 ${count}였습니다.`)
  }
}, [count]) // 의존성 배열

여기서 콜백 함수는 클로저를 통해 count 변수를 캡처하고, 리액트는 이전 렌더링의 count 값을 기억해서 변경 여부를 확인한다.

2. 이벤트 핸들링

이벤트 리스너도 클로저의 전형적인 활용 사례다:

function setupButtons() {
  const buttons = [
    { id: 'btn1', message: '버튼 1을 클릭했습니다!' },
    { id: 'btn2', message: '버튼 2를 클릭했습니다!' },
    { id: 'btn3', message: '버튼 3을 클릭했습니다!' },
  ]

  buttons.forEach((button) => {
    const element = document.getElementById(button.id)

    // 클로저: 각 이벤트 핸들러는 자신의 button.message를 기억
    element.addEventListener('click', function () {
      alert(button.message)
    })
  })
}

setupButtons()

이 예제에서는 반복문 내에서 여러 개의 이벤트 핸들러가 생성되는데, 각 핸들러는 클로저를 통해 자신에 해당하는 button.message를 기억하고 있다. 이벤트 핸들러가 나중에 호출될 때, 정확한 메시지를 표시할 수 있는 것이다.

3. 비동기 처리와 콜백

Ajax 요청이나 Promise 체인에서도 클로저가 필수적으로 활용된다:

function fetchUserData(userId) {
  const requestTime = Date.now()
  const requestId = `request_${Math.random().toString(36).substr(2, 9)}`

  console.log(`${requestId}: ${userId} 사용자 정보 요청 시작`)

  return fetch(`/api/users/${userId}`)
    .then((response) => {
      // 비동기 콜백에서도 requestTime, requestId, userId에 접근 가능
      console.log(`${requestId}: 응답 수신 (${Date.now() - requestTime}ms)`)
      return response.json()
    })
    .then((data) => {
      // 여러 단계의 비동기 처리 후에도 변수들에 접근 가능
      console.log(
        `${requestId}: ${userId} 사용자 데이터 처리 완료 (총 ${Date.now() - requestTime}ms)`
      )
      return {
        ...data,
        processingTime: Date.now() - requestTime,
      }
    })
    .catch((error) => {
      // 에러 처리에서도 동일한 변수들에 접근 가능
      console.error(`${requestId}: ${userId} 사용자 정보 요청 실패 (${Date.now() - requestTime}ms)`)
      throw error
    })
}

// 사용 예
fetchUserData(123).then((userData) => {
  console.log(`사용자 이름: ${userData.name}, 처리 시간: ${userData.processingTime}ms`)
})

이 예제에서는 requestTime, requestId, userId 같은 변수들이 비동기 콜백 체인 전체에서 접근 가능하다. 각 콜백 함수는 클로저를 통해 이 변수들을 기억하고 있다.

4. 디바운싱과 쓰로틀링

이벤트 발생 빈도를 제어하는 기법인 디바운싱(Debouncing)과 쓰로틀링(Throttling)도 클로저를 활용한다:

// 디바운스 함수 구현
function debounce(func, wait) {
  let timeout

  // 반환되는 함수가 클로저
  return function executedFunction(...args) {
    // 이전 타임아웃을 클리어 (이전 호출 취소)
    clearTimeout(timeout)

    // 새로운 타임아웃 설정
    timeout = setTimeout(() => {
      func(...args) // wait 밀리초 후에 원본 함수 실행
    }, wait)
  }
}

// 실제 사용 예: 검색 입력 처리
const searchInput = document.getElementById('search-input')
const originalSearchFunction = (event) => {
  const searchTerm = event.target.value
  console.log(`검색어 "${searchTerm}"로 API 요청 중...`)
  // API 요청 로직
}

// 300ms 디바운스 적용
const debouncedSearch = debounce(originalSearchFunction, 300)

// 이벤트 리스너 등록
searchInput.addEventListener('input', debouncedSearch)

이 구현에서 반환되는 함수는 클로저를 통해 timeout 변수를 계속 참조하여, 연속된 호출 사이에 타이머를 관리할 수 있다. 이를 통해 사용자가 빠르게 타이핑할 때 불필요한 API 요청을 줄이고, 타이핑을 잠시 멈췄을 때만 요청을 보내는 효과를 얻을 수 있다.

5. 모듈 패턴

자바스크립트의 모듈 패턴은 클로저를 활용한 대표적인 디자인 패턴이다:

// 즉시 실행 함수를 이용한 모듈 패턴
const ShoppingCart = (function () {
  // 비공개 변수 (클로저를 통해 보호됨)
  let items = []
  let total = 0

  // 비공개 함수
  function calculateTotal() {
    total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  }

  // 공개 인터페이스
  return {
    // 상품 추가
    addItem(name, price, quantity = 1) {
      items.push({ name, price, quantity })
      calculateTotal()
      return this // 메서드 체이닝 지원
    },

    // 상품 제거
    removeItem(name) {
      const index = items.findIndex((item) => item.name === name)
      if (index !== -1) {
        items.splice(index, 1)
        calculateTotal()
      }
      return this
    },

    // 장바구니 정보 출력
    getCartInfo() {
      return {
        itemCount: items.length,
        total: total,
        items: [...items], // 원본 배열을 직접 노출하지 않고 복사본 반환
      }
    },

    // 장바구니 비우기
    clearCart() {
      items = []
      total = 0
      return this
    },
  }
})()

// 사용 예
ShoppingCart.addItem('노트북', 1200000).addItem('마우스', 35000, 2).addItem('키보드', 120000)

console.log(ShoppingCart.getCartInfo())
// { itemCount: 3, total: 1390000, items: [...] }

이 모듈 패턴에서 itemstotal 변수, 그리고 calculateTotal 함수는 외부에서 직접 접근할 수 없는 비공개 멤버다. 오직 반환된 객체의 메서드를 통해서만 장바구니를 조작할 수 있다.

클로저의 장단점

장점

  • 상태 유지: 함수가 호출된 이후에도 상태를 유지할 수 있다.
  • 정보 은닉: 변수를 외부로부터 보호하고 접근을 제어할 수 있다.
  • 모듈화와 캡슐화: 관련 기능과 데이터를 논리적으로 함께 묶을 수 있다.
  • 유연한 함수 생성: 함수 팩토리, 부분 적용(partial application), 커링(currying) 등 다양한 함수형 패턴을 구현할 수 있다.
  • 콜백 컨텍스트 유지: 비동기 작업에서 원본 컨텍스트에 접근할 수 있다.

단점

  • 메모리 사용: 클로저가 참조하는 환경이 메모리에서 해제되지 않아 메모리 사용량이 증가할 수 있다.
  • 성능 영향: 과도한 클로저 사용은 메모리 사용과 가비지 컬렉션에 영향을 미칠 수 있다.
  • 디버깅 복잡성: 클로저가 참조하는 값이 어떤 스코프의 어떤 값인지 파악하기 어려울 수 있다.
  • 가독성 저하: 중첩된 클로저를 과도하게 사용하면 코드 가독성이 떨어질 수 있다.
  • 메모리 누수 가능성: 실수로 불필요한 참조를 유지하면 메모리 누수가 발생할 수 있다.

클로저 사용 시 메모리 관리 가이드라인

클로저를 효율적으로 사용하고 메모리 문제를 방지하기 위한 몇 가지 실용적인 팁:

1. 필요한 변수만 캡처하기

클로저는 외부 함수의 모든 변수를 캡처하는 것이 아니라, 실제로 사용하는 변수만 캡처한다. 하지만 코드를 작성할 때 명시적으로 필요한 변수만 사용하는 것이 좋다:

// 좋지 않은 예
function createFunction(data) {
  const hugeArray = new Array(10000).fill('불필요한 데이터')
  const necessaryValue = data.value

  return function () {
    // hugeArray는 사용하지 않지만, 이론적으로는 접근 가능하므로
    // 메모리에서 해제되지 않을 수 있음
    return necessaryValue * 2
  }
}

// 개선된 예
function createFunction(data) {
  // 필요한 값만 지역 변수로 저장
  const necessaryValue = data.value

  return function () {
    return necessaryValue * 2
  }
}

2. 클로저 참조 해제하기

클로저가 더 이상 필요하지 않으면 참조를 해제하여 메모리를 확보할 수 있다:

// 이벤트 핸들러에서의 예
function setupHandler(element) {
  // 큰 데이터
  const largeData = new Array(1000000).fill('데이터')

  function handleClick() {
    // largeData 사용
    console.log('데이터 크기:', largeData.length)
  }

  element.addEventListener('click', handleClick)

  // 핸들러 제거 함수 반환
  return function cleanup() {
    element.removeEventListener('click', handleClick)
    // 명시적으로 참조 해제는 불필요하지만, 의도를 명확히 하기 위해 포함
    // handleClick = null;
  }
}

const cleanup = setupHandler(document.getElementById('myButton'))

// 나중에 정리할 때
cleanup()

3. 즉시 실행 함수로 스코프 제한하기

글로벌 스코프 오염을 방지하고 클로저의 영향 범위를 제한하려면 즉시 실행 함수 표현식(IIFE)을 사용할 수 있다:

// 좋지 않은 예: 글로벌 변수 사용
let counter = 0
function incrementCounter() {
  return ++counter
}

// 개선된 예: IIFE와 클로저 사용
const counter = (function () {
  let count = 0 // 비공개 변수

  return function () {
    return ++count
  }
})()

// 사용
console.log(counter()) // 1
console.log(counter()) // 2

4. 대규모 애플리케이션에서 클로저 사용 관리

대규모 애플리케이션에서는 클로저 사용을 체계적으로 관리해야 한다:

// 명확한 수명 주기 관리: 컴포넌트 기반 접근법
function createComponent() {
  // 컴포넌트 상태
  const state = {
    data: [],
    isLoading: false,
  }

  // 비공개 메서드
  function fetchData() {
    state.isLoading = true
    // 데이터 가져오기 로직...
  }

  // 공개 API
  const api = {
    initialize() {
      fetchData()
    },

    update() {
      // 업데이트 로직...
    },

    // 정리 메서드 - 명시적으로 참조 해제
    destroy() {
      state.data = null
      // 다른 정리 작업...
    },
  }

  return api
}

// 사용
const myComponent = createComponent()
myComponent.initialize()

// 필요 없어지면 명시적으로 정리
myComponent.destroy()

자바스크립트 엔진은 어떻게 클로저를 최적화할까?

클로저의 동작 방식을 이해했다면, 자연스럽게 드는 의문이 있다. "클로저가 모든 외부 변수와 환경을 기억한다면, 이는 상당한 메모리와 성능 오버헤드를 발생시키지 않을까?"

초기 자바스크립트 엔진에서는 실제로 클로저 사용이 성능에 부담을 주었다. 하지만 현대 자바스크립트 엔진, 특히 V8 엔진(Chrome과 Node.js에서 사용)은 클로저를 위한 다양한 최적화 기법을 적용하고 있다.

1. 스마트한 변수 캡처링

V8 엔진은 코드를 분석하여 클로저에서 실제로 사용되는 변수만 캡처한다:

function createFunction() {
  const unused = '사용되지 않는 변수'
  const used = '클로저에서 사용되는 변수'

  return function () {
    return used // 오직 used 변수만 클로저에 필요
  }
}

const fn = createFunction()
console.log(fn()) // "클로저에서 사용되는 변수"

초기 엔진은 모든 로컬 변수를 클로저 환경에 포함시켰지만, 현대 엔진은 used 변수만 캡처하여 메모리를 절약한다.

2. 인라인 캐싱으로 변수 접근 최적화

클로저 변수에 반복적으로 접근할 때, V8은 이 접근 경로를 캐싱하여 매번 스코프 체인을 따라 검색하는 비용을 줄인다:

const counter = (function () {
  let count = 0

  return function () {
    // 반복적인 count 변수 접근은 최적화된다
    return ++count
  }
})()

// 반복 호출에서 최적화 효과가 발휘된다
for (let i = 0; i < 1000000; i++) {
  counter()
}

최초 접근 시에는 스코프 체인을 따라 count 변수를 찾지만, 이후 접근에서는 이미 찾은 위치를 기억하여 빠르게 접근할 수 있다.

3. 히든 클래스 최적화

V8은 객체 구조를 효율적으로 다루기 위해 "히든 클래스(Hidden Class)" 메커니즘을 사용한다. 클로저 내에서 일관된 방식으로 객체를 생성하고 접근하면, 이 최적화의 혜택을 받을 수 있다:

function createObjectFactory() {
  // 클로저 환경의 일부
  const shared = { x: 1 }

  return function () {
    // 항상 같은 방식으로 객체를 생성하면 최적화된다
    return { y: shared.x + 1 }
  }
}

const factory = createObjectFactory()
// 동일한 패턴으로 객체 생성 시 히든 클래스 최적화가 적용된다
const obj1 = factory()
const obj2 = factory()

4. 함수 인라인화와 특화

자주 호출되는 클로저 함수에 대해, V8은 함수 호출 오버헤드를 줄이기 위해 인라인화(inlining)와 특화(specialization)를 적용한다:

function createMultiplier(factor) {
  // 클로저를 반환
  return function (x) {
    return x * factor
  }
}

const double = createMultiplier(2)
const triple = createMultiplier(3)

// 같은 인자로 반복 호출 시 엔진이 최적화
let result = 0
for (let i = 0; i < 100000; i++) {
  // JIT 컴파일러가 이 호출을 최적화할 수 있음
  result += double(i)
}

이런 최적화 덕분에 현대 자바스크립트 엔진에서 클로저 사용의 성능 부담은 크게 줄어들었다. 일반적인 사용 패턴에서는 성능 문제를 크게 걱정할 필요가 없다.

클로저에 대한 고찰

클로저는 자바스크립트의 핵심 개념이면서도 이상하게 의식적으로 활용하지 않는 경우가 많다. 그저 '그렇게 동작하니까' 사용하는 경우가 대부분이다. 하지만 클로저의 개념을 제대로 이해하면, 코드를 더 깔끔하고 효율적으로 작성할 수 있다.

자바스크립트의 본질을 생각해보면, 이 언어는 다른 언어와는 다른 독특한 특성을 가지고 있다는 점을 깨달을 수 있다. 근본적으로 자바스크립트는 함수와 클로저를 중심으로 설계된 언어이며, 이것이 자바스크립트만의 강력한 표현력을 만들어낸다.

클로저는 때로는 복잡한 문제를 우아하게 해결하는 강력한 도구이며, 때로는 의도치 않은 버그와 메모리 이슈의 원인이 되기도 한다. 개발자로서 우리의 임무는 이 도구를 이해하고 적절히 활용하여 그 장점을 최대화하고 단점을 최소화하는 것이다.

현대 웹 개발에서 클로저는 단순한 이론적 개념이 아니라 실제로 많은 프레임워크와 라이브러리의 핵심 메커니즘이다. 리액트, Vue, Angular 등 주요 프레임워크들이 모두 클로저를 활용하여 우아한 API와 상태 관리 시스템을 구현하고 있다.

마치며

클로저는 처음에는 이해하기 어려울 수 있지만, 자바스크립트 개발에서 매우 강력한 도구다. 우리가 의식하지 못하는 사이에도 이미 널리 활용되고 있었던 이 개념을 이제는 좀 더 명확히 이해하고 의식적으로 활용할 수 있게 되었다.

앞으로 코드를 작성할 때, "이 부분에 클로저를 활용할 수 있을까?"라는 질문을 가끔씩 던져보는 것도 좋을 것 같다. 아마도 더 우아하고 효율적인 해결책을 찾을 수 있을 것이다. 자바스크립트의 진정한 힘은 이런 독특한 개념들을 제대로 이해하고 활용할 때 나타난다.

결국 클로저는 우리가 의식하지 못했을 뿐, 항상 우리 곁에 있었던 강력한 도구였던 것이다.