BeautifulSoup는 파이썬에서 웹 페이지를 처음 웹 스크래핑할 때 거의 누구나 가장 먼저 떠올리는 라이브러리이면서, 동시에 제대로 된 HTML 파서들 중에서는 실제로 가장 느린 편에 속합니다. 이 두 가지는 모두 사실이고, 어느 쪽도 비판이라고 보긴 어렵습니다. 흥미로운 점은 이 “느림”이 막연한 느낌이 아니라, 정확한 수치로 환산해서 비교할 수 있는 비용이라는 데 있습니다.
저는 bs4(정확히는 beautifulsoup4, 버전 4.15.0, 2026년 6월 배포, MIT 라이선스)를 최신 기능 테스트와 같은 벤치마크 환경에서 재활용한 시간 측정 데이터와 함께 다시 살펴봤습니다. 결론은 일관적입니다. API는 훨씬 친절하고 오류 허용도는 업계 최고 수준인 대신, 속도는 대략 한 자릿수 배의 손해를 감수해야 합니다. 이 교환이 현명한지는 전적으로 작업 규모에 달려 있으므로, 이 리뷰에서는 그 두 측면을 함께 놓고 보겠습니다.
BeautifulSoup은 정확히 무엇이고, 무엇이 아닌가
대부분의 튜토리얼은 가장 중요한 부분을 건너뜁니다. BeautifulSoup는 HTML을 직접 파싱하지 않습니다. 래퍼입니다. 내부적으로는 문서를 세 가지 실제 파서 중 하나—파이썬 내장 html.parser, lxml, html5lib—에 넘기고, 그 결과로 만들어진 트리를 하나의 아주 친절한 탐색·검색 API로 감싸 줍니다. bs4의 역할은 파싱 자체가 아니라, 결과를 다루기 쉽게 만드는 데 있습니다.
제작자도 이를 “스크린 스크래핑 라이브러리”라고 부르며, 기본 구상은 늘 같았습니다. 브라우저도 눈살을 찌푸릴 만큼 엉망인 HTML에 넣어도, 필요한 데이터를 끝내 건져낸다는 것입니다. 그 명성은 분명 타당합니다. 다만 뒤에서 볼 예외 사항 하나가 있습니다.
먼저 핵심 사실부터 짚고 넘어가겠습니다.
| 항목 | 값 |
|---|---|
| 패키지 | beautifulsoup4 (bs4로 import) |
| 테스트한 버전 | 4.15.0 (2026-06-07 업로드) |
| Python 요구사항 | >=3.7.0 |
| 라이선스 | MIT |
| 공식 홈페이지 | crummy.com/software/BeautifulSoup |
| 소스 + 버그 트래커 | Launchpad — GitHub 아님 |
| 유지 관리 | 활성 상태(2026년 6월 4.15.0, 지난 1년간 6회 릴리스) |
이 “GitHub가 아니다”라는 점은 보기보다 중요합니다. bs4는 crummy.com과 Launchpad에서 관리되는 20년 된 라이브러리라서, 흔한 GitHub 스타 수로 건강 상태를 판단하는 방식은 맞지 않습니다. 대신 릴리스 주기를 봐야 하는데, 그 기준으로 보면 충분히 활발하게 유지되고 있습니다.
라이선스도 compliance 팀을 상대해야 하는 분이라면 알아둘 만합니다. 래퍼 자체는 MIT지만, bs4를 쓴다고 해서 실제로 어떤 의존성이 딸려오는지는 어떤 백엔드를 설치하느냐에 따라 달라집니다. html.parser는 Python 표준 라이브러리(PSF 라이선스, 추가 의존성 없음)이고, lxml은 BSD 라이선스지만 libxml2/libxslt라는 외부 C 의존성 위에 올라갑니다. html5lib는 순수 Python이며 MIT입니다. 의존성 구성이 가장 깔끔한 쪽을 원한다면 내장 html.parser가 답이지만, 아이러니하게도 그 백엔드가 가장 큰 함정을 안고 있습니다. 조금 뒤에 보겠습니다.
속도 세금, 수치로 보기
먼저 숫자부터 꺼내겠습니다. 이건 핵심 헤드라인이고, 숨기면 정직하지 못하니까요. 실제적인 parse 후 추출 작업—문자열을 파싱한 뒤 모든 <h3 class="title">와 모든 <a href>를 뽑는 작업—에서 BeautifulSoup는 이 비교군 중 가장 느린 파서였고, 격차도 작지 않았습니다.

