시나리오 배치 실행 엔진: selector fallback과 excel loop¶
개요¶
XGEN 2.0의 시나리오 녹화 시스템은 AI Agent 또는 사용자가 브라우저를 조작하는 과정을 녹화한다. 녹화된 시나리오는 다시 재생(replay)할 수 있다. 그런데 단순 재생이 아니라, 엑셀 파일의 각 행마다 반복 실행하는 "배치 실행"이 필요했다.
예를 들어, 쇼핑몰 관리자가 "상품 등록" 시나리오를 한 번 녹화하면, 엑셀에 있는 100개 상품을 자동으로 등록할 수 있어야 한다. 로그인 → 상품 등록 페이지 이동은 한 번만, 상품명 입력 → 가격 입력 → 저장은 100번 반복한다.
이를 위해 ScenarioExecutor라는 실행 엔진을 설계했다. 1,089줄의 싱글톤 클래스로, selector fallback(대안 셀렉터), 3-Phase 액션 분류(pre-loop / loop / post-loop), 다중 값 해석(literal / credential / excel_column / variable), 그리고 멀티시트 엑셀 Join까지 처리한다.
아키텍처¶
3단계 파이프라인¶
시나리오 배치 실행은 설정 → 실행 → 모니터링의 3단계로 진행된다.
flowchart LR
subgraph Setup[1 설정 단계]
Modal[ExecuteScenarioModal]
Excel[엑셀 파일 로드<br/>MinIO 또는 직접 업로드]
Join[Join 설정<br/>inner/left/right/outer]
Cred[Credential 입력<br/>로그인 정보 등]
end
subgraph Execute[2 실행 단계]
Executor[ScenarioExecutor]
Browser[브라우저 시작<br/>base_url 이동]
Phase[Phase 순회<br/>pre-loop / loop / post-loop]
Action[액션 실행<br/>selector fallback + retry]
end
subgraph Monitor[3 모니터링 단계]
Status[ExecutionStatusPanel]
Progress[Phase/Action/Row 진행률]
Screenshot[스크린샷 미리보기]
Control[일시정지/취소 컨트롤]
end
Setup --> Execute --> Monitor
ScenarioExecutor 클래스¶
ScenarioExecutor는 싱글톤 패턴으로 설계했다. 브라우저 인스턴스를 하나만 유지하면서 여러 Phase를 순차적으로 실행한다.
flowchart TB
execute["execute()"]
startBrowser["브라우저 시작<br/>startForScenario()"]
executeJoins["Join 실행<br/>executeJoins()"]
loopPhases["Phase 순회"]
executePhase["executePhase()"]
classify["classifyActions()<br/>pre-loop / loop / post-loop"]
preLoop["pre-loop 실행<br/>1회"]
loopBody["loop 실행<br/>엑셀 행마다 반복"]
postLoop["post-loop 실행<br/>1회"]
complete["완료 보고"]
execute --> startBrowser --> executeJoins --> loopPhases
loopPhases --> executePhase
executePhase --> classify
classify --> preLoop --> loopBody --> postLoop
loopPhases --> complete
# 커밋: feat: scenario playback engine -- batch execution, selector fallback, excel loop
# 날짜: 2026-02-10 01:53
핵심 구현¶
Phase와 액션 분류: classifyActions¶
시나리오는 여러 Phase로 구성된다. 각 Phase 안에서 액션은 3가지로 분류된다.
| 분류 | 실행 횟수 | 예시 |
|---|---|---|
| pre-loop | 1회 | 로그인, 메뉴 클릭, 등록 페이지 이동 |
| loop | 엑셀 행 수만큼 | 상품명 입력, 가격 입력, 저장 버튼 클릭 |
| post-loop | 1회 | 최종 확인, 로그아웃 |
분류 기준은 각 액션의 in_loop 필드다. Agent가 시나리오를 빌드할 때 autoMapExcelColumns로 LLM이 자동 매핑하거나, 사용자가 UI에서 직접 설정한다.
private classifyActions(actions: RecordedAction[]): {
preLoopActions: RecordedAction[];
loopActions: RecordedAction[];
postLoopActions: RecordedAction[];
} {
const lastLoopIndex = actions.reduce(
(last, a, i) => (a.in_loop ? i : last), -1
);
if (lastLoopIndex === -1) {
return { preLoopActions: actions, loopActions: [], postLoopActions: [] };
}
const firstLoopIndex = actions.findIndex(a => a.in_loop);
const preLoopActions = actions.slice(0, firstLoopIndex);
const loopActions = actions.slice(firstLoopIndex, lastLoopIndex + 1);
const postLoopActions = actions.slice(lastLoopIndex + 1);
return { preLoopActions, loopActions, postLoopActions };
}
핵심 설계 결정: in_loop=true 사이에 끼인 in_loop=false 액션도 loop에 포함한다. 예를 들어 "입력(in_loop) → 확인 클릭(not in_loop) → 다음 입력(in_loop)" 패턴에서, 확인 클릭은 매 행마다 반복해야 하기 때문이다.
Phase 실행: executePhase¶
각 Phase는 루프 여부에 따라 분기된다.
private async executePhase(
phase: ScenarioPhase,
phaseIndex: number,
excelDataMap: Map<string, any[]>,
joinedDataMap: Map<string, any[]>,
credentials: Record<string, string>,
): Promise<void> {
const hasExcelActions = phase.actions.some(
a => a.value_type === 'excel_column'
);
const shouldLoop = phase.loop || hasExcelActions;
if (shouldLoop) {
await this.executePhaseWithLoop(
phase, phaseIndex, excelDataMap, joinedDataMap, credentials
);
} else {
// 루프 없이 전체 액션 순차 실행
for (let i = 0; i < phase.actions.length; i++) {
await this.executeAction(phase.actions[i], i, null, credentials);
}
}
}
phase.loop이 명시적으로 true이거나, 액션 중 하나라도 value_type === 'excel_column'이면 루프 모드로 전환한다. 후자는 사용자가 Phase 단위 루프 설정을 깜빡했을 때의 안전망이다.
루프 실행: executePhaseWithLoop¶
private async executePhaseWithLoop(
phase: ScenarioPhase,
phaseIndex: number,
excelDataMap: Map<string, any[]>,
joinedDataMap: Map<string, any[]>,
credentials: Record<string, string>,
): Promise<void> {
const { preLoopActions, loopActions, postLoopActions } =
this.classifyActions(phase.actions);
// 엑셀 데이터 소스 결정
const excelData = this.getExcelDataForPhaseAuto(
phase, excelDataMap, joinedDataMap
);
// 1. pre-loop: 1회 실행
for (let i = 0; i < preLoopActions.length; i++) {
await this.executeAction(preLoopActions[i], i, null, credentials);
}
// 2. loop: 엑셀 행마다 반복
for (let row = 0; row < excelData.length; row++) {
const rowData = excelData[row];
for (let i = 0; i < loopActions.length; i++) {
await this.executeAction(
loopActions[i], i, rowData, credentials
);
}
}
// 3. post-loop: 1회 실행
for (let i = 0; i < postLoopActions.length; i++) {
await this.executeAction(postLoopActions[i], i, null, credentials);
}
}
엑셀 데이터 소스 결정: loop_source¶
Phase의 loop_source 필드가 어떤 엑셀 데이터를 사용할지 결정한다. 4가지 형식을 지원한다.
| loop_source 형식 | 해석 |
|---|---|
excel:alias |
해당 alias의 엑셀 파일 전체 데이터 |
excel:alias.sheet |
해당 alias의 특정 시트 |
joined:name |
Join 결과 데이터 |
excel_data (레거시) |
기본 엑셀 데이터 (하위 호환) |
private getExcelDataForPhaseAuto(
phase: ScenarioPhase,
excelDataMap: Map<string, any[]>,
joinedDataMap: Map<string, any[]>,
): any[] {
const src = phase.loop_source;
if (src?.startsWith('joined:')) {
const joinName = src.slice(7);
return joinedDataMap.get(joinName) || [];
}
if (src?.startsWith('excel:')) {
const ref = src.slice(6); // "alias" or "alias.sheet"
return excelDataMap.get(ref) || [];
}
// 기본: 첫 번째 엑셀 데이터
const firstKey = excelDataMap.keys().next().value;
return firstKey ? excelDataMap.get(firstKey) || [] : [];
}
Selector Fallback¶
문제: 셀렉터 불안정성¶
녹화 시점과 재생 시점의 DOM이 다를 수 있다. 동적으로 생성되는 ID, CSS Module의 해시 클래스명, 렌더링 순서에 따라 달라지는 nth-child 등이 원인이다.
selector_alternatives¶
녹화 시 각 액션에는 기본 셀렉터 외에 대안 셀렉터 배열이 저장된다. 타입별로 data-testid, id, aria-label, name, CSS path 등 여러 방식으로 수집되며, 각각 confidence(0.0~1.0) 점수가 부여된다.
interface SelectorCandidate {
selector: string;
type: 'css' | 'testid' | 'aria' | 'text' | 'id' | 'name';
confidence: number;
}
fallback 로직¶
executeAction에서 기본 셀렉터가 실패하면 대안을 순회한다.
private async executeAction(
action: RecordedAction,
index: number,
rowData: Record<string, any> | null,
credentials: Record<string, string>,
): Promise<void> {
let effectiveSelector = action.selector;
// 1단계: 기본 셀렉터로 시도
try {
await this.page.waitForSelector(effectiveSelector, { timeout: 5000 });
} catch {
// 2단계: selector_alternatives에서 fallback
if (action.selector_alternatives?.length) {
const alternatives = action.selector_alternatives
.filter(alt => alt.type !== 'text')
.sort((a, b) => b.confidence - a.confidence);
for (const alt of alternatives) {
try {
await this.page.waitForSelector(alt.selector, {
timeout: 3000
});
effectiveSelector = alt.selector;
break;
} catch {
continue;
}
}
}
}
// 3단계: effectiveSelector로 실제 액션 실행
await this.executeSingleAction(action, effectiveSelector, rowData, credentials);
}
# 커밋: feat: scenario playback engine -- batch execution, selector fallback, excel loop
# 날짜: 2026-02-10 01:53
핵심 설계 결정:
- text 타입 제외:
text셀렉터는 페이지 텍스트 내용에 의존하므로, 언어 변경이나 텍스트 수정에 취약하다. fallback 후보에서 제외했다. - confidence 내림차순: data-testid(confidence 0.95)가 nth-child(confidence 0.3)보다 먼저 시도된다.
- 타임아웃 차등: 기본 셀렉터는 5초, 대안은 3초. 대안은 빠르게 실패해야 다음 후보로 넘어간다.
- 원래 셀렉터 폴백: 모든 대안이 실패하면 원래 셀렉터로 다시 시도한다. DOM 로딩 타이밍 문제로 처음 시도 때 실패했지만 시간이 지나면 나타나는 경우가 있다.
selector_alternatives 타입 정규화¶
녹화 시 수집되는 셀렉터 타입 중 testid와 aria는 Pydantic 백엔드 모델에서 유효하지 않았다. 저장 시 css로 정규화하는 처리를 추가했다.
# 커밋: fix: selector_alternatives type 'testid'/'aria' -> 'css' (Pydantic 유효성 검증 오류 수정)
# 날짜: 2026-02-09 16:21
값 해석: resolveValue¶
4가지 value_type¶
액션의 value 필드는 value_type에 따라 다르게 해석된다.
private resolveValue(
action: RecordedAction,
rowData: Record<string, any> | null,
credentials: Record<string, string>,
): string {
switch (action.value_type) {
case 'literal':
case 'fixed':
return action.value || '';
case 'credential':
return credentials[action.value_key || ''] || action.value || '';
case 'excel_column': {
const key = action.value_key || '';
// 1-depth: "column"
if (!key.includes('.')) {
return rowData?.[key] ?? action.value ?? '';
}
// 2-depth: "alias.column"
const parts = key.split('.');
if (parts.length === 2) {
return rowData?.[parts[1]] ?? action.value ?? '';
}
// 3-depth: "alias.sheet.column" → 크로스 참조
return rowData?.[parts[2]] ?? action.value ?? '';
}
case 'variable':
return this.variables[action.value_key || ''] || action.value || '';
default:
return action.value || '';
}
}
| value_type | value_key 예시 | 해석 |
|---|---|---|
literal |
(없음) | action.value 그대로 사용 |
credential |
password |
credentials['password'] 참조 |
excel_column |
상품명 |
현재 행의 상품명 컬럼 값 |
excel_column |
products.상품명 |
products alias의 상품명 컬럼 |
excel_column |
products.Sheet1.상품명 |
크로스 참조 (공통 키로 매칭) |
variable |
last_order_id |
런타임 변수 참조 |
3-depth 크로스 참조는 멀티시트 엑셀에서 시트 간 데이터를 참조할 때 사용한다. 예를 들어 "주문" 시트의 "상품코드"로 "상품" 시트의 "상품명"을 가져오는 경우다.
Join 처리¶
프론트엔드 Join¶
엑셀 파일이 여러 개이거나 멀티시트일 때, 시트 간 데이터를 결합해야 하는 경우가 있다. SQL의 JOIN과 같은 개념을 프론트엔드에서 구현했다.
interface JoinConfig {
name: string; // Join 결과 이름 (loop_source에서 참조)
left_source: string; // "alias" 또는 "alias.sheet"
right_source: string;
join_key: string; // 조인 키 컬럼명
join_type: 'inner' | 'left' | 'right' | 'outer';
}
flowchart LR
Left[주문 시트<br/>주문번호, 상품코드, 수량]
Right[상품 시트<br/>상품코드, 상품명, 가격]
Join[inner join<br/>on 상품코드]
Result[결합 결과<br/>주문번호, 상품코드, 수량, 상품명, 가격]
Left --> Join
Right --> Join
Join --> Result
4가지 Join 타입을 모두 지원한다.
private executeJoins(
joinConfigs: JoinConfig[],
excelDataMap: Map<string, any[]>,
): Map<string, any[]> {
const joinedDataMap = new Map<string, any[]>();
for (const config of joinConfigs) {
const leftData = this.resolveJoinSource(config.left_source, excelDataMap);
const rightData = this.resolveJoinSource(config.right_source, excelDataMap);
const key = config.join_key;
let result: any[];
switch (config.join_type) {
case 'inner':
result = leftData.filter(l =>
rightData.some(r => r[key] === l[key])
).map(l => ({
...l,
...rightData.find(r => r[key] === l[key]),
}));
break;
case 'left':
result = leftData.map(l => ({
...l,
...(rightData.find(r => r[key] === l[key]) || {}),
}));
break;
// right, outer 생략
}
joinedDataMap.set(config.name, result);
}
return joinedDataMap;
}
멀티시트 자동 분리¶
엑셀 파일에 여러 시트가 있고, 시트 이름이 시나리오의 alias와 매칭되면 자동으로 분리한다.
const tryAutoSplitBySheetAlias = (
sheetNames: string[],
requiredAliases: string[],
) => {
const matches = requiredAliases.filter(alias =>
sheetNames.includes(alias)
);
if (matches.length === requiredAliases.length) {
// 모든 alias에 대응하는 시트가 있으면 자동 분리
return matches.map(alias => ({
alias,
sheetName: alias,
}));
}
return null;
};
Agent 모드 엑셀 매핑¶
4단계 변환 파이프라인¶
Tauri 데스크톱 앱에서는 AI Agent가 브라우저를 조작하며 시나리오를 빌드한다. 이 시나리오를 엑셀 루프용으로 변환하는 4단계 파이프라인이 있다.
flowchart LR
Raw[Agent가 생성한<br/>원시 액션 목록]
Clean[cleanupActions<br/>탐색/중복/실패 제거]
Validate[validateActions<br/>LLM 시퀀스 검증]
Map[autoMapExcelColumns<br/>LLM 컬럼 자동 매핑]
Apply[applyExcelMapping<br/>value_type/in_loop 적용]
Raw --> Clean --> Validate --> Map --> Apply
cleanupActions: 7개 규칙¶
Agent가 생성한 액션에는 탐색용 snapshot, 실패한 클릭, 중복 navigate 등 재생에 불필요한 것들이 섞여 있다. cleanupActions는 7개 규칙으로 이를 정리한다.
const cleanupActions = useCallback(() => {
setActions(prev => {
const cleaned: Partial<RecordedAction>[] = [];
for (let i = 0; i < prev.length; i++) {
const action = prev[i];
// 0. 탐색 전용 액션 제거
const exploratoryTypes = [
'snapshot', 'screenshot', 'focused_snapshot',
'tab_list', 'hover'
];
if (exploratoryTypes.includes(action.type as string)) continue;
// 0.5. 짧은 wait 제거 (1초 이하)
if (action.type === 'wait' && action.value &&
parseInt(action.value) <= 1000) continue;
// 1. 연속 중복 navigate 제거 (마지막만 유지)
if (action.type === 'navigate' && i + 1 < prev.length &&
prev[i + 1].type === 'navigate') continue;
// 2. 연속 동일 click 제거
if (action.type === 'click' && i + 1 < prev.length &&
prev[i + 1].type === 'click' &&
action.selector === prev[i + 1].selector) continue;
// 3. 빈 type 제거
if (action.type === 'type' && !action.value) continue;
// 4. 같은 필드에 연속 type → 마지막만 유지
if (action.type === 'type' && i + 1 < prev.length &&
prev[i + 1].type === 'type' &&
action.selector === prev[i + 1].selector) continue;
// 5. Escape 키 제거
if (action.type === 'press_key' &&
action.value === 'Escape') continue;
cleaned.push(action);
}
return cleaned;
});
}, []);
autoMapExcelColumns: LLM 자동 매핑¶
엑셀 컬럼과 시나리오 액션을 LLM에게 보내서, 어떤 액션이 엑셀 데이터로 치환되어야 하는지 자동으로 매핑한다.
const autoMapExcelColumns = useCallback(async () => {
const actionsSummary = actions.map((a, i) => {
const parts = [`${i + 1}. [${a.type}]`];
if (a.selector) parts.push(`selector="${a.selector}"`);
if (a.value) parts.push(`value="${a.value}"`);
return parts.join(' ');
}).join('\n');
const mapPrompt = `아래 시나리오 액션 목록과 엑셀 컬럼 정보를 보고,
어떤 액션이 엑셀 데이터로 치환되어야 하는지 매핑해주세요.
## 현재 액션 목록
${actionsSummary}
## 엑셀 컬럼
${excelMapping.columns.join(', ')}
## 샘플 데이터
${sampleStr}
## 규칙
1. type/fill 액션 중 엑셀 컬럼 값으로 치환해야 하는 것을 찾으세요
2. 로그인, 네비게이션 등은 in_loop=false
3. 데이터 입력 구간은 in_loop=true (클릭, Enter 포함)
## 응답 형식 (JSON만)
{"mappings": [{"index": 4, "value_type": "excel_column",
"value_key": "자재코드", "in_loop": true}]}`;
const response = await callLLM([{ role: 'user', content: mapPrompt }]);
const parsed = JSON.parse(jsonStr.trim());
const mappings = parsed.mappings.map((m: any) => ({
index: (m.index || 1) - 1, // 1-based → 0-based
value_type: m.value_type || 'literal',
value_key: m.value_key,
in_loop: !!m.in_loop,
}));
setExcelMapping(prev => ({ ...prev, mappings }));
}, [actions, excelMapping.columns]);
LLM은 액션의 selector(예: "상품코드 입력란")와 value(예: "A001"), 엑셀 컬럼명(예: "자재코드"), 샘플 데이터(예: {"자재코드": "B002", "수량": 10})를 비교하여 매핑을 결정한다. value가 샘플 데이터의 값과 유사하거나, selector의 의미가 컬럼명과 대응하면 excel_column으로 매핑한다.
applyExcelMapping¶
매핑 결과를 액션에 적용하는 함수다. 각 액션의 value_type, value_key, in_loop 필드를 업데이트한다.
const applyExcelMapping = useCallback(() => {
const { mappings } = excelMapping;
const mappingMap = new Map(mappings.map(m => [m.index, m]));
setActions(prev => prev.map((action, idx) => {
const mapping = mappingMap.get(idx);
if (!mapping) return action;
return {
...action,
value_type: mapping.value_type,
...(mapping.value_key ? { value_key: mapping.value_key } : {}),
in_loop: mapping.in_loop,
};
}));
const appliedCount = mappings.filter(m => m.value_type === 'excel_column').length;
const loopCount = mappings.filter(m => m.in_loop).length;
// 시스템 메시지: "Excel 매핑 적용: N개 액션에 컬럼 매핑, M개 액션 반복 설정"
}, [excelMapping]);
트러블슈팅¶
중복 navigate 방지¶
시나리오에 navigate 액션이 있고, 해당 URL이 이미 현재 페이지와 같은 경우 불필요한 새로고침이 발생했다. 이로 인해 페이지의 입력 상태가 초기화되는 문제가 있었다.
액션 간 안정화 대기¶
빠른 연속 실행 시 이전 액션의 DOM 변경이 완료되기 전에 다음 액션이 시작되는 문제가 있었다. 각 액션 실행 후 짧은 안정화 대기를 추가했다.
in_loop 필드 누락¶
Agent 모드에서 생성한 액션에 in_loop 필드가 없으면 Pydantic 백엔드가 유효성 검증 에러를 발생시켰다. 저장 전에 기본값을 설정하는 sanitize 로직을 추가했다.
# 커밋: fix: add missing in_loop field and sanitize actions for backend pydantic validation
# 날짜: 2026-02-08 12:31
결과 및 회고¶
ScenarioExecutor 메서드 맵¶
| 메서드 | 줄 수 | 역할 |
|---|---|---|
execute() |
진입점 | 브라우저 시작, Join, Phase 순회, 완료 보고 |
executePhase() |
Phase 단위 | 루프 여부 판단 후 분기 |
executePhaseWithLoop() |
루프 실행 | 3분류 + 행별 반복 |
classifyActions() |
분류 | pre-loop / loop / post-loop |
executeAction() |
액션 실행 | selector fallback + retry |
executeSingleAction() |
타입별 실행 | click/type/navigate/select 등 |
resolveValue() |
값 해석 | literal/credential/excel_column/variable |
getExcelDataForPhaseAuto() |
데이터 소스 | loop_source 해석 |
executeJoins() |
Join | inner/left/right/outer |
설계 원칙¶
"녹화와 재생의 비대칭성을 인정하라": 녹화할 때는 한 번의 조작이지만, 재생할 때는 100번 반복해야 한다. 이 비대칭성을 in_loop 플래그와 value_type으로 명시적으로 표현한다. 암묵적인 추론에 의존하지 않는다.
"셀렉터 하나에 목숨을 걸지 마라": 웹 페이지의 DOM은 살아있는 유기체다. 오늘 동작하는 셀렉터가 내일 실패할 수 있다. selector_alternatives로 여러 관문을 두어, 하나가 실패해도 다른 것으로 대체할 수 있게 한다. confidence 순으로 시도하여 가장 안정적인 것부터 검증한다.
"LLM에게 구조화된 판단을 맡겨라": 어떤 액션이 반복 구간인지, 어떤 입력이 엑셀 컬럼과 대응하는지는 규칙 기반으로 판단하기 어렵다. LLM에게 액션 목록, 엑셀 컬럼, 샘플 데이터를 함께 보여주고 JSON으로 응답받는 것이 가장 정확하고 유연하다.
관련 글
- CSS 셀렉터 대체 전략: selector_alternatives로 안정성 확보
AI AgentBrowser AutomationCSS Selector - 새 탭 감지 및 자동 전환: 브라우저 자동화의 까다로운 문제
AI AgentBrowser AutomationPlaywright - 브라우저 자동화 시 페이지 네비게이션 생존 전략
AI AgentBrowser AutomationNavigation - Playwright 스크롤바 강제 표시: headless 환경의 UI 트릭
AI AgentBrowser AutomationCSS - 시나리오 레코더: 사용자 행동 녹화 및 재생 엔진
AI AgentBrowser AutomationPlaywright