/** * 그누보드7 템플릿 엔진 - 데이터 바인딩 엔진 * * {{variable}} 문법을 파싱하고 실제 데이터로 치환하는 엔진 * * @package G7 * @subpackage Template Engine * @since 1.0.0 */ import { createLogger } from '../utils/Logger'; import { TranslationEngine } from './TranslationEngine'; import { hasPipes, splitPipes, executePipeChain } from './PipeRegistry'; import { RAW_PREFIX, wrapRaw, wrapRawDeep } from './rawMarkers'; import type { G7DevToolsInterface } from './G7CoreGlobals'; const logger = createLogger('DataBindingEngine'); /** * G7Core.devTools 인터페이스 가져오기 * * G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다. * DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환 */ function getDevTools(): G7DevToolsInterface | undefined { try { const G7Core = (window as any).G7Core; return G7Core?.devTools; } catch { return undefined; } } /** * 바인딩 컨텍스트 인터페이스 * * 데이터 바인딩 시 사용되는 컨텍스트 객체 */ export interface BindingContext { [key: string]: any; } /** * 해석된 값 타입 * * 바인딩 결과로 반환되는 값 */ export type ResolvedValue = string | number | boolean | null | undefined | object | Array; /** * 바인딩 옵션 인터페이스 */ export interface BindingOptions { /** * 기본값 (변수를 찾을 수 없을 때 반환) */ defaultValue?: any; /** * null-safe 모드 활성화 */ nullSafe?: boolean; /** * 순환 참조 감지 활성화 */ detectCircular?: boolean; /** * 최대 중첩 깊이 (순환 참조 방지) */ maxDepth?: number; /** * 캐시 사용 여부 (기본값: true) * false로 설정하면 캐싱을 건너뜀 * 반복 렌더링(DataGrid의 cellChildren 등)에서 같은 경로가 다른 값을 가져야 할 때 사용 */ skipCache?: boolean; } /** * 캐시 엔트리 인터페이스 */ interface CacheEntry { /** * 캐시된 값 */ value: ResolvedValue; /** * 캐시 생성 시간 */ timestamp: number; } /** * 데이터 바인딩 엔진 에러 클래스 */ export class DataBindingError extends Error { constructor( message: string, public readonly path: string, public readonly context?: any, ) { super(message); this.name = 'DataBindingError'; } } /** * 데이터 바인딩 엔진 클래스 * * {{variable}} 문법을 파싱하고 실제 데이터로 치환 */ export class DataBindingEngine { /** * 바인딩 패턴 정규식 * * {{variable}}, {{object.property}}, {{array[0]}} 등을 매칭 */ private static readonly BINDING_PATTERN = /\{\{([^}]+)\}\}/g; /** * 경로 파싱 정규식 * * 중첩 객체 접근 (.) 및 배열 인덱스 ([n]) 파싱 */ private static readonly PATH_SEGMENT_PATTERN = /([^.[\]]+)|\[(\d+)\]/g; /** * 캐시 만료 시간 (밀리초) * * 성능 최적화를 위해 30초로 설정 * - 바인딩 경로 재계산 빈도 감소 * - _global 경로는 캐싱되지 않으므로 동적 데이터 처리에 영향 없음 */ private static readonly CACHE_EXPIRY = 30000; // 30초 /** * 값 캐시 맵 * * key: 바인딩 경로, value: 캐시 엔트리 */ private cache: Map = new Map(); /** * 표현식 함수 캐시 * * key: 전처리된 표현식, value: 컴파일된 Function * - Function 생성자 비용이 높으므로 동일 표현식 재사용 시 캐시에서 조회 * - 표현식 자체만 캐싱하고, 컨텍스트 값은 실행 시 전달 */ private expressionFnCache: Map = new Map(); /** * 렌더 사이클 캐시 * * 같은 렌더 사이클 내에서 동일 표현식 재평가 방지 * - 렌더 시작 시 startRenderCycle()로 클리어 * - _local, _global 등 동적 경로도 같은 렌더 내에서는 캐시 사용 * - 65,496회 → ~5,000회 바인딩 평가 감소 목표 */ private renderCycleCache: Map = new Map(); /** * 현재 렌더 사이클 ID * * 매 렌더 시작 시 증가하여 캐시 유효성 판단에 사용 */ private currentRenderCycleId: number = 0; /** * 기본 옵션 */ private defaultOptions: BindingOptions = { defaultValue: undefined, nullSafe: true, detectCircular: true, maxDepth: 10, }; /** * 순환 참조 추적용 Set (각 resolve 호출마다 로컬로 생성) */ // 인스턴스 레벨에서 제거 - 각 resolvePath 호출에서 로컬로 관리 /** * 생성자 * * @param options 기본 옵션 */ constructor(options?: Partial) { if (options) { this.defaultOptions = { ...this.defaultOptions, ...options }; } } /** * 새 렌더 사이클 시작 - 렌더 사이클 캐시 클리어 * * DynamicRenderer 루트 컴포넌트에서 렌더링 시작 시 호출됩니다. * 이를 통해 같은 렌더 사이클 내에서 동일 표현식 재평가를 방지하면서도, * 새 렌더 시 최신 상태값으로 평가됩니다. * * @example * ```ts * // DynamicRenderer.tsx 루트 컴포넌트 * useLayoutEffect(() => { * if (!parentComponentContext) { * bindingEngine.startRenderCycle(); * } * }); * ``` */ public startRenderCycle(): void { this.currentRenderCycleId++; this.renderCycleCache.clear(); } /** * 렌더 사이클 캐시 활성화 여부 확인 * * startRenderCycle()이 호출된 적이 있어야 렌더 사이클 캐시 활성화 * 테스트 환경 등에서 startRenderCycle()을 호출하지 않으면 기존 동작 유지 */ private isRenderCycleCacheActive(): boolean { return this.currentRenderCycleId > 0; } /** * 렌더 사이클 캐시에서 값 조회 * * @param key 캐시 키 (표현식 문자열) * @returns 캐시된 값 또는 undefined (렌더 사이클이 활성화되지 않으면 항상 undefined) */ private getFromRenderCycleCache(key: string): any | undefined { if (!this.isRenderCycleCacheActive()) { return undefined; } return this.renderCycleCache.get(key); } /** * 렌더 사이클 캐시에 값 저장 * * @param key 캐시 키 (표현식 문자열) * @param value 저장할 값 * @note 렌더 사이클이 활성화되지 않으면 저장하지 않음 */ private saveToRenderCycleCache(key: string, value: any): void { if (!this.isRenderCycleCacheActive()) { return; } this.renderCycleCache.set(key, value); } /** * 문자열 내의 모든 바인딩 표현식을 데이터로 치환 * * @param template 바인딩 표현식이 포함된 문자열 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 치환된 문자열 * * @example * const engine = new DataBindingEngine(); * const result = engine.resolveBindings("Hello {{user.name}}", { user: { name: "홍길동" } }); * // 결과: "Hello 홍길동" */ public resolveBindings( template: string, context: BindingContext, options?: BindingOptions, trackingInfo?: { componentId?: string; componentName?: string; propName?: string }, ): string { const opts = { ...this.defaultOptions, ...options }; const devTools = getDevTools(); // $computed alias 추가 // layout-json-features.md 문서에 따라 $computed.xxx 형식으로 computed 값에 접근 // 내부적으로 _computed에 저장되므로 $computed를 _computed의 alias로 설정 const extendedContext: BindingContext = context._computed && !context.$computed ? { ...context, $computed: context._computed } : context; return template.replace(DataBindingEngine.BINDING_PATTERN, (_match, path) => { // 공백 제거 const trimmedPath = path.trim(); // raw: 접두사 감지 — 번역 면제 바인딩 // @since engine-v1.27.0 let isRawBinding = false; let effectivePath = trimmedPath; if (trimmedPath.startsWith(RAW_PREFIX)) { isRawBinding = true; effectivePath = trimmedPath.slice(RAW_PREFIX.length); } // 파이프 함수 처리 (예: {{post.created_at | date}}) // 파이프가 있으면 먼저 분리하여 처리 if (hasPipes(effectivePath)) { const pipeStartTime = devTools?.isEnabled() ? performance.now() : 0; try { const [baseExpr, pipes] = splitPipes(effectivePath); // 기본 표현식 평가 (파이프 적용 전) let value: any; let fromCache = false; const isBaseExpression = /[?:|&!+\-*/<>=()]/.test(baseExpr) || /\[['"]/.test(baseExpr); // 동적 경로 여부 판단 const isDynamicPipeBase = baseExpr.startsWith('_global') || baseExpr.startsWith('_local') || baseExpr.startsWith('_isolated') || baseExpr.startsWith('$parent'); if (opts.skipCache) { // skipCache: 모든 캐시 무시 if (isBaseExpression) { value = this.evaluateExpression(baseExpr, extendedContext); } else { value = this.resolvePath(baseExpr, extendedContext, opts); } } else if (isBaseExpression) { // 복잡한 표현식: 렌더 사이클 캐시 사용 const exprCached = this.getFromRenderCycleCache(`expr:${baseExpr}`); if (exprCached !== undefined) { value = exprCached; fromCache = true; } else { value = this.evaluateExpression(baseExpr, extendedContext); this.saveToRenderCycleCache(`expr:${baseExpr}`, value); } } else if (isDynamicPipeBase) { // 동적 경로: 렌더 사이클 캐시 사용 const renderCycleCached = this.getFromRenderCycleCache(baseExpr); if (renderCycleCached !== undefined) { value = renderCycleCached; fromCache = true; } else { value = this.resolvePath(baseExpr, extendedContext, opts); this.saveToRenderCycleCache(baseExpr, value); } } else { // 정적 경로: 영구 캐시 사용 const cached = this.getFromCache(baseExpr); if (cached !== undefined) { value = cached; fromCache = true; } else { value = this.resolvePath(baseExpr, extendedContext, opts); this.saveToCache(baseExpr, value); } } // 파이프 체인 실행 const result = executePipeChain(value, pipes); // DevTools: 파이프 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - pipeStartTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(result), resultType: this.getResultType(result), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'resolveBindings', skipCache: opts.skipCache, }); } const formatted = this.formatValue(result); return isRawBinding ? wrapRaw(formatted) : formatted; } catch (error) { logger.error('Pipe expression evaluation failed:', effectivePath, error); return _match; // 원본 반환 } } // 삼항 연산자나 복잡한 표현식 감지 (?, :, ||, &&, !, 산술 연산자, 함수 호출 등) // 대괄호 안에 따옴표가 있는 문자열 키 접근도 표현식으로 처리 (예: query['filters[0][field]']) // 함수 호출 패턴도 표현식으로 처리 (예: $localized(...)) // 주의: 단일 | (파이프)는 위에서 먼저 처리되므로 여기서는 || 만 표현식으로 인식 const isExpression = /[?:&!+\-*/<>=()]/.test(effectivePath) || /\|\|/.test(effectivePath) || /\[['"]/.test(effectivePath); if (isExpression) { // JavaScript 표현식으로 평가 const exprStartTime = devTools?.isEnabled() ? performance.now() : 0; try { let value: any; let fromCache = false; // skipCache가 아니면 렌더 사이클 캐시 사용 if (!opts.skipCache) { const exprCacheKey = `expr:${effectivePath}`; const exprCached = this.getFromRenderCycleCache(exprCacheKey); if (exprCached !== undefined) { value = exprCached; fromCache = true; } else { value = this.evaluateExpression(effectivePath, extendedContext); this.saveToRenderCycleCache(exprCacheKey, value); } } else { value = this.evaluateExpression(effectivePath, extendedContext); } // DevTools: 복잡한 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - exprStartTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'resolveBindings', skipCache: opts.skipCache, }); } const formatted = this.formatValue(value); return isRawBinding ? wrapRaw(formatted) : formatted; } catch (error) { logger.error('Expression evaluation failed:', effectivePath, error); return _match; // 원본 반환 } } // skipCache 옵션이면 모든 캐시 사용 안 함 if (opts.skipCache) { const startTime = devTools?.isEnabled() ? performance.now() : 0; const value = this.resolvePath(effectivePath, extendedContext, opts); // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - startTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: false, duration, method: 'resolveBindings', skipCache: true, }); } const formatted = this.formatValue(value); return isRawBinding ? wrapRaw(formatted) : formatted; } // 동적 경로 여부 판단 const isDynamicPath = effectivePath.startsWith('_global') || effectivePath.startsWith('_local') || effectivePath.startsWith('_isolated') || effectivePath.startsWith('$parent'); const startTime = devTools?.isEnabled() ? performance.now() : 0; let value: any; let fromCache = false; if (isDynamicPath) { // 동적 경로: 렌더 사이클 캐시 사용 const renderCycleCached = this.getFromRenderCycleCache(effectivePath); if (renderCycleCached !== undefined) { value = renderCycleCached; fromCache = true; } else { value = this.resolvePath(effectivePath, extendedContext, opts); this.saveToRenderCycleCache(effectivePath, value); } } else { // 정적 경로: 영구 캐시 사용 const cached = this.getFromCache(effectivePath); if (cached !== undefined) { value = cached; fromCache = true; } else { value = this.resolvePath(effectivePath, extendedContext, opts); this.saveToCache(effectivePath, value); } } // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - startTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'resolveBindings', skipCache: opts.skipCache, }); } const formatted = this.formatValue(value); return isRawBinding ? wrapRaw(formatted) : formatted; }); } /** * 단일 바인딩 경로의 값을 해석 * * @param path 바인딩 경로 (예: "user.profile.name") * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 해석된 값 * * @example * const engine = new DataBindingEngine(); * const value = engine.resolve("user.name", { user: { name: "홍길동" } }); * // 결과: "홍길동" */ public resolve( path: string, context: BindingContext, options?: BindingOptions, trackingInfo?: { componentId?: string; componentName?: string; propName?: string }, ): T { const opts = { ...this.defaultOptions, ...options }; const devTools = getDevTools(); const startTime = devTools?.isEnabled() ? performance.now() : 0; // skipCache 옵션이 true면 모든 캐시 사용 안 함 (반복 렌더링에서 같은 경로가 다른 값을 가져야 할 때) if (opts.skipCache) { const value = this.resolvePath(path, context, opts); // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: false, duration: performance.now() - startTime, method: 'resolve', skipCache: true, }); } return value as T; } // 동적 경로 여부 판단 // - _global, _local, _isolated, $parent: 동적으로 자주 변경되므로 영구 캐시 사용 안 함 // - 단, 같은 렌더 사이클 내에서는 렌더 사이클 캐시 사용 (성능 최적화) const isDynamicPath = path.startsWith('_global') || path.startsWith('_local') || path.startsWith('_isolated') || path.startsWith('$parent'); let fromCache = false; // 1. 렌더 사이클 캐시 확인 (동적 경로용) // 같은 렌더 사이클 내에서 동일 표현식 재평가 방지 if (isDynamicPath) { const renderCycleCached = this.getFromRenderCycleCache(path); if (renderCycleCached !== undefined) { fromCache = true; // DevTools: 렌더 사이클 캐시 히트 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(renderCycleCached), resultType: this.getResultType(renderCycleCached), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: true, duration: performance.now() - startTime, method: 'resolve', skipCache: opts.skipCache, }); } return renderCycleCached as T; } } else { // 2. 영구 캐시 확인 (정적 경로용) const cached = this.getFromCache(path); if (cached !== undefined) { fromCache = true; // DevTools: 영구 캐시 히트 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(cached), resultType: this.getResultType(cached), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: true, duration: performance.now() - startTime, method: 'resolve', skipCache: opts.skipCache, }); } return cached as T; } } // 값 해석 const value = this.resolvePath(path, context, opts); // 캐시 저장 if (isDynamicPath) { // 동적 경로: 렌더 사이클 캐시에 저장 this.saveToRenderCycleCache(path, value); } else { // 정적 경로: 영구 캐시에 저장 this.saveToCache(path, value); } // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration: performance.now() - startTime, method: 'resolve', skipCache: opts.skipCache, }); } return value as T; } /** * 경로를 파싱하여 중첩된 값을 해석 * * @param path 바인딩 경로 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @param depth 현재 중첩 깊이 * @param visitedObjects 순환 참조 추적용 Set (내부 재귀 호출용) * @returns 해석된 값 * @throws {DataBindingError} 순환 참조 감지 시 또는 최대 깊이 초과 시 */ private resolvePath( path: string, context: BindingContext, options: BindingOptions, depth: number = 0, visitedObjects: WeakSet = new WeakSet(), ): ResolvedValue { // 최대 깊이 체크 (순환 참조 방지) if (options.maxDepth && depth > options.maxDepth) { if (options.detectCircular) { throw new DataBindingError( `Maximum depth exceeded (${options.maxDepth}). Possible circular reference.`, path, context, ); } return options.defaultValue; } // 경로 세그먼트 파싱 const segments = this.parsePath(path); // 값 추출 let current: any = context; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; // null/undefined 체크 (null-safe) if (current == null) { if (options.nullSafe) { return options.defaultValue; } throw new DataBindingError( `Cannot read property '${segment}' of ${current}`, path, context, ); } // 순환 참조 감지 (객체인 경우만) if (options.detectCircular && typeof current === 'object') { if (visitedObjects.has(current)) { throw new DataBindingError( `Circular reference detected at path: ${segments.slice(0, i + 1).join('.')}`, path, context, ); } visitedObjects.add(current); } // 다음 값으로 이동 current = current[segment]; // 디버깅 로그 if (path === '_global.toasts') { logger.log(`[DataBindingEngine] segment: ${segment}, current:`, current); } } // 값이 없으면 기본값 반환 return current !== undefined ? current : options.defaultValue; } /** * 경로 문자열을 세그먼트 배열로 파싱 * * @param path 바인딩 경로 * @returns 세그먼트 배열 * * @example * parsePath("user.profile.name") // ["user", "profile", "name"] * parsePath("items[0].name") // ["items", "0", "name"] * parsePath("data[1][2]") // ["data", "1", "2"] */ private parsePath(path: string): string[] { const segments: string[] = []; let match: RegExpExecArray | null; // 정규식 초기화 DataBindingEngine.PATH_SEGMENT_PATTERN.lastIndex = 0; while ((match = DataBindingEngine.PATH_SEGMENT_PATTERN.exec(path)) !== null) { // match[1]: 일반 프로퍼티 (예: "user", "name") // match[2]: 배열 인덱스 (예: "0", "1") const segment = match[1] || match[2]; if (segment) { segments.push(segment); } } return segments; } /** * 값을 문자열로 포맷 * * @param value 해석된 값 * @returns 문자열 표현 */ private formatValue(value: ResolvedValue): string { // null 또는 undefined if (value == null) { return ''; } // 객체 또는 배열 if (typeof value === 'object') { try { return JSON.stringify(value); } catch (error) { return '[Object]'; } } // 기본 타입 (string, number, boolean) return String(value); } /** * 캐시에서 값 조회 * * @param path 바인딩 경로 * @returns 캐시된 값 (없으면 undefined) */ private getFromCache(path: string): ResolvedValue | undefined { const entry = this.cache.get(path); const devTools = getDevTools(); if (!entry) { // DevTools: 캐시 미스 기록 if (devTools?.isEnabled()) { devTools.recordCacheMiss(); } return undefined; } // 캐시 만료 체크 const now = Date.now(); if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) { this.cache.delete(path); // DevTools: 만료된 캐시는 미스로 기록 if (devTools?.isEnabled()) { devTools.recordCacheMiss(); } return undefined; } // DevTools: 캐시 히트 기록 if (devTools?.isEnabled()) { devTools.recordCacheHit(); } return entry.value; } /** * 캐시에 값 저장 * * @param path 바인딩 경로 * @param value 저장할 값 */ private saveToCache(path: string, value: ResolvedValue): void { this.cache.set(path, { value, timestamp: Date.now(), }); } /** * 객체가 액션 정의(ActionDefinition)인지 판별합니다. * * 액션 정의는 렌더 시점에 resolveObject로 선평가하면 안 됩니다. * 내부 템플릿 문자열(예: "{{_local.checkout}}")이 실행 시점에 * ActionDispatcher.resolveParams()에서 최신 상태로 해소되어야 합니다. * * 콜백 prop(onAddressSelect 등)에 포함된 액션 정의가 resolveObject를 거치면 * {{}} 표현식이 렌더 시점 JS 객체로 고정되어 stale 값이 전송됩니다. * 컴포넌트 레벨 actions 배열은 bindComponentActions에서 별도 처리되므로 비해당. * * @param obj 검사할 객체 * @returns 액션 정의이면 true */ private isActionDefinition(obj: Record): boolean { if (typeof obj.handler !== 'string') return false; return ( obj.params !== undefined || Array.isArray(obj.actions) || typeof obj.target === 'string' || obj.onSuccess !== undefined || obj.onError !== undefined ); } /** * 문자열 전체가 단일 `{{...}}` 바인딩인지 판정하고, 그렇다면 내부 식을 반환합니다. * * 종전 `^\{\{([^}]+)\}\}$` 정규식은 식 안에 `}` 가 든 경우(예: 빈 객체 fallback * `{{error.errors ?? {}}}`)를 단일 바인딩으로 인식하지 못해 원본 문자열이 그대로 * 새어 나갔다. 본 메서드는 외곽 `{{ }}` 안의 중괄호 균형(depth)을 추적해, 내부에서 * 바인딩이 조기 종료(`}}`)되지 않고 끝까지 단일 식으로 닫히는 경우에만 그 식을 반환한다. * `"{{a}} {{b}}"` 처럼 두 개 이상의 바인딩이면 null 을 반환해 문자열 보간 경로로 보낸다. * * @param value 검사할 문자열 * @returns 단일 바인딩이면 내부 식 문자열, 아니면 null */ private extractSingleBinding(value: string): string | null { if (!value.startsWith('{{') || !value.endsWith('}}') || value.length < 5) { return null; } const inner = value.slice(2, -2); // inner 를 스캔하며 `}}` 가 깊이 0(외곽 바인딩 종료)에서 등장하면 단일 바인딩 아님. // `{` / `}` 로 깊이를 추적하되, 식 내부의 `{}`(객체 리터럴)는 균형이 맞으므로 // 깊이가 음수로 떨어지지 않는 한 단일 바인딩으로 본다. let depth = 0; for (let i = 0; i < inner.length - 1; i++) { const c = inner[i]; if (c === '{') { depth++; } else if (c === '}') { if (depth > 0) { depth--; } else if (inner[i + 1] === '}') { // 깊이 0 에서 `}}` 출현 → 외곽 바인딩이 중간에 닫힘 → 다중 바인딩/보간 return null; } } } return inner; } /** * 객체 내의 모든 바인딩 표현식을 데이터로 치환 * * @param obj 바인딩 표현식이 포함된 객체 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @returns 치환된 객체 * * @example * const engine = new DataBindingEngine(); * const result = engine.resolveObject( * { style: { color: "{{theme.color}}" } }, * { theme: { color: "red" } } * ); * // 결과: { style: { color: "red" } } */ public resolveObject( obj: Record, context: BindingContext, options?: BindingOptions & { skipBindingKeys?: string[] }, ): Record { // $switch 특수 객체 처리 // { $switch: "{{key}}", $cases: { ... }, $default: value } if (this.isSwitchExpression(obj)) { const switchDef = obj as { $switch: string; $cases: Record; $default?: any }; return this.resolveSwitch(switchDef, context, options) as Record; } // 액션 정의 객체 감지 → 선평가 건너뛰기 // 콜백 prop(onAddressSelect 등)에 포함된 액션 정의는 렌더 시점에 해소하면 // 내부 {{_local.xxx}} 표현식이 stale 객체로 고정됨. // ActionDispatcher.resolveParams()에서 실행 시점에 최신 상태로 해소해야 함. if (this.isActionDefinition(obj)) { return { ...obj }; } // iteration이 있는 ComponentDefinition 감지 → 선평가 건너뛰기 (engine-v1.19.1) // footerCells/footerCardChildren 등 DataGrid prop 내부의 컴포넌트 정의에서 // iteration이 있으면 source, text, children, props 모두 iteration 변수를 참조할 수 있음. // DynamicRenderer 시점에는 iteration 변수가 존재하지 않으므로 선평가하면 에러 발생. // renderItemChildren에서 적절한 iteration 컨텍스트와 함께 런타임에 해석되어야 함. if ('iteration' in obj) { return { ...obj }; } const result: Record = {}; // 기본 skipBindingKeys (하위 호환성) // 컴포넌트별 설정은 DynamicRenderer에서 처리 // resolveObject는 범용 메서드이므로 기본값만 유지 const DEFAULT_SKIP_BINDING_KEYS = ['cellChildren', 'expandChildren', 'expandContext', 'render']; // 옵션으로 전달된 skipBindingKeys와 기본값 병합 const skipBindingKeys = [ ...DEFAULT_SKIP_BINDING_KEYS, ...(options?.skipBindingKeys || []), ]; for (const [key, value] of Object.entries(obj)) { // cellChildren 등 특정 키는 원본 그대로 전달 if (skipBindingKeys.includes(key)) { result[key] = value; continue; } if (typeof value === 'string') { // 전체가 {{variable}} 패턴인 경우 원본 값(배열/객체) 반환. // `[^}]+` 는 `{{error.errors ?? {}}}` 처럼 식 안에 `}` 가 든 경우(빈 객체 fallback 등) // 첫 `}` 에서 끊겨 매칭에 실패 → 원본 문자열이 그대로 새어 나오던 결함이 있었다. // extractSingleBinding 은 외곽 `{{ }}` 안의 중괄호 균형을 추적해 단일 바인딩 식을 정확히 추출한다. const singleBindingPath = this.extractSingleBinding(value); if (singleBindingPath !== null) { const path = singleBindingPath.trim(); // raw: 접두사 감지 @since engine-v1.27.0 let isRawBinding = false; let effectivePath = path; if (path.startsWith(RAW_PREFIX)) { isRawBinding = true; effectivePath = path.slice(RAW_PREFIX.length); } // 복잡 표현식(연산자 포함)은 evaluateExpression으로 라우팅 // resolveBindings()와 동일한 isBaseExpression 판별 기준 사용 const isBaseExpression = /[?:|&!+\-*/<>=()]/.test(effectivePath) || /\[['"]/.test(effectivePath); const resolved = isBaseExpression ? this.evaluateExpression(effectivePath, context, options) : this.resolve(effectivePath, context, options); result[key] = isRawBinding && resolved != null ? wrapRawDeep(resolved) : resolved; } else { // 문자열 보간 result[key] = this.resolveBindings(value, context, options); } } else if (Array.isArray(value)) { result[key] = value.map((item) => typeof item === 'string' ? this.resolveBindings(item, context, options) : typeof item === 'object' && item !== null ? this.resolveObject(item, context, options) : item, ); } else if (value && typeof value === 'object') { result[key] = this.resolveObject(value, context, options); } else { result[key] = value; } } return result; } /** * 캐시 초기화 * * 바인딩 값 캐시와 표현식 함수 캐시를 모두 초기화합니다. */ public clearCache(): void { this.cache.clear(); this.expressionFnCache.clear(); } /** * 특정 키로 시작하는 캐시 항목 삭제 * * 데이터가 업데이트될 때 해당 키와 관련된 캐시만 선택적으로 무효화합니다. * 예: invalidateCacheByKeys(['users']) 호출 시 * 'users', 'users.data', 'users.data.data', 'users[0]' 등 삭제 * * @param keys 삭제할 최상위 키 배열 */ public invalidateCacheByKeys(keys: string[]): void { for (const path of this.cache.keys()) { for (const key of keys) { if (path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) { this.cache.delete(path); break; } } } } /** * 만료된 캐시 항목 제거 */ public pruneCache(): void { const now = Date.now(); for (const [path, entry] of this.cache.entries()) { if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) { this.cache.delete(path); } } } /** * 캐시 통계 조회 * * @returns 캐시 크기 및 만료된 항목 수 */ public getCacheStats(): { size: number; expired: number } { const now = Date.now(); let expired = 0; for (const entry of this.cache.values()) { if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) { expired++; } } return { size: this.cache.size, expired, }; } /** * JavaScript 표현식 평가 (공개 메서드) * * 삼항 연산자, 논리 연산자 등 복잡한 표현식을 평가합니다. * * @param expr 평가할 표현식 (예: "!_global.sidebarOpen", "_global.sidebarOpen ? 'a' : 'b'") * @param context 데이터 컨텍스트 * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 평가 결과 * @throws {Error} 표현식 평가 실패 시 */ public evaluateExpression( expr: string, context: BindingContext, trackingInfo?: { componentId?: string; componentName?: string; propName?: string; skipCache?: boolean }, ): any { // DevTools: 바인딩 평가 추적 const devTools = getDevTools(); const startTime = devTools?.isEnabled() ? performance.now() : 0; if (devTools?.isEnabled()) { devTools.trackBindingEval(); } try { // 1. 표현식 전체에서 변수 접근을 optional chaining으로 변환 // 예: perm.identifier -> perm?.identifier // 이를 통해 iteration 변수가 undefined일 때 에러 방지 let processedExpr = this.preprocessOptionalChaining(expr); // 2. $t: 패턴을 문자열로 자동 변환 (예: $t:common.error -> "$t:common.error") // 이를 통해 사용자가 따옴표 없이 $t: 문법을 사용할 수 있음 processedExpr = this.preprocessTranslationTokens(processedExpr); // 표현식에서 사용되는 최상위 변수 추출 const usedVariables = this.extractVariablesFromExpression(processedExpr); // 컨텍스트에 없는 변수를 undefined로 추가 const extendedContext = { ...context }; for (const varName of usedVariables) { if (!(varName in extendedContext)) { extendedContext[varName] = undefined; } } // $computed alias 추가 // layout-json-features.md 문서에 따라 $computed.xxx 형식으로 computed 값에 접근 // 내부적으로 _computed에 저장되므로 $computed를 _computed의 alias로 설정 if (context._computed && !extendedContext.$computed) { extendedContext.$computed = context._computed; } // $localized 헬퍼 함수 추가 // 다국어 객체에서 현재 로케일에 해당하는 값을 반환 // 사용법: // $localized(value) - 객체면 로케일 우선순위로 반환, 문자열이면 그대로 // $localized(value, fallbackKey) - 활성 로케일 키 부재 시 $t(fallbackKey) 호출 // (settings JSON 등 시스템 카탈로그의 lang pack 보강용) const locale = context.$locale || 'ko'; extendedContext.$localized = (value: any, fallbackKey?: string): string => { if (value == null && !fallbackKey) { return ''; } if (typeof value === 'string') { return value; } // 1. 객체의 활성 로케일 키 if (value && typeof value === 'object' && value[locale]) { return value[locale]; } // 2. fallbackKey 가 있으면 $t() 로 lang pack 보강 시도 if (fallbackKey && typeof fallbackKey === 'string') { const translated = extendedContext.$t ? extendedContext.$t(fallbackKey) : fallbackKey; if (translated && translated !== fallbackKey) { return translated; } } // 3. 객체의 ko / en / 첫 번째 값 if (value && typeof value === 'object') { return value.ko || value.en || Object.values(value)[0] || ''; } if (value == null) { return ''; } return String(value); }; // $uuid 헬퍼 함수 추가 // UUID v4 형식의 고유 식별자를 생성하여 반환 // 사용법: $uuid() - "550e8400-e29b-41d4-a716-446655440000" 형식의 문자열 반환 const G7Core = typeof window !== 'undefined' ? (window as any).G7Core : undefined; extendedContext.$uuid = (): string => { if (G7Core?.uuid) { return G7Core.uuid(); } if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); }; // $t 헬퍼 함수 추가 // 번역 키를 사용하여 다국어 텍스트를 반환 // 사용법: $t('admin.settings.info.write_only') - 번역된 텍스트 반환 // 컨텍스트에서 번역 관련 정보 추출 // // @since engine-v1.38.2 ActionDispatcher 경로 fallback — bindActionsToProps // 이후 createHandler 에서 빌드된 action data context 는 `$templateId`/`$locale` // 을 명시적으로 포함하지 않을 수 있다. 이 경우 `window.__templateApp.getConfig()` // 로부터 회수하여 `{{$event.target.checked ? '$t:A' : '$t:B'}}` 같은 조건부 // $t: 평가가 raw key 를 반환하던 버그를 해결한다. let templateId = context.$templateId; let resolvedLocale = locale; if (!templateId && typeof window !== 'undefined') { const templateApp = (window as any).__templateApp; const appConfig = templateApp?.getConfig?.(); if (appConfig?.templateId) { templateId = appConfig.templateId; if (!context.$locale && appConfig.locale) { resolvedLocale = appConfig.locale; } } } const translationContext = { templateId: templateId || '', locale: resolvedLocale, }; extendedContext.$t = (key: string): string => { if (!key || typeof key !== 'string') { return ''; } try { const translationEngine = TranslationEngine.getInstance(); return translationEngine.translate(key, translationContext); } catch (error) { logger.warn('$t() translation failed for key:', key, error); return key; // 번역 실패 시 키 자체 반환 } }; // $get 헬퍼 함수 추가 // 깊은 객체 경로 접근과 폴백 값 지원 // 사용법: $get(product.prices, [currency, 'formatted'], defaultPrice) extendedContext.$get = (obj: any, path: string | string[], fallback: any = undefined): any => { // null/undefined 객체는 즉시 폴백 반환 if (obj === null || obj === undefined) { return fallback; } // 경로를 배열로 정규화 const keys = Array.isArray(path) ? path : [path]; // 빈 경로는 객체 자체 반환 if (keys.length === 0) { return obj; } let current = obj; for (const key of keys) { // 현재 위치가 null/undefined이면 폴백 반환 if (current === null || current === undefined) { return fallback; } // 키가 null/undefined이면 폴백 반환 if (key === null || key === undefined) { return fallback; } // 다음 레벨로 이동 current = current[key]; } // 최종 값이 null/undefined이면 폴백 반환 return current ?? fallback; }; // 확장된 컨텍스트의 모든 키를 변수로 사용할 수 있도록 준비 const contextKeys = Object.keys(extendedContext); const contextValues = Object.values(extendedContext); // 캐시 키 생성: 전처리된 표현식 + 컨텍스트 키 조합 // 같은 표현식이라도 컨텍스트 키가 다르면 다른 함수가 필요 const cacheKey = `${processedExpr}|${contextKeys.join(',')}`; // 캐시된 함수 조회 또는 새로 생성 let evaluator = this.expressionFnCache.get(cacheKey); const fromCache = !!evaluator; if (!evaluator) { // Function 생성자를 사용하여 표현식 평가 // 예: expr = "!_global.sidebarOpen" // contextKeys = ["_global", "user", ...] // contextValues = [{sidebarOpen: false}, {...}, ...] // eslint-disable-next-line no-new-func evaluator = new Function(...contextKeys, `return (${processedExpr});`); this.expressionFnCache.set(cacheKey, evaluator); } const result = evaluator(...contextValues); // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - startTime; devTools.trackExpressionEval({ expression: expr, result: this.sanitizeResultForTracking(result), resultType: this.getResultType(result), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'evaluateExpression', skipCache: trackingInfo?.skipCache, }); } return result; } catch (error) { throw new Error( `Failed to evaluate expression "${expr}": ${error instanceof Error ? error.message : String(error)}`, ); } } /** * 결과 값의 타입을 문자열로 반환 */ private getResultType(value: any): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'array'; return typeof value; } /** * 추적을 위해 결과 값을 안전하게 정리 * (순환 참조 제거, 크기 제한 등) */ private sanitizeResultForTracking(value: any): any { if (value === null || value === undefined) return value; if (typeof value !== 'object') return value; try { // 객체/배열의 경우 간략화된 정보만 저장 if (Array.isArray(value)) { if (value.length > 10) { return `[Array(${value.length})]`; } return value.slice(0, 10); } // 객체의 경우 키 개수 제한 const keys = Object.keys(value); if (keys.length > 20) { return `{Object(${keys.length} keys)}`; } // 순환 참조 방지를 위해 JSON 변환 시도 JSON.stringify(value); return value; } catch { return '[Complex Object]'; } } /** * 표현식 내 $t: 토큰을 처리 * * 1. 따옴표 안의 $t:key → $t('key') 함수 호출로 변환 (computed expressions용) * 2. 따옴표 없는 $t:key → "$t:key" 문자열로 변환 (static JSON용) * * @param expr 원본 표현식 * @returns 변환된 표현식 * * @example * preprocessTranslationTokens("'$t:common.all'") * // 결과: "$t('common.all')" * * @example * preprocessTranslationTokens("_global.msg || $t:common.error") * // 결과: '_global.msg || "$t:common.error"' */ private preprocessTranslationTokens(expr: string): string { // 1. 먼저 따옴표 안의 $t: 패턴을 $t() 함수 호출로 변환 // '$t:common.all' → $t('common.all') // "$t:common.all" → $t('common.all') // '$t:sirsoft-page.admin.key' → $t('sirsoft-page.admin.key') (하이픈 포함 모듈 키 지원) // @since engine-v1.28.1 하이픈(-) 지원 추가 expr = expr.replace(/(['"])(\$t:([a-zA-Z_][a-zA-Z0-9_.\-]*))\1/g, (_match, _quote, _fullKey, keyOnly) => { return `$t('${keyOnly}')`; }); // 2. 기존 로직: 따옴표 없는 $t: 패턴을 문자열로 변환 // $t:key.path → "$t:key.path" // @since engine-v1.28.1 하이픈(-) 지원 추가 return expr.replace(/(? { stringLiterals.push(match); return `__STRING_LITERAL_${stringLiterals.length - 1}__`; }); // $t: 패턴을 임시 토큰으로 대체 (번역 키 경로는 변환하지 않음) // @since engine-v1.28.1 하이픈(-) 지원 추가 const translationTokens: string[] = []; tokenizedExpr = tokenizedExpr.replace(/\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*(?:\|[^'"\s,)]+)?/g, (match) => { translationTokens.push(match); return `__TRANSLATION_TOKEN_${translationTokens.length - 1}__`; }); // 변수.속성 패턴을 변수?.속성으로 변환 (이미 ?. 인 경우 제외) // 단, 숫자 뒤의 .은 변환하지 않음 (예: 1.5) tokenizedExpr = tokenizedExpr.replace(/([a-zA-Z_$][a-zA-Z0-9_$]*)\.(?!\?)/g, '$1?.'); // $t: 패턴 복원 tokenizedExpr = tokenizedExpr.replace(/__TRANSLATION_TOKEN_(\d+)__/g, (_match, index) => { return translationTokens[parseInt(index, 10)]; }); // 문자열 리터럴 복원 tokenizedExpr = tokenizedExpr.replace(/__STRING_LITERAL_(\d+)__/g, (_match, index) => { return stringLiterals[parseInt(index, 10)]; }); return tokenizedExpr; } /** * 표현식에서 사용되는 최상위 변수명 추출 * * @param expr 표현식 * @returns 변수명 배열 */ private extractVariablesFromExpression(expr: string): string[] { // JavaScript 키워드 및 예약어 const reserved = new Set([ 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'return', 'function', 'class', 'const', 'let', 'var', 'new', 'delete', 'typeof', 'instanceof', 'this', 'super', 'import', 'export', 'default', 'try', 'catch', 'finally', 'throw', // 내장 객체 'Math', 'Date', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'RegExp', // 컬렉션/표준 내장 객체 (@since engine-v1.50.0) // 이들이 reserved 에 없으면 컨텍스트 변수로 오인되어 undefined 로 가려지고 // new Function 본문에서 `new Set(...)` 가 `new undefined()` 가 되어 실패한다. // eval/Function/globalThis/window 등 위험 전역은 의도적으로 제외한다. 'Set', 'Map', 'WeakSet', 'WeakMap', 'Symbol', 'Promise', 'BigInt', 'Error', // 전역 함수 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent', ]); // 식별자 패턴 (변수명 시작 위치) // 식별자는 문자, $, _로 시작하고 문자, 숫자, $, _로 계속됨 const identifierPattern = /(?(); let match; while ((match = identifierPattern.exec(expr)) !== null) { const varName = match[1]; // 예약어가 아니고, 이미 추출되지 않았다면 추가 if (!reserved.has(varName)) { variables.add(varName); } } return Array.from(variables); } /** * $switch 표현식인지 확인 * * 객체가 $switch 키를 가지고 있으면 $switch 표현식으로 간주합니다. * * @param obj 확인할 객체 * @returns $switch 표현식 여부 * * @example * isSwitchExpression({ $switch: "{{tab}}", $cases: { ... } }) // true * isSwitchExpression({ type: "basic", name: "Div" }) // false */ public isSwitchExpression(obj: any): boolean { return ( obj !== null && typeof obj === 'object' && !Array.isArray(obj) && '$switch' in obj && '$cases' in obj ); } /** * $switch 표현식 해석 * * 동적 값에 따라 다른 값을 반환합니다. * 중첩 삼항 연산자를 대체하는 선언적 분기 표현식입니다. * * 트러블슈팅 주의사항: * - Stale Closure 방지: $switch 키 평가 시 항상 skipCache: true 적용 * - 상태 변경 후 즉시 반영을 위해 최신 컨텍스트에서 평가 * * @param switchDef $switch 정의 객체 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @returns 매칭되는 case의 값 또는 default 값 * * @example * ```typescript * const switchDef = { * $switch: "{{tab}}", * $cases: { * products: "shopping-bag", * contents: "book-open", * policies: "file-check" * }, * $default: "file-text" * }; * * resolveSwitch(switchDef, { tab: "products" }); * // 결과: "shopping-bag" * ``` */ public resolveSwitch( switchDef: { $switch: string; $cases: Record; $default?: any }, context: BindingContext, options?: BindingOptions, ): any { // 항상 skipCache: true로 평가 (Stale Closure 방지) const evalOptions = { ...options, skipCache: true }; // 1. $switch 키 표현식 평가 let keyValue: string; try { const resolved = this.resolveBindings(switchDef.$switch, context, evalOptions); // 빈 문자열이거나 undefined/null인 경우 처리 keyValue = resolved?.toString()?.trim() ?? ''; } catch (error) { logger.warn('resolveSwitch: $switch 키 평가 실패:', switchDef.$switch, error); keyValue = ''; } // 2. $cases에서 매칭되는 값 찾기 let result: any; if (keyValue && switchDef.$cases && keyValue in switchDef.$cases) { result = switchDef.$cases[keyValue]; } else if ('$default' in switchDef) { // 매칭되는 case가 없으면 $default 사용 result = switchDef.$default; } else { // $default도 없으면 undefined 반환 return undefined; } // 3. 결과 값도 바인딩 해석 (중첩 표현식 지원) if (typeof result === 'string') { // 바인딩 표현식이 있으면 해석 if (result.includes('{{')) { return this.resolveBindings(result, context, evalOptions); } return result; } else if (result !== null && typeof result === 'object') { // 중첩 객체인 경우 재귀적으로 해석 return this.resolveObject(result, context, evalOptions); } return result; } } /** * 전역 데이터 바인딩 엔진 인스턴스 * * @example * import { dataBindingEngine } from './DataBindingEngine'; * const result = dataBindingEngine.resolve("user.name", context); */ export const dataBindingEngine = new DataBindingEngine();