본문으로 건너뛰기

양방향 데이터 바인딩 사용법 (syn.$bind)

개요

syn.$bind는 순수 객체({}) 및 평면 객체 배열([{}, {}, ...])을 Proxy로 감싸서, 프로퍼티 값 변경/재할당, 배열 push·pop·splice·정렬, 배열 인덱스 직접 교체, 배열 내 객체 값 변경을 표준 JS 문법만으로 감지하고 화면에 반영하는 모듈입니다.

syn.uicontrols.$datasyn-datafield/syn-options 메타 설정 기반으로 HandStack 전용 컨트롤(syn_grid, syn_data 등)을 바인딩하는 것과는 별개의 기능입니다. $bindsyn-bind/syn-bind-list 속성만으로 표준 HTMLElement는 물론 AUIGrid, Handsontable, tail-select, daterangepicker 같은 임의의 커스텀 컨트롤까지 get/set/on/off 4개 함수(어댑터) 구현만으로 가볍게 양방향 연결하고 싶을 때 사용합니다. 두 시스템은 서로 다른 HTML 속성을 사용하므로 같은 화면에서 함께 써도 충돌하지 않습니다.

로드 방법

syn.js가 로드되면 전역에 syn.$bind로 즉시 사용할 수 있습니다. 별도 활성화 설정이나 외부 라이브러리 의존성은 없습니다.

빠른 시작

<input type="text" syn-bind="value:user.name">
<span syn-bind="text:user.name"></span>
const mounted = syn.$bind.mount(document.body, { user: { name: '홍길동' } });
mounted.store.data.user.name = '김철수'; // 표준 JS 문법으로 값 변경 시 화면 자동 반영
mounted.destroy(); // 바인딩 해제

데이터 변경은 별도 API 없이 표준 JS 문법을 그대로 사용합니다.

store.data.user.name = '홍길동';            // 프로퍼티 값 변경
store.data.user = { name: '김철수' }; // 프로퍼티(객체) 재할당
store.data.items.push({ title: '신규' }); // 배열 push
store.data.items[2] = { title: '교체' }; // 배열 인덱스 직접 변경
store.data.items[2].title = '수정'; // 배열 내 객체 값 변경
store.data.items.splice(1, 1); // 배열 splice

주요 시나리오

선언적 바인딩 문법

syn-bind 속성에 타입1(인자1):경로1; 타입2(인자2):경로2 형태로 여러 바인딩을 세미콜론으로 나열할 수 있습니다. 내장 타입은 text, html, value, checked, radio, edit, show, hide, disabled, class(이름), attr(이름), style(속성), adapter(어댑터명)입니다.

<input type="checkbox" syn-bind="checked:user.active">
<span syn-bind="show:user.active">사용중</span>
<span syn-bind="hide:user.active">미사용</span>

리스트(배열) 반복 바인딩

syn-bind-list="경로" 컨테이너 안에 <template> 1개를 행 템플릿으로 두면, 배열의 push/pop/splice/인덱스 교체에 따라 자동으로 행이 추가·삭제·교체됩니다.

<tbody syn-bind-list="items">
<template>
<tr>
<td><input type="text" syn-bind="value:title"></td>
</tr>
</template>
</tbody>

커스텀 컨트롤 어댑터

표준 HTMLElement가 아닌 컨트롤은 get/set/on/off 4개 함수만 구현해 registerAdapter()에 등록하면, 동일한 syn-bind="adapter(어댑터명):경로" 문법으로 AUIGrid, Handsontable, tail-select, daterangepicker 같은 서드파티 컨트롤도 연결할 수 있습니다.

syn.$bind.registerAdapter('grid', {
get(el) { return el._grid.getData(); },
set(el, value) { el._grid.setData(value || []); },
on(el, handler) { el._grid.on('change', handler); }
});
<div id="grid" syn-bind="adapter(grid):items"></div>

저수준 API: createStore / mount / attach

