# 전역 상태 관리 > **버전**: engine-v1.1.0+ > **관련 문서**: [components.md](components.md) | [data-binding.md](data-binding.md) | [index.md](index.md) --- ## TL;DR (5초 요약) ```text 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](state-management-forms.md) | 로컬 상태 초기화, 폼 자동 바인딩, setState 액션, 깊은 병합, payload 표현식 | | [state-management-advanced.md](state-management-advanced.md) | 예약된 전역 상태, 사용 사례, 조건부 렌더링, 모듈 동기화, G7Core.state API | --- ## 목차 1. [개요](#개요) 2. [상태 계층 구조](#상태-계층-구조) 3. [전역 상태 특징](#전역-상태-특징) 4. [_global 예약 경로](#_global-예약-경로) 5. [_local 로컬 상태](#_local-로컬-상태) 6. [_isolated 격리된 상태](#_isolated-격리된-상태) 7. [주의사항](#주의사항) --- ## 개요 그누보드7 템플릿 엔진은 레이아웃 JSON 전체에서 공유되는 **전역 상태(Global State)**를 지원합니다. 전역 상태를 활용하면 여러 컴포넌트 간 상태 공유와 UI 상태 관리가 가능합니다. --- ## 상태 계층 구조 > **버전**: engine-v1.14.0+ 그누보드7 템플릿 엔진은 4-layer 상태 계층 구조를 지원합니다: | 레이어 | 범위 | 라이프사이클 | 용도 | |--------|------|--------------|------| | `_global` | 앱 전체 | 페이지 이동 시 유지 | 사용자 인증, 사이드바, 설정 | | `_local` | 현재 레이아웃 | 레이아웃 전환 시 초기화 (같은 레이아웃 재진입 시 유지) | 폼 데이터, 필터, 페이지네이션 | | `_isolated` | 격리된 컴포넌트 | 컴포넌트 언마운트 시 소멸 | 독립적 UI 영역 (성능 최적화) | | `_computed` | 계산된 값 | 렌더링마다 재계산 | 파생 데이터 | ### 리렌더링 범위 비교 ```text _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()` | ```json { "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()` 변환 없이 Select `options`에 직접 바인딩합니다. ### 기본 사용법 ```json { "props": { "className": "{{_global.sidebarOpen ? 'open' : 'closed'}}", "text": "{{_global.currentTheme}}" }, "if": "{{_global.isLoading}}" } ``` ### 사용 가능한 위치 - `props` 내 값 - `if` 조건 - `actions`의 `payload` 표현식 - 데이터 바인딩 표현식 (`{{}}`) --- ## _local 로컬 상태 레이아웃 내에서 컴포넌트 간 공유되는 **로컬 상태**입니다. `_global`과 달리 레이아웃 단위로 관리됩니다. ### 핵심 원칙 ```text ✅ 레이아웃 전체에서 공유: 같은 레이아웃 내 모든 컴포넌트에서 접근 가능 ✅ setState로 업데이트: target: "local" 또는 기본값으로 업데이트 ✅ iteration 내부 접근: 반복 렌더링 내부에서도 _local 상태 접근 가능 ✅ API 에러 저장: onError 핸들러에서 에러 데이터 저장에 활용 ``` ### 이중 저장소 구조 (engine-v1.43.0+) ```text ⚠️ 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`에 추적. 플러그인이 `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) ```text ❌ _local에 쓰는 새 경로를 추가하면서 A 또는 B 한쪽만 갱신 ❌ `parentFormContext.setState`를 직접 호출하는 우회 경로 추가 (자동바인딩 내부 API) ❌ `__g7AutoBindingPaths` 레지스트리를 건드리지 않고 자동바인딩 변형 구현 ❌ setLocal의 `render:false` 자동 승격 분기를 임의로 제거하거나 조건 완화 ❌ 자동바인딩의 pending 스냅샷을 저장소 A 값만으로 구성 (B 전용 값이 조용히 사라진다) ❌ 저장소 A 에만 쓰는 `_local` 쓰기 경로 추가 (`context.setState(payload)` 단독 호출) ❌ 키가 **존재하는** 것만 확인하고 그 값이 **최신인지** 보지 않기 (존재 ≠ 신선도) ❌ 하네스에서 `globalState._local` 을 손으로 채워 발산 상황을 위조 (실 writer 를 거치지 않으면 결함이 시험에 등장하지 않는다) ❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의) ``` ### 엔진 수정 시 필수 확인 사항 ```text ✅ _local 쓰기 경로 추가 시 A+B 양쪽 동기화 확인 ✅ 새 `setLocal({render:false})` 사용처가 자동바인딩 경로와 겹치는지 확인 (겹치면 selfManaged 필요) ✅ pending 에 쓰는 값이 렌더러가 만드는 `_local` 과 같은 합성 순서인지 확인 ✅ DynamicRenderer의 레지스트리 useEffect 조건 변경 시 iteration/Strict Mode 이중 마운트 영향 검토 ✅ SPA 네비게이션 시 레지스트리 재초기화 (new Map()) 유지 ✅ 미러는 그 쓰기를 **지배하는 분기 안**에 둔다 (형제 분기의 미러는 이 분기를 면죄하지 않는다) ✅ 계약 테스트는 경로마다 A→B / B→A **양방향 쌍**으로 둔다 (한 방향만 두면 반대 방향 회귀가 초록으로 통과한다) ✅ 하네스는 실제 writer(`G7Core.state.setLocal` · 자동바인딩 · `ActionDispatcher`)를 거친다 ✅ `describe.skip` 은 커버리지가 아니다 — 꺼진 시험은 한 번도 돌지 않는다 ✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인 ``` - 상세 설명: [`docs/extension/plugin-development.md`](../extension/plugin-development.md) "폼 상태 정합성" 섹션 - 구현 참고: [`DynamicRenderer.tsx`](../../resources/js/core/template-engine/DynamicRenderer.tsx) `performStateUpdate` 상단 주석 (~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`](../../resources/js/core/template-engine/localInitSlot.ts) 다. ```text ❌ updateTemplateData 에서 _localInit 을 얕은 스프레드(...data)로 교체 ❌ 소비 여부와 무관하게 _localInit 을 무조건 누적 병합 (소비가 끝난 payload 가 refetchDataSource 시 재적용되어 사용자 폼 편집을 되돌린다) ❌ 소비 여부 판정을 위해 해시 계산식을 생산부에 복제 (생산·소비 양쪽의 판정 기준이 어긋난다 — 슬롯 참조 비교를 쓴다) ❌ __g7LocalInitTracking 을 레이아웃 전환 시 리셋하지 않음 (_local 은 비웠는데 추적 해시가 남아 새 레이아웃의 동일 payload 가 "이미 적용됨" 으로 건너뛰어진다) ``` ```text ✅ 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합 — mergeLocalInitSlot() ✅ 관측 시 슬롯 참조 기록 — markLocalInitConsumed(). 적용/건너뜀 여부와 무관하게 호출 ✅ 레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 초기화 — resetLocalInitTracking() ✅ _forceLocalInit 은 한쪽에만 있으면 보존, 양쪽에 있으면 최신 타임스탬프 (refetchOnMount 강제 초기화 유실 방지) ✅ 소비부는 A(setLocalDynamicState)/B(_globalSetState) 양쪽을 갱신 — 이 대칭을 깨지 않는다 ``` > 이 결함은 정상 CPU 에서 간헐적이라 놓치기 쉽다. CPU 스로틀링(6배 이상)으로 commit 을 지연시켜야 > 경합 창이 결정적으로 열린다. ### 라이프사이클 (SPA 네비게이션) SPA navigate 시 `_local` 상태는 **레이아웃 이름 기준으로 선택적 초기화**됩니다: | 전환 유형 | 동작 | 예시 | | -------- | ---- | --- | | **다른 레이아웃 전환** | `_local = {}` 완전 초기화 후 새 initLocal 적용 | 주문관리 → 배송정책 | | **같은 레이아웃 재진입** | 기존 `_local` 유지, undefined 키만 initLocal에서 채움 | 상품목록 → 상품수정 → 상품목록 | ```text 다른 레이아웃 전환 시 이전 _local 키가 잔존하지 않음 (완전 초기화) ✅ 같은 레이아웃 재진입 시 필터, 컬럼 설정 등 사용자 상태 보존 ``` ### 상태 공유 메커니즘 그누보드7 템플릿 엔진은 `parentComponentContext`를 통해 레이아웃 전체에서 `_local` 상태를 공유합니다: ```text 최상위 DynamicRenderer (state 소유) ↓ parentComponentContext 전달 자식 DynamicRenderer (부모 state 사용) ↓ parentComponentContext 전달 손자 DynamicRenderer (부모 state 사용) ↓ ... ``` 이 메커니즘 덕분에: - 버튼의 `onError`에서 `setState`로 설정한 에러 데이터가 - 형제 컴포넌트인 에러 표시 영역에서도 `{{_local.errors}}`로 접근 가능 ### 로컬 상태 기본 사용법 ```json { "props": { "className": "{{_local.activeTab === 'basic' ? 'active' : ''}}", "value": "{{_local.searchQuery}}" }, "if": "{{_local.errors && Object.keys(_local.errors).length > 0}}" } ``` ### setState로 로컬 상태 업데이트 ```json { "actions": [ { "type": "click", "handler": "setState", "params": { "target": "local", "activeTab": "settings" } } ] } ``` > **참고**: `target`을 생략하면 기본값으로 `"local"`이 사용됩니다. ### API 에러 처리 예시 API 호출 실패 시 에러 데이터를 `_local`에 저장하고 화면에 표시: ```json { "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}}" } } ] } ] } ``` 에러 표시 컴포넌트: ```json { "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` 상태에 접근할 수 있습니다: ```json { "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](state-management-forms.md) 참조 --- ## _isolated 격리된 상태 > **버전**: engine-v1.14.0+ `_isolated`는 특정 컴포넌트 영역 내에서만 유효한 격리된 상태입니다. 해당 영역의 상태 변경이 전체 레이아웃을 리렌더링하지 않아 **성능이 향상**됩니다. ### _local vs _isolated 사용 기준 | 기준 | `_local` 사용 | `_isolated` 사용 | |------|---------------|------------------| | **리렌더링 범위** | 전체 레이아웃 | 격리된 영역만 | | **상태 공유** | 레이아웃 내 다른 컴포넌트와 공유 | 해당 영역 내에서만 사용 | | **성능 최적화** | 불필요 | 빈번한 상호작용 영역 | | **예시** | 폼 전체 데이터, 필터 설정 | 카테고리 선택, 드래그 상태 | ### 레이아웃에서 정의 `isolatedState` 속성으로 격리된 상태를 정의합니다: ```json { "type": "Div", "isolatedState": { "selectedItems": [], "currentStep": 1 }, "isolatedScopeId": "item-selector", "children": [...] } ``` | 속성 | 타입 | 설명 | |------|------|------| | `isolatedState` | `object` | 격리된 상태 초기값 | | `isolatedScopeId` | `string` | (선택) DevTools 식별용 스코프 ID | ### 액션에서 업데이트 `setState` 핸들러에서 `target: "isolated"` 사용: ```json { "handler": "setState", "params": { "target": "isolated", "currentStep": 2, "selectedItems": ["{{item.id}}"] } } ``` ### 바인딩에서 접근 ```json { "text": "{{_isolated.selectedItems.length}}개 선택됨", "if": "{{_isolated.currentStep === 2}}", "props": { "selected": "{{_isolated.selectedItems.includes(item.id)}}" } } ``` ### _isolated 라이프사이클 ```text 1. 생성: isolatedState 속성이 있는 컴포넌트 마운트 시 2. 업데이트: setState target:"isolated" 또는 isolatedContext.mergeState() 3. 소멸: 해당 컴포넌트 언마운트 시 4. 페이지 이동: 초기화됨 5. 모달 열기/닫기: 모달 내 isolated는 독립, 부모 isolated 유지 ``` ### 사용 예시: 카테고리 선택 ```json { "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](state-management-forms.md) - FormContext, 깊은 병합, payload 표현식 - [고급 상태 관리](state-management-advanced.md) - 예약 상태, G7Core.state API, 사용 사례 - [g7core-api.md](g7core-api.md) - G7Core 전역 API 레퍼런스 - [components.md](components.md) - 컴포넌트 개발 규칙 - [data-binding.md](data-binding.md) - 데이터 바인딩 문법 - [layout-json.md](layout-json.md) - 레이아웃 JSON 스키마 (모달 시스템 포함) - [responsive-layout.md](responsive-layout.md) - 반응형 레이아웃 (전역 상태 활용)