junyeokk
Blog
vanilla-js-patterns·2024. 08. 22

클로저 기반 useState 구현

React의 useState는 함수 컴포넌트에서 상태를 관리하는 핵심 훅이다. 그런데 프레임워크 없이 Vanilla JS로 개발할 때도 비슷한 패턴이 필요한 순간이 온다. 예를 들어 DOM 요소를 동적으로 생성하는 함수 안에서 "이 입력 필드의 원래 값"을 기억해야 할 때, 전역 변수를 쓰자니 지저분하고 클래스 인스턴스 프로퍼티를 쓰자니 과하다.

이런 상황에서 클로저를 활용하면 React의 useState와 유사한 인터페이스를 Vanilla JS에서 구현할 수 있다.


왜 필요한가

DOM을 직접 조작하는 Vanilla JS 코드에서 상태 관리는 보통 이렇게 한다.

javascript
// 1. 전역 변수
let inputValue = "";

function createForm() {
  const input = document.createElement("input");
  input.value = inputValue;
  input.addEventListener("change", (e) => {
    inputValue = e.target.value;
  });
  return input;
}

간단하지만 문제가 많다. 전역 변수는 어디서든 접근 가능하니까 의도치 않게 덮어씌워질 수 있고, 같은 함수를 여러 번 호출하면 모든 인스턴스가 하나의 변수를 공유해버린다. 폼이 두 개면? 값이 섞인다.

javascript
// 2. 객체 프로퍼티로 관리
const state = {
  form1Input: "",
  form2Input: "",
  form1Textarea: "",
  // ... 계속 늘어남
};

상태가 늘어날수록 객체가 비대해지고, 어떤 상태가 어떤 UI에 연결되어 있는지 추적하기 어려워진다.

React의 useState가 해결하는 문제가 바로 이것이다. 상태를 선언한 곳에서 캡슐화하고, getter/setter 쌍으로 접근을 제어한다. 이 아이디어를 클로저로 옮기면 프레임워크 없이도 같은 효과를 낼 수 있다.


구현

javascript
const useState = (initialValue) => {
  let value = initialValue;

  const setValue = (newValue) => {
    value = newValue;
  };

  return [() => value, setValue];
};

이게 전부다. 10줄도 안 되는 코드지만, 여기에 클로저의 핵심이 담겨 있다.

동작 원리

  1. useState가 호출되면 value라는 지역 변수가 생긴다
  2. setValue와 반환되는 getter 함수 () => value가 이 value클로저로 캡처한다
  3. useState 함수의 실행이 끝나도 value는 가비지 컬렉션되지 않는다 — getter와 setter가 참조를 유지하고 있기 때문
  4. 외부에서는 getter/setter를 통해서만 value에 접근할 수 있다

React useState와의 차이점

겉보기에는 비슷하지만, React의 useState와는 근본적으로 다른 점이 있다.

getter가 함수다

React에서는 const [count, setCount] = useState(0) 하면 count가 바로 값이다. 하지만 Vanilla JS 버전에서는 getter가 함수여야 한다.

javascript
// React
const [count, setCount] = useState(0);
console.log(count); // 0

// Vanilla JS 버전
const [getCount, setCount] = useState(0);
console.log(getCount()); // 0

이유는 간단하다. JavaScript에서 원시 값(number, string, boolean)은 값에 의한 전달(pass by value) 이 적용된다. 만약 값을 직접 반환하면 어떻게 될까?

javascript
// 만약 이렇게 구현했다면
const useState = (initialValue) => {
  let value = initialValue;
  const setValue = (newValue) => {
    value = newValue;
  };
  return [value, setValue]; // 값을 직접 반환
};

const [count, setCount] = useState(0);
setCount(5);
console.log(count); // 여전히 0! value가 복사되었기 때문

구조 분해 할당 시점에 value현재 값count에 복사된다. 이후 setValue로 클로저 내부의 value를 바꿔도 이미 복사된 count는 변하지 않는다.