이 시간 측정치는 selectolax 벤치마크 장비에서 재활용한 것입니다(동일한 머신, 동일한 3회 측정 방식, 기준일 2026-07-13). CPU 경합과 중복 작업을 피하기 위해, 이 리뷰에서는 별도의 시간 벤치마크를 다시 실행하지 않았습니다. 중앙 p50 지연 시간은 밀리초 기준으로 다음과 같습니다.
| 페이지 크기 | bs4 (html.parser) | bs4 (lxml) | selectolax-Lexbor | lxml | bs4-hp 느림 | bs4-lxml 느림 |
|---|---|---|---|---|---|---|
| 1 KB | 0.323 | 0.281 | 0.027 | 0.036 | 12.0x | 10.5x |
| 10 KB | 2.049 | 1.705 | 0.160 | 0.166 | 12.8x | 10.7x |
| 100 KB | 20.599 | 16.540 | 1.464 | 1.423 | 14.1x | 11.3x |
| 1 MB | 232.558 | 181.855 | 14.901 | 14.177 | 15.6x | 12.2x |
| 10 MB | 2788.746 | 2261.557 | 159.935 | 172.933 | 17.4x | 14.1x |
즉 bs4(html.parser)는 selectolax-Lexbor 같은 C 파서보다 대략 12~17배 느립니다. 백엔드를 lxml로 바꿔도 10.5~14배 느린 수준이라, 여전히 한 단계 이상 뒤처집니다. 이유는 버그가 아니라 구조적인 문제입니다. 어떤 백엔드가 실제 파싱을 하든, bs4는 모든 노드마다 완전한 Python 객체(Tag 또는 NavigableString)를 하나씩 만듭니다. 그 객체화 비용은 C 파서들이 치르지 않습니다.
페이지가 커질수록 배수가 오히려 올라간다는 점도 눈여겨볼 만합니다. 1 KB에서는 12.0배, 10 MB에서는 17.4배입니다. 이는 초기 시작 비용이 아니라, 노드를 얼마나 많이 만들었는지에 비례하는 per-node 비용이라는 뜻입니다.
이제 시각을 조금 바꿔 보겠습니다. “10배 느리다”는 말은 실제로는 생각보다 덜 무섭습니다. 1 MB 페이지에서 232 ms와 15 ms의 차이입니다. 만약 당신의 일이 “수백 KB짜리 페이지 수백~수천 개를 긁어오는 것”이라면, 이 절대적인 차이는 사실상 크게 체감되지 않습니다. 최적화해 봐야 얻는 것도 거의 없습니다. 반대로 백만 페이지 파이프라인이라면, 같은 비율이 작업이 끝나는지 못 끝나는지를 가릅니다. 숫자는 같아도 판정은 완전히 다릅니다. 벤치마크 수치가 아니라 실제 볼륨 기준으로 판단해야 합니다.
아니요, 백엔드를 바꾼다고 해결되진 않습니다
bs4에 lxml 백엔드를 넣으면 lxml만큼 빨라진다는 오해가 끈질기게 따라다닙니다. 하지만 그렇지 않습니다. 왜 그런지 이해하는 것이 중요합니다. 100,000개 노드 배치 CSS 쿼리(모든 <a>를 선택해 href를 읽는 작업, 트리는 이미 생성됨)에서 처리량 차이는 아주 분명합니다.
| 파서 | 쿼리 p50 | 노드/초 |
|---|---|---|
| lxml | 33.30 ms | 3,002,646 |
| selectolax-Modest | 34.19 ms | 2,924,550 |
| selectolax-Lexbor | 39.46 ms | 2,534,027 |
| bs4 (lxml) | 250.56 ms | 399,111 |
bs4(lxml)는 초당 약 399,000개 노드를 처리하는데, 자체 백엔드가 lxml임에도 불구하고 세 C 엔진보다 대략 6.3~7.5배 느립니다. 백엔드는 트리 생성만 빠르게 합니다. 쿼리와 순회는 여전히 soupsieve를 거쳐 bs4 Tag 객체로 들어가고, 매칭된 각 노드는 결국 Python 객체 상자 안에 담깁니다. 따라서 “bs4에 lxml을 붙이면 lxml처럼 빨라진다”는 생각은 틀렸습니다. 백엔드는 한 단계만 빠르게 할 뿐이고, 가장 느린 단계는 그 단계가 아니기 때문입니다.
메모리와 콜드 스타트도 비용에 포함해야 합니다. 10 MB 문서 기준으로 bs4는 selectolax나 lxml보다 대략 1.5~1.75배 많은 RSS 메모리를 사용합니다(218226 MB 대 129145 MB). 같은 이유, 즉 노드마다 Python 객체를 하나씩 만들기 때문입니다. 또 bs4 import는 lxml.html의 14.1 ms에 비해 약 33.4 ms가 걸려서 2.36배 느립니다. 이 차이는 장시간 실행 프로세스에서는 거의 무시할 수준이지만, CLI 도구나 자주 cold start 되는 서버리스 함수에서는 꽤 현실적인 비용입니다.
왜 스레드를 더 붙인다고 해결되지 않는가
CPU 바운드 작업이 느릴 때 “스레드를 더 넣자”는 생각이 먼저 든다면, bs4는 그 습관을 바로 벌합니다. 1 MB 페이지를 48번 파싱했을 때, 단일 스레드와 4개 스레드의 비교는 다음과 같습니다.
| 파서 | 1 스레드 | 4 스레드 | 속도 향상 |
|---|---|---|---|
| selectolax-Lexbor | 0.563 s | 0.159 s | 3.54x |
| lxml | 0.459 s | 0.378 s | 1.21x |
| bs4 (lxml) | 6.945 s | 26.842 s | 0.26x |
맨 아래 줄을 두 번 보십시오. 스레드 4개가 bs4를 더 빠르게 만든 것이 아니라, 약 3.9배 더 느리게 만들었습니다. 실험 결과가 말해 주는 것은 “GIL을 잡고 있을 가능성이 높다”는 점입니다. bs4의 트리 생성은 순수 Python이라 Global Interpreter Lock 아래에서 직렬화되고, 스레드를 더 얹는 것은 실제로 병렬 실행되지 못하는 작업에 스케줄링 오버헤드만 추가합니다. selectolax가 약 3.5배 속도를 낸 이유는 C 코어가 이 락을 풀기 때문이고, bs4에는 그런 여지가 없습니다.
free-threading 시대로 넘어가더라도 실용적인 결론은 같습니다. BeautifulSoup를 병렬화해야 한다면 스레드가 아니라 multiprocessing(ProcessPoolExecutor)을 쓰십시오. selectolax와 lxml은 스레드에서 확장성이 있지만, bs4는 그렇지 않습니다. 다만 엄밀히 말해 여기서의 GIL 추정은 단 한 번의 관찰(스레드 4개, 페이지 크기 1 MB)에 기반한 것입니다. 어떤 코드 경로가 정확히 락을 잡고 있는지 계측해서 확인한 것은 아니므로, 메커니즘은 추정이고 방향성은 분명하다고 보는 편이 맞습니다.
기본 백엔드가 함정입니다. 이것부터 읽으세요
이 리뷰에서 하나만 가져가야 한다면 이걸 가져가십시오. 두 번째 인자 없이 BeautifulSoup(html)를 쓰면 기본값은 html.parser이고, html.parser는 HTML5의 선택적 종료 태그 규칙을 구현하지 않습니다. 처음엔 사소해 보여도, 데이터가 조용히 망가질 수 있습니다.

