Files
HeuJung 21fc114f37 fix(core,extensions): 보안 결함 3건과 이중저장소·레이아웃 중복키 결함군 폐쇄
KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고,
그 과정에서 드러난 두 결함군을 함께 닫는다.

- 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다
- 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다
- 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고,
 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는
 결제창 미완료 주문은 만료 자동취소가 거둔다
- 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다
 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다
- 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
2026-09-02 17:36:11 +09:00

22 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

목차

  1. 개요
  2. 상태 계층 구조
  3. 전역 상태 특징
  4. _global 예약 경로
  5. _local 로컬 상태
  6. _isolated 격리된 상태
  7. 주의사항

개요

그누보드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() 변환 없이 Select options에 직접 바인딩합니다.

기본 사용법

{
  "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 전용 값이 조용히 사라진다)
❌ 저장소 A 에만 쓰는 `_local` 쓰기 경로 추가 (`context.setState(payload)` 단독 호출)
❌ 키가 **존재하는** 것만 확인하고 그 값이 **최신인지** 보지 않기 (존재 ≠ 신선도)
❌ 하네스에서 `globalState._local` 을 손으로 채워 발산 상황을 위조 (실 writer 를 거치지 않으면 결함이 시험에 등장하지 않는다)
❌ 구독 기반 선택적 리렌더 재시도 (과거에 도입 후 롤백된 실패 경로 — 반드시 검토 후 논의)

엔진 수정 시 필수 확인 사항

✅ _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` 은 커버리지가 아니다 — 꺼진 시험은 한 번도 돌지 않는다
✅ 수정 후 이중 저장소 동기화 관련 회귀 테스트 전수 통과 확인

_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 응답 데이터
모달 열림 상태 비즈니스 로직 상태

관련 문서