PyQuery는 jQuery 문법을 더하면서도 이번 벤치마크에서는 선택 기준에 영향을 줄 만큼의 오버헤드가 없었다

최종 업데이트: August 18, 2026
PyQuery는 jQuery 문법을 더하면서도 이번 벤치마크에서는 선택 기준에 영향을 줄 만큼의 오버헤드가 없었다
AI 요약
PyQuery는 lxml 위에 jQuery 스타일 API를 얹는다. 1 KB부터 10 MB까지 다섯 가지 페이지 크기를 비교한 결과, 표시된 모든 중앙값이 raw lxml보다 낮았고, 가장 큰 크기에서는 1.5% 차이였다. 다만 이 벤치마크가 래퍼가 더 빠르다는 것을 입증한 것은 아니다. selector와 read 작업을 바꿀 만큼의 차이는 없었다. 표시된 중앙값 기준으로는 10 KB 이상에서 selectolax와도 몇 퍼센트 이내의 근접한 결과를 보였다. 사전 정의된 동등성 범위가 없다면, 이는 통계적 동률이 아니라 근접 결과로 봐야 한다.

PyQuery는 lxml 위에 jQuery 스타일 API를 얹은 라이브러리다. 1 KB부터 10 MB까지 다섯 가지 페이지 크기를 비교해 본 결과, 표시된 다섯 개 중앙값이 모두 raw lxml보다 낮았고, 가장 큰 크기에서는 차이가 1.5%에 불과했다. 그렇다고 이 벤치마크가 래퍼가 더 빠르다는 걸 입증한 건 아니다. selector-and-read 선택을 바꿀 만큼의 차이도 보이지 않았다.

10 KB 이상 구간의 표시된 중앙값에서는 selectolax와도 몇 퍼센트 안쪽의 차이를 보였다. 사전에 정해 둔 동등성 기준이 없다면, 이건 통계적으로 완전히 같은 결과라기보다 그냥 꽤 비슷한 결과로 보는 편이 맞다.

PyQuery란 무엇인가

PyQuery는 Python에서 lxml 문서 트리에 jQuery의 선택자·체이닝 API를 제공하는 라이브러리다. 테스트한 버전은 2.1.0이고, BSD 라이선스를 쓴다. GitHub 스타는 2,380개, 열려 있는 이슈는 59개, 마지막 푸시는 2026-07-27이다.

공식 문서: PyQuery documentation.

System diagram: jQuery Syntax Over lxml

from pyquery import PyQuery as pq
d = pq(html)
titles = [e.text_content() for e in d("h3.title")]
hrefs = [e.get("href") for e in d("a")]

PyQuery가 돌려주는 요소는 lxml 요소라서, lxml로 할 수 있는 건 그대로 쓸 수 있다. 이게 바로 PyQuery의 설계 의도다. 즉, 파서를 대체하는 도구가 아니라 사용성을 높이는 레이어다. pip install pyquery를 하면 3개 패키지 — lxml, cssselect, PyQuery 자체 — 가 설치되고, 용량은 20.1 MiB다. 거의 전부가 lxml의 컴파일된 확장 모듈이다.

Node에서 cheerio를 써 본 적이 있다면, Python에서 비슷한 API 감각이라고 보면 된다. 여기서 검증한 두 선택자는 양쪽 모두에서 동작했다. 다만 이 테스트만으로 cssselect와 cheerio 사이의 선택자 언어가 완전히 같다고 단정할 수는 없다.

측정 방식

이 연구 베이스에는 많은 벤치마크가 갖추지 못한 장치가 이미 있었다. 바로 추출 결과를 해시로 검증하는 패리티 게이트다. 정렬된 titles와 정렬된 hrefs를 기준 파서와 비교해 해시를 맞추기 때문에, 덜 일한 라이브러리가 괜히 빠른 기록을 내는 일이 없다. 페이지 크기 5종, 각 50회 반복, 독립 실행 3회였다.

여기에 PyQuery를 추가하려면 두 가지가 필요했다.

기준 재실행. selectolax를 같은 프로세스에서 다시 돌렸다. 콘텐츠 해시는 5개 크기 모두에서 재현됐고, p50은 공개된 수치의 0.989×~1.079× 범위였다. 즉, 같은 머신과 같은 테스트 환경이라는 뜻이다.

