자동바인딩은 키입력마다 __g7PendingLocalState 에 저장소 A 기반 전체 스냅샷을 대입해 왔다. 그 base(parentFormContext.state)는 extendedDataContext useMemo 의 결과라, selfManaged 플러그인이 저장소 B 에만 쓴 뒤 memo 가 재계산되지 않은 구간에서는 편집 이전 스냅샷으로 고정된다. 이어지는 setLocal 이 currentSnapshot = pendingState || baseLocal 로 그것을 채택하면 저장소 B 가 통째 교체되어 편집분이 사라진다. pending 은 getLocal 이 읽는 "화면과 같은 전체 스냅샷" 이므로, 렌더러가 _local 을 만드는 순서(dataContext._local → dynamicState → __g7ForcedLocalFields)를 그대로 따르게 합성한다. 방금 입력한 경로는 마지막에 다시 얹어 오버레이의 직전 값이 입력을 되돌리지 못하게 했다. 저장소 A 경로와 setLocal 의 base 우선순위는 건드리지 않았다. 전자는 2026-04-22 에 로그인 폼 email 손실로 철회된 자리이고, 후자는 _localInit 이 초기 데이터를 pending 에만 싣는 구간을 깨뜨린다. engine-v1.63.3이 저장 클릭 경로를 고쳤다면 이번 수정은 그 앞의 키입력 경로를 고친다. 성립 조건에는 memo deps 와 무관한 리렌더가 선행해야 하며, 리사이즈 없는 대조군은 수정 전에도 정상이다(브라우저 실측). 트러블슈팅 246 사례 전수 판정: 영향 있음 0 · 개선 3 · 불변 10.
21 KiB
전역 상태 관리
버전: engine-v1.1.0+ 관련 문서: components.md | data-binding.md | index.md
TL;DR (5초 요약)
1. 전역 상태: _global.속성명 (앱 전체 공유, 페이지 이동 시 유지)
2. 로컬 상태: _local.속성명 (레이아웃 전체, 레이아웃 전환 시 초기화, 같은 레이아웃 재진입 시 유지)
3. 격리 상태: _isolated.속성명 (컴포넌트 영역, 성능 최적화용) (engine-v1.14.0+)
4. 바인딩: {{_global.xxx}}, {{_local.xxx}}, {{_isolated.xxx}}
5. 변경: setState 핸들러 (target: "global"|"local"|"isolated")
분리된 문서
이 문서는 가독성을 위해 다음과 같이 분리되었습니다:
| 문서 | 내용 |
|---|---|
| state-management.md (현재) | 개요, 전역 상태 특징, _global, _local, 주의사항 |
| state-management-forms.md | 로컬 상태 초기화, 폼 자동 바인딩, setState 액션, 깊은 병합, payload 표현식 |
| state-management-advanced.md | 예약된 전역 상태, 사용 사례, 조건부 렌더링, 모듈 동기화, G7Core.state API |
목차
개요
그누보드7 템플릿 엔진은 레이아웃 JSON 전체에서 공유되는 **전역 상태(Global State)**를 지원합니다. 전역 상태를 활용하면 여러 컴포넌트 간 상태 공유와 UI 상태 관리가 가능합니다.
상태 계층 구조
버전: engine-v1.14.0+
그누보드7 템플릿 엔진은 4-layer 상태 계층 구조를 지원합니다:
| 레이어 | 범위 | 라이프사이클 | 용도 |
|---|---|---|---|
_global |
앱 전체 | 페이지 이동 시 유지 | 사용자 인증, 사이드바, 설정 |
_local |
현재 레이아웃 | 레이아웃 전환 시 초기화 (같은 레이아웃 재진입 시 유지) | 폼 데이터, 필터, 페이지네이션 |
_isolated |
격리된 컴포넌트 | 컴포넌트 언마운트 시 소멸 | 독립적 UI 영역 (성능 최적화) |
_computed |
계산된 값 | 렌더링마다 재계산 | 파생 데이터 |
리렌더링 범위 비교
_global 변경 → 전체 앱 리렌더링 (사이드바, 헤더 등)
_local 변경 → 전체 레이아웃 리렌더링
_isolated 변경 → 해당 격리된 영역만 리렌더링 ✅ 성능 최적화
_computed 변경 → 자동 재계산 (상태 변경 아님)
전역 상태 특징
| 특징 | 설명 |
|---|---|
| 모든 컴포넌트 접근 | 레이아웃 내 어떤 컴포넌트에서든 접근 가능 |
_global 예약 경로 |
_global.속성명 형식으로 접근 |
| 자동 재렌더링 | 상태 변경 시 관련 컴포넌트 자동 재렌더링 |
| 페이지 새로고침 시 초기화 | 영구 저장되지 않음 (휘발성) |
_global 예약 경로
전역 상태에 접근하려면 _global 예약 경로를 사용합니다.
시스템 주입 속성
G7이 자동으로 주입하는 _global 속성입니다. 레이아웃에서 읽기 전용으로 사용합니다.
| 속성 | 설명 | 출처 |
|---|---|---|
_global.settings |
관리자 환경설정 (defaults.json 기반 영속 설정) | G7Config.settings → TemplateApp.loadG7Config() |
_global.appConfig |
시스템 설정 (config/frontend.php 기반 정적 config 값) | G7Config.appConfig → TemplateApp.loadG7Config() |
{
"comment": "appConfig 활용 예시 - 동적 Select 옵션 (타임존)",
"props": {
"options": "{{_global.appConfig?.supportedTimezones ?? []}}",
"searchable": true
}
}
_global.appConfig.supportedTimezones는[{value: 'Asia/Seoul', label: '(UTC+09:00) Asia/Seoul'}, ...]형식의{value, label}객체 배열입니다(오프셋 오름차순 정렬). 백엔드SettingsService::buildTimezoneOptions()가 매 요청 시점에 UTC 오프셋을 계산하여 라벨을 생성하므로 DST 전환이 자동 반영됩니다. 별도.map()변환 없이 Selectoptions에 직접 바인딩합니다.
기본 사용법
{
"props": {
"className": "{{_global.sidebarOpen ? 'open' : 'closed'}}",
"text": "{{_global.currentTheme}}"
},
"if": "{{_global.isLoading}}"
}
사용 가능한 위치
props내 값if조건actions의payload표현식- 데이터 바인딩 표현식 (
{{}})
_local 로컬 상태
레이아웃 내에서 컴포넌트 간 공유되는 로컬 상태입니다. _global과 달리 레이아웃 단위로 관리됩니다.
핵심 원칙
✅ 레이아웃 전체에서 공유: 같은 레이아웃 내 모든 컴포넌트에서 접근 가능
✅ setState로 업데이트: target: "local" 또는 기본값으로 업데이트
✅ iteration 내부 접근: 반복 렌더링 내부에서도 _local 상태 접근 가능
✅ API 에러 저장: onError 핸들러에서 에러 데이터 저장에 활용
이중 저장소 구조 (engine-v1.43.0+)
⚠️ CRITICAL (엔진 유지보수자 필독): _local은 내부적으로 두 저장소로 관리된다.
이 구조는 "단일화 시도 실패" 이력의 결과이며 엔진 설계 전제이다.
변경 제안 전 반드시 과거 경로 기반 구독 시스템 롤백 이력 검토.
| 저장소 | 실체 | 쓰는 곳 | 읽는 곳 |
|---|---|---|---|
| A | React localDynamicState (useState) |
Form 자동바인딩 (Input/Textarea onChange) | DOM value, 부분 리렌더 |
| B | globalState._local (TemplateApp 싱글톤) |
G7Core.state.setLocal/getLocal |
apiCall body 바인딩, 플러그인 동기화 |
레이아웃 JSON·일반 플러그인 개발자는 이 구조를 의식할 필요가 없다. 엔진이 양방향 동기화를 자동 처리한다.
엔진 자동 동기화 메커니즘 (요약):
- A→B 방향: 자동바인딩
performStateUpdate가 A에 쓸 때 B에도setLocal({render:false})로 동기 기록 - B→A 방향: 자동바인딩 활성 경로를
__g7AutoBindingPaths: Map<string, number>에 추적. 플러그인이setLocal({render:false})로 그 경로를 건드리면 엔진이 자동으로render:true로 승격 - 예외:
selfManaged: true명시한 호출은 자동 승격 제외 (CKEditor5 등 자체 DOM 관리 플러그인 전용) - pending 스냅샷: 자동바인딩은
__g7PendingLocalState에 "지금 화면과 같은 전체 스냅샷" 을 싣는다. 이 값이 뒤이은setLocal의 base 가 되므로, 저장소 A 스냅샷을 그대로 실으면 B 에만 있던 값(selfManaged 플러그인이 쓴 편집기 본문 등)이 사라진다. 그래서 렌더러가 화면을 만드는 순서(dataContext._local → dynamicState → __g7ForcedLocalFields)를 그대로 따라 합성한다 (engine-v1.63.4)
엔진 수정 시 금지 사항 (CRITICAL)
❌ _local에 쓰는 새 경로를 추가하면서 A 또는 B 한쪽만 갱신
❌ `parentFormContext.setState`를 직접 호출하는 우회 경로 추가 (자동바인딩 내부 API)
❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현
❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화
❌ 자동바인딩의 pending 스냅샷을 저장소 A 값만으로 구성 (B 전용 값이 조용히 사라진다)
❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의)
엔진 수정 시 필수 확인 사항
✅ _local 쓰기 경로 추가 시 A+B 양쪽 동기화 확인
✅ 새 `setLocal({render:false})` 사용처가 자동바인딩 경로와 겹치는지 확인 (겹치면 selfManaged 필요)
✅ pending 에 쓰는 값이 렌더러가 만드는 `_local` 과 같은 합성 순서인지 확인
✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토
✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지
✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인
- 상세 설명:
docs/extension/plugin-development.md"폼 상태 정합성" 섹션 - 구현 참고:
DynamicRenderer.tsxperformStateUpdate상단 주석 (~50줄)
_localInit 은 단일 슬롯이 아니다 (engine-v1.52.2+)
데이터소스의 initLocal 이 만든 초기화 payload 는 dataContext._localInit 한 키로 전달된다.
생산부는 updateTemplateData(3개 write site), 소비부는 DynamicRenderer 의 _localInit useEffect 다.
소비는 React commit 이후에 일어난다. progressive 데이터소스는 응답이 오는 대로 각자 독립적으로
updateTemplateData({ _localInit }) 를 호출하므로, initLocal 을 가진 progressive 소스가 둘 이상이면
두 호출이 같은 commit 사이에 들어올 수 있다. 이때 슬롯을 교체하면 먼저 도착한 payload 가
한 번도 관측되지 않고 사라진다.
_localInit 슬롯 병합과 __g7LocalInitTracking 레지스트리의 단일 소유자는
localInitSlot.ts 다.
❌ updateTemplateData 에서 _localInit 을 얕은 스프레드(...data)로 교체
❌ 소비 여부와 무관하게 _localInit 을 무조건 누적 병합
(소비가 끝난 payload 가 refetchDataSource 시 재적용되어 사용자 폼 편집을 되돌린다)
❌ 소비 여부 판정을 위해 해시 계산식을 생산부에 복제
(생산·소비 양쪽의 판정 기준이 어긋난다 — 슬롯 참조 비교를 쓴다)
❌ __g7LocalInitTracking 을 레이아웃 전환 시 리셋하지 않음
(_local 은 비웠는데 추적 해시가 남아 새 레이아웃의 동일 payload 가 "이미 적용됨" 으로 건너뛰어진다)
✅ 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합 — mergeLocalInitSlot()
✅ 관측 시 슬롯 참조 기록 — markLocalInitConsumed(). 적용/건너뜀 여부와 무관하게 호출
✅ 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 초기화 — resetLocalInitTracking()
✅ _forceLocalInit 은 한쪽에만 있으면 보존, 양쪽에 있으면 최신 타임스탬프 (refetchOnMount 강제 초기화 유실 방지)
✅ 소비부는 A(setLocalDynamicState)/B(_globalSetState) 양쪽을 갱신 — 이 대칭을 깨지 않는다
이 결함은 정상 CPU 에서 간헐적이라 놓치기 쉽다. CPU 스로틀링(6배 이상)으로 commit 을 지연시켜야 경합 창이 결정적으로 열린다.
라이프사이클 (SPA 네비게이션)
SPA navigate 시 _local 상태는 레이아웃 이름 기준으로 선택적 초기화됩니다:
| 전환 유형 | 동작 | 예시 |
|---|---|---|
| 다른 레이아웃 전환 | _local = {} 완전 초기화 후 새 initLocal 적용 |
주문관리 → 배송정책 |
| 같은 레이아웃 재진입 | 기존 _local 유지, undefined 키만 initLocal에서 채움 |
상품목록 → 상품수정 → 상품목록 |
다른 레이아웃 전환 시 이전 _local 키가 잔존하지 않음 (완전 초기화)
✅ 같은 레이아웃 재진입 시 필터, 컬럼 설정 등 사용자 상태 보존
상태 공유 메커니즘
그누보드7 템플릿 엔진은 parentComponentContext를 통해 레이아웃 전체에서 _local 상태를 공유합니다:
최상위 DynamicRenderer (state 소유)
↓ parentComponentContext 전달
자식 DynamicRenderer (부모 state 사용)
↓ parentComponentContext 전달
손자 DynamicRenderer (부모 state 사용)
↓ ...
이 메커니즘 덕분에:
- 버튼의
onError에서setState로 설정한 에러 데이터가 - 형제 컴포넌트인 에러 표시 영역에서도
{{_local.errors}}로 접근 가능
로컬 상태 기본 사용법
{
"props": {
"className": "{{_local.activeTab === 'basic' ? 'active' : ''}}",
"value": "{{_local.searchQuery}}"
},
"if": "{{_local.errors && Object.keys(_local.errors).length > 0}}"
}
setState로 로컬 상태 업데이트
{
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "local",
"activeTab": "settings"
}
}
]
}
참고:
target을 생략하면 기본값으로"local"이 사용됩니다.
API 에러 처리 예시
API 호출 실패 시 에러 데이터를 _local에 저장하고 화면에 표시:
{
"id": "save_button",
"type": "basic",
"name": "Button",
"text": "저장",
"actions": [
{
"type": "click",
"handler": "apiCall",
"target": "/api/users",
"params": { "method": "POST" },
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"errors": "{{$response?.errors}}"
}
}
]
}
]
}
에러 표시 컴포넌트:
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "bg-red-50 border border-red-200 rounded-lg p-4"
},
"children": [
{
"type": "basic",
"name": "Ul",
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, msgs]) => msgs)",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"text": "{{message}}"
}
]
}
]
}
iteration 내부에서 _local 접근
반복 렌더링 내부에서도 _local 상태에 접근할 수 있습니다:
{
"iteration": {
"source": "_local.items",
"item_var": "item"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "{{_local.selectedId === item.id ? 'bg-blue-100' : 'bg-white'}}"
},
"text": "{{item.name}}"
}
]
}
_global vs _local 비교
| 항목 | _global |
_local |
|---|---|---|
| 범위 | 전체 애플리케이션 | 현재 레이아웃 |
| 용도 | 사이드바, 테마, 모달 | 폼 상태, 탭, 에러 |
| 접근 방식 | {{_global.xxx}} |
{{_local.xxx}} |
| setState target | "global" |
"local" (기본값) |
| 페이지 이동 시 | 유지 | 다른 레이아웃: 초기화 / 같은 레이아웃: 유지 |
사용 시점
| 사용 O | 사용 X |
|---|---|
| 폼 입력값 임시 저장 | 전역 UI 상태 (사이드바) |
| 탭/아코디언 상태 | 여러 페이지에서 공유할 데이터 |
| API 에러 메시지 | 영구 저장이 필요한 데이터 |
| 검색 필터 상태 | 비즈니스 로직 데이터 |
상세 문서: 로컬 상태 초기화, 폼 자동 바인딩은 state-management-forms.md 참조
_isolated 격리된 상태
버전: engine-v1.14.0+
_isolated는 특정 컴포넌트 영역 내에서만 유효한 격리된 상태입니다. 해당 영역의 상태 변경이 전체 레이아웃을 리렌더링하지 않아 성능이 향상됩니다.
_local vs _isolated 사용 기준
| 기준 | _local 사용 |
_isolated 사용 |
|---|---|---|
| 리렌더링 범위 | 전체 레이아웃 | 격리된 영역만 |
| 상태 공유 | 레이아웃 내 다른 컴포넌트와 공유 | 해당 영역 내에서만 사용 |
| 성능 최적화 | 불필요 | 빈번한 상호작용 영역 |
| 예시 | 폼 전체 데이터, 필터 설정 | 카테고리 선택, 드래그 상태 |
레이아웃에서 정의
isolatedState 속성으로 격리된 상태를 정의합니다:
{
"type": "Div",
"isolatedState": {
"selectedItems": [],
"currentStep": 1
},
"isolatedScopeId": "item-selector",
"children": [...]
}
| 속성 | 타입 | 설명 |
|---|---|---|
isolatedState |
object |
격리된 상태 초기값 |
isolatedScopeId |
string |
(선택) DevTools 식별용 스코프 ID |
액션에서 업데이트
setState 핸들러에서 target: "isolated" 사용:
{
"handler": "setState",
"params": {
"target": "isolated",
"currentStep": 2,
"selectedItems": ["{{item.id}}"]
}
}
바인딩에서 접근
{
"text": "{{_isolated.selectedItems.length}}개 선택됨",
"if": "{{_isolated.currentStep === 2}}",
"props": {
"selected": "{{_isolated.selectedItems.includes(item.id)}}"
}
}
_isolated 라이프사이클
1. 생성: isolatedState 속성이 있는 컴포넌트 마운트 시
2. 업데이트: setState target:"isolated" 또는 isolatedContext.mergeState()
3. 소멸: 해당 컴포넌트 언마운트 시
4. 페이지 이동: 초기화됨
5. 모달 열기/닫기: 모달 내 isolated는 독립, 부모 isolated 유지
사용 예시: 카테고리 선택
{
"type": "Div",
"isolatedState": {
"selectedCategories": [null, null, null, null],
"currentLevel": 0
},
"children": [
{
"type": "basic",
"name": "CategoryLevel",
"props": {
"level": 0,
"selected": "{{_isolated.selectedCategories[0]}}"
},
"actions": [
{
"type": "click",
"handler": "setState",
"params": {
"target": "isolated",
"selectedCategories": "{{[..._isolated.selectedCategories.slice(0, 0), $event.id, null, null, null]}}",
"currentLevel": 1
}
}
]
}
]
}
_global vs _local vs _isolated 비교
| 항목 | _global |
_local |
_isolated |
|---|---|---|---|
| 범위 | 전체 앱 | 현재 레이아웃 | 격리된 컴포넌트 |
| 용도 | 사이드바, 테마 | 폼 데이터, 필터 | 독립 UI 영역 |
| 접근 | {{_global.xxx}} |
{{_local.xxx}} |
{{_isolated.xxx}} |
| target | "global" |
"local" |
"isolated" |
| 리렌더링 | 전체 앱 | 전체 레이아웃 | 해당 영역만 |
| 페이지 이동 | 유지 | 레이아웃 전환: 초기화 / 같은 레이아웃: 유지 | 초기화 |
언제 _isolated를 사용할까?
| 사용 O | 사용 X |
|---|---|
| 카테고리/태그 선택 UI | 폼 전체 데이터 |
| 드래그 앤 드롭 상태 | 다른 컴포넌트와 공유할 상태 |
| 아코디언 열림/닫힘 | API 호출 결과 |
| 멀티 셀렉트 체크박스 | 에러 메시지 |
| 자주 변경되는 임시 상태 | 필터/정렬 설정 |
주의:
target: "isolated"는isolatedState속성이 정의된 컴포넌트 내에서만 동작합니다. 격리 스코프 외부에서 호출 시_local로 폴백되며 경고 로그가 출력됩니다.
주의사항
권장 사항 (DO)
| 항목 | 설명 |
|---|---|
| ✅ UI 상태 관리 전용 | 전역 상태는 UI 상태 관리에만 사용 |
| ✅ 비즈니스 로직은 API | 비즈니스 로직은 API에서 처리 |
| ✅ target 명확히 지정 | "global" 사용 시 _global.속성으로 접근 |
| ✅ 표현식은 DataBindingEngine | payload 표현식은 DataBindingEngine이 평가 |
| ✅ 자동 재렌더링 활용 | 상태 변경 시 자동 재렌더링 |
주의 사항 (CAUTION)
| 항목 | 설명 |
|---|---|
| 캐싱 안 됨 | 전역 상태는 캐싱되지 않음 (항상 최신 값) |
| 과도한 사용 지양 | 너무 많은 전역 상태는 성능에 영향 |
| 컴포넌트 로컬 우선 | 가능하면 컴포넌트 로컬 상태 사용 |
| 영향 범위 인지 | 전역 상태는 모든 컴포넌트에 영향 |
언제 전역 상태를 사용할까?
| 사용 O | 사용 X |
|---|---|
| 사이드바 열림/닫힘 | 폼 입력값 |
| 다크모드 상태 | 단일 컴포넌트 토글 |
| 전역 로딩 표시 | API 응답 데이터 |
| 모달 열림 상태 | 비즈니스 로직 상태 |
관련 문서
- 폼 자동 바인딩 및 setState - FormContext, 깊은 병합, payload 표현식
- 고급 상태 관리 - 예약 상태, G7Core.state API, 사용 사례
- g7core-api.md - G7Core 전역 API 레퍼런스
- components.md - 컴포넌트 개발 규칙
- data-binding.md - 데이터 바인딩 문법
- layout-json.md - 레이아웃 JSON 스키마 (모달 시스템 포함)
- responsive-layout.md - 반응형 레이아웃 (전역 상태 활용)