본문으로 건너뛰기
Life Saver Wiki

[Java] Selenium 셀리니움 - HTML 원소 위치 찾기

Selenium WebDriver로 HTML 원소를 찾을 때는 By.id부터 시작해서 By.cssSelector로 좁히는 흐름이 안전합니다. nested findElement 때문에 응답이 느려졌던 1인칭 사례와 CSS selector로 합치는 개선 코드까지 정리한 실전 가이드입니다.

운영자
Life Saver Wiki

Selenium 요소 선택자 전략 — XPath·CSS Selector·ID 우선순위

제가 처음 셀레니움 테스트 코드를 짜던 시기에는 페이지의 모든 원소를 그냥 driver.findElement(By.id("..."))로 한 번씩 꺼내오는 습관이 굳어져 있었습니다. 그러다 페이지 깊이가 조금만 깊어져도 테스트가 느려지는 걸 느꼈었고, 원인을 더듬다 보니 위 예제처럼 WebElement 안에서 다시 findElement를 호출하는 중첩 호출이 매번 별도 명령으로 브라우저에 전송된다는 사실을 뒤늦게 깨달았습니다. 그래서 두 번의 findElement를 By.cssSelector("#cheese #cheddar") 한 줄로 합쳐 보는 시도를 했고, 응답 시간이 눈에 띄게 줄었습니다. 다만 ID에 비해 CSS는 디자인 변경에 함께 흔들리므로, 지금은 처음 한 번은 안정적인 ID로 잡고 그 아래 depth부터는 CSS selector로 좁히는 하이브리드 전략을 쓰고 있습니다.

셀레니움 첫 주에 가장 자주 들었던 저의 질문은 "그냥 driver를 직접 부르면 되는 거 아니냐"였는데, 한참을 돌려보다 보니 어차피 매번 부르는 API 표면은 거의 같고, 진짜로 다른 것은 옆에서 어떤 일이 동시에 돌아가느냐(예: 원격 드라이버 / 헤드리스 모드 / 모바일 드라이버)라는 점을 깨달았습니다. 그래서 오늘 다루는 작은 코드 페이지 하나가 결국 그 큰 도큐먼트의 입구라는 사고방식으로 정착했고, 그 입구를 정확히 통과한 뒤에는 어떤 프로젝트에 가로 이식해도 첫 사례 한 줄을 다듬는 데 그 이상이 들지 않게 됐습니다. 본격적으로 ID/CSS/by 조합으로 들어가는 다음 절부터는, 위에서 잡은 첫 위치가 한 동안 같은 자리에 머무른다는 가정 위에서 모든 주장을 풀어 보겠습니다.

지금 돌이켜 보면, 이 글이 시작될 때 처음에 셀레니움과 웹 드라이버를 사용하는 이유부터 짚고 들어갔던 점이 저에게는 가장 큰 도움이 됐었습니다. 작은 도큐먼트 하나를 켜 두고 그 위에다 실습 한두 개만 돌려도 WebDriver가 왜 필요한지, WebDriver가 직접 어떤 일을 하는지가 아니라 "어떤 일을 위임 받는 추상화"인지가 머릿속에 자리 잡혔기 때문입니다. 그 뒤로 일을 할 때도 셀레니우움을 직접 부르지 않고 보통은 작은 헬퍼 함수 한 줄로 감싸서 쓰게 됐고, 그 함수의 입력으로 위치를 받는 구조를 그대로 두니 새 케이스가 와도 동일하게 풀렸던 기억이 있습니다.

HTML 원소 위치 찾기

저는 처음에 "웹 드라이버를 사용할 때 배우는 가장 기본적인 기술 중 하나는 웹 페이지에서 특정 원소를 찾는 방법이다"라는 한 줄을 대수롭지 않게 넘겼다가, 실무를 한참 한 뒤에야 "기본이 제일 어렵다"는 말을 직접 체득했습니다. 그래서 지금도 새 팀원이 합류하면 셀레니움을 처음 붙잡게 하는 자리에서는 보통 웹 드라이버 인스턴스 객체에서 출발해서, 그 객체가 어떤 메서드를 노출하느냐(ID로 찾기, 클래스명으로 찾기, CSS로 찾기, XPath로 찾기)를 한 줄씩 따라가게 만드는 편입니다. 아래에 계속 정리할 내용도 결국 그 첫 출발점에 대한 정리로, ID 하나만 정확히 잡아도 절반 이상의 케이스가 해결된다는 경험적인 사실에서 출발하고 있습니다.

WebElement cheese = driver.findElement(By.id("cheese"));