lxml도 같은 프로세스에서 다시 실행. 공개 벤치마크는 머신과 Python 버전은 기록하지만 라이브러리 버전은 기록하지 않는다. 그래서 해당 lxml 행이 PyQuery가 감싸는 현재 lxml과 다른 릴리스에서 나온 것일 가능성을 배제할 수 없다. 그 상태로 비교하면 서로 다른 lxml 버전을 비교해 놓고 래퍼 비용이라고 오해할 수 있다. 같은 프로세스에서 함께 실행하면 그 질문이 사라진다. 이번 venv에서는 둘 다 lxml 6.1.1이다.

페이지 크기selectolaxPyQuerylxmlPyQuery vs lxml
1 KB0.0286 ms0.0456 ms0.0508 ms0.90×
10 KB0.1725 ms0.1728 ms0.1802 ms0.96×
100 KB1.4855 ms1.4093 ms1.4145 ms1.00×
1 MB14.97 ms14.96 ms15.03 ms1.00×
10 MB158.10 ms162.86 ms165.25 ms0.99×

p50 밀리초, 3회 실행의 중앙값, 모두 한 프로세스에서 측정. parser-bench.json. 모든 크기에서 세 번의 콘텐츠 해시가 기준과 일치했다.

존재하지 않는 래퍼 비용

Measured results chart: PyQuery and lxml on the same fixture

PyQuery는 표시된 모든 중앙값에서 raw lxml과 같거나 더 낮았다. 그렇다고 래퍼가 파싱을 더 빠르게 만든다는 증거는 아니다. 3회 실행의 중앙값과 사전 정의된 동등성 범위가 없기 때문에, 이 고정된 조건에서는 selector 오버헤드가 판단에 영향을 줄 수준은 아니었다고 해석하는 게 맞다.

10 MB에서는 PyQuery의 3회 측정값이 162.86, 163.17, 161.13 ms였고, lxml은 169.46, 165.25, 164.18 ms였다. 범위는 가깝지만 겹치지 않는다. 1 MB에서는 두 중앙값 차이가 0.5%였다. 이 정도의 작은 반복 측정은 실무 판단에는 충분하지만, 통계적 동등성을 증명하는 수준은 아니다.

System diagram: Wrapper and Parser Boundaries

작동 원리는 단순하다. pq(html)가 lxml 트리를 한 번 만들고, d("h3.title")tree.cssselect()처럼 cssselect를 통해 CSS 선택자를 컴파일하며, 반환되는 요소는 lxml 요소다. 이번 측정의 핫 패스에서 PyQuery가 추가로 하는 일은 많지 않다. 순회, 조작, 반복 조회, import, 메모리는 모두 이 selector timing 주장 밖에 있다.

10 KB 이상에서는 결과가 매우 근접했다

더 중요한 결과는 첫 번째 열이다.

10 KB 이상에서 selectolax, lxml, PyQuery 세 파서의 가장 빠른 값과 가장 느린 값의 중앙값 차이는 10 KB에서 4.5%, 100 KB에서 5.4%, 1 MB에서 0.5%, 10 MB에서 4.5%였다. 이번 실행은 동등성 검정은 아니었지만, 실무적으로는 이 정도 차이면 이 워크로드에서 파서 선택을 바꿀 가능성은 낮다고 볼 수 있다.

selectolax는 1 KB에서 확실히 더 빠르다. 0.0286 ms로 0.0456, 0.0508보다 낮다. 하지만 이 구간은 쓸 수 없다. 세 파서의 해당 크기 격차는 **77.6%**이고, selectolax 자체 3회 측정값도 0.0267~0.0404 ms로 흔들렸다. 28 마이크로초 수준에서는 타이머와 스케줄러가 결과를 지배한다. 그 구간에서는 어떤 순위도 매기지 않겠다.

이 selector-and-read 작업에서는 예상 속도 서열보다 API와 측정된 의존성 사실을 기준으로 고르는 편이 낫다. PyQuery는 lxml에 대해 결정에 영향을 줄 만한 페널티를 보이지 않았다. selectolax는 다른 파서 스택을 사용하지만, 이 글에서는 설치 용량, wheel 지원 범위, 빌드 요구사항을 같은 기준으로 측정하지 않았다.

비교를 위해, 공개 벤치마크는 같은 10 MB fixture에서 Python 옵션 두 개를 더 측정했는데, 실제 차이가 드러나는 항목은 아래와 같다.

파서 (10 MB)p50
selectolax (lexbor)159.93 ms
lxml172.93 ms
parsel231.85 ms
selectolax (modest)247.95 ms
BeautifulSoup + lxml2,261.56 ms
BeautifulSoup + html.parser2,788.75 ms

