XPath vs CSS Selector: 언제 뭘 써야 할까
- 27 Dec, 2025
XPath vs CSS Selector: 언제 뭘 써야 할까
오늘도 깨진 테스트
출근했다. 슬랙 알람 17개.
“자동화 테스트 전부 실패했는데요?”
어제 개발자가 로그인 폼 디자인 살짝 바꿨다. class 이름 하나 변경. 내 테스트 스크립트 83개가 깨졌다.
driver.find_element(By.XPATH, "//div[@class='login-container']/form/input[1]")
이런 XPath를 썼었다. 멍청했다.
2시간 동안 로케이터 전부 수정했다. 점심도 못 먹었다. 오늘은 이 얘기를 써야겠다고 생각했다.
XPath와 CSS Selector. 둘 다 쓸 줄 알지만 언제 뭘 써야 하는지 정확히 아는 사람은 적다. 나도 3년 차까지는 몰랐다.

로케이터가 뭔지부터
자동화 테스트에서 웹 요소를 찾는 방법이다.
“이 버튼 클릭해” 하려면 일단 그 버튼을 찾아야 한다. Selenium이나 Playwright한테 “여기 있어” 알려주는 게 로케이터다.
방법은 여러 개다.
- ID
- Name
- Class Name
- Tag Name
- Link Text
- XPath
- CSS Selector
ID가 제일 좋다. 유일하고 빠르고 안 깨진다. 근데 현실은 ID 없는 요소가 태반이다.
그래서 XPath나 CSS Selector를 쓴다. 이 둘이 제일 강력하다. 거의 모든 요소를 찾을 수 있다.
문제는 강력할수록 잘못 쓰기 쉽다는 거다. 칼이 날카로울수록 조심해야 하듯이.
나는 신입 때 XPath만 썼다. 개발자 도구에서 Copy XPath 버튼 눌러서 복붙했다.
/html/body/div[1]/div[2]/div[3]/form/div[1]/input
이런 게 나온다. 겁나 길다. 그리고 UI 조금만 바뀌면 바로 깨진다.
3년 차 되니까 CSS Selector가 더 편하다는 걸 알았다. 4년 차인 지금은 상황에 따라 섞어 쓴다.
XPath의 장점과 단점
XPath는 XML Path Language다. HTML은 XML의 일종이니까 쓸 수 있다.
장점부터.
상위로 올라갈 수 있다.
# 버튼 찾고 그 부모의 부모 찾기
driver.find_element(By.XPATH, "//button[@id='submit']/../../")
CSS Selector는 이게 안 된다. 부모나 형제를 찾을 수 없다. 오직 자식만.
복잡한 DOM 구조에서 특정 요소 기준으로 위로 올라가야 할 때 XPath만 답이다.
텍스트로 찾을 수 있다.
# "로그인" 텍스트를 가진 버튼
driver.find_element(By.XPATH, "//button[text()='로그인']")
# "확인"을 포함하는 버튼
driver.find_element(By.XPATH, "//button[contains(text(), '확인')]")
진짜 편하다. 디자이너가 class 이름 맘대로 바꿔도 텍스트는 잘 안 바뀐다.
복잡한 조건을 쓸 수 있다.
# position으로 찾기
//div[@class='item'][position()>2]
# 여러 조건 and/or
//input[@type='text' and @name='username']
이런 건 CSS Selector로 못 한다.
단점도 명확하다.
느리다.
CSS Selector보다 평균 10-20% 느리다. 브라우저는 CSS Selector를 네이티브로 지원한다. XPath는 그렇지 않다.
테스트 케이스 10개면 상관없다. 1000개면 체감된다.
문법이 복잡하다.
//, /, @, [], .. 이런 기호들. 헷갈린다. 실수하기 쉽다.
신입 QA한테 가르치기도 어렵다. “이건 왜 슬래시가 2개예요?” 질문 받으면 설명이 길어진다.
브라우저마다 미묘하게 다르다.
Chrome에서 되는 XPath가 Firefox에서 안 될 때가 있다. 많지는 않지만 가끔 당한다.