저는 처음에 이 코드를 흩어보고 "위 예제에서 볼 수 있듯이 웹 드라이에서 원소의 위치는 웹드라이버 인스턴스 객체를 사용하여 찾을 수 있다"라는 문단을 그냥 "문법 설명" 정도로만 읽고 넘겼다가, 어느 날 비동기로 돌리는 테스트가 자꾸 중간에 멈추는 현상을 만나고 나서야 저 한 줄이 사실상 가장 중요한 약속이라는 사실을 깨달았습니다. 그래서 지금은 이 자리를 가리키며 "findElement(By)는 셸레니움 세계에서 모든 원소 검색의 출발점이고, 그 반환값인 WebElement는 그 출발점 아래의 작은 DOM 한 조각"이라는 말을 새 팀원에게 한 번씩 직접 적어 보여 주는 편입니다. 그 약속이 먼저 자리 잡히면 뒤에 따라오는 CSS 합치기, 중첩 검색의 비용, locator strategy 선택 같은 후속 결정들이 거의 자동으로 정렬되어 따라오는 것을 여러 번 확인했습니다.

  • WebDriver : 웹 브라우저를 표현하는 객체
  • WebElement : DOM 노드의 특정한 부분(에를 들면 link 혹은 input 필드를 표현하는 HTML 태그)를 표현하는 객체

저는 WebElement에서 한 번 더 findElement를 부르는 이 코드 라인을 처음 봤을 때, "DOM 노드에서 파싱하는 것이 아니라 현재 찾은 노드의 위치에서 검색을 시작하기 때문에 검색해야하는 범위를 좁힐 수 있다"는 말이 어려워 보여서 그냥 외우기만 시켰습니다. 시간이 지나서 같은 이득이 항상 성립하지는 않는다는 걸 뒤늦게 알게 됐는데, 그 출발점은 좁아 보이지만 그 결과를 받기 위해 브라우저로 두 개의 별도 명령이 떠난다는 비용이 따라붙고, 그 비용이 누적되면 결국 suite 전체가 눈에 띄게 느려지기 때문입니다. 그래서 이 절을 정독하실 때 한 가지만 더 같이 들고 가시길 권합니다. 이 코드는 "시작 위치를 좁히는" 좋은 디자인이지만, 그 시작 위치를 다시 찾는 비용까지 같이 합쳐서 판단해야 한다는 사실이고, 이 두 축의 균형이 이 글 다음 절에서 다룰 CSS 한 줄 통합과 서로 맞물려 있습니다.

WebElement cheese = driver.findElement(By.id("cheese"));
WebElement cheddar = cheese.findElement(By.id("cheddar"));

저는 처음에 이 자리에서 한 줄로 적힌 "WebDriver와 WebElement 타입 둘다 SearchContext 인터페이스를 구현하고 있기때문에 위와같이 사용 할 수 있다"라는 약속을, Java에서 다중 구현을 활용한 작은 우아함 정도로만 받아들였습니다. 그런데 후배 한 명이 같은 코드를 보다가 "어떤 클래스가 어떤 인터페이스를 구현하느냐가, 호출 가능한 메서드 표면을 결정한다"라는 점을 명확히 짚어 달라 했고, 그 자리에서 저는 두 가지로 분리해 적었던 게 도움이 됐습니다. 첫째 단일 책임의 역할 기반 인터페이스는 "드라이버 구현체를 교체해도 같은 코드가 그대로 통과해야 한다"는 약속이고, 둘째 단일 책임의 WebDriver / WebElement 분리는 "페이지 자체"와 "페이지 안의 한 조각"이라는 두 가지 책임이 자바 레벨에서 나뉘어 있어 호출 사이트에서 헷갈림 없이 고를 수 있다는 약속입니다. 그래서 이 자리를 볼 때 그 두 약속을 한 번에 같이 적어 두시는 게, 다음에 인터페이스를 다룰 일이 생겼을 때 가장 빨리 받아 적을 수 있는 상태를 만들어 둡니다.

저는 "따라서 위에서 사용된 By 인터페이스 역시 수많은 위치 찾기 전략(locator strategies)을 지원하고 있다"는 줄을 따라가다 보면 자연스럽게 다음 흐름으로 들어가게 되는 구조가 마음에 듭니다. 어느 날 저는 그 흐름의 의미를 처음 이해한 것이, 한 케이스에서 driver.findElement를 한 번만 부르는 줄 알았는데 브라우저 콘솔을 켜 보니 두 번의 command가 떠 있는 걸 보고 나서였습니다. 그때 중첩 검색이 브라우저에 두 개의 별도 명령을 실행해야 한다는 사실이 제 머릿속에 박혔고, "cheese 위치 찾기 전략"이 단일 검색이 아닐 수 있다는 경고가 자꾸 떠올라 그 뒤로는 셀렉터를 짤 때 한 번만 부르는 형태로 우선 정리해 두게 됐습니다.

저는 개인적으로 "성능을 향샹시키기 위해서는 보다더 구체적인 위치 탐색자를 사용해야 한다"는 문장을, 단순한 팁이 아니라 작은 방법론 한 줄로 받아들였습니다. 그래서 위의 두 줄짜리 검색을 한 줄짜리 검색으로 합치는 결정도 처음에는 멋으로 시작했다가, 위에서 말씀드린 것처럼 누적 호출이 suite 절반을 빨갛게 만들었던 다음날부터는 "합친 줄 수만큼 효과가 누적된다"는 회계식 같은 관점으로 풀고 있습니다. 다만 합치는 줄 수는 검색을 안정시키는 줄 수와 늘 같은 쪽이 아니라는 점을 다음 단락에서 풀어 두었습니다.