createStore(data)는 DOM에 연결하지 않고 반응형 store만 만듭니다. mount(root, dataOrStore)는 store 생성과 DOM 스캔·바인딩을 한 번에 수행합니다. attach(root, store)는 이미 있는 store를 다른 DOM 영역에 추가로 연결만 하는 저수준 API입니다.

const store = syn.$bind.createStore({ count: 0 });
const mounted = syn.$bind.mount(document.body, store);
syn.$bind.attach(document.getElementById('extra'), store);

store 조회/변경/구독

store.get(path)/store.set(path, value)store.data.xxx 접근과 동일한 결과를 냅니다. store.subscribe(path, cb, { deep })은 경로 변경을 구독하며(반환값은 구독 해제 함수), deep: true면 하위 경로 변경까지 함께 수신합니다. store.batch(fn)splice처럼 한 번의 호출에서 여러 트랩 호출이 발생하는 연산을 감싸 구독 콜백이 항상 연산 완료 후의 안정된 상태만 관찰하도록 합니다.

const unsubscribe = store.subscribe('items', (ev) => console.log(ev.type, ev.path, ev.value), { deep: true });
store.batch(() => { store.data.items.splice(1, 1); });
unsubscribe();

scope와 raw

store.scope(basePath)는 특정 경로를 기준으로 상대 경로만 쓰는 얇은 뷰(ScopedStore)를 만듭니다(리스트 바인딩이 각 행마다 내부적으로 사용하는 것과 같은 방식). syn.$bind.raw(value)/store.toRaw()는 프록시를 원본 순수 객체/배열로 되돌립니다.

const rowScope = store.scope('items[0]');
rowScope.set('title', '수정된 제목');

const rawItem = syn.$bind.raw(store.data.items[0]);

주의: toRaw()로 얻은 원본 객체를 직접 수정하면 실제 값은 바뀌지만 Proxy의 set 트랩을 거치지 않아 이벤트가 발생하지 않고 화면도 갱신되지 않습니다. 화면까지 갱신하려면 항상 store.data.xxx = ... 형태로 Proxy를 통해야 합니다.

실전 예제 페이지

/sample/syn/binding.html 예제에서 다음 항목을 실습할 수 있습니다.

  • 1~3번 섹션: value/checked/radio/edit 등 기본 바인딩 타입, 중첩 객체 재할당, syn-bind-list(push/pop/splice/index 교체/정렬)
  • 4~5번 섹션: registerAdapter()로 만든 별점/토글/미니 그리드 데모, 실제 AUIGrid/Handsontable/tail-select/daterangepicker 어댑터 참고 코드
  • 6번 섹션: 루트 구독(store.subscribe('', cb, { deep: true }))으로 모든 변경 이벤트 로그 확인, store.toRaw() 실시간 스냅샷
  • 7번 섹션: createStore, mount/attach, registerBinding, registerAdapter, raw, get/set, subscribe, batch, toRaw, scope API 전체를 개별적으로 실습

주의 사항

  • 순수 객체/평면 객체 배열 전용입니다. Date, Map, Set 등 다른 참조 타입은 관찰 대상이 아닙니다.
  • 동일한 원본 객체는 내부 WeakMap 캐시로 Proxy를 재사용하므로, 같은 데이터를 여러 곳에서 바인딩해도 항상 동일한 Proxy 인스턴스를 참조합니다.
  • syn-bind-list 컨테이너에는 반드시 <template> 자식이 하나 있어야 하며, 없으면 오류가 발생합니다.
  • adapter(어댑터명) 사용 전에 syn.$bind.registerAdapter()로 먼저 등록해야 합니다. 미등록 어댑터를 참조하면 오류가 발생합니다.
  • syn.uicontrols.$data/syn-datafield 기반 바인딩과는 별개의 시스템입니다. 서로 다른 HTML 속성을 사용하므로 같은 화면에서 함께 사용해도 충돌하지 않지만, 같은 데이터를 두 시스템에 동시에 연결하지는 않도록 주의하십시오.