저는 의도적으로 깨진 HTML 샘플 15개를 세 백엔드 모두에 통과시켰고, 각 샘플에는 실행 전에 백엔드와 무관한 구조적 검증을 미리 등록했습니다(나중에 보고 싶은 쪽을 골라 승자처럼 꾸미지 못하게 하기 위함입니다). 점수는 다음과 같습니다.
| 백엔드 | 기대 충족 / 15 |
|---|---|
| lxml | 15 |
| html5lib | 15 |
| html.parser | 12 |
실패한 세 케이스는 모두 같은 원인을 공유합니다. 예를 들어 닫히지 않은 테이블 <table><tr><td>a<td>b<tr><td>c<td>d</table>을 보면, html.parser에서는 추출된 셀 텍스트가 ['abcd','bcd','cd','d']로 나옵니다. 각 <td>가 뒤의 모든 내용을 삼켜 버리는데, 파서가 셀을 닫지 않고 안에 또 넣어 버리기 때문입니다. 반면 lxml과 html5lib는 올바르게 ['a','b','c','d']를 반환합니다. 단순한 리스트 항목도 같습니다. <li>a<li>b<li>c는 html.parser에서 ['abc','bc','c']처럼 중첩되고, 나머지 두 파서에서는 깔끔하게 ['a','b','c']가 나옵니다. 중복 속성도 다릅니다. <div id="first" id="second">는 html.parser에서 "second"를 유지하지만, lxml/html5lib는 "first"를 유지하며, HTML5 명세는 첫 번째를 보존하라고 말합니다.
이게 단순히 귀찮은 수준이 아니라 위험한 이유는, 에러를 내지 않고 일어나기 때문입니다. BeautifulSoup(html)을 아무 생각 없이 사용하다가 닫히지 않은 테이블이나 리스트를 만나면—옛날 사이트, 손으로 작성한 HTML, 닫는 태그를 빼먹은 템플릿에서는 놀랄 만큼 흔합니다—인접한 셀 텍스트가 하나의 필드로 섞여 버리고, 더러운 데이터를 건네주면서도 한 번도 불평하지 않습니다. 해결 방법은 인자 하나입니다. BeautifulSoup(html, "lxml") 또는 BeautifulSoup(html, "html5lib")를 쓰면 됩니다.
html.parser를 공정하게 보자면, 나머지 12개/15개의 깨진 샘플은 세 백엔드 모두에서 동일하게 처리됐습니다. 잘못 중첩된 태그(<b><i></b></i>), 누락된 html/body 골격, 따옴표 없는 속성, 고아 닫는 태그, 닫히지 않은 주석, 중첩 폼, 대소문자 혼용 등에서는 bs4의 관용성이 정말 강합니다. 백엔드 간 차이는 거의 전적으로 선택적 종료 태그 계열에 집중돼 있습니다. 그리고 이것 역시 새 발견은 아닙니다. bs4의 공식 “파서 간 차이점” 문서도 html.parser가 “덜 관대하다”고 이미 명시합니다. 이 매트릭스가 더하는 것은, “덜 관대함”이 실제로 잘못된 출력으로 이어지는 구체적이고 재현 가능한 사례들입니다.
포기하지 않는 것: API와 CSS가 가장 큰 장점이다
따라서 bs4는 느리고, 단일 스레드에 가깝고, 기본 백엔드에 함정도 있습니다. 그런데도 사람들이 계속 사용하는 이유는, 교환의 “친절함” 쪽이 완전히 사실이기 때문이며, 실제 테스트에서도 그 장점이 유지되기 때문입니다.