CSS Selector의 장점과 단점
CSS Selector는 웹 개발자들이 스타일링할 때 쓰는 그거다.
장점.
빠르다.
브라우저 엔진이 직접 지원한다. 최적화도 잘 돼 있다. XPath보다 확실히 빠르다.
대규모 E2E 테스트 스위트 돌릴 때 차이가 난다.
문법이 직관적이다.
# ID로 찾기
driver.find_element(By.CSS_SELECTOR, "#username")
# Class로 찾기
driver.find_element(By.CSS_SELECTOR, ".login-button")
# 속성으로 찾기
driver.find_element(By.CSS_SELECTOR, "input[type='password']")
# 자식 찾기
driver.find_element(By.CSS_SELECTOR, "form > input")
깔끔하다. 읽기 쉽다. 후배한테 가르치기도 편하다.
크로스 브라우저 호환성이 좋다.
CSS는 표준이다. Chrome이든 Firefox든 Safari든 똑같이 작동한다.
단점.
위로 못 올라간다.
부모 선택자가 없다. :has()가 있긴 한데 Selenium에서 지원 안 하는 경우가 많다.
형제 요소 찾기도 제한적이다. 바로 다음 형제만 +로 찾을 수 있다.
텍스트로 못 찾는다.
이게 제일 아쉽다. 버튼 텍스트로 직접 찾을 방법이 없다.
# 이런 거 안 됨
driver.find_element(By.CSS_SELECTOR, "button[text='로그인']") # 틀림
XPath 써야 한다.
복잡한 조건에 약하다.
position이나 조건 로직 같은 건 표현하기 어렵다.
내가 쓰는 기준
4년 동안 삽질하면서 정리한 내 원칙이다.
기본은 CSS Selector.
웬만하면 CSS Selector 쓴다. 빠르고 읽기 쉽고 안정적이다.
# 좋음
driver.find_element(By.CSS_SELECTOR, "button[data-testid='submit']")
driver.find_element(By.CSS_SELECTOR, ".modal-content input[name='email']")
텍스트로 찾아야 하면 XPath.
버튼 라벨, 링크 텍스트, 에러 메시지. 이런 건 텍스트로 찾는 게 제일 안정적이다.
# 텍스트 기반 로케이터
driver.find_element(By.XPATH, "//button[text()='다음']")
driver.find_element(By.XPATH, "//a[contains(text(), '비밀번호 찾기')]")
driver.find_element(By.XPATH, "//span[@class='error' and contains(text(), '필수')]")
UI 디자인 바뀌어도 버튼에 쓰인 “다음” 텍스트는 잘 안 바뀐다.
부모/형제 찾아야 하면 XPath.
체크박스 옆의 라벨 텍스트로 체크박스 찾기. 이런 패턴.
# 라벨 텍스트로 체크박스 찾기
checkbox = driver.find_element(By.XPATH, "//label[text()='이용약관 동의']/preceding-sibling::input[@type='checkbox']")
CSS Selector로는 불가능하다.
테이블 같은 복잡한 구조는 XPath.
특정 행의 특정 열 찾기. position 필요할 때.
# 3번째 행의 2번째 셀
cell = driver.find_element(By.XPATH, "//table[@id='data-table']/tbody/tr[3]/td[2]")
절대 경로는 쓰지 않는다.
# 이런 거 절대 금지
driver.find_element(By.XPATH, "/html/body/div[1]/div[2]/form/input[3]")
Copy XPath 해서 나온 거 그대로 쓰면 이렇다. 개발자가 div 하나만 추가해도 깨진다.
상대 경로를 써야 한다.
# 이렇게
driver.find_element(By.XPATH, "//form[@id='login-form']//input[@name='username']")
안정적인 로케이터 전략
UI 변경에 강한 로케이터를 만드는 게 핵심이다.
1. data-testid 속성을 쓴다.
개발자한테 요청해서 테스트용 속성을 넣어달라고 한다.
<button data-testid="submit-button">제출</button>
driver.find_element(By.CSS_SELECTOR, "[data-testid='submit-button']")
이게 제일 안정적이다. 디자인 바뀌어도 안 깨진다.
우리 팀은 모든 주요 요소에 data-testid 붙이기로 컨벤션 정했다. 개발자들한테 처음엔 귀찮다는 소리 들었다. 지금은 당연하게 생각한다.
2. 의미 있는 속성을 우선한다.
좋은 순서:
- ID (있으면)
- data-testid
- name
- type + name 조합
- class (여러 개 중 의미 있는 것)
- 텍스트
- 구조 기반 (최후의 수단)
# 좋음
driver.find_element(By.CSS_SELECTOR, "#username")
driver.find_element(By.CSS_SELECTOR, "input[name='email']")
# 나쁨
driver.find_element(By.CSS_SELECTOR, "div > div > input:nth-child(2)")
3. 동적 class는 피한다.
# 나쁨 - 빌드마다 바뀌는 해시
driver.find_element(By.CSS_SELECTOR, ".css-1hw23kj-button")
# 좋음 - 안정적인 class
driver.find_element(By.CSS_SELECTOR, ".primary-button")
Tailwind나 CSS Modules 쓰면 class 이름에 해시가 붙는다. 절대 쓰면 안 된다.
4. nth-child는 조심한다.
# 위험 - 순서 바뀌면 깨짐
driver.find_element(By.CSS_SELECTOR, "form input:nth-child(2)")
# 나음 - 속성으로 특정
driver.find_element(By.CSS_SELECTOR, "form input[type='password']")
리스트나 테이블에서 position이 중요한 경우에만 써야 한다.
5. 로케이터를 변수로 관리한다.
Page Object Pattern을 쓴다.
class LoginPage:
USERNAME_INPUT = (By.CSS_SELECTOR, "input[name='username']")
PASSWORD_INPUT = (By.CSS_SELECTOR, "input[name='password']")
SUBMIT_BUTTON = (By.XPATH, "//button[text()='로그인']")
def login(self, username, password):
driver.find_element(*self.USERNAME_INPUT).send_keys(username)
driver.find_element(*self.PASSWORD_INPUT).send_keys(password)
driver.find_element(*self.SUBMIT_BUTTON).click()
로케이터 한 곳에서 관리. UI 바뀌면 여기만 수정하면 된다.

