몇 달에 한 번씩 더 빠른 HTML 파서가 나오고, 벤치마크가 돌고, 누군가는 기존 강자들은 이제 끝났다고 말합니다. 그런데 막상 특정 단어를 포함한 모든 문단을 골라내거나, 매칭된 노드의 부모를 가져오려는 순간, 왜 lxml이 여전히 다른 탭에서 열려 있는지 떠올리게 됩니다.
lxml은 20년 된 libxml2 바인딩입니다. 화려하지도 않고, 새롭지도 않습니다. 하지만 딱 한 가지 작업, 즉 제대로 된 XPath가 필요한 일에서는 주류 Python 생태계에서 lxml과 붙어볼 만한 다른 선택지가 사실상 없습니다. 이 글은 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 |
| 마지막 푸시 | 2026-07-02 |
| PyPI 안정 버전 | 6.1.1 (2026-05-18) |
| 번들 엔진 | libxml2 2.14.6 + libxslt 1.1.43 |
먼저 짚고 넘어갈 점이 하나 있습니다. 이 리뷰에는 숨겨진 비밀이 없습니다. lxml은 충분히 오래된 라이브러리라, 여기서 말하는 모든 동작은 lxml 문서, libxml2 변경 로그, 또는 launchpad 스레드 어딘가에 이미 적혀 있습니다. 문서에 없는 독점적인 꼼수는 찾지 못했고, 억지로 만들어낼 생각도 없습니다. 아래 내용의 가치가 있다면, 그건 새로운 소식이라서가 아니라 체계적으로 정리했고, 수치화했고, 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개 항목 매트릭스에 넣어 검증했습니다. 각 케이스의 기대 결과를 테스트 실행 전에 소스에 적어 두었기 때문에, 결과를 유리하게 채점할 가능성 자체를 없앴습니다. 축(axis) 10개, predicate 스타일 9개, 내장 함수 10개, 스칼라 반환형 3개, 그리고 lxml의 1.0 엔진이 거부해야 하는 XPath 2.0 전용 문법 함정 5개를 넣었습니다.
| 범주 | 범위 | 결과 |
|---|---|---|
| 축(axis) | 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() / sequence / 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 문서에 설명된 그대로였고, 바로 그 점이 중요합니다.
하나 인정할 게 있습니다. 이 하네스가 처음엔 틀렸는데, 그래야 비로소 진짜 믿을 수 있는 37/37이 됩니다. //div[.//a[@href]]에 대한 제 첫 기대값은 두 개였습니다. 그런데 실제 실행 결과는 하나였죠. 처음엔 lxml이 잘못했다고 30초쯤 생각했지만, fixture를 확인해 보니 두 번째 요소는 <div>가 아니라 <footer>였습니다. 잘못은 엔진이 아니라 제 기대값에 있었던 겁니다. 기대값을 수정하고, 그 실수를 소스 주석에 남겼습니다. 오래된 C 라이브러리보다 먼저 의심해야 하는 건 언제나 자기 테스트입니다.
XPath vs CSS: CSS로는 문자 그대로 표현할 수 없는 것들
“XPath가 더 강력하다”는 추상적인 말도 구체적인 수치로 보여 줄 필요가 있습니다. lxml은 .xpath()와 .cssselect()를 모두 제공하는데, 후자는 내부적으로 CSS를 XPath로 변환합니다. 그래서 선택 대상 10개를 정하고, CSS로 실제 표현 가능한지 검증했습니다.