검색, CSS, 트리 탐색, 텍스트 추출, DOM 수정까지 아우르는 29개의 API 검사를 수행했습니다. 29개 모두 통과했습니다. 각 검사는 눈대중이 아니라 실제 반환값과 기대값을 비교해 판정했습니다. 그중 C 파서들이 제공하지 못하는 사용성은 두 가지가 특히 두드러집니다.
find/find_all의 함수형 조건식.soup.find(lambda t: t.name == "a" and "btn" in t.get("class", []))처럼 쓰면, 복잡한 조건을 Python 한 줄로 표현할 수 있습니다. “전부 선택한 뒤 다시 필터링”하는 두 단계가 필요 없습니다.- 이름이 붙은 양방향 트리 탐색.
.parent,.next_sibling,.find_parent,.stripped_strings,.descendants같은 탐색은 읽기만 해도 의미가 분명하고, 양방향으로 이동할 수 있습니다. selectolax는 이런 항목 중 일부를 여러 단계로 나눠야 하거나 아예 제공하지 않습니다.
이것이 바로 “개발자 시간을 사는” 측면입니다. 마케팅 문구가 아니라 29개의 초록 체크가 증명합니다.
공정한 리뷰라면 양쪽 다 말해야 하므로, 주의할 함정도 두 가지 적겠습니다. 첫째, 불리언 속성입니다. <input disabled>는 bs4에서 disabled가 빈 문자열 ""로 반환됩니다(selectolax는 None). 둘 다 falsy이기 때문에 if node.get("disabled")처럼 쓰면 실제로 존재하는 불리언 속성을 두 라이브러리 모두에서 놓칠 수 있습니다. 안전한 검사는 "disabled" in tag.attrs입니다. 둘째, get_text(strip=True)는 제거 후 노드 텍스트를 구분자 없이 이어 붙입니다. 그래서 "...with " 뒤에 "link1"가 오면 "withlink1"가 됩니다. 단어 경계가 필요하면 separator=" "를 넘기십시오. 이 두 함정은 bs4 전용도 아니고, 여러 라이브러리에서 공통으로 겪을 수 있는 문제입니다.
그리고 많은 사람이 놀라는 부분이 하나 더 있습니다. bs4를 선택한다고 해서 CSS 커버리지를 포기하는 것은 아닙니다. CSS 엔진인 soupsieve는 이 비교군 전체에서 가장 완성도가 높습니다. selectolax 벤치마크에서 재활용한 41개 기본 사례에서 soupsieve는 41/41을 기록해, 이 분야 유일의 만점을 받았습니다. selectolax-Lexbor의 39/41, cssselect(lxml/parsel)의 37/41보다 앞섰습니다. 여기에 soupsieve 문서가 추가로 안내하는 20개의 확장 사례도 돌려 봤는데, 20/20으로 통과했습니다. Lexbor가 아예 거부하는 :lang(en), soupsieve 전용인 :-soup-contains('featured'), :is(), :where(), :has(> a)까지 포함입니다. 실제로 부족한 부분은 XPath(soupsieve는 CSS 전용)와 parsel의 ::text / ::attr() pseudo-element뿐입니다. XPath에 익숙한 사람이라면 그 마이그레이션은 꽤 아플 수 있습니다.
이 섹션의 결론은 명확합니다. BeautifulSoup를 고를 때 포기하는 것은 속도입니다. API 사용성도 아니고, CSS 커버리지도 아닙니다.
운영 환경에서 돈을 미리 잡아먹는 두 가지 함정
기본 백엔드 외에도, 장기 실행 작업이나 비 UTF-8 워크로드에서 특히 문제를 일으키는 동작이 두 가지 있습니다.
참조 순환: 긴 루프에서는 decompose()를 호출하세요
bs4의 각 Tag는 부모와 자식 모두를 참조하므로 참조 순환이 생깁니다. CPython의 참조 카운팅만으로는 이런 순환을 스스로 해제할 수 없고, 세대별 가비지 컬렉터가 처리해야 합니다. 이 영향이 어느 정도인지 보기 위해, GC를 끈 상태에서 트리를 300번 만들고 지운 뒤, 메모리에 남아 있는 Tag 객체 수를 셌습니다.