그래서 getter를 함수로 반환해야 한다. 함수는 호출될 때마다 클로저 내부의 최신 value를 읽어오기 때문이다.

javascript
const [getCount, setCount] = useState(0);
setCount(5);
console.log(getCount()); // 5 — 항상 최신 값

React에서는 이 문제를 리렌더링으로 해결한다. 상태가 바뀌면 컴포넌트 함수가 다시 호출되면서 새로운 count 값을 받기 때문이다. Vanilla JS에는 리렌더링 시스템이 없으니까 getter 함수로 우회하는 것이다.

리렌더링이 없다

React의 setState는 상태 변경 → 리렌더링 → UI 업데이트를 자동으로 트리거한다. Vanilla JS 버전은 순수하게 값만 저장/반환할 뿐, DOM 업데이트는 별도로 처리해야 한다.

javascript
const [getValue, setValue] = useState("");

input.addEventListener("change", (e) => {
  setValue(e.target.value);
  // DOM 업데이트는 직접 해야 한다
  display.textContent = getValue();
});

이건 단점이 아니라 설계 선택이다. 자동 리렌더링이 필요하면 Observer 패턴이나 별도의 렌더링 시스템을 결합하면 된다.


사용 패턴

기본 사용

javascript
function createEditableCard(data) {
  const [getOriginalTitle, setOriginalTitle] = useState(data?.title ?? "");
  const [getOriginalDesc, setOriginalDesc] = useState(data?.description ?? "");

  const card = document.createElement("div");

  const titleInput = document.createElement("input");
  titleInput.value = getOriginalTitle();

  const descTextarea = document.createElement("textarea");
  descTextarea.value = getOriginalDesc();

  // 취소 버튼: 원래 값으로 복원
  const cancelBtn = document.createElement("button");
  cancelBtn.textContent = "취소";
  cancelBtn.addEventListener("click", () => {
    titleInput.value = getOriginalTitle();
    descTextarea.value = getOriginalDesc();
  });

  // 저장 버튼: 현재 값을 원본으로 갱신
  const saveBtn = document.createElement("button");
  saveBtn.textContent = "저장";
  saveBtn.addEventListener("click", () => {
    setOriginalTitle(titleInput.value);
    setOriginalDesc(descTextarea.value);
    // API 호출 등
  });

  card.append(titleInput, descTextarea, cancelBtn, saveBtn);
  return card;
}

각 카드가 독립적인 useState 클로저를 가지므로, 카드를 여러 개 생성해도 상태가 섞이지 않는다.

여러 인스턴스의 독립성

javascript
const [getA, setA] = useState(1);
const [getB, setB] = useState(100);

setA(2);
console.log(getA()); // 2
console.log(getB()); // 100 — 서로 독립적

useState를 호출할 때마다 새로운 클로저가 생성되므로, 각각의 value는 완전히 분리된 메모리 공간에 존재한다.


클로저 깊이 이해하기

이 구현이 동작하는 이유를 좀 더 깊이 파보자.

렉시컬 스코프

JavaScript의 함수는 자신이 정의된 위치의 스코프를 기억한다. 이를 렉시컬 스코프(lexical scope)라고 한다.

javascript
const useState = (initialValue) => {
  let value = initialValue; // --- 이 스코프가

  const setValue = (newValue) => {
    value = newValue; // --- 여기서 기억된다
  };

  return [() => value, setValue];
  //          ^^^^^ 여기서도 기억된다
};

setValue와 getter 함수가 정의된 시점value가 존재하는 스코프를 캡처한다. useState 함수의 실행 컨텍스트가 콜 스택에서 사라져도, 반환된 함수들이 그 스코프에 대한 참조를 유지하는 한 value는 살아 있다.

가비지 컬렉션과의 관계

javascript
function example() {
  const [getValue, setValue] = useState(42);

  // getValue와 setValue가 이 스코프 안에서만 존재하면
  // example() 실행이 끝난 후 GC 대상이 된다

  return { getValue, setValue };
  // 하지만 외부로 반환하면 참조가 유지되어 GC되지 않는다
}

