2010년대 초반 jQuery는 웹 개발의 표준이었다. 시간이 지나면서 React, Vue, Angular 같은 프레임워크가 등장했고, 이들은 「상태가 변경되면 화면이 자동으로 업데이트된다」 는 생각을 내놓았다. 여기서 「상태」는 화면이 지금 어떤 모습이어야 하는지를 정하는 값(상태) — 이 글에서는 장바구니의 수량과 단가 — 이고, 「화면」은 브라우저에 떠 있는 웹 페이지다.

그래서 나는 이런 질문을 했다. jQuery만 써도 이런 상태 관리를 구현할 수 있을까?

답은 「된다」다. ES6에 들어 있는 Proxy와 jQuery의 DOM 조작 능력을 합치면 React/Vue와 같은 반응형(리액티브) 패턴을 만들 수 있다. Proxy는 아래 「Proxy는 객체에 값을 넣는 순간을 가로챈다」에서 코드로 본다.

이 글의 코드는 전부 JavaScript다. 자바와 문법이 비슷해 보여도 다른 점이 몇 가지 있어서, 코드 블록마다 처음 나오는 문법을 짚고 넘어간다.


기존 jQuery 방식은 상태가 바뀔 때마다 화면을 손으로 고친다

먼저 기존 jQuery 방식을 본다. 장바구니 예제다. 웹 페이지에 수량 입력칸이 있고, 수량을 바꾸면 총액 칸이 바뀌어야 한다. 두 칸에는 각각 quantity, total이라는 id(페이지 안에서 그 칸 하나를 가리키는 이름)가 붙어 있다. 이 칸들을 적은 HTML은 아래 「Step 1」에서 본다.

기존 방식: 수동 업데이트

// 상태 객체
const cart = {
  quantity: 1,
  unitPrice: 10000
};

// jQuery로 이벤트 처리
$('#quantity').on('change', function() {
  cart.quantity = $(this).val();
  
  // 총액을 일일이 계산해서 업데이트해야 함
  const total = cart.quantity * cart.unitPrice;
  $('#total').text(total.toLocaleString() + '원');
  
  // 상태가 복잡해질수록 이런 코드가 계속 증가...
  updateCart();
  recalculateDiscount();
  validateInventory();
});

한 줄씩 따라간다.

  • const cart = { quantity: 1, unitPrice: 10000 }; — 클래스 없이 중괄호 안에 이름과 값을 짝지어 바로 객체를 만든다(객체 리터럴). cart.quantity로 읽고 쓴다. 자바의 필드와 비슷한 이것을 자바스크립트에서는 「속성(property)」이라 부른다. const는 이 이름에 다른 객체를 다시 대입하지 않겠다는 표시다
  • $('#quantity') — $는 jQuery 함수의 이름이다. 괄호 안 문자열은 CSS 선택자 문법으로 「어떤 요소를 고를지」를 적는다. #quantity는 id="quantity"인 요소를 고른다
  • .on('change', function() { ... }) — 고른 요소에 「change가 일어나면 이 함수를 실행해라」를 걸어 둔다(이벤트 리스너). 이벤트는 클릭 · 입력처럼 사용자가 화면에서 한 일이다. change는 사용자가 값을 고친 뒤 입력칸을 벗어나는 것처럼 값이 확정될 때 일어난다(MDN — Mozilla가 운영하는 웹 기술 문서 사이트). function() { ... }는 이름 없이 그 자리에서 만든 함수이고, jQuery가 나중에 대신 불러 준다(콜백)
  • $(this).val() — jQuery가 이 함수를 부를 때 this에는 이벤트가 일어난 요소, 즉 수량 입력칸이 들어 있다. $(this)로 감싸야 jQuery 메서드를 쓸 수 있다. .val()은 입력칸에 적힌 값을 문자열로 돌려준다
  • cart.quantity * cart.unitPrice — 수량이 문자열 "3"이어도 *는 숫자로 바꿔서 곱한다. 그래서 30000이 나온다
  • total.toLocaleString() + '원' — 숫자에 천 단위 쉼표를 넣은 문자열로 바꾼다. 20000은 "20,000"이 되고, 뒤에 원을 붙인다
  • $('#total').text(...) — id="total"인 요소의 글자를 그 문자열로 바꾼다
  • updateCart() 같은 세 줄 — 이 글에 정의가 없는, 가상의 함수다. 상태가 늘어나면 「수량이 바뀌면 이것도, 저것도 다시 해야 한다」는 호출이 이렇게 계속 붙는다는 것을 보여 주려고 넣었다

문제점:

  • 상태가 바뀔 때마다 UI를 고치는 코드를 손으로 적는다
  • 상태가 많아질수록 코드가 복잡해진다
  • UI 업데이트 로직이 여러 곳에 흩어진다
  • 고쳐야 할 곳 하나를 빠뜨리기 쉽다

