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)은 다음 섹션에서 하나씩 만든다.
(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
- 사용자가 입력칸 값을 바꾸면 jQuery 이벤트 리스너가 불린다
- 리스너가
state.quantity = 3처럼 상태 객체에 대입한다 - 상태 객체가 Proxy라서 handler의
set()이 대신 불린다 set()이 화면을 고치는 함수updateBoundUI를 부른다updateBoundUI가 jQuery 선택자로data-bind="quantity"가 붙은 요소를 찾는다- 찾은 요소만 새 값으로 고친다
구현은 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보다 위에 있어도 요소를 찾을 수 있다
동작 흐름:
- 사용자가 수량 칸에 숫자를 입력한다 →
setupTwoWayBinding이 건 리스너가 불린다 - 리스너가
state.quantity에 대입한다 → Proxy의set이 불린다 set이updateBoundUI('quantity', ...)를 부른다 →[data-bind="quantity"]요소가 고쳐진다- 이어서
set이updateBoundUI('total', target.total)을 부른다 → gettertotal(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 코드의 현대화
- 간단한 프로젝트의 빠른 구현
- 프레임워크를 배우기 전의 디딤돌
- 성능과 유지보수성의 균형
실무 적용 팁
- 작은 모듈부터 시작 — 전체 페이지가 아닌 특정 화면 조각부터 적용한다
- data-bind 이름 규칙을 정한다 — 팀 안에서 일관성을 지킨다
- 복잡도를 지켜본다 — 상태가 너무 복잡해지면 프레임워크 도입을 고려한다
- 에러 처리를 넣는다 — Proxy의
set안에서updateBoundUI가 실패하면 대입한 쪽까지 예외가 올라간다.set안을try-catch로 감싼다. 어떤 예외를 잡아 무엇을 할지는 이 글에서 정하지 않았다
이 패턴을 써 본 경험을 댓글로 듣고 싶다
이 패턴을 실제 프로젝트에 적용해 본 경험이 있는지, 아니면 처음 보는 개념인지 궁금하다. 댓글로 남겨 주면 다음 글의 주제로 삼겠다.
- jQuery 프로젝트에서 이 패턴이 도움될 만한 부분이 있는가
- 더 알고 싶은 심화 내용이 있는가
- 다른 방식의 상태 관리 패턴에 관심이 있는가
자신만의 철학을 만들어가는 중입니다.
댓글남기기