성능 비교 실험
궁금해서 직접 측정해봤다.
테스트 환경:
- 페이지: 복잡한 대시보드 (DOM 요소 500+)
- 반복: 각 로케이터 100회 실행
- 브라우저: Chrome 120
결과:
ID: 평균 12ms
CSS Selector: 평균 18ms
XPath (상대경로): 평균 23ms
XPath (절대경로): 평균 35ms
ID가 제일 빠르다. 당연하다.
CSS Selector가 XPath보다 약 30% 빠르다.
절대 경로 XPath는 두 배 느리다. 쓰면 안 되는 이유가 하나 더.
100회면 차이가 1초. 전체 테스트 스위트 1000개면 10초다. 무시 못 한다.
근데 성능보다 중요한 게 안정성이다. 0.01초 빨라도 매주 깨지면 소용없다.
실제 사례들
사례 1: 동적 폼
회원가입 폼. 선택 항목에 따라 필드가 추가된다.
처음에 이렇게 짰다:
# 나쁨
driver.find_element(By.XPATH, "//form/div[3]/input")
“기업 회원” 선택하면 사업자등록번호 필드가 생긴다. 그럼 div 순서가 바뀐다. 깨진다.
수정:
# 좋음
driver.find_element(By.CSS_SELECTOR, "input[name='phone']")
driver.find_element(By.XPATH, "//label[text()='전화번호']/following-sibling::input")
name 속성이나 라벨 텍스트로 찾으니 안정적이다.
사례 2: 모달 창
“정말 삭제하시겠습니까?” 확인 모달.
페이지에 여러 모달이 있다. class 이름이 전부 .modal이다.
# 나쁨 - 어떤 모달인지 모름
driver.find_element(By.CSS_SELECTOR, ".modal button")
첫 번째 모달의 버튼을 찾는다. 원하는 모달이 아닐 수 있다.
수정:
# 좋음 - 텍스트로 특정
modal = driver.find_element(By.XPATH, "//div[contains(@class, 'modal') and contains(., '삭제하시겠습니까')]")
confirm_button = modal.find_element(By.XPATH, ".//button[text()='확인']")
모달 내용으로 찾고, 그 안에서 버튼을 찾는다.
사례 3: 동적 리스트
상품 목록. 개수가 계속 바뀐다.
특정 상품의 “장바구니” 버튼 클릭해야 한다.
# 나쁨
driver.find_element(By.XPATH, "//div[@class='product-list']/div[5]/button")
5번째 상품이 뭔지 모른다. 상품 추가되면 순서 바뀐다.
수정:
# 좋음
product_name = "무선 키보드"
button = driver.find_element(
By.XPATH,
f"//div[contains(@class, 'product-item') and contains(., '{product_name}')]//button[text()='장바구니']"
)
상품 이름으로 찾으니 확실하다.
디버깅 팁
로케이터 안 될 때 내가 하는 것들.
1. 개발자 도구에서 직접 테스트
Console에서:
// CSS Selector 테스트
document.querySelector("input[name='username']")
// XPath 테스트
$x("//button[text()='로그인']")
결과가 나오면 로케이터는 맞다. 타이밍 문제다.
null 나오면 로케이터가 틀렸다.
2. 암묵적 대기 vs 명시적 대기
# 암묵적 대기 - 전역 설정
driver.implicitly_wait(10)
# 명시적 대기 - 특정 요소
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "input[name='username']"))
)
동적 로딩이 많으면 명시적 대기가 낫다.
3. Screenshot 찍기
요소 못 찾으면 그 순간 스크린샷 저장.
try:
driver.find_element(By.CSS_SELECTOR, ".submit-button").click()
except NoSuchElementException:
driver.save_screenshot("debug.png")
raise
뭔가 예상과 다르게 렌더링됐는지 바로 알 수 있다.
4. 여러 로케이터 시도
def find_element_flexible(driver, *locators):
for locator in locators:
try:
return driver.find_element(*locator)
except NoSuchElementException:
continue
raise NoSuchElementException(f"None of the locators worked: {locators}")
# 사용
button = find_element_flexible(
driver,
(By.CSS_SELECTOR, "[data-testid='submit']"),
(By.XPATH, "//button[text()='제출']"),
(By.CSS_SELECTOR, "button.primary")
)
백업 로케이터를 둔다. 하나 바뀌어도 다른 걸로 찾는다.
팀 컨벤션
우리 팀 규칙.
1. 로케이터 우선순위
1. data-testid (CSS)
2. ID (CSS)
3. name (CSS)
4. 텍스트 (XPath)
5. 복합 속성 (CSS)
6. 구조 + 텍스트 (XPath)
이 순서대로 시도. 문서화했다.
2. 절대 경로 금지
코드 리뷰에서 절대 경로 보이면 즉시 reject.
# ❌ Rejected
"/html/body/div[1]/..."
# ✅ Approved
"//form[@id='login']//input"
3. Page Object 필수
직접 로케이터 쓰지 말고 Page Object 통해서.
# ❌ Bad
driver.find_element(By.CSS_SELECTOR, "input[name='username']").send_keys("test")
# ✅ Good
login_page.enter_username("test")
4. 로케이터 네이밍
# 명확하게
USERNAME_INPUT = (By.CSS_SELECTOR, "input[name='username']")
SUBMIT_BUTTON = (By.XPATH, "//button[text()='로그인']")
# 애매하게 말고
INPUT_1 = (By.CSS_SELECTOR, "input:nth-child(1)") # ❌
BTN = (By.XPATH, "//button") # ❌
5. 주석으로 이유 설명
특이한 로케이터는 왜 그렇게 했는지 적는다.
# XPath 사용 이유: 부모 요소 기준으로 찾아야 함
# 라벨 텍스트로 체크박스 선택
TERMS_CHECKBOX = (By.XPATH, "//label[text()='이용약관 동의']/preceding-sibling::input")
실전 체크리스트
로케이터 짤 때 내가 확인하는 것들.
작성 전:
- 이 요소에 ID나 data-testid 있나?
- 개발자한테 추가 요청 가능한가?
- 텍스트 기반으로 찾을 수 있나?
- 여러 개 있는 요소인가? (리스트, 테이블)
작성 중:
- 절대 경로 안 썼나?
- 동적 class 안 썼나?
- nth-child 꼭 필요한가?
- 더 안정적인 속성 없나?
작성 후:
- 개발자 도구에서 테스트했나?
- 페이지 새로고침해도 작동하나?
- 다른 데이터로도 작동하나? (예: 다른 상품명)
- 백업 로케이터 필요한가?
결론
CSS Selector를 기본으로. XPath는 필요할 때만.
빠르고 읽기 쉬운 게 CSS Selector다. 대부분 이걸로 해결된다.
텍스트로 찾거나 부모로 올라가야 하면 XPath 쓴다.
근데 제일 중요한 건 로케이터 종류가 아니다. 얼마나 안정적으로 짜느냐다.
ID나 data-testid 쓸 수 있으면 무조건 그거 쓴다. CSS든 XPath든 상관없다.
개발자랑 협업해서 테스트하기 좋은 마크업 만드는 게 제일 중요하다.
로케이터 전략이 없으면 UI 바뀔 때마다 테스트 고친다. 시간 낭비다.
전략 세워두면 테스트가 안정적이다. 자동화가 의미 있어진다.
나도 초반엔 닥치는 대로 Copy XPath 했다. 계속 깨졌다.
지금은 로케이터 짜는 데 시간 쓴다. 그게 나중에 시간 아낀다.
오늘 점심 먹으면서 후배한테 이 얘기 했다. “로케이터 전략부터 세워” 라고. 후배는 고개를 끄덕였다. 다음 PR에서 확인할 거다.