React와 Vue는 상태만 바꾸면 화면이 따라온다

// React 방식
const [quantity, setQuantity] = useState(1);

// quantity만 변경하면 관련된 모든 UI가 자동으로 업데이트됨
setQuantity(5);
  • useState(1)은 React의 훅이다. 처음 값이 1인 상태를 만들고, 「지금 값」과 「값을 바꾸는 함수」 두 개를 배열로 돌려준다
  • const [quantity, setQuantity] = ... — 돌려받은 배열의 첫째 칸을 quantity에, 둘째 칸을 setQuantity에 나눠 담는 문법이다
  • setQuantity(5) — 값을 직접 대입하지 않고 이 함수로 바꾼다. React는 이 함수가 불린 것을 보고 「값이 바뀌었다」를 알아채 화면을 다시 그린다
// Vue 방식
data() {
  return {
    quantity: 1
  }
}

// quantity만 변경하면 자동 업데이트
this.quantity = 5;
  • data() { ... } — Vue 화면 조각을 정의하는 객체 안에 들어가는 메서드다(위 코드는 그 일부만 잘라 보였다). 여기서 돌려준 객체가 그 화면 조각의 상태가 된다
  • this.quantity = 5 — 그 화면 조각 안의 코드에서 상태에 바로 대입한다. Vue는 React와 달리 대입 자체를 알아채고 화면을 고친다. 이 「대입을 알아채는」 일을 jQuery에서 직접 해 보는 것이 이 글이다

핵심은 개발자는 상태 변경만 신경 쓰고, UI 업데이트는 프레임워크가 알아서 한다는 것이다.


Proxy는 객체에 값을 넣는 순간을 가로챈다

jQuery에서도 이 방식을 만들 수 있다. 열쇠는 Proxy다.

Proxy는 원본 객체 앞에 서서 대신 요청을 받는 객체다. 쓰는 쪽은 원본을 쓰는 것처럼 state.count = 5라고 적지만, 실제로는 Proxy가 먼저 받는다. 그래서 「속성에 값을 넣는 순간」이나 「속성을 읽는 순간」에 내 코드를 끼워 넣을 수 있다.

// 기본 Proxy 개념
const handler = {
  set(target, property, value) {
    // property를 변경하려는 모든 시도가 여기를 거쳐감
    console.log(`${property}이 ${value}로 변경됩니다!`);
    target[property] = value;
    return true;
  }
};

const state = new Proxy({count: 0}, handler);

state.count = 5;
// 콘솔 출력: "count이 5로 변경됩니다!"
  • const handler = { set(...) { ... } } — set이라는 이름의 메서드 하나를 가진 객체다. 이 이름은 내가 지은 것이 아니라 Proxy가 정해 둔 이름이다
  • new Proxy({count: 0}, handler) — 원본 객체 {count: 0}과 handler를 받아 Proxy를 만든다. 이제 state는 원본 대신 쓰는 객체다
  • state.count = 5; — Proxy에 값을 대입하면, 대입이 바로 일어나지 않고 handler.set이 대신 불린다. 이때 target에는 원본 객체, property에는 속성 이름 문자열 "count", value에는 5가 들어온다
  • `${property}이 ${value}로 변경됩니다!` — 백틱으로 감싼 문자열 안의 ${...} 자리에 변수 값이 들어간다(템플릿 리터럴). console.log는 브라우저 콘솔에 글을 찍는다
  • target[property] = value; — 자바스크립트는 obj["count"]처럼 대괄호 안에 문자열을 넣어 속성에 접근할 수 있다. property에 어떤 이름이 들어올지 미리 모르니 이 문법을 쓴다. 이 줄이 원본에 실제로 값을 넣는다. 이 줄을 빼면 원본은 그대로다
  • return true; — 「대입이 성공했다」는 뜻이다. MDN 문서에 따르면 false를 돌려주면 strict mode(자바스크립트의 엄격한 실행 방식)에서는 대입한 쪽에 TypeError가 난다. 이 글의 코드는 늘 true를 돌려주므로 이 경우를 만나지 않는다

데이터 바인딩 흐름은 입력에서 화면 갱신까지 여섯 칸이다

이 글이 만드는 데이터 바인딩의 흐름을 그림으로 보면 이렇다. 그림 속 이름(updateBoundUI, data-bind)은 다음 섹션에서 하나씩 만든다.