| 시나리오 | del 후 남은 Tag 수 |
|---|---|
| GC 끔 | 120,900 (300회 순환, 아무 것도 회수 안 됨) |
| GC 켬 | 26,598 (루프 도중 세대별 GC 작동) |
강제 gc.collect() 후 | 0 (모두 회수) |
| 순환 없는 대조군(문자열 리스트, GC 끔) | delta 0 |
GC를 끈 상태에서는 del soup가 아무 것도 회수하지 못했습니다. 120,900개의 객체가 그대로 남았는데, 이는 참조 순환이 참조 카운팅을 무력화하기 때문입니다. gc.collect() 한 번이면 이 모든 것이 사라졌습니다. 순환이 없는 대조군(순환이 없다고 알려진 단순 문자열 리스트)은 delta가 0이었으므로, 증가분은 bs4의 순환 구조에서 온 것이지 측정 잡음이 아니었습니다. bs4 공식 문서도 객체들이 “촘촘히 연결되어 있고… 가비지 컬렉터가 어려워하는 바로 그 유형”이라고 설명하므로, 이건 문서화된 동작입니다. 이 테스트가 더한 것은 남아 있던 객체 수와 collect()가 그것을 0으로 만든다는 증명입니다.
실무적으로는 이렇게 이해하면 됩니다. 큰 페이지를 연속으로 여러 번 파싱하는 파이프라인에서, 코드나 고처리량 설정이 GC를 비활성화하거나 너무 자주 돌리지 않으면 bs4 트리가 오래 머물며 메모리가 계속 올라갑니다. 각 페이지 처리 후 soup.decompose()를 호출하십시오. bs4가 이 메서드를 제공하는 이유가 바로 순환을 끊고 더 빨리 회수하게 하려는 데 있습니다. selectolax와 lxml의 C 트리는 이런 문제가 없습니다.
인코딩: UnicodeDammit은 bs4의 조용한 강점입니다
bs4에는 빠른 파서들이 갖지 못한 구성 요소가 하나 있습니다. UnicodeDammit입니다. 이 컴포넌트는 문서의 인코딩을 추정하고 자동으로 유니코드로 변환합니다. 저는 “선언된 문자셋 vs 실제 문자셋” 8개 사례를 넣어 테스트했습니다.