| 대상 | XPath | CSS (cssselect) |
|---|---|---|
텍스트 내용으로 필터링(contains(text(),"bargain")) | 가능 | 텍스트 predicate 없음 |
자식에서 부모 선택(//b/parent::p) | 가능 | parent 선택자 없음 |
속성값 반환(//a/@href) | 가능 | 요소만 반환 |
텍스트 노드 반환(//p/text()) | 가능 | 텍스트 노드 없음 |
ancestor 축(//td/ancestor::div) | 가능 | 상위 탐색 불가 |
자식 개수로 부모 필터링(//ul[count(li)=4]) | 가능 | count predicate 없음 |
텍스트 길이로 필터링(string-length(text())>5) | 가능 | 길이 predicate 없음 |
nth-child / last-child / adjacent sibling | 가능 | 가능(기본 3개) |
10개 대상 중 7개는 CSS로는 아예 표현할 수 없습니다. 텍스트 내용 필터링, 부모와 조상으로 올라가는 탐색, 속성값이나 순수 텍스트 노드를 결과로 직접 뽑아내기, 개수 기반 predicate 같은 것은 CSS가 말할 수 없습니다. 둘 다 가능한 건 nth-child, last-child, adjacent sibling 정도뿐입니다. 이것이 바로 “lxml을 쓰면 무엇이 달라지냐”에 대한 수치화된 답입니다. selectolax는 CSS 전용이며 xpath() 메서드가 없으므로, 이런 7가지 쿼리는 거기서 Python 루프로 여러 단계로 풀어야 하거나 아예 불가능합니다. 스크래핑 로직이 이런 표현들에 기대고 있다면, 선택은 사실상 끝난 셈입니다.
(그리고 네, 여기서도 하네스가 저를 한 번 더 잡았습니다. string-length(text())>5를 빈 집합으로 예측했는데, 여섯 글자 문자열 두 개가 매칭됐습니다. 도구가 아니라 기대값을 고쳤습니다.)
엄격성의 세 기어: etree vs recover vs lxml.html
XPath가 lxml을 고르는 이유라면, 세 단계의 엄격성 제어는 그 선택을 계속 유지하게 만드는 이유입니다.

대부분의 파서는 깨진 입력에 대해 하나의 동작만 제공합니다. lxml은 세 가지를 제공하고, 그 차이가 충분히 예측 가능해서 6종의 잘못된 마크업을 각각 통과시킨 뒤, 각 경로가 어떻게 동작해야 하는지 미리 등록해 두고 검증했습니다.
| 잘못된 입력 | lxml.etree (strict) | etree + recover=True | lxml.html (lenient) |
|---|---|---|---|
닫히지 않은 태그 <root><a>x</root> | 예외 발생 | 복구 | 허용 |
중첩이 꼬인 태그 <b><i></b></i> | 예외 발생 | 복구 | 허용 |
정의되지 않은 엔티티 | 예외 발생 | 복구 | 허용 |
단독 & (Tom & Jerry) | 예외 발생 | 복구 | 허용 |
여러 루트 <a>1</a><b>2</b> | 예외 발생 | 복구 | 허용 |
| 올바른 XML | 허용 | 허용(오류 0개) | 허용 |
불리언 속성 <input disabled> | 예외 발생 | 복구 | 허용 |
7개 중 7개가 미리 정한 기대와 일치했습니다. lxml.etree는 이 6종의 잘못된 클래스 모두에서 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> 요소, 총 약 26.7 MB(26,744,801 bytes) 기준으로 ru_maxrss를 통해 피크 RSS를 확인했습니다.
| 모드 | 피크 RSS 증가량 | 비고 |
|---|---|---|
iterparse + clear (fast_iter) | 약 1-2 MB | 처리하면서 해제; 개수와 무관하게 평탄 |
iterparse without clear | 약 386 MB | 참조를 유지함; 전체 로드만큼 무거움 |
etree.parse (전체 로드, 기준값) | 약 386 MB | 무거운 기준값; 메터가 실제 크기를 읽는지 검증 |
제한된 모드는 피크 RSS 증가량을 전체 로드의 약 386 MB에 비해 대략 1-2 MB 수준으로 묶어 둡니다. 규모로 보면 0.3-0.4% 차이입니다. 게다가 첫 번째 record 이벤트는 파일 읽기가 끝나기도 전에 발생하므로, 가짜 스트리밍이 아니라 진짜 점진 처리입니다. 가장 교훈적인 줄은 가운데 줄입니다. 똑같은 iterparse 루프를 돌리되 clear()만 빼면 메모리는 다시 약 386 MB까지 치솟습니다. 모든 것을 참조로 붙잡고 있기 때문입니다. 즉, 이득은 iterparse 자체가 아니라 clear()에서 나옵니다. 전체 로드 기준값이 제한 모드보다 훨씬 높다는 사실은 RSS 메터가 실제 크기 차이를 읽고 있음을 보여 주는 추가 확인이기도 합니다. (이 메모리 테스트는 이번 패키지에서 제가 새로 실행한 것입니다. 타이밍 수치와는 다른 종류의 footprint 측정입니다.)
실무에서는 이렇습니다. RAM에 올라가지 않는 수 GB짜리 XML export는 selectolax로는 아예 방법이 없습니다. lxml의 스트리밍 파서를 쓰거나, 다른 언어를 써야 합니다.
네임스페이스: RSS, SVG, 그리고 기본 네임스페이스 함정
네임스페이스 12개 케이스를 검사했습니다. 세 개 네임스페이스가 섞인 RSS, 기본 네임스페이스와 xlink가 함께 있는 SVG, 기본 네임스페이스 XML까지 포함했습니다. 결과는 전부 통과였습니다.
lxml은 RSS 피드에서 //dc:creator/text()를 정확히 ["Alice", "Bob"]로 꺼내고, 같은 문서 안의 서로 다른 세 네임스페이스를 가로질러 //atom:link/@href와 //content:encoded를 해석하며, SVG의 두 번째 네임스페이스에서 //s:rect와 //s:use/@xlink:href를 처리합니다. QName으로 Clark 표기법 {uri}local 이름을 분리하고, nsmap으로 네임스페이스를 조회합니다. 이것은 유지 관리되고 문서화된 동작이며, arbitrary XML 네임스페이스를 다루지 않는 HTML5 전용 selectolax가 건드리지 못하는 완전히 다른 차원의 기능입니다.
기억해 둘 만한 문서화된 함정도 하나 있습니다. XPath에는 기본 네임스페이스라는 개념이 없습니다. xmlns="urn:..."를 선언한 문서에 //book을 쓰면 결과는 0개입니다. lxml 문서가 설명하듯, XPath 입장에서는 빈 prefix가 정의되어 있지 않기 때문입니다. 인위적인 prefix를 바인딩해서 //c:book처럼 써야 하며 (namespaces={"c": "urn:..."}), 그러면 3개를 찾습니다. 또는 //*[local-name()='book']로 돌아갈 수도 있습니다. 이 경우도 3개입니다. 버그가 아니라 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였습니다. 이 일치가 있어야 재사용이 진짜 같은 조건 비교라는 걸 알 수 있고, 같은 라벨을 단 두 개의 다른 측정값이 아니라는 점이 확인됩니다.
보조 결과도 하나 있습니다. strict XML 파서는 11개 중 10개에서 예외를 냈습니다. 현실의 웹페이지는 압도적으로 well-formed XML이 아니기 때문에, libxml2의 HTML 복구 모드가 존재합니다. 유일한 예외는 Next.js로 렌더링된 BBC News였고, strict XML로도 버틸 만큼 잘 형식화되어 있었습니다. “HTML”이라고 적혀 있다고 해서 꼭 복구 모드가 필요한 건 아닙니다.
하나 놓치기 쉬운 카운팅 노트도 있습니다. docs_python.html에서 //a[@href](속성 존재)는 343개로 계산됐고, selectolax 패키지의 if n.get("href")(truthy 값) 카운트는 341개였습니다. 추가된 두 개는 빈 href="" 링크입니다. 이는 lxml 동작 차이가 아니라, 속성 존재 여부와 속성의 비어 있지 않음을 어떻게 셀지에 대한 기준 차이입니다. 스크래핑할 때 기억할 점은, 빈 href를 포함할지 말지는 파서가 아니라 필터의 선택이라는 것입니다.
버그처럼 보이지만 버그가 아닌 깊이 제한
selectolax 패키지에서는 lxml이 1,000단계와 5,000단계로 중첩된 <div> 마크업에서 가장 깊은 내용을 놓치는 것으로 기록되어 있었고, 이를 “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 이중 슬롯 모델까지 모두 통과했습니다. 예를 들어 <p>head<b>bold</b>tail</p>에서 p.text는 "head", b.text는 "bold", b.tail은 "tail"입니다.
직렬화는 5개 중 5개 통과였습니다. XML과 HTML 모드의 tostring(HTML에서는 void element가 자기 닫힘으로 잘못 바뀌지 않음), pretty_print, C14N canonicalization(method="c14n", 역시 lxml 전용), 그리고 깨끗한 round-trip까지 확인했습니다.
인코딩은 lxml이 조용히 차별화되는 지점입니다. "<p>café éè</p>".encode("latin-1") 같은 비-UTF-8 바이트를 lxml.html.fromstring에 넣어도 café éè를 그대로 복원합니다. U+FFFD 대체 문자도 없고, 바이트가 날아가거나 하지도 않습니다. 이는 selectolax 패키지에서 lxml이 “깨끗한 기준값”이었던 역할을 그대로 재현한 것입니다. 같은 입력이 다른 두 엔진에서는 조용히 손상됐습니다(Lexbor는 대체 문자를 만들었고, Modest는 바이트를 아예 버렸습니다). libxml2 기반의 문자셋 감지는 이 부분에서 훨씬 안정적입니다.
반대편도 있습니다. 인코딩을 어떻게 선언하느냐에는 엄격합니다. XML 선언에서 encoding="latin-1"를 쓰면 XMLSyntaxError: Unsupported encoding: latin-1가 발생하지만, IANA 표준 이름인 encoding="ISO-8859-1"는 정상 파싱되고 café를 돌려줍니다. libxml2는 별칭이 아니라 정식 canonical 이름만 허용합니다. launchpad #613302에 오래전부터 적혀 있는 세부 사항입니다. 모르고 있으면 짜증 나지만, 알고 나면 별일 아닙니다.
마지막은 노드 생명주기입니다. 세 가지 stale-handle 상황을 분리된 subprocess에서 돌렸습니다(심각한 크래시는 non-zero exit로 보입니다). 트리가 garbage-collected 된 뒤 노드를 잡고 있기, drop_tree() 이후 핸들 읽기, remove() 이후 노드 사용하기. 어느 경우에도 segfault는 없었습니다. lxml은 use-after-free를 막기 위해 노드가 트리를 참조하는 관계를 유지합니다. 이 테스트에서도 selectolax와 같은 합격점을 받았습니다.
속도와 메모리(빌려왔지만, 솔직하게)
이 섹션의 모든 내용은 2026-07-13 기준으로 selectolax 패키지에서 재사용한 것입니다. 이번 패키지는 타이밍 수치를 하나도 새로 만들지 않았고, 그 사실을 두 번 말하는 편이 낫습니다.
| 항목 | lxml 값 | 해석 |
|---|---|---|
| 순수 파싱 p50 (10 MB) | 77.9 ms | selectolax-Lexbor보다 약 33-34% 빠름 |
| 전체 파싱 + 추출 p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | 작은 크기에서는 Lexbor와 거의 비슷 |
| 10만 노드 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배 빠름 |
순수 파싱과 처리량 수치는 강합니다. lxml은 측정된 6개 파서 중 메모리를 가장 아끼는 쪽이기도 합니다. 다만 스레딩 그림에는 단서가 필요합니다. 재사용한 데이터에서 4개 스레드의 wall-clock speedup은 1.21x에 그쳤고, 결론은 불명확(inconclusive)로 표시되었습니다. 하지만 이것은 공유 기본 파서를 쓰는 경로입니다. lxml FAQ는 각 스레드가 자기 파서(또는 복사한 기본 파서)를 사용할 때만 파싱 중 GIL이 해제된다고 분명히 밝힙니다. 공유 파서는 접근을 직렬화합니다. 이를 제대로 하는 API 표면(XMLParser.copy(), get/set_default_parser, 내부 락을 가진 XPathEvaluator)은 구조적으로 확인했지만, 스레드별 파서에서의 실제 속도 향상은 새로 측정하지 않았습니다. 이번 패키지에서는 그런 신규 타이밍을 만들지 않기 때문입니다. 따라서 1.21x는 lxml의 한계라기보다 “순진한 공유 경로에서의 수치”로 읽어야 합니다.
또 하나의 별표가 있습니다. 이 숫자들은 모두 단일 플랫폼, macOS arm64 기준입니다. lxml의 순수 파싱이 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 작성과 유지보수, 결과 구조화까지 모두요. 이것은 호스팅된 추출 서비스와는 다른 층위이며, 둘은 경쟁자라기보다 이웃에 가깝습니다.
개발자가 fetch-render-select-maintain 스택 전체를 직접 떠안고 싶지 않다면, 그 상위 계층에 Thunderbit 같은 서비스가 있습니다. 이 독자층에 맞는 건 브라우저 확장보다는 API, MCP 서버, CLI입니다. Thunderbit Open API는 POST /distill로 페이지를 깔끔한 Markdown으로 바꾸고, POST /extract로 JSON Schema를 기준으로 구조화된 데이터를 추출합니다. renderMode 스위치와 대량 처리를 위한 batch 작업도 제공합니다. 같은 엔진은 에이전트와 코딩 어시스턴트를 위한 MCP 서버(thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract)와, 터미널에서 바로 실행할 수 있는 npx @thunderbit/thunderbit-cli 형태의 CLI로도 사용할 수 있습니다. JS 렌더링, anti-bot, CAPTCHA를 기본적으로 처리하고, 스키마에 맞는 JSON을 반환합니다. 즉, 파싱 위의 계층이지 대체재가 아닙니다.
프레이밍은 단순합니다. 트리를 이해하고 있고 수술하듯 정교한 XPath 제어가 필요하다면 lxml을 고르세요. 셀렉터와 렌더링을 직접 유지보수하고 싶지 않다면 AI 추출 API를 고르세요. 실제 시스템들은 두 방식을 함께 씁니다. 구조가 잘 잡힌 피드에는 lxml을, 지저분하고 긴 꼬리의 페이지에는 추출 서비스를 쓰는 식입니다.
이 리뷰에서 테스트하지 않은 것
이건 최종 점수표가 아니라 잠정 리뷰입니다. 따라서 커버하지 못한 부분도 분명히 적어 둡니다.
모든 타이밍과 메모리 수치는 재사용된 단일 플랫폼(macOS arm64, Python 3.14) 기준이며, 해당 패키지의 주의사항을 그대로 계승합니다. “lxml이 순수 파싱에서 더 빠르다”는 결과는 일반적 합의와 반대이므로 Linux x86_64에서 재검증이 필요합니다. 스레드별 파서의 성능 향상은 테스트하지 않았습니다(새 타이밍 측정이 필요합니다). 300k 레코드에서 iterparse 메모리는 측정했지만, GB 규모 실제 XML은 아니고, HTML 대비 XML의 iterparse 차이도, 장시간 soak 테스트도 하지 않았습니다. lxml의 XSLT 1.0, RelaxNG / XMLSchema / DTD 검증, EXSLT 확장은 여기서 전혀 테스트하지 않았습니다. 파싱과 선택의 핵심은 아니지만 기능 범위는 큽니다. 두 번째 깊이 한계가 2,045라는 점은 확인했지만, libxml2의 정확한 재귀 상수는 특정하지 못했습니다. 안정 버전 6.1.1만 테스트했으며, 7.0.0 alpha는 보지 않았습니다. Windows, 소스 빌드, free-threaded 3.14t 빌드도 테스트하지 않았습니다. XPath 자체에 대해서도 내장 함수는 확인했지만, XPath 변수, 사용자 정의 Python 확장 함수, 미리 컴파일된 etree.XPath 객체 재사용은 다루지 않았습니다.
결론
lxml은 새롭고 빠른 최신 물건이 아닙니다. 바로 그 점이 추천 이유입니다. 이것은 20년 된 libxml2 바인딩이지만, 주류 Python 대안으로는 따라올 수 없는 완전한 XPath 1.0 엔진을 갖고 있고, 세 단계로 예측 가능한 파싱 엄격성 제어와 그 중간에 있는 error log, 메모리에 안 들어가는 문서를 위한 진짜 스트리밍 파서, 멀티 네임스페이스와 인코딩의 올바른 처리, 그리고 완전히 permissive한 라이선스를 제공합니다. 약간 날카로운 부분 — 약 253단계 깊이 제한과 공유 파서 스레딩 수치 — 도 문서화되어 있고, 설정 가능하며, 이제는 그 이유도 설명할 수 있습니다.
스크래핑 파이프라인을 직접 소유하고 XPath를 적극적으로 쓴다면, 여전히 lxml이 먼저 손이 가는 파서입니다. 셀렉터와 렌더링을 직접 관리하고 싶지 않다면, Thunderbit API, MCP, CLI 같은 AI 추출 레이어가 그 역할입니다. 경쟁 관계라기보다 역할 분담에 가깝습니다. 어느 쪽이든, 이 숫자들은 잠정값으로 보고, 설계 문서에 적기 전에 본인 플랫폼에서 다시 확인하세요.
웹 데이터 추출용 Thunderbit 체험하기 Get Started Free
자주 묻는 질문
lxml은 웹 스크래퍼인가요?
아니요. lxml은 파서이자 serializer입니다. libxml2/libxslt에 대한 Python 바인딩으로, 마크업을 편집하고 쿼리 가능한 트리로 바꿔 줍니다. 페이지를 가져오거나, JavaScript를 렌더링하거나, anti-bot 방어를 처리하지는 않습니다. 요청 계층(requests, httpx, 헤드리스 브라우저, 또는 스크래핑 서비스)을 직접 준비한 뒤 그 바이트를 lxml에 넘겨야 합니다.
BeautifulSoup이나 selectolax 대신 언제 lxml을 써야 하나요?
lxml이 필요한 순간은 XPath가 필요할 때입니다. BeautifulSoup도 백엔드 파서로 lxml을 사용할 수는 있지만, 네이티브 XPath는 제공하지 않습니다. selectolax는 CSS 전용이고 그 좁은 영역에서 더 빠릅니다. 선택 로직이 텍스트 내용 필터링, 부모/조상 탐색, 속성/텍스트 노드 추출, 개수 기반 predicate를 필요로 한다면, 그것들을 직접 표현할 수 있는 주류 Python 옵션은 lxml의 XPath 엔진뿐입니다.
lxml이 깊게 중첩된 콘텐츠를 조용히 버리는 이유는 무엇인가요?
기본 파서가 약 253단계에서 중첩을 제한하기 때문입니다. 이건 버그가 아니라 libxml2의 DoS 방어입니다. huge_tree=True(예: lxml.html.HTMLParser(huge_tree=True))를 설정하면 300과 1,000 깊이는 정상적으로 복구됩니다. 다만 huge_tree로도 지워지지 않는 약 2,045단계의 두 번째, 더 강한 재귀 한계가 있습니다.
lxml은 멀티스레드 파싱에서 GIL을 해제하나요?
조건부로만 그렇습니다. lxml FAQ에 따르면 각 스레드가 자기 파서 또는 복사한 기본 파서를 사용할 때 파싱 중 GIL이 해제됩니다. 공유 파서를 쓰면 접근이 직렬화됩니다. 이번에 재사용한 4스레드 speedup 1.21x는 순진한 공유 파서 경로를 반영한 것이고, 스레드별 파서의 상한은 측정하지 않았습니다.
2026년에도 lxml은 아직 유지보수되고 있나요?
네. 안정 릴리스 6.1.1은 2026-05-18에 배포되었고, 저장소는 2026-07-02에 마지막으로 푸시되었으며, 7.0.0 alpha도 진행 중입니다. GitHub 스타가 약 3,000개이고 기반의 libxml2도 적극적으로 유지보수되고 있으므로, 과거 유물이라기보다 현재형 라이브러리라고 보는 편이 맞습니다.