graph LR A["사용자 입력
(input 변경)"] -->|jQuery 이벤트| B["상태 객체
변경
(state.property = value)"] B -->|Proxy 감지| C["Proxy Handler
set() 호출"] C -->|업데이트 함수| D["updateBoundUI
호출"] D -->|jQuery 선택자| E["[data-bind] 요소
찾기"] E -->|선택적 업데이트| F["UI 갱신
(필요한 부분만)"] style A fill:#2d5f8d,stroke:#4a8fbe,color:#fff style B fill:#2d5f8d,stroke:#4a8fbe,color:#fff style C fill:#5b3e7a,stroke:#8b5fb8,color:#fff style D fill:#5b3e7a,stroke:#8b5fb8,color:#fff style E fill:#6b4423,stroke:#9b6e3d,color:#fff style F fill:#2d6b3f,stroke:#4a9b5f,color:#fff
  1. 사용자가 입력칸 값을 바꾸면 jQuery 이벤트 리스너가 불린다
  2. 리스너가 state.quantity = 3처럼 상태 객체에 대입한다
  3. 상태 객체가 Proxy라서 handler의 set()이 대신 불린다
  4. set()이 화면을 고치는 함수 updateBoundUI를 부른다
  5. updateBoundUI가 jQuery 선택자로 data-bind="quantity"가 붙은 요소를 찾는다
  6. 찾은 요소만 새 값으로 고친다

구현은 HTML 표시, 갱신 함수, Proxy, 양방향 연결, 버튼 다섯 단계다

Step 1: HTML에 데이터 바인딩 표시

<!-- data-bind 속성으로 상태와 UI 요소를 연결 -->
<div class="cart">
  <input id="quantity" type="number" value="1" data-bind="quantity">
  <span id="unitPrice" data-bind="unitPrice">10,000원</span>
  <span id="total" data-bind="total">10,000원</span>
  <button id="decreaseBtn">-</button>
  <button id="increaseBtn">+</button>
</div>

HTML은 웹 페이지의 뼈대를 태그로 적는 언어다.

  • <div> — 다른 요소를 묶는 상자다. class="cart"는 이 상자에 붙인 이름표다
  • <input type="number" value="1"> — 숫자를 입력하는 칸이고, 처음 값은 1이다
  • <span> — 글자를 담는 칸이다. 여기서는 단가와 총액을 보여 준다
  • <button> — 누르는 버튼이다
  • id="..." — 페이지 안에서 그 요소 하나를 가리키는 고유한 이름이다. jQuery의 $('#quantity')가 이것으로 요소를 찾는다

id, type, value처럼 태그 안에 이름="값"으로 붙인 것을 HTML 속성이라 부른다. 앞에서 본 객체의 속성(필드)과 이름은 같지만 다른 것이다.

핵심: data-bind="속성이름"으로 상태와 연결한다. data-로 시작하는 HTML 속성은 개발자가 이름을 지어 붙일 수 있다. 나는 「이 요소는 상태 객체의 어느 속성을 보여 주는가」를 여기에 적어 두기로 했다. data-bind="total"은 「이 칸은 state.total을 보여 준다」는 뜻이다.

Step 2: UI 업데이트 함수 구현

/**
 * Proxy가 감지한 변경사항을 UI에 반영
 * @param {string} property - 변경된 속성명
 * @param {*} value - 변경된 값
 */
function updateBoundUI(property, value) {
  // 1. data-bind="property"인 모든 요소 선택
  const $elements = $(`[data-bind="${property}"]`);
  
  // 2. 요소 타입에 따라 다르게 업데이트
  $elements.each(function() {
    const $el = $(this);
    
    // input/textarea는 val()로 업데이트
    if ($el.is('input, textarea')) {
      $el.val(value);
    }
    // 다른 요소는 text()로 업데이트
    else {
      $el.text(value);
    }
  });
}
  • /** ... @param ... */ — 함수가 무엇을 받는지 적는 설명 주석(JSDoc)이다. 자바의 Javadoc과 같다. {*}는 「어떤 타입이든 된다」는 표기다
  • $(`[data-bind="${property}"]`) — 대괄호 선택자 [이름="값"]은 「그 HTML 속성의 값이 정확히 그 값인 요소」를 고른다. property가 "quantity"면 [data-bind="quantity"]가 되어 data-bind="quantity"가 붙은 요소를 전부 고른다
  • const $elements — 변수 이름 앞의 $는 문법이 아니라 관례다. 「이 변수에는 jQuery로 감싼 요소가 들어 있다」를 이름으로 알리려고 붙인다
  • $elements.each(function() { ... }) — 고른 요소를 하나씩 돌며 함수를 부른다. 자바의 for-each와 같다. 함수 안의 this에는 지금 차례인 요소가 들어 있다
  • $el.is('input, textarea') — 이 요소가 <input>이나 <textarea>(여러 줄 입력칸)인지 묻고 참/거짓을 돌려준다
  • $el.val(value) / $el.text(value) — 둘을 나누는 이유는 화면에 값이 담기는 자리가 다르기 때문이다. 입력칸은 사용자가 적은 값(value)을 보여 주고, <span> 같은 요소는 태그 사이의 글자를 보여 준다. jQuery도 값을 넣는 메서드를 val()과 text() 둘로 나눠 두었다

text(value)는 값을 그대로 쓴다. 그래서 Step 1의 HTML이 처음에 10,000원으로 보여도, 한 번 갱신되면 20000처럼 쉼표와 원이 없는 숫자가 된다. 표시 형식을 맞추는 일은 이 예제가 다루지 않는다.

Step 3: Proxy를 이용한 상태 객체 생성

// 초기 상태
const initialState = {
  quantity: 1,
  unitPrice: 10000,
  get total() {
    return this.quantity * this.unitPrice;
  }
};

// Proxy Handler 정의
const handler = {
  set(target, property, value) {
    // 실제 값 할당
    target[property] = value;
    
    // 변경된 속성에 해당하는 UI만 업데이트
    updateBoundUI(property, value);

    // total은 아무도 대입하지 않아 set이 불리지 않으므로 직접 다시 그린다
    updateBoundUI('total', target.total);
    
    return true;
  }
};

// Proxy 객체 생성
const state = new Proxy(initialState, handler);
  • get total() { ... } — 이런 것을 getter라 부른다. get을 앞에 붙이면 메서드처럼 정의하지만 필드처럼 괄호 없이 state.total로 읽는다. 읽을 때마다 안의 코드가 실행되어 그때의 수량 × 단가를 돌려준다(계산된 속성). 자바로 치면 getTotal()을 필드처럼 부르는 것이다
  • target[property] = value; — 원본에 값을 넣는다
  • updateBoundUI(property, value); — 바뀐 속성과 연결된 요소를 고친다. state.quantity = 3이면 data-bind="quantity" 요소만 고쳐진다
  • updateBoundUI('total', target.total); — 이 줄이 없으면 총액 칸은 바뀌지 않는다. total은 수량이 바뀌면 값이 달라지지만, 누구도 state.total = ...이라고 대입하지 않으니 set이 total로 불릴 일이 없다. 그래서 set이 불릴 때마다 total을 다시 읽어 총액 칸도 고친다. 이 줄은 처음 글에 없었다. 「총액도 자동으로 갱신된다」고 적어 두었지만, Node.js(브라우저 없이 자바스크립트를 실행하는 프로그램)로 돌려 보니 set은 quantity로만 불렸다
  • new Proxy(initialState, handler) — 이제부터 상태는 initialState가 아니라 state로 바꾼다. 원본에 바로 대입하면 set을 거치지 않아 화면이 안 바뀐다

Step 4: 양방향 바인딩 설정

/**
 * 사용자 입력 이벤트를 감시하여 상태 업데이트
 */
function setupTwoWayBinding(state) {
  $('[data-bind]').on('input change', function() {
    const $el = $(this);
    const property = $el.attr('data-bind');
    const value = $el.is('input[type="number"]') 
      ? parseInt($el.val()) 
      : $el.val();
    
    // 상태 변경 -> Proxy 감지 -> updateBoundUI 호출
    state[property] = value;
  });
}

// 양방향 바인딩 초기화
setupTwoWayBinding(state);

Step 2와 3은 「상태 → 화면」 방향이었다. 이 단계는 반대 방향 「화면 → 상태」를 잇는다. 두 방향이 다 이어지면 양방향 데이터 바인딩이다.

  • $('[data-bind]') — 값과 상관없이 data-bind 속성이 붙은 요소를 전부 고른다
  • .on('input change', ...) — 이벤트 이름을 띄어 써서 여럿 적으면, 그중 어느 것이 일어나도 같은 함수를 부른다. input은 사용자가 입력칸을 고칠 때마다(한 글자 칠 때마다) 일어나고, change는 입력칸을 벗어나는 것처럼 값이 확정될 때 일어난다(MDN). <span>에도 걸리지만 사용자가 <span>에 입력할 일이 없어서 불리지 않는다
  • $el.attr('data-bind') — 그 요소의 data-bind 속성 값을 읽는다. 수량 칸이면 "quantity"다
  • 조건 ? A : B — 조건이 참이면 A, 거짓이면 B를 값으로 쓴다(자바의 삼항 연산자와 같다). 숫자 입력칸이면 parseInt로 문자열을 정수로 바꾸고, 아니면 문자열 그대로 쓴다
  • state[property] = value; — 상태에 대입한다. state가 Proxy라서 Step 3의 set이 불리고, 화면이 고쳐진다

여기서 궁금해지는 것이 있다. set → updateBoundUI가 방금 입력한 칸에 $el.val(value)로 값을 다시 쓴다. 그러면 또 이벤트가 일어나서 끝없이 도는가? 돌지 않는다. jQuery 문서에 따르면 .val(value)로 값을 넣어도 change 이벤트는 일어나지 않고, MDN에 따르면 input 이벤트도 스크립트가 값을 넣을 때는 일어나지 않는다. 두 이벤트는 사용자가 직접 고칠 때만 일어난다.

자바스크립트는 HTML 파일 안에 <script> 태그로 넣는다. 브라우저는 HTML을 위에서부터 읽다가 <script>를 만나면 그 자리에서 바로 실행하고, 실행이 끝나야 아래쪽 HTML을 마저 읽는다(MDN). 그래서 <script>가 입력칸보다 위에 있으면, 실행하는 순간에는 입력칸이 아직 페이지에 없다.

setupTwoWayBinding(state);를 여기서 바로 부르는 것은 이 스크립트가 HTML 요소들보다 아래에 놓여 있다고 가정해서다. 요소가 아직 없으면 $('[data-bind]')가 아무것도 못 고른다. 아래 완전한 예제는 이 가정을 없애려고 $(function() { ... }) 안에서 부른다.

Step 5: 이벤트 핸들러 연결

// 증가/감소 버튼
$('#increaseBtn').on('click', function() {
  state.quantity += 1;  // <- 상태만 변경하면 UI가 자동 갱신!
});

$('#decreaseBtn').on('click', function() {
  state.quantity = Math.max(1, state.quantity - 1);
});
  • + 버튼을 누르면 state.quantity += 1이 실행된다. 이것은 state.quantity = state.quantity + 1이라 대입이 일어나고, set이 불려 수량 칸과 총액 칸이 고쳐진다
  • - 버튼은 Math.max(1, ...)로 1보다 작아지지 않게 막는다
  • 두 함수 모두 화면을 고치는 코드가 한 줄도 없다. 상태만 바꾼다

완전한 동작 예제 — 다섯 단계를 한 파일로 합친다

다음은 위 자바스크립트 코드를 한 파일로 합친 것이다. 실행하려면 브라우저에서 여는 HTML 파일에 Step 1의 HTML을 두고, jQuery를 불러온 뒤 이 코드를 <script> 태그 안에 넣는다. jQuery를 불러오는 방법과 실행 가능한 HTML 파일 전체는 이 글에서 다루지 않는다.

// ============ 설정 ============
const initialState = {
  quantity: 1,
  unitPrice: 10000,
  get total() {
    return this.quantity * this.unitPrice;
  }
};

// ============ UI 업데이트 함수 ============
function updateBoundUI(property, value) {
  const $elements = $(`[data-bind="${property}"]`);
  $elements.each(function() {
    const $el = $(this);
    if ($el.is('input, textarea')) {
      $el.val(value);
    } else {
      $el.text(value);
    }
  });
}

// ============ Proxy 생성 ============
const state = new Proxy(initialState, {
  set(target, property, value) {
    target[property] = value;
    updateBoundUI(property, value);
    updateBoundUI('total', target.total);  // 계산된 속성은 직접 다시 그린다
    return true;
  }
});

// ============ 양방향 바인딩 ============
function setupTwoWayBinding(state) {
  $('[data-bind]').on('input change', function() {
    const $el = $(this);
    const property = $el.attr('data-bind');
    const value = $el.is('input[type="number"]') 
      ? parseInt($el.val()) 
      : $el.val();
    state[property] = value;
  });
}

// ============ DOM Ready ============
$(function() {
  setupTwoWayBinding(state);
  
  $('#increaseBtn').on('click', function() {
    state.quantity += 1;
  });
  
  $('#decreaseBtn').on('click', function() {
    state.quantity = Math.max(1, state.quantity - 1);
  });
});

Step 1~5와 다른 곳은 두 군데다.

  • new Proxy(initialState, { set(...) {...} }) — Step 3에서는 handler라는 이름의 변수에 담았지만, 여기서는 그 자리에서 바로 객체를 만들어 넘긴다. 하는 일은 같다
  • $(function() { ... }) — $에 함수를 넘기면 jQuery는 「DOM이 다 만들어져 요소를 다룰 수 있게 되면」 그 함수를 실행한다(jQuery 문서). $(document).ready(함수)와 같은 뜻이다(document는 브라우저가 읽은 HTML 문서 전체를 가리키는 객체다). 그래서 이 스크립트가 HTML보다 위에 있어도 요소를 찾을 수 있다

동작 흐름:

  1. 사용자가 수량 칸에 숫자를 입력한다 → setupTwoWayBinding이 건 리스너가 불린다
  2. 리스너가 state.quantity에 대입한다 → Proxy의 set이 불린다
  3. set이 updateBoundUI('quantity', ...)를 부른다 → [data-bind="quantity"] 요소가 고쳐진다
  4. 이어서 set이 updateBoundUI('total', target.total)을 부른다 → getter total(Step 3의 get total())이 그 순간의 값으로 계산되고 [data-bind="total"] 요소가 고쳐진다

Proxy 방식의 장점은 간결함 · 갱신 범위 · 유지보수 · 프레임워크 학습이다

1. 코드 간결성

// Before (jQuery 기존 방식)
$('#quantity').on('change', function() {
  cart.quantity = $(this).val();
  $('#total').text((cart.quantity * cart.unitPrice).toLocaleString() + '원');
  $('#display').text(cart.quantity);
});

// After (Proxy 방식)
state.quantity = 5;  // 끝! UI는 자동으로 갱신됨
  • Before의 $('#display')는 Step 1 HTML에는 없는, 수량을 한 번 더 보여 주는 칸이 있다고 친 것이다. 보여 주는 칸이 하나 늘 때마다 이런 줄이 하나씩 는다
  • After는 Step 1~5를 한 번 해 둔 뒤의 모습이다. 이벤트 연결은 setupTwoWayBinding이 한 곳에서 맡고 있으니, 상태를 바꾸는 쪽은 대입 한 줄만 쓴다. 보여 주는 칸이 늘어도 HTML에 data-bind만 붙이면 된다

2. 갱신 범위

  • 페이지 전체를 다시 그리지(렌더링) 않는다
  • 바뀐 속성과 연결된 요소만 고친다
  • 필요 없는 DOM 조작을 하지 않는다

3. 유지보수성

  • 상태와 UI의 연결이 HTML의 data-bind에 드러난다
  • UI를 고치는 로직이 updateBoundUI 한 곳에 모인다
  • 이미 있는 상태 속성을 보여 줄 칸을 늘릴 때는 HTML에 data-bind만 붙인다. 단, total처럼 계산된 속성을 새로 보여 줄 때는 Step 3처럼 set에도 한 줄을 더해야 한다

4. React/Vue로 옮겨 가기 쉽다

  • 「상태만 바꾸면 화면이 따라온다」는 패턴을 먼저 겪어 본다
  • 나중에 프레임워크로 바꿀 때 개념을 이해하기 쉽다

계산된 속성은 읽을 때 계산되고, 워처는 set 안에 로직을 더한다

계산된 속성 (Getter)

위 예제에서 이미 썼다.

const state = {
  quantity: 1,
  unitPrice: 10000,
  get total() {
    return this.quantity * this.unitPrice;
  }
};

total은 읽을 때마다 그때의 quantity와 unitPrice로 다시 계산된다. 값이 낡을 일은 없다. 하지만 화면이 저절로 바뀌는 것은 아니다. 누군가 total을 읽어서 화면에 써 줘야 한다. 그래서 Step 3의 set에 updateBoundUI('total', target.total)을 넣었다.

워처: 특정 속성 변경 감시

워처(watcher)는 「특정 속성이 바뀔 때만 추가로 할 일」이다. Proxy의 set은 모든 속성의 대입에 불리므로, 그 안에서 if로 속성 이름을 골라 로직을 붙이면 워처가 된다.

const state = new Proxy(initialState, {
  set(target, property, value) {
    const oldValue = target[property];
    target[property] = value;
    
    // 특정 속성 변경 시 추가 로직
    if (property === 'quantity') {
      if (value > 100) {
        console.warn('대량 구매: 할인 적용됨!');
        target.discount = 0.1;  // 10% 할인
      }
    }
    
    updateBoundUI(property, value);
    return true;
  }
});
  • const oldValue = target[property]; — 바꾸기 전 값을 담아 둔다. 이 예제에서는 쓰지 않는다. 「이전 값과 비교해서 늘었을 때만」 같은 조건을 붙일 때 쓰는 자리다
  • property === 'quantity' — 수량이 바뀔 때만 안쪽 로직이 돈다. ===는 두 값이 같은지 비교하는 자바스크립트 연산자다
  • console.warn — 콘솔에 경고로 찍는다
  • target.discount = 0.1; — Proxy(state)가 아니라 원본(target)에 바로 넣었으니 set이 다시 불리지 않고, 화면에도 반영되지 않는다. discount는 처음 상태에 없던 속성이고, 이 예제에서는 값을 저장만 해 둔다. 화면에 보이려면 updateBoundUI('discount', 0.1)을 같이 부르고 HTML에 data-bind="discount" 칸을 만들어야 한다

실무 활용 사례 — 폼 검증, 목록 필터, 장바구니

1. 실시간 폼 검증

<input id="email" type="email" data-bind="email">
<span id="emailError" data-bind="emailError"></span>

type="email"은 이메일을 적는 입력칸이라는 표시다. 이 예제에서 이메일 모양인지 검사하는 일은 아래 자바스크립트가 한다.

const state = new Proxy({
  email: '',
  emailError: ''
}, {
  set(target, property, value) {
    target[property] = value;
    
    if (property === 'email') {
      const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
      target.emailError = isValid ? '' : '유효한 이메일을 입력하세요';
      updateBoundUI('emailError', target.emailError);
    }
    
    updateBoundUI(property, value);
    return true;
  }
});
  • /^[^\s@]+@[^\s@]+\.[^\s@]+$/ — 이메일 모양인지 보는 정규식이다. 대략 「공백과 @가 아닌 글자들 + @ + 같은 글자들 + . + 같은 글자들」이다. .test(value)는 맞으면 true를 돌려준다. a@b.co는 통과하고 ab.co는 떨어진다
  • target.emailError = ... — 맞으면 빈 문자열, 틀리면 안내 문구를 넣는다
  • updateBoundUI('emailError', target.emailError); — 에러 칸을 고친다. 이 줄은 처음 글에 없었다. emailError는 원본에 바로 넣어서 set을 거치지 않고, 마지막 줄의 updateBoundUI(property, value)는 email 칸만 고친다. 그래서 이 줄이 없으면 에러 문구가 화면에 뜨지 않는다

2. 필터링 목록

<input id="searchInput" data-bind="searchTerm">
<ul id="resultList" data-bind="filteredItems"></ul>

<ul>은 목록을 담는 HTML 요소다. 여기서는 text()로 쉼표로 이은 글자를 넣는다.

const state = new Proxy({
  items: ['Apple', 'Banana', 'Cherry'],
  searchTerm: '',
  get filteredItems() {
    return this.items.filter(item => 
      item.toLowerCase().includes(this.searchTerm.toLowerCase())
    ).join(', ');
  }
}, {
  set(target, property, value) {
    target[property] = value;
    updateBoundUI(property, value);
    updateBoundUI('filteredItems', target.filteredItems);
    return true;
  }
});
  • item => ... — item을 받아 화살표 뒤의 값을 돌려주는 짧은 함수다(화살표 함수)
  • this.items.filter(...) — 배열에서 함수가 true를 돌려준 항목만 모은 새 배열을 만든다
  • item.toLowerCase().includes(...) — 둘 다 소문자로 바꾼 뒤, 항목 이름에 검색어가 들어 있는지 본다
  • .join(', ') — 배열 항목을 , 로 이어 문자열 하나로 만든다. 검색어가 an이면 "Banana"가 된다
  • 두 번째 인자 — 처음 글은 여기에 Step 3의 handler를 그대로 넘겼다. 그 handler는 바뀐 속성(searchTerm)과 total만 다시 그리니 목록 칸이 바뀌지 않는다. 그래서 filteredItems를 다시 그리는 set을 따로 둔다. Step 3의 total과 같은 이유다

3. 장바구니 관리

const cartState = new Proxy({
  items: [],
  get itemCount() {
    return this.items.length;
  },
  get totalPrice() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  },
  addItem(item) {
    this.items.push(item);
    // 이 코드는 수작업 필요 (배열 참조 문제)
  }
}, handler);
  • this.items.reduce((sum, item) => sum + item.price, 0) — 0에서 시작해 항목마다 가격을 더해 간다. 가격 3과 4인 항목이 있으면 7이 된다. 자바로 치면 for-each로 합계를 내는 일이다
  • }, handler); — Step 3의 handler를 그대로 넘긴다. 그 set은 updateBoundUI('total', target.total)도 부르는데, 이 객체에는 total이 없어서 undefined(값 없음)가 넘어간다. 페이지에 data-bind="total" 칸이 없으면 고를 요소가 없어 아무 일도 일어나지 않는다
  • addItem(item) — 배열에 항목을 넣는 메서드다. 그런데 push는 set을 부르지 않아서 화면이 안 바뀐다. 「수작업 필요」는 화면을 고치는 코드를 따로 불러야 한다는 뜻이다. 왜 set이 안 불리는지는 다음 섹션에서 본다

