몇 달에 한 번씩 더 빠른 HTML 파서가 등장하고, 벤치마크 결과가 돌고, 누군가는 기존 도구들이 구식이 됐다고 말합니다. 그런데도 모든 문단에서 특정 단어가 들어간 항목만 골라내거나, 매칭된 노드의 부모를 가져와야 하는 순간이면 왜 lxml이 여전히 다른 탭에 열려 있는지 다시 떠올리게 됩니다.
lxml은 20년 된 libxml2 바인딩입니다. 화려하지도, 새롭지도 않습니다. 하지만 딱 한 가지 작업, 즉 진짜 XPath가 필요할 때는 메인스트림 Python 생태계에서 제대로 맞붙을 상대가 없습니다. 이 글은 lxml이 무엇을 잘하는지, 조용히 어디서 이기는지, 그리고 기본값을 모르면 발목을 잡을 수 있는 지점은 어디인지 직접 써 본 관점에서 정리한 리뷰입니다.
한 문단으로 보는 lxml: 정확히 무엇인가
lxml은 C 라이브러리 libxml2와 libxslt를 Python에서 쓸 수 있게 해 주는 바인딩입니다. 스크래퍼도 아니고 브라우저도 아닙니다. 마크업을 쿼리하고 편집할 수 있는 트리로 바꾸고, 다시 바이트로 직렬화하는 파서이자 시리얼라이저입니다. ElementTree 호환 API, 완전한 XPath 1.0 엔진, XSLT 1.0, 스키마 검증 기능을 제공하며, Stefan Behnel이 "Python 언어에서 XML과 HTML을 처리하기 위한 가장 기능이 풍부하고 사용하기 쉬운 라이브러리"라는 문구 아래 유지하고 있습니다.
2026-07-14 기준으로 GitHub와 PyPI 스냅샷을 보면 현재 위치는 다음과 같습니다.
| 항목 | 값 |
|---|---|
| 저장소 | lxml/lxml |
| 별점 | 3,043 |
| 포크 | 620 |
| 열린 이슈 | 16 |
| 라이선스 | BSD-3-Clause |
| 생성일 | 2011-02-11 |
| 마지막 push | 2026-07-02 |
| PyPI 안정판 | 6.1.1 (2026-05-18) |
| 포함 엔진 | libxml2 2.14.6 + libxslt 1.1.43 |
먼저 분명히 해둘 점이 있습니다. 이 리뷰에는 숨겨진 비밀이 없습니다. lxml은 이미 오래된 프로젝트라서, 여기서 언급하는 모든 동작은 lxml 문서, libxml2 변경 로그, 또는 관련 토론 스레드 어딘가에 이미 적혀 있습니다. 문서화되지 않은 비밀 팁 같은 건 찾지 못했고, 억지로 만들어낼 생각도 없습니다. 여기서 중요한 건 새로움이 아니라, lxml을 중심으로 체계적으로 묶고 수치화해서 정리했다는 점입니다.
테스트 환경과 시간 수치가 차용된 이유
이 리뷰에는 두 종류의 데이터가 들어가며, 출처가 서로 다르기 때문에 미리 구분해 두겠습니다.
기능 테스트 — XPath 동작, 두 파서 API, 네임스페이스, 인코딩, 노드 생명주기 — 는 제 macOS arm64 머신, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6 환경에서 새로 실행했습니다. artifacts/raw/*.json 파일에 들어 있는 값은 사람이 직접 적은 게 아니라 스크립트 실행 결과로 계산된 수치입니다. 기능 테스트는 결정적인 boolean/enum 결과이므로 한 번만 돌려도 안정적이며, 머신 부하가 //a/@href가 속성 문자열을 반환하는지 여부를 바꾸지는 않습니다.
반면 시간과 메모리 수치는 이 번들에서 새로 측정한 값이 아닙니다. 이전 selectolax 벤치마크 번들에서 그대로 재사용한 값입니다. 같은 머신, 같은 가상환경, 같은 lxml/libxml2 빌드, 2026-07-13 기준 벤치마크 결과이며, 이 글에서는 다시 돌리지 않았습니다. 의도적인 선택입니다. 기능 스크립트와 시간을 동시에 재면 CPU 경합이 생겨 재사용 수치를 오염시킬 수 있고, 중복 작업이기도 합니다. lxml은 그 이전 번들에서 이미 완전히 계측된 기준 라이브러리였기 때문입니다. 같은 벤치를 재사용해야만 두 측정치를 진짜로 비교 가능한 상태로 유지할 수 있습니다. 따라서 아래에서 밀리초 수치를 볼 때는 "같은 테스트 장비, 2026-07-13 기준"으로 읽어야지, "오늘 다시 측정한 값"으로 읽으면 안 됩니다.
결과에는 신뢰 태그를 붙였습니다. 결정론적 기능 테스트는 single-observation, 재사용한 시간 분포는 triple-run, 제가 원인을 직접 분리하지 못한 가설은 hypothesis입니다.
XPath: selectolax와 BeautifulSoup이 갖지 못한 단 하나
이게 핵심이므로 먼저 보겠습니다.

lxml의 xpath()를 37개 항목 매트릭스로 검증했습니다. 사전에 등록한 테스트였고, 각 케이스의 기대 결과를 실행 전에 소스에 적어 두었기 때문에 결과를 유리하게 채점할 여지가 없었습니다. 10개 축(axis), 9개 predicate 스타일, 10개 내장 함수, 3개 스칼라 반환 타입, 그리고 XPath 2.0 전용 문법을 써서 일부러 함정을 만든 5개 케이스를 넣었습니다. lxml의 1.0 엔진이라면 당연히 거부해야 하는 것들입니다.
| 범주 | 범위 | 결과 |
|---|---|---|
| 축 | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 통과 |
| Predicate | [1] / last() / position()<n / 속성 동등 비교 / 속성 존재 / and / or / 중첩된 [.//a] / not() | 9/9 통과 |
| 함수 | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 통과 |
| 반환 타입 | boolean / number 스칼라 | 3/3 통과 |
| 함정 케이스 | matches() / sequences / if-then-else / except / syntax error | 5/5 올바르게 거부 |
점수는 37/37입니다. 그리고 중요한 건 함정 케이스입니다. matches(), sequence 표현식, if/then/else, except는 모두 XPath 2.0 문법인데, libxml2의 1.0 엔진은 이를 어설프게 지원하지 않고 XPathEvalError를 던지며 명확히 거부합니다. 즉, 잘못된 노드셋을 조용히 반환하지 않습니다. 이건 느슨하게 주워 담은 만점이 아니라, 일부러 깨보려 한 뒤 얻은 만점입니다. 여기서의 동작은 모두 lxml XPath 문서에 적힌 그대로였고, 그 점이 바로 핵심입니다.
하나 인정할 점도 있습니다. 하네스가 처음엔 잘못 계산했습니다. 제 첫 번째 예상값은 //div[.//a[@href]]에서 두 개가 나올 거라고 봤는데, 실제 실행 결과는 하나였습니다. 처음엔 lxml이 틀렸다고 30초 정도 생각했지만, fixture를 확인해 보니 두 번째 요소는 <div>가 아니라 <footer>였습니다. 기대값이 틀렸던 것이지 엔진이 틀린 게 아니었습니다. 기대값을 수정하고 그 실수는 소스 주석으로 남겨 두었습니다. 따지자면, 20년 된 C 라이브러리보다 먼저 내 테스트를 의심하는 게 맞습니다.
XPath vs CSS: CSS로는 아예 표현할 수 없는 것들
막연한 "XPath가 더 강력하다"는 말을 숫자로 보여주기 위해 차이를 측정했습니다. lxml은 .xpath()와 .cssselect()를 둘 다 제공하는데, 후자는 내부적으로 CSS를 XPath로 변환합니다. 선택 대상 10개를 두고 CSS로 실제 표현 가능한지 확인했습니다.

| 대상 | XPath | CSS (cssselect) |
|---|---|---|
텍스트 내용으로 필터링 (contains(text(),"bargain")) | 가능 | 텍스트 predicate 없음 |
자식에서 부모 선택 (//b/parent::p) | 가능 | 부모 선택자 없음 |
속성 값 반환 (//a/@href) | 가능 | 요소만 반환 |
텍스트 노드 반환 (//p/text()) | 가능 | 텍스트 노드 없음 |
ancestor 축 (//td/ancestor::div) | 가능 | 상향 탐색 불가 |
자식 수로 부모 필터링 (//ul[count(li)=4]) | 가능 | count predicate 없음 |
텍스트 길이로 필터링 (string-length(text())>5) | 가능 | 길이 predicate 없음 |
nth-child / last-child / 인접 형제 | 가능 | 가능 (기본 3개) |
10개 중 7개는 CSS로 아예 표현할 수 없습니다. 텍스트 내용 필터링, 부모나 조상으로 올라가는 탐색, 속성 값이나 텍스트 노드를 결과로 직접 가져오기, count 기반 predicate는 CSS만으로는 불가능합니다. 오직 3개(nth-child, last-child, 인접 형제)만 둘 다 가능합니다. 이게 바로 "lxml을 쓰면 실제로 무엇을 얻는가"에 대한 정량적 답입니다. selectolax는 CSS 전용이라 xpath() 메서드가 없으므로, 저 7가지 쿼리는 거기서 Python 루프로 여러 단계를 거치거나 아예 구현되지 않습니다. 선택 로직이 이런 기능에 기대고 있다면, 답은 이미 정해진 셈입니다.
(그리고 네, 하네스가 여기서도 한 번 더 잡아냈습니다. string-length(text())>5는 빈 결과일 거라고 예상했는데, 6자 문자열이 두 개 매칭됐습니다. 도구가 아니라 기대값을 수정했습니다.)
세 가지 엄격도 모드: etree, recover, lxml.html
XPath가 lxml을 선택하는 이유라면, 세 단계의 엄격도 조절은 lxml을 계속 쓰게 만드는 이유입니다.

대부분의 파서는 깨진 입력에 대해 하나의 행동만 제공합니다. lxml은 세 가지를 제공합니다. 그리고 그 동작은 충분히 예측 가능해서, 6종류의 잘못된 마크업을 각각 통과시키며 모든 경로의 기대 결과를 미리 등록해 두었습니다.
| 잘못된 입력 | lxml.etree (엄격) | etree + recover=True | lxml.html (관대) |
|---|---|---|---|
닫히지 않은 태그 <root><a>x</root> | 예외 발생 | 복구 | 수용 |
잘못 중첩된 <b><i></b></i> | 예외 발생 | 복구 | 수용 |
정의되지 않은 엔티티 | 예외 발생 | 복구 | 수용 |
단독 & (Tom & Jerry) | 예외 발생 | 복구 | 수용 |
여러 루트 <a>1</a><b>2</b> | 예외 발생 | 복구 | 수용 |
| 올바른 XML | 수용 | 수용 (오류 0개) | 수용 |
불리언 속성 <input disabled> | 예외 발생 | 복구 | 수용 |
7개 중 7개가 기대값과 일치했습니다. lxml.etree는 여섯 종류의 잘못된 입력 모두에서 XMLSyntaxError를 던집니다. 같은 파서에 recover=True를 추가하면 오류를 삼키고 사용할 수 있는 트리를 재구성합니다. 그리고 과소평가되기 쉬운 포인트가 하나 더 있습니다. parser.error_log에 삼켜진 모든 오류가 기록됩니다. lxml.html은 이런 입력도 아무 말 없이 받아들입니다.
"예외 발생 vs 복구 vs 수용"을 가르는 분류 로직은 하드코딩이 아니라 런타임 error_log 길이에 따라 결정됩니다. 그래서 recover=True로 처리한 올바른 문서는 빈 로그를 기준으로 정확히 "수용"으로 분류됩니다. 제 첫 버전은 recover=True면 무조건 "복구"로 태그를 붙여 깨끗한 입력까지 잘못 분류했는데, 실제 error_log를 읽도록 바꾸니 해결됐습니다.
실무적으로 이게 의미하는 바는 분명합니다. 깨진 피드가 있으면 실패를 크게 드러내야 할 때는 lxml.etree를 쓰십시오. 지저분한 실제 HTML을 일단 통과시켜야 할 때는 lxml.html을 쓰십시오. 그리고 대부분의 도구가 못 하는 중간 영역, 즉 "관대하게 처리하되, 무엇이 깨졌는지 정확히 알려줘서 로깅하고 싶다"면 recover=True와 error log를 활용하면 됩니다. selectolax는 관대 모드만 있고, 엄격 모드도 error log도 없습니다.
iterparse: selectolax에는 전혀 없는 스트리밍 모드
이건 속도 조절이 아니라 기능 차이입니다. selectolax는 문자열 전체를 한 번에 먹는 방식만 지원하고, 점진적 인터페이스가 없습니다. 반면 lxml의 iterparse는 요소가 닫히는 시점마다 하나씩 내보내며, 전통적인 fast_iter 패턴(elem.clear()를 호출하고 진행하면서 앞선 형제들을 삭제)을 함께 쓰면 문서가 아무리 커도 메모리가 거의 평평하게 유지됩니다.

300,000개의 <record> 요소, 총 약 15MB 규모 데이터에서 ru_maxrss로 피크 RSS를 직접 측정했습니다. 각 대상은 새 프로세스에서 독립적으로 실행했습니다.
| 모드 | 피크 RSS 증가량 | 비고 |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | 처리하면서 해제; 개수와 무관하게 평평함 |
iterparse without clear | ~386 MB | 참조를 유지; 전체 적재와 비슷하게 무거움 |
etree.parse (전체 적재, 기준) | ~386 MB | 무거운 것이 정상; 메모리 측정기의 스케일 확인용 |
bounded 모드는 전체 적재 약 386MB에 비해 피크 RSS 증가량을 약 12MB로 억제합니다. 규모 차이는 0.30.4% 수준입니다. 게다가 첫 record 이벤트는 파일 읽기가 끝나기도 전에 발생하므로, 진짜 점진적 처리이지 가짜 스트리밍이 아닙니다. 가장 교훈적인 부분은 가운데 행입니다. 같은 iterparse 루프를 돌리더라도 clear()를 건너뛰면 메모리는 다시 386MB 수준으로 올라갑니다. 모든 참조를 붙잡고 있기 때문입니다. 성능 개선은 iterparse 자체가 아니라 clear()에서 나옵니다. 전체 적재 기준이 bounded 모드보다 훨씬 높은 것도 RSS 측정기가 실제로 그 차이를 읽어내고 있음을 보여줍니다. (이 메모리 테스트는 이 번들에서 새로 실행한 것이며, 재사용한 시간 수치와는 다른 footprint 측정입니다.)
실무적으로 말하면, RAM에 못 들어가는 수 GB 규모 XML export는 selectolax로는 갈 수 있는 길이 없습니다. lxml의 스트리밍 파서를 쓰거나, 아예 다른 언어를 써야 합니다.
네임스페이스: RSS, SVG, 그리고 기본 네임스페이스 함정
네임스페이스 케이스 12개를 검사했습니다. RSS는 세 개의 네임스페이스를 가로질렀고, SVG는 기본 네임스페이스와 xlink를 함께 썼으며, 기본 네임스페이스 XML도 포함했습니다. 12개 모두 통과했습니다.
lxml은 RSS 피드에서 //dc:creator/text()를 정확히 ["Alice", "Bob"]으로 추출하고, 같은 문서에서 세 개의 별도 네임스페이스를 가로지르는 //atom:link/@href와 //content:encoded를 해석하며, SVG의 두 번째 네임스페이스에서 //s:rect와 //s:use/@xlink:href를 처리하고, Clark 표기법 {uri}local 이름을 QName으로 분해하며, nsmap으로 네임스페이스를 조회합니다. 이것이 문서화된 정상 동작이며, selectolax는 여기서 아예 닿지 못하는 영역입니다. selectolax는 HTML5 전용이고, 임의의 XML 네임스페이스를 처리하지 않기 때문입니다.
기억해 둘 만한 문서화된 함정도 하나 있습니다. XPath에는 기본 네임스페이스라는 개념이 없습니다. xmlns="urn:..."를 선언한 문서에 //book을 적용하면 한 건도 잡히지 않습니다. XPath에서 빈 prefix는 정의되지 않기 때문이며, lxml 문서에 그대로 나와 있습니다. 그래서 인위적인 prefix를 바인딩해야 합니다(namespaces={"c": "urn:..."}와 함께 //c:book, 그러면 세 개 전부 찾음). 또는 //*[local-name()='book']로 우회할 수도 있습니다(이 역시 세 개). 버그가 아니라 XPath 명세가 충실히 구현된 결과입니다. 다만 거의 모든 사람이 한 번은 놀라는 부분입니다.
실제 지저분한 페이지: 11개 실제 스크랩에서의 충실도
합성 테스트는 깨끗하지만 웹은 그렇지 않습니다. selectolax 번들의 fixture 세트에서 실제 캡처한 11개 페이지를 그대로 재사용했고(2026-07-10 기준, 읽기 전용), lxml.html을 대상으로 돌렸습니다.
| fixture | 크기 | 링크 수 | libxml2 복구 오류 | Strict XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | raised |
| wiki_scraping.html | 227 KB | 460 | 0 | raised |
| gov_whitehouse.html | 289 KB | 154 | 0 | raised |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | raised |
| forum_reddit.html | 129 KB | 318 | 0 | raised |
| docs_python.html | 80 KB | 341 | 2 | raised |
| ecommerce_books.html | 51 KB | 94 | 0 | raised |
| news_hackernews.html | 35 KB | 229 | 0 | raised |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | raised |
| spa_quotes_js.html | 6 KB | 5 | 0 | raised |
11개 모두 lxml.html로 파싱할 수 있었고, 링크/헤딩/이미지 개수는 selectolax 번들에서 재사용한 lxml 수치와 11개 모두 일치했습니다. 교차 검증 결과는 true입니다. 이 일치가 바로 재사용 수치가 진짜로 같은 조건에서 나온 것인지, 아니면 같은 라벨만 붙인 다른 측정인지 구분해 주는 기준입니다.
부수적으로 얻은 결론도 있습니다. 엄격 XML 파서는 11개 중 10개에서 예외를 던졌습니다. 실제 웹 페이지는 대개 XML로서 올바르지 않습니다. 그래서 libxml2의 HTML 복구 모드가 존재합니다. 예외적으로 BBC News만은 Next.js로 렌더링되어 엄격 XML 파서도 통과할 만큼 잘 구성돼 있었습니다. "HTML"이라고 해서 모두 복구 모드가 필요한 건 아닙니다.
헷갈리기 쉬운 카운트 차이도 하나 있습니다. docs_python.html에서 //a[@href](속성 존재)는 343개였지만, selectolax 번들의 if n.get("href")(truthy 값) 기준은 341개였습니다. 두 개 차이는 빈 href="" 링크 때문입니다. 이것은 lxml 동작 차이가 아니라 속성 존재 여부와 속성이 비어 있지 않음의 카운팅 기준 차이입니다. predicate를 맞추면 수치는 다시 일치합니다. 스크래핑할 때는 빈 href를 셀지 말지가 파서가 아니라 필터의 선택이라는 점을 기억할 만합니다.
버그처럼 보이지만 버그가 아닌 깊이 제한
selectolax 번들에는 1,000단계와 5,000단계로 중첩된 <div> 마크업에서 lxml이 가장 깊은 내용을 잘라내는 현상이 기록돼 있었고, 이를 "lxml이 가장 깊은 내용을 조용히 잃는다"고 설명했습니다. 저는 그 메커니즘을 확인하고 싶어서 기본 파서와 huge_tree=True를 비교해 봤습니다.

| 요청 깊이 | 기본 파서 도달 깊이 | huge_tree=True 도달 깊이 |
|---|---|---|
| 300 | 253 (나머지 잘림) | 299 (복구) |
| 1000 | 253 (나머지 잘림) | 999 (복구) |
| 5000 | 253 (나머지 잘림) | 2045 (여전히 잘림) |
기본 파서는 약 253단계에서 잘라 버리고 그보다 깊은 내용은 조용히 버립니다. 이건 버그가 아닙니다. libxml2의 DoS 방어책입니다. 적대적인 문서가 스택을 터뜨리지 못하게 하는 약 256단계 중첩 제한이며, lxml launchpad 스레드의 XML_PARSE_HUGE 설명에도 나와 있습니다. huge_tree=True를 주면 300과 1,000 깊이는 완전히 복구됩니다. 하지만 5,000 깊이는 huge_tree를 켜도 2,045까지만 도달합니다. 즉, 설정 가능한 제한보다 더 위에 있는 두 번째, 더 강한 libxml2 재귀 한계가 있고, huge_tree는 그것까지 없애 주지는 못합니다.
따라서 실무적으로는 분명합니다. 신뢰할 수 있는 소스에서 아주 깊은 마크업을 파싱할 때는 lxml.html.HTMLParser(huge_tree=True)를 사용하십시오. 이 번들이 재사용한 관찰 위에 덧붙여 줄 수 있는 정보는 메커니즘(데이터 손상이 아니라 안전 제한), 해결책(huge_tree), 그리고 그 해결책이 닿지 못하는 두 번째 제한의 존재입니다.
읽기/쓰기 DOM, 직렬화, 인코딩
lxml은 읽기 전용 추출기가 아니라 읽기/쓰기 트리입니다. 편집 기능도 항목별로 확인했습니다. SubElement, insert, remove, replace, strip_tags(태그만 지우고 텍스트는 유지), strip_elements(태그와 텍스트 모두 제거), drop_tree(lxml.html 전용), 그리고 초보자가 자주 헷갈리는 text/tail 이중 슬롯 모델까지 8개 모두 통과했습니다. <p>head<b>bold</b>tail</p>에서 p.text는 "head", b.text는 "bold", b.tail은 "tail"입니다.
직렬화는 5/5 통과였습니다. XML/HTML 모드의 tostring(HTML 모드에서는 void 요소가 잘못 self-close되지 않음), pretty_print, C14N canonicalization(method="c14n", 역시 lxml 전용), 그리고 깨끗한 round-trip입니다.
인코딩은 lxml이 조용히 차별화되는 지점입니다. 비-UTF-8 바이트, 예를 들어 "<p>café éè</p>".encode("latin-1")를 lxml.html.fromstring에 넣으면 café éè를 그대로 복원합니다. U+FFFD 대체 문자도 없고, 바이트가 빠지지도 않습니다. 이는 selectolax 번들에서 "기준(reference)" 역할을 했던 lxml의 강점을 그대로 재현한 것입니다. 같은 입력이 다른 두 엔진에서는 조용히 깨졌습니다(Lexbor는 대체 문자를 만들었고, Modest는 바이트를 아예 떨어뜨렸습니다). libxml2 기반의 문자셋 감지는 여기서 훨씬 안정적입니다.
반대로, 인코딩을 어떻게 선언하느냐에는 엄격합니다. XML 선언문에 encoding="latin-1"을 쓰면 XMLSyntaxError: Unsupported encoding: latin-1가 발생하지만, IANA 표준 이름인 encoding="ISO-8859-1"은 정상 파싱되고 café를 반환합니다. libxml2는 별칭이 아니라 정식 인코딩 이름만 허용합니다. launchpad #613302에 오래전부터 문서화된 내용입니다. 모르면 답답하지만, 알고 나면 사소한 문제입니다.
마지막으로 노드 생명주기입니다. 세 개의 stale-handle 시나리오를 격리된 서브프로세스에서 실행했습니다(진짜 크래시라면 비정상 종료 코드로 보였을 것입니다). 트리가 가비지 컬렉션된 뒤 노드를 유지하는 경우, drop_tree() 이후 핸들을 읽는 경우, remove() 후 노드를 사용하는 경우입니다. 세 경우 모두 segfault는 없었습니다. lxml은 use-after-free를 막기 위해 노드가 자신의 트리를 계속 참조하게 유지합니다. 이 테스트에서 selectolax도 동일하게 깨끗했습니다.
속도와 메모리: 재사용했고, 그 점도 분명히 말합니다
이 섹션의 내용은 모두 selectolax 번들에서 2026-07-13 기준으로 재사용한 것입니다. 이 번들은 자체적으로 새로운 시간 수치를 생산하지 않았고, 그 사실은 두 번 강조할 가치가 있습니다.
| 지표 | lxml 값 | 해석 |
|---|---|---|
| 순수 parse p50 (10 MB) | 77.9 ms | selectolax-Lexbor보다 약 33~34% 빠름 |
| 전체 parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | 작은 크기에서는 Lexbor와 거의 비슷 |
| 100k 노드 CSS 처리량 | 3,002,646 nodes/s | 세 C 엔진 중 최상위 |
| 10 MB RSS 증가량 | 128.9 MB | 측정한 6개 파서 중 가장 절약적, BeautifulSoup보다 약 1.7배 효율적 |
| import cold start | 14.1 ms | parsel 스타일 import보다 약 2.3배 빠름 |
순수 parse와 처리량 수치는 강력하고, lxml은 측정된 6개 파서 중 메모리 사용이 가장 적었습니다. 다만 스레딩 결과에는 주의가 필요합니다. 재사용 데이터에서는 4스레드 wall-clock 속도가 1.21배에 불과해 불확실한 결과로 표시돼 있지만, 이것은 공유 기본 파서를 쓴 경로입니다. lxml FAQ는 각 스레드가 자신만의 파서(혹은 복사한 기본 파서)를 사용할 때만 파싱 중 GIL이 해제된다고 명시합니다. 공유 파서를 쓰면 접근이 직렬화됩니다. 올바른 사용을 위한 API(XMLParser.copy(), get/set_default_parser, XPathEvaluator의 내부 락)는 구조적으로 확인했지만, 스레드별 파서를 쓸 때의 성능 향상은 새로 측정하지 않았습니다. 이 번들은 그런 새 시간 측정을 만들지 않기 때문입니다. 따라서 1.21x는 lxml의 한계가 아니라 "단순한 공유 경로에서의 결과"로 읽어야 합니다.
그리고 이 모든 수치에는 플랫폼별 주석이 붙습니다. macOS arm64 단일 플랫폼 값입니다. lxml의 순수 parse가 Lexbor보다 빠르다는 주장은 일반적으로 Lexbor 기반 파서가 가장 빠르다는 통념과 어긋나므로, 진지하게 받아들이려면 Linux x86_64에서 재검증이 필요합니다.
라이선스: 지루하지만 확실한 승리
lxml은 BSD-3-Clause로 배포되며, 포함하는 C 라이브러리인 libxml2와 libxslt는 둘 다 MIT 라이선스입니다. 즉, 어디에도 copyleft가 없는 완전한 permissive 체인입니다. 재배포가 시작되는 순간 이 차이는 중요해집니다. 비교하자면 selectolax wheel은 LGPL-2.1 Modest와 Apache-2.0 Lexbor를 포함하므로, 폐쇄형 제품에 넣어 배포할 때는 lxml 쪽이 더 깔끔한 선택입니다.
설치 측면의 실용적인 장점도 있습니다. lxml은 libxml2와 libxslt를 정적 링크한 사전 빌드 wheel을 배포하므로, 보통 pip install lxml만으로 시스템 libxml2나 컴파일러 없이 설치됩니다. 소스에서 직접 빌드하는 경험과는 꽤 다릅니다.
lxml의 역할, 그리고 AI 추출 계층이 들어오는 지점
경계를 분명히 해야 합니다. 이건 쉽게 범주 오류가 생기기 때문입니다. lxml은 파싱 라이브러리입니다. 트리를 넘겨주고, 훌륭한 쿼리 엔진도 제공합니다. 하지만 그 트리 주변의 일은 여전히 사용자의 몫입니다. 페이지 가져오기, JavaScript 렌더링, 봇 차단 우회, XPath 작성과 유지보수, 결과 구조화까지 모두 직접 해야 합니다. 이것은 호스팅된 추출 서비스와는 다른 계층이며, 둘은 경쟁자라기보다 이웃에 가깝습니다.
가져오기-렌더링-선택-유지보수 스택을 직접 관리하고 싶지 않은 개발자에게는, 그 위 계층에 Thunderbit 같은 도구가 있습니다. 이 글의 독자 기준으로는 브라우저 확장보다 API, MCP 서버, CLI가 핵심입니다. Thunderbit Open API는 POST /distill로 페이지를 깔끔한 Markdown으로 바꾸고, POST /extract로 JSON Schema를 기준으로 구조화된 데이터를 추출합니다. renderMode 스위치와 대량 처리를 위한 batch job도 제공합니다. 같은 엔진을 MCP 서버(thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract)로 에이전트와 코딩 어시스턴트에 연결할 수 있고, CLI로는 npx @thunderbit/thunderbit-cli를 통해 터미널에서 바로 실행할 수 있습니다. JS 렌더링, 봇 차단, CAPTCHA를 기본적으로 처리하고, 스키마에 맞는 JSON을 반환합니다. 즉, 파싱을 대체하는 게 아니라 그 위 계층입니다.
웹 데이터 추출을 위해 Thunderbit 사용해 보기
정리는 단순합니다. 파이프라인을 직접 소유하고, 이해하고 있는 트리에 대해 정교한 XPath 제어가 필요하면 lxml을 쓰십시오. 셀렉터와 렌더링을 아예 유지하고 싶지 않다면 AI 추출 API를 쓰면 됩니다. 실제 시스템 중에는 두 방식을 함께 쓰는 경우도 많습니다. 직접 제어하는 구조화 피드에는 lxml을, 다루기 까다로운 장기 꼬리 페이지에는 추출 서비스를 쓰는 식입니다.
이 리뷰가 테스트하지 않은 것
이 글은 최종 성적표가 아니라 예비 리뷰이므로, 다루지 않은 부분도 분명히 적어 둡니다.
모든 시간 및 메모리 수치는 재사용된 것이고, 단일 플랫폼(macOS arm64, Python 3.14) 기준이며, 해당 번들의 주의사항을 그대로 가집니다. "lxml이 순수 parsing에서 더 빠르다"는 결과는 일반적인 통념과 반대이므로 Linux x86_64에서 재검증이 필요합니다. 스레드별 parser 속도 향상은 테스트하지 않았습니다(새 시간 측정이 필요하기 때문입니다). 300k record에서 iterparse 메모리는 측정했지만, GB 단위 실제 XML, HTML과 XML의 iterparse 차이, 장시간 soak 테스트는 하지 않았습니다. lxml의 XSLT 1.0, RelaxNG / XMLSchema / DTD 검증, EXSLT 확장은 전혀 테스트하지 않았습니다. 이것들은 큰 기능 범위이지만, 여기서는 파싱과 선택의 핵심을 벗어납니다. 2,045에서 두 번째 깊이 제한을 관찰했지만 libxml2의 정확한 recursion 상수까지는 특정하지 않았습니다. 테스트한 것은 안정판 6.1.1뿐이며 7.0.0 alpha는 다루지 않았습니다. Windows, 소스 빌드, free-threaded 3.14t 빌드도 미검증입니다. XPath 내부에서도 내장 함수는 확인했지만, XPath 변수, 사용자 정의 Python 확장 함수, 사전 컴파일된 etree.XPath 객체 재사용은 다루지 않았습니다.
결론
lxml은 새롭고 빠른 유행템이 아닙니다. 바로 그 점이 추천 이유입니다. 이것은 20년 된 libxml2 바인딩이지만, 메인스트림 Python 대안이 따라오지 못하는 완전한 XPath 1.0 엔진, 예측 가능한 3단계 파싱 엄격도와 중간의 error log, 메모리에 안 들어가는 문서를 위한 진짜 스트리밍 파서, 여러 네임스페이스와 인코딩 처리의 정확성, 그리고 완전한 permissive 라이선스를 갖고 있습니다. 약간 날카로운 부분인 약 253단계 깊이 제한과 공유 파서 스레딩 수치는 문서화되어 있고, 조정 가능하며, 지금은 그 이유까지 설명됩니다.
스크래핑 파이프라인을 직접 소유하고 XPath에 의존한다면, 지금도 lxml이 먼저 떠올려야 할 파서입니다. 셀렉터와 렌더링을 직접 유지하고 싶지 않다면 Thunderbit API, MCP, CLI 같은 AI 추출 계층이 그 역할을 합니다. 경쟁이라기보다 역할 분담에 가깝습니다. 어느 쪽이든 이 수치는 예비 값으로 보고, 설계 문서에 인용하기 전에 자신의 플랫폼에서 다시 확인하십시오.
웹 데이터 추출을 위해 Thunderbit 사용해 보기 Get Started Free
자주 묻는 질문
lxml은 웹 스크래퍼인가요?
아닙니다. lxml은 파서이자 시리얼라이저입니다. libxml2/libxslt에 대한 Python 바인딩으로, 마크업을 편집 가능하고 쿼리 가능한 트리로 바꿉니다. 페이지를 가져오거나, JavaScript를 렌더링하거나, 봇 차단을 처리하지는 않습니다. 요청 계층은 사용자가 제공해야 하며(requests, httpx, 헤드리스 브라우저, 또는 스크래핑 서비스), 바이트 데이터를 lxml에 넘기면 됩니다.
BeautifulSoup이나 selectolax 대신 lxml을 써야 하는 경우는 언제인가요? XPath가 필요할 때 lxml을 선택하십시오. BeautifulSoup은 내부적으로 lxml을 백엔드 파서로 사용할 수는 있지만 네이티브 XPath는 제공하지 않습니다. selectolax는 CSS 전용이고 좁은 영역에서만 더 빠릅니다. 선택 로직에 텍스트 내용 필터링, 부모/조상 탐색, 속성/텍스트 노드 추출, count predicate가 필요하다면, 이를 직접 표현할 수 있는 메인스트림 Python 옵션은 lxml의 XPath 엔진뿐입니다.
왜 lxml은 깊게 중첩된 내용을 조용히 버리나요?
기본 파서가 약 253단계 중첩을 상한으로 두기 때문입니다. 버그가 아니라 적대적 문서를 막기 위한 libxml2의 DoS 방어책입니다. huge_tree=True(예: lxml.html.HTMLParser(huge_tree=True))를 주면 300과 1,000 깊이는 완전히 복구됩니다. 다만 huge_tree로도 없앨 수 없는 약 2,045단계의 더 강한 두 번째 recursion 상한이 있습니다.
lxml은 멀티스레드 파싱에서 GIL을 해제하나요? 조건이 맞아야만 그렇습니다. lxml FAQ에 따르면 각 스레드가 자신만의 파서 또는 복사된 기본 파서를 사용할 때 파싱 중 GIL이 해제됩니다. 공유 파서를 쓰면 접근이 직렬화됩니다. 재사용된 4스레드 속도 향상 1.21배는 단순한 공유 파서 경로의 결과이며, 여기서는 측정하지 않은 스레드별 파서 한계는 반영하지 않습니다.
2026년에도 lxml은 여전히 유지보수되나요? 그렇습니다. 안정판 6.1.1은 2026-05-18에 배포되었고, 저장소는 2026-07-02에 마지막으로 push되었으며, 7.0.0 alpha도 진행 중입니다. GitHub 별점은 약 3,000개이고 아래에는 활발히 유지보수되는 libxml2가 있으므로, 여전히 현재형이고 지원이 잘 되는 라이브러리이지 레거시가 아닙니다.