관련 모듈

  • API 상세는 아래 API 참조 섹션을 확인하세요.

API 참조

모듈 정보

항목내용
전역 별칭syn.$bind
소스 위치2.Modules/wwwroot/wwwroot/js/syn.js (약 11925~12468번째 줄, 원본 모듈: 1.WebHost/ack/wwwroot/assets/src/syn.binding.js)
예제 페이지/sample/syn/binding.html
의존 모듈syn.$l(eventLog), syn.$object(getType)
활성화 조건없음. syn.js 로드 즉시 사용 가능.
HTML 속성syn-bind="타입(인자):경로"(세미콜론으로 다중 바인딩 나열), syn-bind-list="경로"(배열 반복 바인딩, <template> 자식 1개 필요), syn-bind-adapter(어댑터 이름을 별도 속성으로 지정하고 싶을 때)

내장 바인딩 타입 (syn.$bind.bindings)

타입인자방향이벤트설명
text-단방향(store→DOM)-el.textContent에 값을 반영.
html-단방향(store→DOM)-el.innerHTML에 값을 반영.
show-단방향(store→DOM)-값이 참이면 display를 원래대로, 거짓이면 none.
hide-단방향(store→DOM)-show의 반대.
disabled-단방향(store→DOM)-el.disabled = !!value.
class(이름)클래스명단방향(store→DOM)-값의 참/거짓에 따라 classList.toggle(이름, value).
attr(이름)속성명단방향(store→DOM)-값이 null/undefined/false면 속성 제거, 아니면 setAttribute.
style(속성)CSS 속성명단방향(store→DOM)-el.style[속성] = value.
value-양방향input(일반)/change(SELECT)el.value 동기화. type="number"/"range"는 숫자로 변환해 store에 반영.
checked-양방향changeel.checked 동기화(체크박스).
radio-양방향changeel.checked = (el.value === String(value)), 변경 시 el.value를 store에 반영.
edit-양방향inputcontenteditable 요소의 textContent 동기화.
adapter(어댑터명)어댑터명(생략 시 syn-bind-adapter 속성 값 사용)양방향(어댑터 구현에 따름)어댑터의 on/offregisterAdapter()로 등록한 커스텀 컨트롤 어댑터를 연결.

메서드

syn.$bind.createStore(data)

  • 설명: 순수 객체/평면 객체 배열을 Proxy로 감싼 반응형 Store 인스턴스를 생성합니다. DOM에 연결하지 않으며, 화면 반영이 필요하면 subscribe()로 직접 연결하거나 이후 attach()/mount()로 DOM에 붙여야 합니다.
  • 매개변수
    이름타입필수설명
    dataobject | ArrayY감쌀 원본 데이터(순수 객체 또는 평면 객체 배열).
  • 반환값: Store
  • 예시
    const store = syn.$bind.createStore({ count: 0 });

syn.$bind.mount(root, dataOrStore)

  • 설명: createStore + attach를 한 번에 수행합니다. root 내부에서 [syn-bind-list], [syn-bind] 엘리먼트를 찾아 바인딩을 연결합니다.
  • 매개변수
    이름타입필수설명
    rootHTMLElement | DocumentFragmentY바인딩 대상을 탐색할 루트 노드.
    dataOrStoreobject | Array | StoreY순수 데이터를 넘기면 내부에서 createStore를 호출하고, 이미 만든 Store를 넘기면 그대로 재사용.
  • 반환값: { store: Store, destroy(): void }destroy()를 호출하면 이 mount() 호출로 생성된 모든 바인딩(DOM 이벤트 리스너, store 구독)이 해제됩니다.
  • 예시
    const mounted = syn.$bind.mount(document.body, { user: { name: '홍길동' } });
    mounted.store.data.user.name = '김철수';
    mounted.destroy();