주의: 배열 조작은 Proxy가 자동으로 감지하지 못한다. 배열을 감싸는 객체를 따로 만들거나, 명시적으로 상태를 갱신해야 한다. 감싸는 방법의 예시는 이 글에 없고, 다음 섹션은 새 배열을 대입하는 방법만 보인다.


Proxy는 배열 조작과 안쪽 객체 변경을 감지하지 못한다

1. 배열 조작 감지 문제

// 이건 Proxy가 감지하지 못함
state.items.push(newItem);

// 이렇게 새 배열을 할당해야 감지됨
state.items = [...state.items, newItem];

이유는 set이 「Proxy의 속성에 대입할 때」만 불리기 때문이다. state.items.push(newItem)은 두 단계다. 먼저 state.items를 읽어서 원본 배열을 꺼내고, 그 배열 자체에 항목을 넣는다. state의 속성에 무언가를 대입하는 일은 없다. Node.js로 돌려 보니 배열에는 항목이 들어갔지만 set은 한 번도 불리지 않았다.

  • [...state.items, newItem] — ...는 배열 안의 항목을 그 자리에 펼쳐 넣는다(펼침 문법). 기존 항목을 다 넣고 끝에 newItem을 붙인 새 배열이 된다
  • state.items = 새 배열 — 대입이니 set이 불린다

