XPath vs CSS Selector: 언제 뭘 써야 할까

XPath vs CSS Selector: 언제 뭘 써야 할까

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. 의미 있는 속성을 우선한다.

좋은 순서:

  1. ID (있으면)
  2. data-testid
  3. name
  4. type + name 조합
  5. class (여러 개 중 의미 있는 것)
  6. 텍스트
  7. 구조 기반 (최후의 수단)
# 좋음
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에서 확인할 거다.