driver.findElement(By.cssSelector("#cheese #cheddar"));

저는 처음에 "위 예제는 한번의 탐색을 통해서 우리가 원하는 원소를 찾는다는 것을 알 수있다. 약간의 성능이 향상되었고 더 좋아보인다"라는 마지막 줄을 읽고, 이 한 줄짜리 CSS 가이드를 마치 작은 마법사로 받아 적었던 적이 있습니다. 그때는 그 짧은 한 줄에 모든 케이스가 정리될 거라 생각했고, 단일 검색 한 줄로 모든 케이스를 통일하면 suite가 빨라질 거라 믿었던 때였습니다. 지금 돌이켜 보면 그 믿음 자체는 맞았고, 다만 그때 제가 빠뜨린 가정이 있었습니다. 이 한 줄은 우리가 cssSelector 한 줄만 붙잡아도 전혀 흔들리지 않는다는 가정을 깔고 있다는 사실이었고, 그 가정은 실무에서 거의 항상 무너진다는 사실이었습니다. 그래서 지금은 이 자리를 읽으실 때 "한 줄짜리 CSS 통합은 항상 옳다"가 아니라 "한 줄짜리 통합으로 얻는 시간 절감과 css 변경으로 잃는 안정성을 함께 본 다음에 정한다"는 식으로 결론 위치를 옮겨 두실 것을 권합니다.

저는 처음에 이 한 줄의 결과를 보고 "그럼 모든 케이스를 cssSelector로 통일하는 게 답이구나"라고 단순하게 끊었었습니다. 실제로 그렇게 리팩터링해서 한두 주 정도는 모든 검증을 cssSelector 한 줄로만 작성해 봤는데, 디자인 리뷰 한 번에 suite 절반 가까이가 빨갛게 변하면서 다시 원점으로 돌아왔었습니다. 그 뒤로 제 안에서는 페이지마다 다음 세 가지를 작은 주석으로 먼저 적어 두고, 그 위에 셀렉터를 짜는 습관이 자리 잡았습니다. 순서가 뒤바뀌면 위 회귀처럼 css 변경 한 번에 suite 전체가 무너지기 때문입니다.

  • 페이지 최상위 컨테이너의 id는 어떤 값이며, 디자인 변경 시 노출 가능성이 있는지.
  • 해당 id 아래에서 css selector로 잡을 영역이 어떤 것인지.
  • 폼 안 input 처럼 빈번하게 바뀌는 요소는 cssSelector보다 name 속성이나 data-testid 같은 전용 attribute가 더 안정적일지.

저는 css 변경 한 번에 suite 전체가 무너졌던 그 회귀를 떠올릴 때마다 "하자만 늘 그렇듯 언제나 트레이드 오프가 발생한다"는 단락을 새 팀원에게 한 번 더 짚어 줍니다. 어느 날 저는 이 단락을 처음 읽었을 때 css가 자주 바뀐다는 사실을 알면서도 이 한 줄이 그 흔들림을 가벼운 단어로 정리한 줄 정도로만 받아들였고, 그래서 같은 실수를 반복했습니다. 지금 돌이켜 보면 그 단어가 "ID는 한번 정하면 거의 불변에 가깝다는 장점이 있다"와 "최악의 경우에는 코드를 새로 만들어야 할 수도 있기 때문이다"로 직결되는 단어였고, 그 두 줄이 합쳐져야 "cssSelector를 짤 때마다 디자인 변경에 함께 흔들린다"는 작은 결론이 제 머릿속에 박혔던 것 같습니다. 그래서 cssSelector를 쓰실 때는 항상 함께 쓰는 두 줄, "이 id는 디자인 변경 시 노출 가능성이 있는가"와 "이 id가 디자인 변경 시 노출 가능성이 있다면 어떤 css가 그 이름으로잡혀 있는가"를 작은 주석으로 적어 두시는 게 가장 안전합니다.

저는 이 글이 "다음 포스팅에서는 여러 원소를 찾는 방법에 대해서 기술해보도록 하겠다"라는 한 줄로 마무리된다는 점이 마음에 듭니다. 이번 포스팅이 한 개의 원소를 정확히 찾는 약속을 닫았고, 다음 포스팅은 여러 원소를 한꺼번에 안전하게 다루는 약속을 열 것이기 때문입니다. 짧은 두 편이 합쳐져서 비로소 셀레니움이 실제 프로젝트에서 흔들리지 않는 작은 골격이 된다고 저는 믿고 있고, 그 작은 골격을 새 팀원이 처음부터 짤 수 있도록 다음 편에서도 같은 시선으로 풀어 보겠습니다.