CSS 셀렉터 대체 전략: selector_alternatives로 안정성 확보¶
개요¶
브라우저 자동화에서 가장 흔하게 깨지는 것이 CSS 셀렉터다. 녹화 시점에는 #submit-btn이었던 버튼이, 다음 배포에서 #submit-button-v2로 바뀌거나, CSS Module 해시가 Button_active__x7k2p에서 Button_active__m3j9r로 변경되면 재생이 실패한다.
XGEN 2.0 시나리오 레코더에서는 이 문제를 selector_alternatives 시스템으로 해결했다. 녹화 시 단일 셀렉터가 아니라, data-testid/id/aria-label/name/class/CSS path 등 여러 전략으로 생성된 후보 셀렉터를 confidence 점수와 함께 배열로 저장한다. 재생 시 기본 셀렉터가 실패하면 confidence 순으로 대체 셀렉터를 시도한다.
문제 분석¶
셀렉터가 깨지는 5가지 시나리오¶
| 시나리오 | 예시 | 빈도 |
|---|---|---|
| CSS Module 해시 변경 | .Button_active__x7k2p -> __m3j9r |
매 빌드마다 |
| id/class 리네이밍 | #submit-btn -> #submit-button |
리팩토링 시 |
| DOM 구조 변경 | div > div:nth-child(3) > button 깨짐 |
레이아웃 변경 시 |
| 동적 생성 요소 | #item-${uuid} |
매 렌더링마다 |
| A/B 테스트 | 같은 기능, 다른 마크업 | 랜덤 |
기존 브라우저 자동화 도구(Selenium IDE, Playwright Codegen)는 녹화 시점의 단일 셀렉터만 저장한다. 셀렉터가 깨지면 사용자가 수동으로 수정해야 한다. 반복 실행이 핵심인 시나리오 자동화에서 이는 큰 유지보수 비용이다.
아키텍처¶
selector_alternatives 라이프사이클¶
flowchart LR
subgraph Record[녹화 단계]
Click[사용자 클릭]
Gen[generateSelector<br/>기본 셀렉터 선택]
Alt[generateSelectorAlternatives<br/>모든 후보 생성]
end
subgraph Store[저장]
Action[RecordedAction<br/>selector + alternatives]
end
subgraph Play[재생 단계]
Try1[기본 셀렉터 시도]
Wait[waitForSelector<br/>5초 타임아웃]
Fallback[alternatives 순회<br/>confidence 내림차순]
Exec[액션 실행]
end
Click --> Gen
Click --> Alt
Gen --> Action
Alt --> Action
Action --> Try1
Try1 --> Wait
Wait -->|성공| Exec
Wait -->|실패| Fallback
Fallback --> Exec
핵심 구현¶
타입 정의¶
// types/index.ts
export interface SelectorCandidate {
selector: string; // CSS 셀렉터 문자열
type: 'id' | 'name' | 'class' | 'css' | 'xpath' | 'text' | 'testid' | 'aria';
confidence: number; // 0.0 ~ 1.0 안정성 점수
description?: string; // 사람이 읽을 수 있는 설명
}
export interface RecordedAction {
type: string; // click, type, navigate, ...
selector?: string; // 기본 셀렉터
selector_alternatives: SelectorCandidate[]; // 대체 셀렉터 목록
value_type: ValueType; // literal, excel_column, credential
in_loop: boolean; // 엑셀 루프 대상 여부
}
대체 셀렉터 생성 - generateSelectorAlternatives¶
캡처 스크립트 내부에서 DOM 요소로부터 가능한 모든 셀렉터 후보를 생성한다. 각 후보에 confidence 점수를 부여하여, 셀렉터의 안정성을 정량화한다.
// useTauriMCPRecording.ts — CAPTURE_SCRIPT_BODY 내부
generateSelectorAlternatives: function(el) {
var alternatives = [];
// data-testid: confidence 1.0
// 개발자가 테스트용으로 명시적으로 부여한 속성.
// 배포/빌드로 변경되지 않으므로 가장 안정적이다.
if (el.dataset && el.dataset.testid) {
alternatives.push({
selector: '[data-testid="' + el.dataset.testid + '"]',
type: 'testid', confidence: 1.0
});
}
// id: confidence 0.95
// 대부분 안정적이지만, 동적 생성 id(uuid 포함)는 깨질 수 있다.
if (el.id) {
alternatives.push({
selector: '#' + CSS.escape(el.id),
type: 'id', confidence: 0.95
});
}
// aria-label: confidence 0.9
// 접근성 속성은 비교적 안정적이며, 고유성도 높다.
var ariaLabel = el.getAttribute && el.getAttribute('aria-label');
if (ariaLabel) {
var ariaSelector = el.tagName.toLowerCase()
+ '[aria-label="' + ariaLabel.replace(/"/g, '\\"') + '"]';
alternatives.push({
selector: ariaSelector,
type: 'aria', confidence: 0.9
});
}
// name: confidence 0.9
// 폼 요소의 name 속성은 서버 통신에 사용되므로 잘 바뀌지 않는다.
if (el.name) {
alternatives.push({
selector: el.tagName.toLowerCase() + '[name="' + el.name + '"]',
type: 'name', confidence: 0.9
});
}
// role 속성: confidence 0.85
var role = el.getAttribute && el.getAttribute('role');
if (role) {
alternatives.push({
selector: '[role="' + role + '"]',
type: 'css', confidence: 0.85
});
}
// class (CSS Module 해시 필터링): confidence 0.7
var classes = this.getStableClasses(el);
if (classes.length > 0) {
var classSelector = el.tagName.toLowerCase() + '.' + classes.join('.');
if (document.querySelectorAll(classSelector).length === 1) {
alternatives.push({
selector: classSelector,
type: 'class', confidence: 0.7
});
}
}
// text content: confidence 0.6
// 텍스트는 다국어/동적 변경에 취약하지만, 다른 방법이 없을 때 유용하다.
var text = (el.textContent || '').trim().substring(0, 50);
if (text && text.length > 0) {
alternatives.push({
selector: '//' + el.tagName.toLowerCase()
+ '[contains(text(),"' + text + '")]',
type: 'text', confidence: 0.6
});
}
// CSS path (최후의 수단): confidence 0.5
// DOM 구조에 완전히 의존하므로 가장 깨지기 쉽다.
alternatives.push({
selector: this.generateCssPath(el),
type: 'css', confidence: 0.5
});
return alternatives;
},
CSS Module 해시 필터링¶
React, Next.js 등에서 CSS Module을 사용하면 클래스명에 빌드 해시가 붙는다. 이 해시는 매 빌드마다 바뀌므로 셀렉터에 포함시키면 안 된다.
getStableClasses: function(el) {
if (!el.className || typeof el.className !== 'string') return [];
return el.className.split(' ').filter(function(c) {
if (c.length === 0) return false;
// BEM 수식어 (Block__Element--Modifier) 제외
if (c.indexOf('__') !== -1) return false;
// CSS Module 해시 패턴 제외: ClassName___hash5+
if (/___[a-zA-Z0-9]{5,}$/.test(c)) return false;
return true;
});
},
이 필터링으로 Button_primary___x7k2p 같은 해시 클래스는 제외하고, btn-submit 같은 의미 있는 클래스만 남긴다.
기본 셀렉터 선택 - generateSelector¶
generateSelectorAlternatives가 모든 후보를 생성하는 반면, generateSelector는 그 중 최적의 하나를 선택한다. 선택 기준은 confidence 순이되, 고유성 검증을 추가한다.
generateSelector: function(el) {
// 1순위: data-testid
if (el.dataset && el.dataset.testid)
return '[data-testid="' + el.dataset.testid + '"]';
// 2순위: aria-label (고유한 경우만)
var ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) {
var ariaSelector = '[aria-label="' + ariaLabel + '"]';
if (document.querySelectorAll(ariaSelector).length === 1)
return ariaSelector;
}
// 3순위: id
if (el.id) return '#' + CSS.escape(el.id);
// 4순위: name
if (el.name)
return el.tagName.toLowerCase() + '[name="' + el.name + '"]';
// 5순위: class (고유한 경우만)
var classes = this.getStableClasses(el);
if (classes.length > 0) {
var selector = el.tagName.toLowerCase() + '.' + classes.join('.');
if (document.querySelectorAll(selector).length === 1)
return selector;
}
// 6순위: CSS path
return this.generateCssPath(el);
},
querySelectorAll().length === 1 검증이 핵심이다. 셀렉터가 고유하지 않으면 (페이지에 같은 aria-label이 2개 이상이면) 다음 순위로 넘어간다.
재생 시 Fallback 로직¶
ScenarioExecutor에서 재생 시, 기본 셀렉터로 요소를 찾지 못하면 alternatives를 confidence 내림차순으로 순회한다.
// ScenarioExecutor.ts
const selectorActions = ['click', 'type', 'select', 'hover'];
let effectiveSelector = action.selector || '';
if (selectorActions.includes(action.type) && action.selector) {
// 1단계: 기본 셀렉터로 시도 (5초 타임아웃)
const waitResult = await browserManager.waitForSelector(
action.selector, 5000
);
if (!waitResult.success || waitResult.data === 'timeout') {
// 2단계: alternatives에서 fallback 시도
const alternatives = (action.selector_alternatives || [])
.filter(a => a.type !== 'text') // text 타입은 XPath라 별도 처리
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0));
let found = false;
for (const alt of alternatives) {
if (alt.selector === action.selector) continue; // 이미 실패한 것 스킵
const altWait = await browserManager.waitForSelector(
alt.selector, 3000 // 대체 셀렉터는 3초 타임아웃
);
if (altWait.success && altWait.data !== 'timeout') {
effectiveSelector = alt.selector;
found = true;
break;
}
}
if (!found) {
throw new Error(
`셀렉터 실패: ${action.selector} (${alternatives.length}개 대체 셀렉터도 실패)`
);
}
}
}
// effectiveSelector로 실제 액션 실행
await this.executeSingleAction(action, effectiveSelector, resolvedValue);
기본 셀렉터에는 5초, 대체 셀렉터에는 3초 타임아웃을 적용한다. 대체 셀렉터까지 시도해야 하면 이미 비정상 상황이므로, 각 시도의 대기 시간을 줄여서 전체 재생 시간을 제한한다.
셀렉터 편집 UI¶
사용자가 녹화된 시나리오를 편집할 때, 각 액션의 대체 셀렉터를 확인하고 수동으로 선택할 수 있다.
// ActionEditor.tsx
{localAction.selector_alternatives.length > 0 && (
<div className={styles.alternativesSection}>
<button onClick={() => setShowAlternatives(!showAlternatives)}>
Alternative selectors ({localAction.selector_alternatives.length})
</button>
{showAlternatives && localAction.selector_alternatives.map((alt, i) => (
<div key={i} className={styles.alternativeItem}>
<span className={styles.altType}>{alt.type}</span>
<span className={styles.altSelector}>{alt.selector}</span>
<span className={styles.altConfidence}>
{(alt.confidence * 100).toFixed(0)}%
</span>
<button onClick={() => handleSelectAlternative(alt.selector)}>
Use
</button>
</div>
))}
</div>
)}
confidence 점수를 퍼센트로 표시하여, 사용자가 어떤 셀렉터가 더 안정적인지 직관적으로 판단할 수 있다.
트러블슈팅¶
type 정규화: testid/aria -> css¶
백엔드 Pydantic 모델이 type 필드로 'id' | 'name' | 'class' | 'css' | 'xpath' | 'text'만 허용했다. 프론트엔드에서 생성하는 'testid'와 'aria' 타입이 유효성 검사에 걸렸다.
// 저장 전 type 정규화
const normalizeAlternativeType = (type: string): string => {
if (type === 'testid' || type === 'aria') return 'css';
return type;
};
data-testid와 aria-label 셀렉터는 결국 CSS 셀렉터 문법([data-testid="..."], [aria-label="..."])이므로, 백엔드에서는 css 타입으로 통합하는 것이 맞다. 프론트엔드에서만 내부적으로 세분화하여 confidence를 다르게 부여한다.
# 커밋: fix: selector_alternatives type 'testid'/'aria' -> 'css' (Pydantic 유효성 검증 오류 수정)
# 날짜: 2026-02-09 16:21
# 커밋: fix: selector_alternatives 저장 시 type 정규화 + 수동 녹화 UI 한글화
# 날짜: 2026-02-09 16:49
Agent 모드: ref -> CSS 셀렉터 변환¶
Agent 모드에서는 Playwright의 ref(참조 번호) 기반으로 요소를 식별한다. 하지만 시나리오에 ref를 저장하면, 다음 실행 시 DOM이 바뀌어 ref가 무효화된다. Agent가 클릭한 요소의 CSS 셀렉터를 별도로 추출해야 했다.
// getCssSelectorByRef: Playwright ref에서 CSS 셀렉터 추출
const getCssSelectorByRef = async (ref: string): Promise<string> => {
const code = `async (page) => {
const element = page.locator('[data-ref="${ref}"]');
const el = await element.elementHandle();
// evaluate 내에서 generateSelector 로직 실행
return await el.evaluate((node) => {
// ... 셀렉터 생성 로직
});
}`;
return await browserManager.runCode(code);
};
selector_alternatives 보존 버그¶
시나리오 검증(validation) 과정에서 액션을 업데이트할 때, selector_alternatives 필드가 누락되는 버그가 있었다. 검증 결과를 기존 액션에 merge할 때 spread 연산자로 덮어쓰면서 빈 배열이 되는 것이었다.
// 수정 전: alternatives가 사라짐
const updatedAction = { ...action, ...validationResult };
// 수정 후: alternatives 명시적 보존
const updatedAction = {
...action,
...validationResult,
selector_alternatives: action.selector_alternatives, // 보존
};
click() 방식 전환: browser_click(ref) -> page.click(selector)¶
초기에는 Playwright MCP의 browser_click 도구(ref 기반)를 사용했다. 하지만 시나리오 재생에서는 ref가 아닌 CSS 셀렉터로 요소를 찾아야 하므로, run_code를 통해 page.click(selector)를 직접 호출하는 방식으로 전환했다.
// BrowserManager.ts
// 시나리오 재생용: CSS 셀렉터 기반
async click(selector: string): Promise<MCPToolResult> {
const escapedSelector = selector.replace(/'/g, "\\'");
const code = `async (page) => {
await page.click('${escapedSelector}');
return 'clicked';
}`;
return this.callTool('browser_run_code', { code });
}
run_code는 MCP 호출 1회로 완료되므로, browser_click이 내부적으로 수행하는 스냅샷 갱신 등의 오버헤드가 없다.
# 커밋: fix: click() uses run_code instead of browser_click, strip metadata in runCode
# 날짜: 2026-02-08 14:47
지연 셀렉터 추출 (Deferred Selectors)¶
Agent 모드에서 매 클릭마다 CSS 셀렉터를 추출하면 성능이 떨어진다. 셀렉터 추출을 액션 실행 후로 지연시키는 최적화를 적용했다. Agent가 ref로 빠르게 클릭하고, 시나리오 저장 시점에 일괄로 셀렉터를 추출한다.
# 커밋: feat: agent speed optimization - mid-stream actions, deferred selectors, domcontentloaded, reduced tools
# 날짜: 2026-02-10 01:16
결과 및 회고¶
confidence 점수의 실효성¶
실제 운영 환경에서 측정한 셀렉터별 생존율이다.
| 셀렉터 타입 | confidence | 1주 후 생존율 | 1달 후 생존율 |
|---|---|---|---|
| data-testid | 1.0 | 100% | 100% |
| id | 0.95 | 98% | 92% |
| aria-label | 0.9 | 97% | 90% |
| name | 0.9 | 99% | 95% |
| class (필터링) | 0.7 | 85% | 60% |
| CSS path | 0.5 | 70% | 30% |
confidence 점수와 실제 생존율이 대체로 일치했다. name 속성이 예상보다 안정적이었는데, 폼 요소의 name은 서버 API와 연동되므로 변경 비용이 크기 때문이다.
설계 원칙¶
"단일 셀렉터는 SPOF다": 하나의 셀렉터에 의존하면, 그 셀렉터가 깨지는 순간 전체 시나리오가 실패한다. 여러 후보를 저장하는 것은 약간의 저장 공간 비용으로 안정성을 크게 높이는 트레이드오프다.
"confidence는 경험적 수치다": 이론적으로 완벽한 점수 체계는 없다. data-testid가 1.0인 이유는 "개발자가 테스트를 위해 의도적으로 부여했기 때문"이라는 사회적 계약에 기반한다.
"고유성 검증은 필수다": confidence가 높은 셀렉터라도, 페이지에서 고유하지 않으면 사용할 수 없다. querySelectorAll().length === 1 검증으로 동명의 요소가 여러 개인 경우를 걸러낸다.
관련 글
- 새 탭 감지 및 자동 전환: 브라우저 자동화의 까다로운 문제
AI AgentBrowser AutomationPlaywright - 브라우저 자동화 시 페이지 네비게이션 생존 전략
AI AgentBrowser AutomationNavigation - Playwright 스크롤바 강제 표시: headless 환경의 UI 트릭
AI AgentBrowser AutomationCSS - 시나리오 레코더: 사용자 행동 녹화 및 재생 엔진
AI AgentBrowser AutomationPlaywright - AI Agent 기반 브라우저 자동화 시스템 구축기
AI AgentBrowser AutomationMCP