공개 수치 출처: bench_parse.json.

이 역사적 벤치마크 행들은 BeautifulSoup이 이 fixture에서 빠른 파서 중앙값보다 한 자릿수 이상 느리다는 점을 보여준다. 다만 이 행들은 현재 프로세스의 PyQuery/lxml 쌍으로 다시 측정한 것이 아니므로, 주된 결론을 위한 통제된 배수라기보다 맥락 정보로 보는 게 맞다.

Cheerio의 저장된 수치는 2,927.89 ms였고(parser-bench.json2927.8857), 추출 콘텐츠 해시도 일치했다. 하지만 이 교차 런타임 결과는 Node, 패키지 버전, 당시 실행 제어 조건에도 영향을 받으므로, 독립적인 라이브러리 속도 배수로 읽으면 안 된다.

설치 현실

라이브러리패키지 수디스크 용량라이선스스타마지막 푸시
PyQuery320.1 MiBBSD2,3802026-07-27
cheerio (Node)22 (npm)9.0 MiBMIT30,4492026-08-11

공식 정보: PyQuery on PyPI.

metadata-snapshot.json.

패키지 3개면 의존성 구조가 꽤 깔끔하다. 그중 2개는 — lxml과 cssselect — 많은 Python 스크래핑 프로젝트가 이미 갖고 있을 법한 것들이다. 그런 경우 PyQuery의 추가 비용은 몇십 KiB 수준이다.

20.1 MiB는 PyQuery 자체가 아니라 lxml의 컴파일된 확장 모듈 때문이다. lxml을 직접 쓸 때도 똑같이 지불하는 약 20 MiB다.

이 패키지는 BSD 라이선스다. 특정 시점 스냅샷 기준으로 열린 이슈는 59개였고, 테스트 3주 전에 푸시가 있었다. 하지만 이 정보만으로 유지보수 품질이나 향후 호환성을 단정할 수는 없다.

메모리와, 깨진 HTML이 메모리에 미치는 영향

이번 배치의 모든 리뷰가 미측정으로 남겨 두었던 두 항목도 이제 측정했다.

보다 넓은 스트레스 테스트 맥락은 10개 라이브러리 메모리 및 손상된 HTML 비교에 있다.

피크 상주 메모리/usr/bin/time -l로 측정했으며, 각 셀마다 새 프로세스를 사용했다. import floor는 라이브러리가 로드된 채 대기할 때 드는 비용이고, peak는 문서를 포함한 값이다.

라이브러리RuntimeImport floor226 KB peak10 MB peak
html2textpython3.1418.719.971.2
pyquerypython3.1430.333.9172.5
resiliparsepython3.1420.525.1225.1
markdownifypython3.1423.928.9278.5
goose3python3.1444.152.4398.5
cheerionode2266.876.5398.5
justextpython3.1430.336.6431.2
newspaper4kpython3.1452.661.8668.5
trafilaturapython3.1452.564.8927.1
turndownnode2247.868.42947.1

memory-results.json. Python과 Node의 baseline은 서로 직접 비교할 수 없다. 인터프리터가 둘 다 안에 들어 있기 때문이다.

PyQuery는 대형 문서에서 resiliparse보다 가볍다 — 172.5 MiB 대 225.1 MiB — 비록 import floor는 더 높지만 말이다. lxml의 트리는 간결하고, PyQuery의 30.3 MiB floor 대부분은 PyQuery 자체보다 lxml을 불러오는 데서 나온다.

깨진 HTML. 정확히 하나씩만 문제가 있는 12개 문서 — 닫히지 않은 태그, 잘못 중첩된 inline 요소, 공백이 들어간 따옴표 없는 속성, 뜬금없는 닫는 태그, 아예 없는 <html>, 중복 속성, 태그 한가운데서 잘린 문서, 잘못된 엔티티, 닫히지 않은 <script>, 거짓 charset 선언, 마크업이 들어간 주석, 그리고 600단계 중첩 — 에 더해, 크기가 맞는 정상 문서 2개를 추가했다. 왜냐하면 “아무것도 반환하지 않았다”는 말은 같은 크기의 정상 문서에서도 아무 말이 없다면, 그건 단지 라이브러리가 조용했을 뿐인지, 정말로 malformed 때문에 실패한 것인지 구분할 수 없기 때문이다.