| 케이스 | 실제 인코딩 | UnicodeDammit 추정 | 복구 여부 |
|---|---|---|---|
| utf8_no_decl | utf-8 | utf-8 | 예 |
| utf16_bom | utf-16 | utf-16le | 예 |
| gbk_chinese | gbk | gb18030 | 예(상위호환) |
| shiftjis | shift_jis | cp932 | 예(상위호환) |
| latin1_declared_utf8 | latin-1 (utf-8로 선언) | iso-8859-1 | 예(거짓 선언 무시) |
| latin1_no_decl | latin-1 | cp720 | 아니오 |
| cp1252_no_decl | cp1252 | cp862 | 아니오 |
| utf8_declared_latin1 | utf-8 (latin-1로 선언) | iso-8859-1 | 아니오(거짓 선언을 따름) |
8개 중 5개를 복구했습니다. UTF-8, BOM이 포함된 UTF-16, GBK, Shift-JIS, 그리고 잘못 라벨링된 latin-1까지 제대로 복원했고, 상위호환 추정(GBK→gb18030, Shift-JIS→cp932)도 문제없이 디코딩됩니다. 다만 실패 모드도 알아둘 만합니다. 짧은 latin-1/cp1252 바이트 샘플은 DOS 코드 페이지로 잘못 판정되기 쉬운데, 통계 기반 탐지기는 짧은 입력에 약하고 DOS 박스 그리기 문자가 Latin-1 코드 포인트와 겹치기 때문입니다. 또 <meta charset> 선언이 틀렸다면 UnicodeDammit는 그 선언을 신뢰합니다. bs4 문서도 샘플이 “너무 짧아서 Unicode, Dammit이 제대로 고정하지 못할 수 있다”고 경고하며, 더 많은 데이터가 더 나은 추정을 만든다고 설명합니다.
selectolax처럼 비 UTF-8 바이트를 조용히 잘못 해석하고 직접 디코딩해야 하는 도구와 비교하면, 이건 분명한 장점입니다. bs4는 적어도 추정을 시도하고, 종종 성공합니다. 다만 보장은 아닙니다. 인코딩을 이미 알고 있다면 추측은 건너뛰고 명시적으로 넣으십시오: BeautifulSoup(bytes, from_encoding="...").
실제 페이지에서도 백엔드가 정말 다를까?
깨진 HTML 매트릭스는 의도적으로 손상된 입력에서 백엔드가 갈라진다는 것을 보여줍니다. 그렇다면 실무에서는 이 차이가 의미가 있는지 묻는 것이 자연스럽습니다. 그래서 BBC, Wikipedia, Craigslist, MDN, old.reddit, Python docs, Hacker News, Books to Scrape, webscraper.io, whitehouse.gov, 그리고 JS 렌더링된 quotes 페이지까지 총 11개의 실제로 가져온 페이지에 세 백엔드를 모두 돌려 링크/헤딩/이미지 개수를 비교했습니다.
결과는 모두 일치했습니다. 11개 전부에서 차이가 없었습니다. 즉, 트랩 섹션에서 본 백엔드 차이는 의도적으로 깨진 HTML에서만 나타납니다. 현대의 프로덕션 사이트가 충분히 잘 구조화되어 있다면—겉보기엔 “지저분”해 보여도—백엔드 선택이 추출 결과를 바꾸지 않습니다. 실무적으로는, 대중적인 잘 형식화된 사이트에서는 html.parser도 충분하고 의존성도 줄여 줍니다. 반면 눈에 띄게 비표준이거나 손으로 만든 HTML, 혹은 오래된 HTML을 긁는 경우에만 백엔드 선택이 결과를 바꾸기 시작하고, 그때 lxml이나 html5lib로 바꾸면 됩니다.
이 실험에서 하나 덧붙이면, 실제로 만나는 예외 사례가 하나 있었습니다. MDN 페이지에는 <template> 요소가 있었고, 모든 bs4 백엔드는 508개의 링크를 반환했습니다. 즉 bs4는 <template> 내부 내용을 메인 트리로 평탄화합니다. 이는 lxml과 같은 방향이고, HTML5 명세를 엄격하게 따르는 selectolax-Lexbor와는 반대입니다. selectolax-Lexbor는 <template>를 inert DocumentFragment로 취급하여 그 안의 11개 링크를 조용히 제외하고 497개만 반환합니다. 따라서 bs4는 <template> 안의 데이터를 잡아낼 수 있습니다. 유용하지만, 브라우저에서는 렌더링되지 않을 “가짜” 콘텐츠까지 같이 잡을 수도 있다는 뜻이기도 합니다. 어느 쪽도 틀린 것은 아닙니다. 다만 서로 다른 명세 해석일 뿐이므로, 자신이 어느 쪽을 얻는지 알고 있어야 합니다.
BeautifulSoup의 포지션, 그리고 아닌 것
이 모든 내용을 하나의 0~100 점수로 뭉개 버리면, 정말 중요한 트레이드오프가 가려집니다. 그래서 각 차원별 점수를 정리하고, 각 행마다 주의할 점을 덧붙이겠습니다.
| 차원 | 테스트 결과 | 읽을 때의 주의점 |
|---|---|---|
| 설치 / 첫 실행 | 순수 래퍼, 브라우저/세팅 불필요; html.parser는 무의존성; 모든 백엔드 prebuilt wheel 제공 | lxml 백엔드는 C 의존성이 필요 |
| C 파서 대비 속도 | 전 크기에서 12 | 단일 환경, selectolax 데이터 재활용 |
| CSS 쿼리 처리량 | 100k 노드에서 약 6~7.5x 느림; lxml 백엔드도 해결 못함 | 재활용 데이터; Python Tag 비용 지불 |
| 메모리 | selectolax/lxml보다 1.5~1.75x; 가장 큼 | 재활용; RSS로 측정 |
| import 콜드 스타트 | 2.36x 느림(33.4 대 14.1 ms) | 재활용; 작은 항목 |
| 스레드 확장성 | bs4-lxml은 4 스레드에서 약 3.9x 느려짐 (GIL 보유) | 단일 관찰; multiprocessing 사용 권장 |
| API 사용성 | 29/29 통과; 함수형 predicate find + 양방향 탐색 | 빈 문자열 불리언 속성과 strip 단어 경계 함정 주의 |
| CSS 커버리지 | soupsieve가 최강: 기본 41/41 + 확장 20/20; :lang 지원 | XPath 없음, ::text 없음 |
| 3개 백엔드 허용도 | lxml/html5lib 15/15; html.parser 12/15 | 차이는 깨진 HTML에서만 발생 |
| 실제 페이지 일관성 | 3개 백엔드 11/11 일치; 모두 <template>를 평탄화(508) | 잘 형식화된 사이트에서는 백엔드 영향 거의 없음 |
| 참조 순환 GC | 트리가 순환 구조; 300회 루프에서 120,900개 보존, collect로 0회수 | 긴 루프에서는 decompose() 필요 |
| 인코딩 | UnicodeDammit 5/8 복구; 짧은 샘플 오판, 잘못된 선언은 그대로 따름 | 단일 관찰 |
| 유지 관리 | 활성(4.15.0, 2026년 6월); MIT | crummy/Launchpad에 있고 GitHub는 아님 |
그렇다면 BeautifulSoup는 누구를 위한 도구일까요? 읽기 쉬운 API와 관대한 파싱을 원하고, 처리량은 중간 정도인 사람들—프로토타입, 일회성 스크래핑, 내부 도구, 개발자 시간 비용이 실행 시간보다 더 비싼 팀—에게 잘 맞습니다. 반대로 어디를 봐야 할까요? 속도 세금이 누적되어 실제 비용이 되는 백만 페이지 파이프라인, 스레드 수준 병렬화가 필요한 작업, 그리고 XPath에 묶여 있는 분들입니다.
실제 웹 스크래핑 스택에서 이것이 어디에 들어가는지, 그리고 우리의 도구가 어디에 위치하는지도 짚어 보겠습니다. BeautifulSoup는 이미 HTML을 가지고 있는 상태를 전제로 합니다. 페이지를 가져오지도 않고, JavaScript를 렌더링하지도 않으며, 봇 차단이나 CAPTCHA 문제를 해결해 주지도 않습니다. 그것은 별개의 작업이며, 현대 웹에서는 꽤 어려운 문제입니다. 그래서 AI 스크래핑 API는 다른 계층에 자리합니다. Thunderbit의 개발자 스택—REST API, MCP 서버, CLI—는 가져오기, JS 렌더링, 봇 차단 문제를 처리한 뒤, 셀렉터를 직접 작성하지 않아도 깔끔한 Markdown(POST /distill) 또는 스키마에 맞는 구조화 JSON(POST /extract)을 돌려줍니다. 둘은 경쟁 제품이 아니라 보완 관계입니다. bs4는 이미 손에 쥔 HTML을 파싱하고, Thunderbit의 API, MCP, CLI는 애초에 쉽게 접근할 수 없는 HTML을 가져오게 해 줍니다. 병목이 파싱이라면 bs4가 좋은 답입니다. 병목이 획득이라면, 그건 다른 계층의 문제입니다.
결론
BeautifulSoup는 이 비교군에서 가장 친절한 API, 가장 강한 깨진 HTML 허용도, 가장 완성도 높은 CSS 엔진을 제공하지만, 그 대가로 대략 한 자릿수 배의 속도 비용과 가장 큰 메모리 사용량을 요구합니다. 이것이 전부입니다. 기본 html.parser 백엔드가 유일하게 진짜 조심해야 할 함정이며, 닫히지 않은 테이블과 리스트를 조용히 망가뜨립니다. 입력이 지저분할 수 있다면 언제나 "lxml"이나 "html5lib"를 명시적으로 넘기십시오. 스레드로는 빨라지지 않지만 multiprocessing은 가능합니다. 그리고 오래 도는 루프에서는 각 페이지마다 decompose()를 호출해 참조 순환이 쌓이지 않도록 하십시오.
마지막으로 두 가지 한계를 짚고 마치겠습니다. 여기의 모든 측정은 단일 플랫폼(macOS arm64, Python 3.14, prebuilt wheel)에서 수행되었고, 시간 배수는 selectolax 벤치마크 장비에서 재활용한 값입니다(동일한 bench, 기준일 2026-07-13). 따라서 이 수치들은 단일 플랫폼 한계를 그대로 물려받으며, Linux x86_64나 소스 컴파일 환경에서는 정확한 숫자가 달라질 수 있습니다. 또 이 결과들 가운데 새로운 발견은 없습니다. bs4는 20년 된 라이브러리이므로, 테스트한 모든 동작은 이미 문서화되었거나 공개적으로 기록된 것입니다. 이 글의 가치는 특종이 아니라, 문서가 정성적으로만 설명해 온 트레이드오프에 실제 숫자를 붙였다는 데 있습니다.
자주 묻는 질문
BeautifulSoup는 느린가요?
네, 측정 가능할 정도로 느립니다. parse+extract 작업에서 기본 html.parser 백엔드로는 selectolax-Lexbor 같은 C 파서보다 대략 1217배 느리고, 14배 느립니다. 이유는 노드마다 Python 객체를 만들기 때문입니다. 이 차이가 중요한지는 규모에 달려 있습니다. 1 MB 페이지에서는 232 ms 대 15 ms라서, 수천 페이지에서는 잘 느껴지지 않지만 백만 페이지 파이프라인에서는 결정적입니다.lxml 백엔드를 써도 10.5
BeautifulSoup에서는 html.parser, lxml, html5lib 중 무엇을 써야 하나요?
잘 형식화된 일반 사이트라면 기본 html.parser도 괜찮고, 추가 의존성도 없습니다. 다만 HTML5의 선택적 종료 태그를 구현하지 않기 때문에, 닫히지 않은 테이블이나 리스트에서는 오류 없이 인접 텍스트를 섞어 버릴 수 있습니다. 입력이 깨졌을 가능성, 손으로 작성한 HTML, 오래된 HTML이 있다면 "lxml"이나 "html5lib"를 명시적으로 넘기십시오. 깨진 HTML 매트릭스에서 두 파서는 모두 15/15를 기록했고, html.parser는 12/15였습니다.
BeautifulSoup를 스레드로 병렬 처리할 수 있나요?
아니요. bs4의 트리 생성은 순수 Python이고 GIL을 잡기 때문에, 스레드를 늘리면 빨라지는 게 아니라 오히려 느려집니다. 실제 테스트에서도 4개 스레드는 1 MB 파싱을 단일 스레드보다 약 3.9배 더 느리게 만들었습니다. bs4를 병렬화하려면 multiprocessing(ProcessPoolExecutor)을 사용하세요. selectolax와 lxml처럼 C 코어를 가진 라이브러리만 스레드 수준 병렬화의 이점을 얻습니다.
BeautifulSoup는 깨진 HTML을 잘 처리하나요?
대체로는 그렇습니다. 잘못 중첩된 태그, 누락된 골격, 따옴표 없는 속성 등 다양한 깨진 샘플에서 세 백엔드 모두 깔끔하게 복구했습니다. 약점은 기본 html.parser와 선택적 종료 태그입니다. 닫히지 않은 <td>/<li>는 닫히는 대신 중첩되어 추출 텍스트가 오염됩니다. lxml 또는 html5lib 백엔드로 바꾸면 이 계열의 문제는 사라집니다.
BeautifulSoup vs lxml — 무엇이 더 낫나요?
용도가 다릅니다. lxml은 트리 생성과 쿼리 모두에서 훨씬 빠르고, XPath도 지원합니다. BeautifulSoup는 lxml 등을 더 친절한 API로 감싸며, soupsieve를 통해 CSS 커버리지도 더 넓습니다. 다만 lxml 백엔드를 넣는다고 bs4가 lxml만큼 빨라지는 것은 아닙니다. 백엔드는 파싱만 빠르게 하고, 쿼리와 순회는 여전히 bs4의 per-node Python 객체 비용을 치르기 때문에, 대규모 배치 선택에서는 대략 6~7.5배 느린 상태로 남습니다.
웹 데이터 추출에 Thunderbit 사용해 보기 Get Started Free