const state = example();
state.getValue(); // 42 — 클로저가 살아있다

클로저가 메모리 누수의 원인이 될 수 있다는 점도 알아야 한다. 더 이상 필요 없는 상태의 참조를 null로 설정하면 GC가 수거할 수 있다.


확장: setter에 함수 전달

React의 useState는 setter에 함수를 전달할 수 있다. setCount(prev => prev + 1) 형태로 이전 값을 기반으로 새 값을 계산하는 패턴이다. 이것도 쉽게 추가할 수 있다.

javascript
const useState = (initialValue) => {
  let value = initialValue;

  const setValue = (newValue) => {
    if (typeof newValue === "function") {
      value = newValue(value);
    } else {
      value = newValue;
    }
  };

  return [() => value, setValue];
};
javascript
const [getCount, setCount] = useState(0);
setCount(1);             // 직접 값 설정
setCount((prev) => prev + 1); // 이전 값 기반 업데이트
console.log(getCount()); // 2

함수형 업데이트는 특히 이벤트 핸들러에서 유용하다. 비동기 상황에서 최신 값을 보장받을 수 있기 때문이다.


확장: 변경 감지 콜백 추가

상태가 바뀔 때 자동으로 뭔가 실행하고 싶다면 콜백을 추가할 수 있다.

javascript
const useState = (initialValue, onChange) => {
  let value = initialValue;

  const setValue = (newValue) => {
    const resolved = typeof newValue === "function"
      ? newValue(value)
      : newValue;

    if (resolved !== value) {
      const oldValue = value;
      value = resolved;
      onChange?.(value, oldValue);
    }
  };

  return [() => value, setValue];
};
javascript
const [getName, setName] = useState("", (newVal, oldVal) => {
  console.log(`이름 변경: ${oldVal} → ${newVal}`);
  nameDisplay.textContent = newVal;
});

setName("홍길동"); // 콘솔: "이름 변경:  → 홍길동"

이렇게 하면 Observer 패턴과 결합하지 않아도 간단한 리액티브 동작을 구현할 수 있다.


한계

이 패턴은 만능이 아니다.

  1. 리렌더링 시스템이 없다 — 상태 변경이 자동으로 UI에 반영되지 않는다. DOM 업데이트 로직을 직접 연결해야 한다.
  2. 디버깅이 어렵다 — 클로저 내부의 값은 외부에서 직접 확인할 수 없다. React DevTools 같은 도구도 없다. getValue()를 호출해서 확인해야 한다.
  3. 직렬화가 안 된다 — 클로저는 JSON으로 변환할 수 없다. 상태를 저장/복원해야 한다면 별도의 직렬화 로직이 필요하다.
  4. 객체 참조 주의 — 원시 값이 아닌 객체를 저장하면 외부에서 직접 수정이 가능하다.
javascript
const [getUser, setUser] = useState({ name: "Kim" });
const user = getUser();
user.name = "Lee"; // 클로저 내부 값이 직접 변경됨!

이를 방지하려면 getter에서 깊은 복사를 반환하거나, setter에서만 변경을 허용하는 규칙을 정해야 한다.


정리

항목React useState클로저 기반 useState
반환 형태[값, setter][getter 함수, setter]
리렌더링자동없음 (수동)
인스턴스 독립성컴포넌트별 자동 분리호출별 클로저 분리
디버깅DevTools 지원getter 호출로 확인
의존성React 필요의존성 없음

클로저 기반 useState는 프레임워크 없이 상태를 캡슐화하는 가장 가벼운 방법이다. React의 편의 기능(리렌더링, 배치 업데이트, DevTools)은 없지만, "값을 안전하게 저장하고 접근을 제어한다"는 핵심 목적은 충실히 달성한다. 작은 Vanilla JS 프로젝트에서 전역 변수 지옥에 빠지기 전에, 이 패턴을 한 번 고려해보는 것을 추천한다.