pyquery는 14개 중 0개에서 예외를 던졌고, 1개에서 아무것도 반환하지 않았으며, 깨진 fixture에서 sentinel 10/22개를 복구했다(malformed-results.json). 한 개의 fixture는 이 집계에서 제외했다. HTML5 기준으로 닫히지 않은 <script> 뒤의 모든 내용은 script content라서, 거기서 사라지는 건 올바른 동작이고 복구되는 쪽이 오히려 기대와 다르다. 직접 비교 대상들의 sentinel 결과가 옆에 없는 상태에서 10/22는 파서 순위가 아니라 강건성 관찰로만 읽어야 한다.

장점과 단점

장점. jQuery 문법은 프런트엔드 JavaScript를 써 봤거나 cheerio를 사용해 본 사람에게 익숙하다. 이번 fixture에서는 raw lxml 대비 결정에 영향을 줄 수준의 selector 오버헤드가 보이지 않았다. 패키지는 3개뿐이고, 그중 2개는 이미 프로젝트에 들어 있을 가능성이 높다. 반환값이 lxml 요소라서 기존 lxml 기법을 계속 쓸 수 있다. BSD 라이선스다. 모든 다섯 크기에서 콘텐츠 해시가 기준과 일치했다.

단점. 20.1 MiB는 결국 lxml 때문이다. 스타 2,380개는 cheerio의 30,449개보다 커뮤니티가 훨씬 작다는 뜻이다. 그래서 특이한 상황에서 참고할 예제가 더 적다. 또한 이것은 편의 레이어이므로 lxml이 못 하는 일은 PyQuery도 못 한다. 그리고 jQuery API가 성능까지 챙겨줄 거라고 기대했다면, 그렇지는 않다. 얻는 것은 사용성이고, 실제 작업은 아래의 파서가 수행한다.

누가 써야 하고, 누가 굳이 안 써도 되는가

PyQuery를 쓰기 좋은 경우는 Python에서 jQuery 스타일 선택자를 선호할 때다. 측정된 생성, 두 번의 선택, 읽기 작업에서는 lxml 대비 눈에 띄는 불이익이 없었다. 다른 PyQuery 연산은 측정하지 않았다.

lxml을 직접 쓰면 좋은 경우는 XPath를 선호하거나 패키지를 하나라도 줄이고 싶을 때다. 이번 실행에서는 둘 중 하나를 고를 만큼의 selector 속도 차이가 드러나지 않았다.

selectolax를 검토할 만한 경우는 그 파서 API와 의존성 구조가 프로젝트와 잘 맞을 때다. 1 KB 행은 명시적으로 순위 제외이며, 이 글은 “가장 의존성이 적다”는 주장도 뒷받침하지 않는다.

Node 환경에서는 cheerio가 비슷한 API 형태다. 저장된 교차 런타임 수치는 여기서 더 느렸지만, 런타임 차이와 당시 실행 조건 차이 때문에 깔끔하게 라이브러리만의 결론으로 보기는 어렵다.

관리형 API가 적합한 지점

PyQuery는 이미 가지고 있는 HTML을 파싱한다. 페이지를 가져오거나, JavaScript를 렌더링하거나, 안티봇 레이어를 처리하지는 않는다. 이번 비교의 어떤 파서도 그런 일은 못 하며, 실제 사이트에서는 그 부분이 더 어려운 절반인 경우가 많다.

작성자 메모: Thunderbit은 URL을 넣으면 가져오기·렌더링·추출까지 맡기는 관리형 옵션이다. 여기서는 PyQuery와 직접 비교하지 않았다. 핵심 경계는 이미 HTML을 보유하고 로컬 셀렉터를 쓰고 싶은지, 아니면 페이지 수집과 추출 자체를 서비스로 맡기고 싶은지에 있다.

솔직히 말하면, HTML을 이미 가지고 있고 셀렉터도 알고 있다면 PyQuery는 무료이고 쓰기 편하다. 하지만 셀렉터가 계속 깨지거나 대규모 수집이 필요하다면, 그건 다른 선택이다.

더 넓은 관점에서는 웹 스크래핑 API 모음에서 호스팅 옵션을, 오픈 소스 스크래퍼 종합 가이드에서 셀프 호스팅 옵션을 확인할 수 있다. 파싱 결과를 모델에 넣는다면 Python에서 HTML을 Markdown으로 변환하기에서 fidelity가 어디서 손실되는지도 살펴보자.

웹 데이터 추출을 위해 Thunderbit 사용해 보기

PyQuery를 써야 할까?

Python에서 jQuery 스타일 문법을 원하고, 이번에 측정한 selector/read 경로가 자신의 워크로드를 대표한다면 그렇다.