syn.$bind.attach(root, store)

  • 설명: mount()의 저수준 버전으로, store를 새로 만들지 않고 이미 있는 store를 다른 DOM 영역에 추가로 연결만 합니다. 여러 화면 영역이 같은 store를 공유할 때 사용합니다.
  • 매개변수
    이름타입필수설명
    rootHTMLElement | DocumentFragmentY바인딩 대상을 탐색할 루트 노드.
    storeStoreY연결할 기존 store.
  • 반환값: Array<Function> — 이 attach() 호출로 생성된 정리(cleanup) 함수 배열. 각 함수를 호출하면 해당 바인딩이 해제됩니다.
  • 예시
    syn.$bind.attach(document.getElementById('extra'), mounted.store);

syn.$bind.registerBinding(type, handler)

  • 설명: syn-bind 선언 문법에서 사용할 새로운 바인딩 타입을 등록합니다. 반드시 mount()/attach() 호출 이전에 등록해야 해당 타입이 인식됩니다.
  • 매개변수
    이름타입필수설명
    typestringYsyn-bind="타입:경로"에서 사용할 타입 이름.
    handlerobjectY{ toDOM(el, value, arg), event?, fromDOM?(el) }. event를 생략하면 단방향(store→DOM) 바인딩이 되고, 문자열 또는 (el) => string 함수로 지정하면 해당 DOM 이벤트 발생 시 fromDOM(el) 결과를 store에 반영하는 양방향 바인딩이 됩니다.
  • 반환값: 없음
  • 예시
    syn.$bind.registerBinding('uppercase', {
    toDOM(el, v) { el.textContent = (v || '').toUpperCase(); }
    });
    <b syn-bind="uppercase:user.name"></b>

syn.$bind.registerAdapter(name, adapter)

  • 설명: 표준 HTMLElement가 아닌 커스텀 컨트롤(AUIGrid, Handsontable, tail-select, daterangepicker 등)을 syn-bind="adapter(name):경로" 문법으로 연결할 수 있도록 어댑터를 등록합니다. get/set은 필수이며, on/off를 생략하면 store→컨트롤 단방향 바인딩만 동작합니다.
  • 매개변수
    이름타입필수설명
    namestringY어댑터 이름.
    adapterobjectY{ get(el), set(el, value), on?(el, handler), off?(el, handler) }. get/set이 함수가 아니면 오류가 발생합니다.
  • 반환값: 없음
  • 예시
    syn.$bind.registerAdapter('grid', {
    get(el) { return el._grid.getData(); },
    set(el, value) { el._grid.setData(value || []); },
    on(el, handler) { el._grid.on('change', handler); }
    });

syn.$bind.raw(value)

  • 설명: 트리 중간의 Proxy 값(예: store.data.items[0])을 원본 순수 객체/배열로 변환합니다. Proxy가 아닌 값은 그대로 반환합니다.
  • 매개변수
    이름타입필수설명
    valueanyY변환할 값.
  • 반환값: any — Proxy면 원본 객체/배열, 아니면 입력값 그대로.
  • 예시
    const rawRow = syn.$bind.raw(store.data.items[0]);

syn.$bind.bindings / syn.$bind.controlAdapters / syn.$bind.Store

  • 설명: 각각 등록된 내장/커스텀 바인딩 타입 테이블, 등록된 커스텀 컨트롤 어댑터 테이블, Store 클래스 참조입니다. registerBinding/registerAdapter로 채워지는 내부 테이블을 직접 조회하거나, new syn.$bind.Store(data)처럼 클래스를 직접 사용할 때 참조합니다.

Store 인스턴스 메서드

createStore()/mount()가 반환하는 Store 인스턴스는 다음 메서드를 제공합니다.

store.data

  • 설명: 원본 데이터를 감싼 최상위 Proxy입니다. store.data.user.name = '값'처럼 표준 JS 문법으로 값을 변경하면 즉시 화면에 반영됩니다.

