Jsoup 내부 원리 — HTTP · 파싱 · DOM · Selector
Jsoup을 쓸 때 대부분 이 두 줄로 시작한다.
Document doc = Jsoup.connect(url).get();
Elements items = doc.select("div.item > a");동작은 하는데 안에서 무슨 일이 일어나는지 모른 채 쓰기 쉽다. 그러다 이런 상황을 만난다.
- 브라우저에서 보이는 값이
doc.select(...)에서는 안 나온다 - 한글이 깨진다
- 응답이 302 리다이렉트에서 멈춘다
- Selector가 왜 요소를 못 찾는지 모른다
이런 문제는 Jsoup 내부 단계를 이해하면 대부분 원인이 명확해진다. 이 글은 그 네 단계를 하나씩 뜯는다.
1. HTTP 요청
2. HTML 파싱 (문자열 → 토큰 → DOM Tree)
3. DOM Tree 구조와 탐색
4. CSS Selector 매칭
시나리오는 이전 글들과 동일하다.
예시 시나리오 Spring Boot + Spring Batch 기반 예시 수집기가 있다.
jsoupCrawlStep에서 Jsoup으로 대상 사이트를 긁는 중이다.
1. HTTP 요청 단계
Jsoup은 자체 HTTP 클라이언트를 가지고 있다. 경량이고 크롤링에 맞게 튜닝되어 있지만, HttpClient나 OkHttp처럼 범용은 아니다.
Jsoup.connect(url) 이 진짜로 하는 일
Jsoup.connect()는 즉시 요청을 날리지 않는다. Connection 객체를 만들 뿐이다. 실제 요청은 .get() / .post() / .execute()가 호출될 때 이뤄진다.
Connection conn = Jsoup.connect(url) // 아직 요청 안 함
.userAgent("Mozilla/5.0 ...") // 헤더 설정
.header("Accept-Language", "ko")
.cookie("session", "...")
.timeout(5000)
.maxBodySize(1024 * 1024) // 응답 최대 1MB
.followRedirects(true);
Document doc = conn.get(); // 여기서 실제 HTTP GET내부 흐름
[Connection 설정]
│
▼
[HttpURLConnection 열기] ← 내부적으로 JDK의 HttpURLConnection 사용
│
▼
[요청 전송: 헤더 + (POST면 body)]
│
▼
[응답 수신]
│ - status code
│ - 응답 헤더 (Content-Type, Charset 등)
│ - body (byte stream)
│
▼
[Charset 결정]
│ 1) Content-Type 헤더의 charset
│ 2) HTML <meta charset="..."> 감지
│ 3) 기본값(UTF-8)
│
▼
[byte → String 변환]
│
▼
[Parser로 넘김]
여기서 자주 나는 문제들
- 한글 깨짐 → 서버가 Content-Type에 charset을 안 줬거나 잘못 줬을 때.
.parser(Parser.htmlParser())와 함께 응답을execute()로 받아body(String, Charset)로 직접 파싱하는 방식으로 해결. - 응답이 잘림 →
maxBodySize가 기본 2MB. 큰 페이지는.maxBodySize(0)(무제한) 설정. - 404/403 → 기본은 HTTP 에러 시
HttpStatusException..ignoreHttpErrors(true)로 넘길 수 있음. - 로그인/세션 필요 →
Response에서 쿠키를 꺼내 다음 요청에 넣어주기. 세션이 복잡하면 이때가 브라우저 자동화 도구를 고민할 시점.
Spring Boot에서의 실전 팁
Jsoup의 내장 클라이언트는 편하지만 커넥션 풀 관리가 약하다. 예시 수집기처럼 초당 수백 요청을 날려야 한다면:
- HTTP는
OkHttp나Spring WebClient로 처리 - 응답 HTML만 문자열로 받아
Jsoup.parse(html, baseUri)로 파싱
이 조합이 훨씬 안정적이다. HTTP 계층과 파싱 계층을 분리하는 것이 핵심.
String html = webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class)
.block();
Document doc = Jsoup.parse(html, url); // baseUri는 상대경로 해석에 씀2. HTML 파싱 단계
Jsoup.parse(...)가 호출되면 문자열이 DOM Tree로 변신한다. 이 과정은 두 단계로 나뉜다.
HTML 문자열
│
▼
[Tokeniser] ← 문자를 하나씩 읽으며 상태 머신을 돌려 "토큰" 생성
│ - Start tag, End tag, Text, Comment, Doctype 등
▼
[TreeBuilder] ← 토큰을 받아서 DOM Tree를 조립
│ - 열린 태그 스택 관리
│ - `<p>` 안에 `<div>`가 오면 자동으로 `<p>` 닫아주는 등 관대한 처리
▼
Document 객체 (DOM Tree)
Jsoup 파서의 큰 특징은 관대함(lenient) 이다. 브라우저처럼, 닫히지 않은 태그·이상한 중첩·missing quote 같은 것을 알아서 교정해준다.
<html>
<body>
<div>
<p>hi
<p>hello
</body>이런 어설픈 HTML도 Jsoup는 알아서 </p>를 채워 넣고 Tree를 만든다. 이게 실무에서 크게 도움 된다 — 크롤링 대상 사이트의 HTML은 항상 지저분하다.
Parser 종류
Jsoup은 파서를 골라 쓸 수 있다.
Parser.htmlParser()— 기본. 관대함 최대. 대부분의 경우 이걸 쓴다.Parser.xmlParser()— 엄격. 태그 자동 교정 안 함. XML 응답이나 XHTML 파싱용.
RSS나 sitemap.xml을 파싱할 때는 xmlParser로 바꿔야 한다. 안 그러면 태그가 이상하게 재정렬된다.
Document xml = Jsoup.parse(bodyStr, baseUri, Parser.xmlParser());3. DOM Tree 구조
파싱이 끝나면 Document 객체가 반환된다. 내부 구조는 이렇다.
Document (root)
├── DocumentType (doctype)
└── Element(html)
├── Element(head)
│ ├── Element(meta)
│ └── Element(title) ─ TextNode("...")
└── Element(body)
├── Element(div, class="item")
│ └── Element(a, href="...") ─ TextNode("링크")
└── ...
모든 노드는 Node의 서브클래스다.
Element— 태그 (<div>,<a>등)TextNode— 텍스트DataNode—<script>나<style>내부 텍스트Comment— HTML 주석DocumentType— doctype 선언
자주 쓰는 탐색 API
Element body = doc.body();
Elements links = doc.getElementsByTag("a"); // 모든 <a>
Element main = doc.getElementById("main"); // id로
Elements items = doc.getElementsByClass("item"); // class로
// 상대 탐색
Element parent = element.parent();
Elements children = element.children();
Element next = element.nextElementSibling();
// 값 뽑기
String href = element.attr("href"); // 속성값
String absHref = element.absUrl("href"); // 절대 URL (baseUri 기준)
String text = element.text(); // 자식 텍스트 모두 합침
String html = element.html(); // 내부 HTMLtext() vs html() vs ownText()
이걸 자주 헷갈린다.
<div>안녕 <b>세상</b>!</div>text()→"안녕 세상 !"(자식 노드까지 다 합친 텍스트)ownText()→"안녕 !"(자기 자신의 텍스트만,<b>세상</b>은 제외)html()→"안녕 <b>세상</b>!"(내부 HTML 원본)
크롤링 초보가 자주 실수하는 지점: text()는 공백을 정리해준다. 원본 그대로 필요하면 html()이나 wholeText() 를 써야 한다.
4. CSS Selector 매칭
select("...")은 Jsoup의 꽃이다. CSS Selector로 원하는 노드를 뽑아온다.
실제 동작
"div.article > h2.title"
│
▼
[QueryParser] ← Selector 문자열을 파싱해 Evaluator 트리로 변환
│ - "div", "article", ">", "h2", "title" 각각을 평가 규칙으로
▼
[Evaluator tree]
│
▼
[Collector] ← DOM Tree를 순회하며 각 노드에 Evaluator를 적용
│ - 매칭되는 노드를 Elements에 담음
▼
Elements (매칭된 노드 목록)
핵심은 Selector가 매번 파싱된다는 점이다. 같은 Selector를 반복해 쓸 거면 QueryParser.parse(...) 결과를 캐싱하면 성능 이득이 있다 (대량 처리 시).
Jsoup가 지원하는 주요 Selector
CSS3 대부분 + Jsoup 확장이 있다.
| 문법 | 설명 | 예 |
|---|---|---|
tag | 태그 이름 | a |
#id | id | #main |
.class | class | .item |
[attr] | 속성 존재 | [href] |
[attr=val] | 속성값 일치 | [data-type=book] |
[attr^=val] | 시작 | [href^=/] |
[attr$=val] | 끝 | [src$=.jpg] |
[attr*=val] | 포함 | [href*=search] |
E > F | 직계 자식 | div > p |
E F | 후손 | div p |
E + F | 바로 뒤 형제 | h1 + p |
E ~ F | 이후 형제 | h1 ~ p |
E:has(F) | F를 자손으로 가진 E | div:has(a.link) |
:contains(text) | 텍스트 포함 (Jsoup 확장) | a:contains(다음) |
:matches(regex) | 정규식 매칭 (Jsoup 확장) | a:matches(^\\d+) |
이 중에서 크롤링 실무에서 특히 유용한 두 개가 있다.
:contains(...)— 라벨 텍스트로 요소 찾기. "다음" 버튼, "가격" 라벨 옆 값 등을 잡을 때.:has(...)— "링크를 포함하는 li만" 같은 조건부 필터링.
Selector가 안 될 때 진짜 원인
이 문제 하나만 이해해도 크롤링 디버깅 시간이 절반으로 준다.
브라우저에서 F12로 본 DOM과, Jsoup가 파싱한 DOM은 다르다.
브라우저의 DOM은 JS 실행 후의 결과다. Jsoup의 DOM은 서버가 준 원본 HTML 그대로다. Selector가 안 먹으면 90%는 이 차이 때문이다.
디버깅 순서:
doc.html()로 실제 Jsoup가 파싱한 HTML을 눈으로 확인.- 원하는 요소가 거기 있으면 → Selector가 잘못된 것.
- 원하는 요소가 없으면 → JS 렌더링 페이지. Jsoup으로는 못 잡음. 브라우저 자동화나 내부 API 호출로 우회.
Jsoup vs BeautifulSoup 상세 비교
앞선 글에서 간단히 봤지만, 내부 관점에서 다시 비교해보자.
| 항목 | Jsoup | BeautifulSoup |
|---|---|---|
| HTTP 요청 | 내장. Jsoup.connect(...) | 없음. requests/httpx 등과 조합 |
| 파싱 엔진 | 자체 파서 1종 (html/xml 모드) | html.parser, lxml, html5lib 3종 중 선택 |
| 파서 관대함 | 매우 관대 (브라우저 유사) | 파서에 따라 다름. html5lib가 가장 관대 |
| Selector | CSS Selector + :contains/:matches 확장 | CSS Selector (soup.select) + find_all DSL |
| DOM 조작 | 트리 수정·직렬화 지원 (element.html("...")) | 지원 (.replace_with(...) 등) |
| 성능 | 순수 Java. 빠름 | lxml 선택 시 매우 빠름 (C 기반) |
| 에코시스템 | JVM 라이브러리들과 자연 결합 | Python 데이터 스택(pandas, scrapy 등)과 결합 |
핵심 차이는 "HTTP를 내장하느냐" 다. Jsoup는 파싱+HTTP를 한 데 묶어 편하지만, 커넥션 풀이나 재시도 정책이 약하다. BeautifulSoup는 파싱만 담당하고 HTTP는 외부에 위임하는 구조다.
그래서 실무 팁으로 Jsoup을 쓸 때도 HTTP 계층은 분리하라고 앞서 얘기했다. 사실상 BeautifulSoup 스타일로 쓰는 게 안정적이다.
Spring Boot에서의 구조 설계
예시 수집기에 Jsoup를 녹일 때 좋은 구조를 정리해보자.
계층 분리
┌───────────────────────────────────┐
│ Batch Step (Reader/Processor/Writer) ← 배치 흐름
└───────────────────────────────────┘
│ 호출
▼
┌───────────────────────────────────┐
│ CrawlerService ← 도메인 로직 (수집 방침)
└───────────────────────────────────┘
│ 호출
▼
┌───────────────────────────────────┐
│ HtmlFetcher (HTTP 계층) ← WebClient/OkHttp
│ HtmlParser (Jsoup 파싱 계층) ← Jsoup.parse(...)
└───────────────────────────────────┘
- HTTP와 파싱을 각각 다른 컴포넌트로 분리.
- Batch는 도메인 서비스만 알고, HTTP/파싱 세부는 몰라야 한다.
- 테스트할 때
HtmlParser만 골라서 저장된 HTML 파일로 검증 가능.
코드 스케치
public interface HtmlFetcher {
String fetch(String url);
}
@Component
public class WebClientHtmlFetcher implements HtmlFetcher { ... }
public interface HtmlParser<T> {
T parse(String html, String baseUri);
}
@Component
public class ArticleHtmlParser implements HtmlParser<List<Article>> {
@Override
public List<Article> parse(String html, String baseUri) {
Document doc = Jsoup.parse(html, baseUri);
return doc.select("div.article").stream()
.map(this::toArticle)
.toList();
}
// ...
}
@Component
@RequiredArgsConstructor
public class CrawlerService {
private final HtmlFetcher fetcher;
private final ArticleHtmlParser parser;
public List<Article> crawl(String url) {
String html = fetcher.fetch(url);
return parser.parse(html, url);
}
}이 구조의 장점:
ArticleHtmlParser는 HTTP 없이도 테스트 가능. 저장해둔 HTML fixture로 단위 테스트.- 사이트 리뉴얼로 파싱 로직이 바뀌면 파서만 손대면 됨.
- 나중에 HTTP를 OkHttp로 바꾸든 브라우저 자동화로 바꾸든 인터페이스만 유지하면 됨.
Batch Step에서의 사용
@Bean
public Step jsoupCrawlStep(JobRepository repo, PlatformTransactionManager tx,
CrawlerService crawler, JsonFileWriter writer) {
return new StepBuilder("jsoupCrawlStep", repo)
.<String, List<Article>>chunk(20, tx)
.reader(targetUrlReader()) // 크롤 대상 URL 목록
.processor(url -> crawler.crawl(url)) // Jsoup 파싱 결과
.writer(writer) // JSON으로 저장
.faultTolerant()
.retry(IOException.class).retryLimit(3)
.skip(ParseException.class).skipLimit(50)
.build();
}- Chunk size 20 → 20개 URL 처리 후 커밋. 중간에 죽어도 21번부터 재개.
IOException(네트워크) → 3회 재시도.ParseException(사이트 HTML 변형) → 최대 50건 skip, 배치는 완주.
Batch의 안전장치 위에 Jsoup의 파싱이 얹히는 그림이다.
정리
- Jsoup 동작은 HTTP → 파싱(tokenise + tree build) → DOM Tree → Selector 매칭 4단계.
- HTTP 클라이언트는 내장이지만 대량 크롤링에는 약함 → WebClient/OkHttp + Jsoup.parse로 분리 권장.
- 파서는 매우 관대 (브라우저 유사). RSS/XML은
xmlParser로 명시. text()/ownText()/html()/wholeText()의 차이를 정확히 알아야 값이 맞게 나옴.- Selector가 안 먹으면 먼저
doc.html()을 눈으로 확인. 브라우저 DOM과 원본 HTML은 다르다. - BeautifulSoup와의 실질 차이는 "HTTP 내장 여부". 언어 스택 따라가면 됨.
- Spring Boot에서는 HTTP 계층 / 파서 계층 / 도메인 서비스를 분리하고, Batch Step에는 도메인 서비스만 노출하는 구조가 유지보수하기 좋다.
다음 글에서는 Jsoup으로는 불가능한 SPA 사이트를 위해 Selenium과 Playwright를 파헤친다.