이 벤치마크는 콘텐츠 해시 일치를 유지하면서도 lxml 대비 결정에 영향을 줄 만한 selector 오버헤드를 찾지 못했다. 다만 라이브러리 전체 비용이 0이라는 뜻은 아니다.

다섯 가지 크기 전반에서 세 Python 파서의 중앙값은 모두 꽤 근접했다. 이 작업에서는 API 적합성이 성능 차이보다 더 중요할 가능성이 높다. 이 판단을 더 넓은 파서 순위로 확장하려면 동등성 범위를 정하고, 정확히 같은 대안을 다시 측정하라.

웹 데이터 추출을 위해 Thunderbit 사용해 보기 Get Started Free

자주 묻는 질문

PyQuery가 lxml을 느리게 하나요? 이번 실행에서는 결정에 영향을 줄 수준의 selector 오버헤드가 보이지 않았다. 다섯 페이지 크기 모두에서 중앙값은 raw lxml과 같거나 더 낮았고, 둘 다 같은 프로세스에서 lxml 6.1.1을 사용했다. 10 MB에서는 범위가 겹치지 않았다. PyQuery는 161.13163.17 ms, lxml은 164.18169.46 ms였다. pq(html)는 lxml 트리를 만들고, 테스트한 선택자는 cssselect를 통해 컴파일된다.

selectolax가 PyQuery보다 더 빠른가요? 1 KB 중앙값은 더 낮았지만, 마이크로초 단위에서는 변동성이 너무 커서 그 행은 순위에서 제외했다. 10 KB 이상에서는 중앙값 차이가 0.5%~5.4%였다. 이 작업에서는 꽤 근접한 결과이며, 모든 경우에 동등하거나 범위가 겹친다는 증거는 아니다.

왜 공개 수치를 그대로 쓰지 않고 lxml을 다시 실행했나요? 공개 벤치마크는 머신과 Python 버전은 기록하지만 라이브러리 버전은 기록하지 않기 때문이다. 해당 lxml 행이 PyQuery가 감싸는 현재 버전과 다른 lxml에서 나온 것일 수 있고, 그 버전 차이가 있었다면 존재하지 않는 래퍼 비용처럼 보였을 수 있다. 둘을 같은 프로세스에서 lxml 6.1.1로 실행하면 그 모호함이 사라진다.

cheerio와는 어떻게 다른가요? 기본적인 API 아이디어는 같지만 생태계는 다르다. 여기서 사용한 두 선택자는 양쪽 모두에서 동작했고 콘텐츠 해시도 다섯 크기 모두에서 일치했지만, 이것이 전체 선택자 호환성을 의미하지는 않는다. cheerio의 저장된 실행 시간은 더 느렸지만, 교차 런타임 및 역사적 실행 조건 때문에 라이브러리 단독 배수로 읽을 수는 없다.

여기서 테스트하지 않은 것은 무엇인가요? 메모리는 import-only, 226 KB 문서, 10 MB 문서에 대해 peak RSS로 측정했다. malformed HTML은 12개의 깨진 문서와 2개의 매칭 컨트롤로 시험했다. 아직 측정하지 않은 것은 PyQuery의 조작 및 순회 성능, 반복 조회 캐싱, URL fetch, 동시성, 그리고 실제 사이트를 대표하는 워크로드다. 1 KB 타이밍은 여전히 순위 제외 상태다.

Ke
Ke
Thunderbit CTO | 시니어 데이터 사이언티스트 & ML 전문가 머신러닝과 데이터 과학 분야에서 약 10년에 가까운 경험을 쌓아온 Ke Shen은 컬럼비아 대학교 출신이며, 전 Walmart Labs의 시니어 데이터 사이언티스트였습니다. Python, R, Java, 통계 분야에서 동료들에게도 인정받는 깊은 전문성을 바탕으로, 복잡한 AI 알고리즘을 이론에서 실제 운영 수준의 아키텍처로 전환하는 데 필요한 실전 인사이트를 공유합니다.
목차
Thunderbit · AI 웹 데이터 에이전트

1클릭 안에서 어떤 페이지든 데이터 추출

25만 명 이상의 사용자가 신뢰
무료 플랜 제공
웹페이지에서 스프레드시트까지
필요한 내용만 설명하세요 — Thunderbit의 AI 에이전트가 수집하고 Excel, Google Sheets, Airtable, Notion으로 내보냅니다. 시작은 무료입니다.
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week