2. 깊은 객체 바인딩

const state = new Proxy({
  user: { name: 'John', age: 30 }
}, handler);

// 중첩된 속성 변경은 감지 안 됨
state.user.name = 'Jane';

// 객체 전체를 재할당해야 함
state.user = { ...state.user, name: 'Jane' };

배열과 같은 이유다. Proxy로 감싼 것은 바깥 객체 하나뿐이고, 안쪽 user 객체는 평범한 객체다. state.user.name = 'Jane'은 state.user를 읽어 안쪽 객체를 꺼낸 뒤, 그 객체의 name에 대입한다. 대입받는 쪽이 Proxy가 아니니 set이 불리지 않는다.

  • { ...state.user, name: 'Jane' } — 기존 user의 항목을 펼쳐 넣고 name만 'Jane'으로 덮은 새 객체를 만든다. 이름이 같으면 뒤에 적은 값이 이긴다
  • state.user = 새 객체 — state의 속성에 대입하니 set이 불린다

3. 성능

매우 큰 규모의 애플리케이션에서는 React/Vue 같은 프레임워크가 더 최적화되어 있다. 중소 규모 프로젝트에서는 이 방식으로 충분하다. 다만 이것은 처음 글을 쓸 때의 판단이고, 어느 규모부터 차이가 나는지와 그 이유는 재 보지 않았다. 아래 표의 「성능」 칸도 같다.