store.get(path) / store.set(path, value)

  • 설명: "items[0].title" 형식의 경로 문자열로 값을 조회/설정합니다. store.data.xxx로 접근/대입하는 것과 완전히 동일한 결과를 냅니다.
  • 매개변수
    이름타입필수설명
    pathstringY"user.name", "items[0].title" 형식의 경로.
    valueanyset만 Y설정할 값.
  • 반환값: get은 조회된 값(any), set은 없음.
  • 예시
    const name = store.get('user.name');
    store.set('user.name', '김민수');

store.subscribe(path, cb, opts)

  • 설명: 지정한 경로의 변경을 구독합니다. opts.deep === true면 하위 경로(items[0].title 등) 변경도 함께 수신합니다. 경로를 빈 문자열('')로 구독하면 모든 변경을 수신합니다(디버깅/전체 감시용).
  • 매개변수
    이름타입필수설명
    pathstringY구독할 경로. 빈 문자열이면 모든 변경 수신.
    cb(event) => voidYevent = { path, type, key, value, oldValue, parentPath }. type'set'/'add'/'delete'/'length' 중 하나.
    opts{ deep?: boolean }Ndeep: true면 하위 경로 변경까지 수신.
  • 반환값: Function — 호출하면 구독을 해제하는 함수.
  • 예시
    const unsubscribe = store.subscribe('items', (ev) => console.log(ev.type, ev.path), { deep: true });
    unsubscribe();

store.batch(fn)

  • 설명: splice처럼 한 번의 호출에서 내부적으로 여러 트랩(get/set/deleteProperty) 호출이 연속 발생하는 연산을 감쌉니다. batch 없이는 연산 도중 구독 콜백이 불완전한 중간 상태를 볼 수 있지만, batch로 감싸면 연산이 모두 끝난 뒤 한 번만(모아서) 통지됩니다.
  • 매개변수
    이름타입필수설명
    fn() => voidY배칭할 동기 함수.
  • 반환값: 없음
  • 예시
    store.batch(() => { store.data.items.splice(1, 1); });

store.toRaw()

  • 설명: 루트 데이터를 원본 순수 객체/배열(Proxy가 아닌 실제 참조)로 반환합니다. 반환값을 직접 수정하면 데이터는 실제로 바뀌지만, Proxy의 set 트랩을 거치지 않으므로 이벤트가 발생하지 않고 화면도 갱신되지 않습니다.
  • 반환값: object | Array
  • 예시
    const snapshot = JSON.stringify(store.toRaw(), null, 2);

store.scope(basePath)

  • 설명: 특정 경로를 기준으로 상대 경로만 사용하는 얇은 뷰(ScopedStore)를 만듭니다. syn-bind-list의 각 행이 내부적으로 자동 생성하는 것과 같은 방식으로, 리스트 행 컴포넌트처럼 상위 경로를 몰라도 되는 코드를 작성할 때 유용합니다.
  • 매개변수
    이름타입필수설명
    basePathstringY기준 경로(예: "items[0]").
  • 반환값: ScopedStore
  • 예시
    const rowScope = store.scope('items[0]');
    rowScope.set('title', '수정된 제목');

ScopedStore 인스턴스 메서드

store.scope(basePath)가 반환하는 ScopedStorebasePath 기준 상대 경로로 동작하는 get/set/subscribe/batch/scope를 제공하며, 내부적으로 원본 store에 위임합니다.

scopedStore.data

  • 설명: store.get(basePath)와 동일(getter). basePath 위치의 Proxy 값을 반환합니다.

scopedStore.get(relPath) / scopedStore.set(relPath, value)

  • 설명: basePath 기준 상대 경로(생략 시 basePath 자체)로 조회/설정합니다. 상대 경로가 [로 시작하면("[0]") 인덱스로, 아니면 .로 이어붙인 프로퍼티 경로로 해석합니다.

scopedStore.subscribe(relPath, cb, opts) / scopedStore.batch(fn)

  • 설명: 각각 store.subscribe/store.batchbasePath 기준 절대 경로로 변환해 위임합니다.

scopedStore.scope(relPath)

  • 설명: 현재 basePath 기준 상대 경로로 한 단계 더 좁힌 ScopedStore를 반환합니다.