React/Vue와 비교하면 레거시 호환성이 이 방식의 강점이다

항목 jQuery + Proxy React Vue
번들 크기 jQuery + 사용자 코드 ~40KB ~30KB
학습 곡선 낮음 중간 낮음
기능성 중간 높음 높음
성능 중간 높음 높음
레거시 코드 호환성 우수 낮음 낮음
커뮤니티 소형 (이 패턴) 매우 큼 큼

학습 곡선은 배우기 어려운 정도다. 번들 크기는 브라우저가 페이지를 열 때 내려받아야 하는 자바스크립트의 양이다. 클수록 페이지가 뜨기까지 오래 걸린다. 표의 React · Vue 크기는 처음 글을 쓸 때 적은 값이고, 이번에 다시 재지 않았다.

레거시 코드 호환성이 「우수」인 것은 이미 jQuery로 짜여 돌아가는 페이지에 이 방식을 그대로 얹을 수 있어서다. React나 Vue로 가려면 화면을 그 도구의 방식으로 다시 짜야 한다.


결론 및 실무 팁

jQuery는 과거의 도구지만, 올바른 패턴을 적용하면 여전히 쓸 만하다. Proxy + jQuery 조합이 주는 것은 넷이다.

  • 기존 jQuery 코드의 현대화
  • 간단한 프로젝트의 빠른 구현
  • 프레임워크를 배우기 전의 디딤돌
  • 성능과 유지보수성의 균형

실무 적용 팁

  1. 작은 모듈부터 시작 — 전체 페이지가 아닌 특정 화면 조각부터 적용한다
  2. data-bind 이름 규칙을 정한다 — 팀 안에서 일관성을 지킨다
  3. 복잡도를 지켜본다 — 상태가 너무 복잡해지면 프레임워크 도입을 고려한다
  4. 에러 처리를 넣는다 — Proxy의 set 안에서 updateBoundUI가 실패하면 대입한 쪽까지 예외가 올라간다. set 안을 try-catch로 감싼다. 어떤 예외를 잡아 무엇을 할지는 이 글에서 정하지 않았다

이 패턴을 써 본 경험을 댓글로 듣고 싶다

이 패턴을 실제 프로젝트에 적용해 본 경험이 있는지, 아니면 처음 보는 개념인지 궁금하다. 댓글로 남겨 주면 다음 글의 주제로 삼겠다.

  • jQuery 프로젝트에서 이 패턴이 도움될 만한 부분이 있는가
  • 더 알고 싶은 심화 내용이 있는가
  • 다른 방식의 상태 관리 패턴에 관심이 있는가

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기