
들어가며
뉴스레터 검색 기능을 재설계 및 개선하다가
뜻밖에 먼저 건드려야 할 친구를 하나 발견했다. 바로 HTML 태그였다.
“본문은 텍스트가 중요한데, 왜 굳이 태그까지 다 들고 있어야 하지?”
라는 질문에서 시작된 고민과, 그걸 정리해 가는 과정들을 한 번 적어봤다.
뉴스레터 검색, 첫 번째 장애물: HTML 태그
우리 서비스에서 다루는 뉴스레터는 이런 식으로 생겼다.

모든 뉴스레터는 HTML 형태로 들어오고, DB에는 대략 이런 형태로 저장된다.

검색 기능을 개선하면서, 이 `contents` 칼럼에서 HTML 태그를 걷어내는 작업이 필요해졌다.
현재 `contents`에는 HTML 태그를 포함한 본문 전체가 들어 있다.
문제는 이 칼럼의 길이가 보통 8,000자 ~ 15,000자 정도라는 점이다.
검색 성능을 위해 일부 아티클만 골라서 FULLTEXT INDEX + ngram 파서를 쓰고 있는데,
HTML 태그까지 전부 포함해서 토큰을 만들면
- 토큰 수가 불필요하게 많아지고
- 아티클이 쌓일수록 인덱스 용량이 기하급수적으로 증가한다
결국 “검색에 진짜 필요한 텍스트”만 남겨놓고 인덱스를 만드는 게 훨씬 효율적이다.
실제로 HTML 태그를 걷어내면,
본문 길이가 평균 800 ~ 1,000자 수준까지 줄어든다.
검색 인덱스 입장에서는 거의 10배 가까이 가벼워지는 셈이라,
HTML 태그 제거는 사실상 “있으면 좋은 기능”이 아니라 거의 필수에 가까운 기능이 되었다.
그럼 자연스럽게 다음 질문이 나온다.
“HTML 태그, 어떻게 제거할까?”
제일 먼저 떠오른 건 “잘 만들어진 서드파티 라이브러리 하나 가져다 쓰자”였다.
이걸 단순히 정규표현식으로 지워버리는 건 리스크가 크다고 생각했다.
예를 들어 본문에 이런 문장이 들어올 수 있다.
진짜 텍스트 안에 <이렇게> 꺾쇠를 써야 하는 경우
이걸 무지성으로 `<[^>]*>` 같은 정규식으로 지워버리면
HTML 태그가 아니라, 사용자가 적어둔 실제 텍스트까지 같이 날아갈 수 있다.
그래서 처음부터 정규식을 주력으로 쓰는 방식은 제외하고,
“HTML을 이해하고 파싱하는 서드파티”들을 먼저 살펴보기로 했다.
그렇다면, 어떤 서드파티들이 있을까?
서드 파티 종류
1. Jsoup
HTML을 DOM으로 파싱해서, text() 한 번으로 태그 싹 지우는 라이브러리
태그/속성/텍스트 구조를 다 이해하는 제대로 된 파서
태그 제거뿐 아니라, 크롤링, DOM 탐색, 필터링 등에도 자주 쓰임
이 라이브러리를 쓴다면 워낙 쉽기 때문에 학습 비용이 거의 없고 문서와 예제가 많다.
구현 방식에서 DOM을 구성하기 때문에 아주 거대한 HTML에선 메모리/속도 이슈가 발생할 수 있다고 한다.


레포지토리를 가보면 사용하는 사람도 많고 버전관리도 잘하고 있다.
지금까지 본 것 중 가장 많은 사용자들이 있는 것 같다.
2. Jericho HTML Parser
Jericho HTML Parser는 예전에 많이 쓰이던 HTML 파서 라이브러리다.
최근에는 릴리스가 거의 멈춘 상태지만, 기본적인 텍스트 추출 기능 자체는 아직도 동작한다.
HTML 문서에서 텍스트를 뽑아내거나, 일부 태그를 분석·조작하는 용도로 설계되어 있다.
jsoup처럼 “브라우저스러운 DOM”을 만들어서 다루기보다는, 마크업을 훑으면서 필요한 부분만 처리하는 스타일에 가깝다.
마지막 릴리스가 2010년대 중반쯤이라, 최근 HTML5 환경이나 새로운 자바 버전에 맞춰진 발전은 거의 없다.
2015년 이후로는 릴리스가 끊긴 상태라, “지속적으로 관리되는 라이브러리”를 선호하기에 선택하기는 어려울 것 같다.


3. HTMLCleaner
HTMLCleaner는 이름 그대로, 지저분한 HTML을 한 번 “정리(clean)”해서 잘formed된 구조로 바꿔주는 자바 기반 HTML 파서다.
원래 목적 자체가 “웹에서 긁어온 더러운 마크업을 브라우저처럼 해석해서, 괜찮은 XML/HTML로 만들어주자”에 더 가깝다.
HTMLCleaner는 대략 이런 흐름으로 동작한다.
- 깨진 태그, 잘못 닫힌 태그, 중첩이 엉킨 HTML을 파싱한다.
- 브라우저가 DOM을 만들 때 비슷하게 적용하는 규칙을 따라, 태그를 재배치·보정해서 정상적인 트리 구조로 만든다.
- 그 정리된 트리에서 텍스트를 꺼내 쓰거나, XML/깔끔한 HTML로 다시 출력할 수 있다.
그래서 Jsoup처럼 “바로 DOM에 접근해서 text()를 꺼내 쓰는 라이브러리”라기보다는,
“먼저 HTML을 정돈하고 나서 쓰는 정리기(Cleaner)”라는 색깔이 더 강하다.
장점부터 보면, 정말 지저분하게 깨진 HTML을 만나도 한 번 정리 과정을 거치고 나면 훨씬 다루기 편해진다는 점이 가장 크다. 게다가 결과를 꽤 깔끔한 HTML/XHTML 형태로 다시 내보낼 수 있기 때문에, 이후에 다른 XML/HTML 도구나 파이프라인에 태워야 할 때도 궁합이 괜찮다. “먼저 HTMLCleaner로 한 번 씻기고, 그 다음 다른 도구에 넘기는” 식의 구조를 만들기 좋은 편이다.
반대로 단점도 분명하다. 그냥 “HTML 태그만 빠르게 걷어내고 텍스트만 얻고 싶다” 정도의 요구라면, HTMLCleaner는 조금 과한 선택일 수 있다. 구조를 정리하고, 트리를 만들고, 다시 출력할 수 있는 기능까지 포함하다 보니, 단순 태그 제거만 필요한 경우에는 오버스펙에 가까운 느낌이다. 게다가 Jsoup처럼 친숙한 문법과 풍부한 예제를 제공하는 라이브러리는 아니라서, 처음 접하는 입장에서는 문서나 API 스타일이 다소 낯설게 느껴질 수도 있다. 프로젝트 분위기도 최신 트렌디한 라이브러리라기보다는 “꽤 오래된 도구”에 가깝다.
정리하자면, HTMLCleaner는 HTML을 한 번 깨끗하게 ‘세탁’한 뒤에 텍스트를 뽑거나, 다른 도구들로 넘기고 싶을 때 고려해볼 만한 선택지다.
4. Apache Tika
Apache Tika는 “텍스트 추출 전담 엔진”에 가까운 라이브러리다.
단순히 HTML만 다루는 게 아니라, 아래 같은 것들을 전부 텍스트로 바꿔준다.
- HTML, XML
- MS Office(Word, Excel, PowerPoint)
- OpenOffice, RTF
- 이미지에서의 메타데이터 등등…
사용법 자체는 꽤 단순하다.
Tika tika = new Tika();
String text = tika.parseToString(inputStreamOrBytes);
이렇게만 호출해도, 파일 포맷을 스스로 감지해서
“이 문서에서 뽑을 수 있는 텍스트를 최대한 긁어서” 문자열로 돌려준다.
그래서 이 라이브러리는, “우리는 HTML만 처리하는 서비스야” 같은 경우에는 솔직히 좀 과하고, “메일 본문뿐 아니라 PDF·Word 같은 첨부파일까지 한 번에 검색하고 싶다” 같은 요구가 있을 때 진지하게 검토할 만한 도구에 가깝다.
장점을 정리하면,
문서 포맷을 일일이 구분하지 않고 “그냥 문서를 넣고 텍스트를 받고 싶을 때” 압도적으로 편하다.
이메일 시스템에서 첨부파일까지 검색 대상에 포함시키고 싶다면, Apache Tika는 거의 항상 후보 목록에 올라오는 편이다. “첨부까지 전부 검색 가능하게 만들고 싶다”는 요구가 나오는 순간, Tika는 꽤 매력적인 선택지가 된다.
반대로 단점도 분명하다.
HTML 태그만 제거해서 텍스트로 만들고 싶은 정도의 요구라면 너무 무겁다.
라이브러리 자체도 크고, 내부에서 포맷 감지 → 파서 선택 → 파싱 과정을 전부 거치기 때문에 그만큼 비용이 든다.
그래서 단순히 “뉴스레터 본문 HTML → 텍스트” 수준의 작업에는 Jsoup 같은 HTML 파서에 비해 확실히 과한 선택일 수 있다.
정리하자면, Apache Tika는 HTML만 다루는 서비스 입장에선 다소 과한 도구이고,
메일 본문뿐 아니라 PDF·Word 같은 첨부파일까지 함께 검색하고 싶을 때 빛을 발하는 도구라고 볼 수 있다.
“검색 범위를 어디까지 보고 있는지”에 따라, 사용할지 말지를 결정하게 되는 라이브러리다.
5. JFiveParse
JFiveParse는 digitalfondue에서 만든 순수 자바 HTML5 파서다.
이름처럼 HTML5 파싱에 꽤 진심인 친구인데, html5lib에서 제공하는 토크나이저/트리 구성 테스트 중 스크립트가 필요 없는 부분은 전부 통과하도록 만들어졌다.
특징을 정리하면
HTML5 스펙을 꽤 충실히 따라가는 파서
html5lib 테스트 스위트(토크나이저 + 트리 구성 비스크립트 케이스)를 통과하도록 구현됨
0 dependencies + 작은 JAR 크기
외부 의존성이 아예 없고, JAR 크기도 대략 150KB 정도로 가볍게 유지하는 걸 목표로 한다.
문서 전체/조각(fragment) 둘 다 파싱 가능
String에서 바로 파싱하거나, Reader를 통해 스트리밍 파싱도 지원한다. 다만 인코딩은 호출하는 쪽에서 알고 있어야 하고, 자동 인코딩 감지는 지원하지 않는다.
지속적으로 관리되는 프로젝트
GitHub를 보면 2025년까지도 커밋이 이어지는 등, 생각보다 꾸준히 관리되고 있는 편이다.
장점 쪽으로 보면,
HTML5 스펙에 더 가까운 파서가 필요할 때 후보로 올릴 만하다.
“브라우저가 파싱하는 방식과 얼추 비슷하게, 깨진 HTML까지 최대한 관대하게 받아주고 싶다” 쪽에 가깝다.
라이브러리 크기/의존성에 민감할 때 꽤 매력적이다.
0 dependency + 소형 JAR이라, “메일 처리용 유틸에 괜히 거대한 HTML 파서를 끌어오고 싶지 않다”는 팀에 잘 맞는다.
반대로 단점/고민거리는 이런 쪽에 가깝다.
생태계와 예제가 Jsoup만큼 풍부하진 않다.
Maven Central에서 사용 예시를 보면, 실제로 의존하는 프로젝트 수가 많지는 않다.
“태그 싹 지우고 텍스트만”이라는 고수준 API는 직접 만들어야 한다.
DOM 비슷한 트리를 제공해주지만, Jsoup의 doc.text()처럼 한 줄로 “텍스트만 주세요” 하는 느낌은 아니어서,
보통은 JFiveParse로 파싱 → 노드를 순회하면서 텍스트 노드만 모으는 커스텀 TextExtractor를 한 번 더 감싸서 쓰게 된다.

활발하지는 않지만 관리는 되고있다는 걸 알 수 있다.
왜 굳이 여러 서드파티를 살펴볼까?
얼핏 보면 “그냥 적당한 거 하나 골라 쓰면 되지 않나?” 싶은데, 막상 라이브러리 고르기 모드에 들어가 보면 체크리스트가 끝도 없이 늘어난다.
- 유지보수가 잘 되고 있는지
- 얼마나 많은 사람들이 쓰는지
- 문서와 예제가 충분한지
- 이슈가 꾸준히 처리되는지
이런 것들을 보는 이유도 물론 있다.
그런데 내가 여러 후보를 일부러 더 찾아보는 진짜 이유는 따로 있다.
“하나가 실패했을 때, 바로 대신 들어올 다음 주자가 있는가?”
서드파티 라이브러리는 결국 내가 통제할 수 없는 코드다.
HTML이 비정상적으로 들어온다든지, 라이브러리 내부 버그라든지,
어딘가에서 예상 못 한 예외가 터질 수 있다.
그때 그냥 실패로 끝낼 건지,
아니면 다음 후보로 넘겨서 한 번 더 시도해볼 수 있는 구조인지에 따라
안정성 측면에서 차이가 크게 벌어진다.
그래서 나는 메인 1개 + 서브 1개 정도면 충분하다고 생각했고,
그 둘을 어떻게 엮어서 "안전한 구조"로 만들지에 더 집중했다.
DispatcherServlet의 getHandler()에서 가져온 아이디어
구조를 어떻게 잡을까 고민하다가,
우테코 레벨2에서 스프링을 배우면서 DispatcherServlet을 뜯어보던 기억이 났다.
그때 봤던 getHandler() 메서드가 머릿속에 딱 떠올랐다.
DispatcherServlet은 한 번에 딱 하나의 핸들러만 고르는 역할을 한다.
이때 내부에서 하는 일은 대략 이런 흐름이다.
등록된 HandlerMapping 들을 순서대로 훑어보면서
“이 요청을 처리할 수 있는 핸들러가 누구인지”를 차례로 물어보고
그 가운데 가장 먼저 “제가 처리할 수 있습니다”라고 응답하는 핸들러를 선택한다
즉, 여러 후보를 앞에서부터 차례로 시도해 보고, 가장 먼저 ‘가능하다’고 답하는 애를 채택하는 구조다.
나는 이 동작 방식이 HTML 파서에도 잘 어울린다고 느꼈다.
단순히 아래처럼 1차원적인 `try-catch`로만 묶는 구조가 아니라,
try {
서드파티 1 동작
} catch (e) {
서드파티 2 동작
}
동작 가능한 파서들을 리스트에 담아두고 앞에서부터 순서대로 돌려 보다가 처음으로 성공한 결과를 바로 쓰는 방식으로 가져오고 싶었다.
동작 가능한 후보들을 리스트로 만들어두고, 차례대로 시도하면서, 가장 먼저 성공한 서드파티를 사용한다.
이게 훨씬 읽기 좋고, 나중에 후보를 추가하거나 순서를 바꾸기 편하다.
구현
먼저 인터페이스를 정의해줬다.
public interface HtmlTagCleaner {
String clean(String html);
}
그 다음, 실제로 HTML을 파싱해서 태그를 제거하는 구현체들을 만든다.
예를 들어 Jsoup를 사용하는 구현체는 이렇게 생겼다.
import org.jsoup.Jsoup;
public class JsoupHtmlTagCleaner implements HtmlTagCleaner {
@Override
public String clean(String html) {
return Jsoup.parse(html).text();
}
}
(여기서 JFiveTextExtractor, RegexHtmlTagCleaner 등 다른 후보들도
같은 인터페이스를 구현하도록 맞춰준다.)
여러 구현체를 failover 체인으로 묶기 위해 스프링 설정에서 FailoverHtmlTagCleaner를 Bean으로 등록한다.
@Configuration
public class HtmlCleanerConfig {
@Bean
public HtmlTagCleaner htmlTagCleaner() {
return new FailoverHtmlTagCleaner(
new JsoupHtmlTagCleaner(),
new JFiveTextExtractor(),
new RegexHtmlTagCleaner()
);
}
}
서비스 쪽에서는 HtmlTagCleaner 인터페이스만 주입받고,
뒤에 어떤 구현체들이 몇 개나 줄 서 있는지는 전혀 알 필요가 없다.
@Slf4j
public class FailoverHtmlTagCleaner implements HtmlTagCleaner {
private final List<HtmlTagCleaner> cleaners;
// .. (생략)
@Override
public String clean(String html) {
if (!StringUtils.hasText(html)) {
return "";
}
for (HtmlTagCleaner cleaner : cleaners) {
try {
String cleaned = cleaner.clean(html);
if (cleaned != null) {
return cleaned;
}
log.warn("{} 가 null을 반환해서 다음 후보로 넘어갑니다.", cleaner.getClass().getSimpleName());
} catch (Exception e) {
log.warn("Cleaner {} 실패", cleaner.getClass().getSimpleName(), e);
}
}
log.warn("모든 cleaners 실패");
return html;
}
}
FailoverHtmlTagCleaner에서는
- HtmlTagCleaner 구현체들을 List<HtmlTagCleaner>로 받아두고
- for문을 돌리면서 하나씩 clean()을 호출해보고
- 예외가 나면 quietly 로그만 남기고 다음 후보로 넘어가고
- 처음으로 정상적인 결과를 돌려준 구현체의 결과를 그대로 반환한다.
이렇게 해두면,
- Jsoup이 대부분의 케이스를 처리하고
- 만약 Jsoup이 예상치 못한 HTML에서 실패하더라도
- Jericho 같은 두 번째 후보가 뒤에서 받아준다
이렇게 해두면, Jsoup이 대부분의 케이스를 처리하고,
만약 Jsoup이 예상치 못한 HTML에서 실패하더라도,
JFiveTextExtractor나 정규식 기반 클리너 같은 두 번째, 세 번째 후보가 뒤에서 받아주는
failover 구조가 자연스럽게 만들어진다.
결국 중요한 건
“어떤 서드파티를 쓰느냐” 뿐만 아니라
“그 서드파티가 실패했을 때, 시스템이 어떻게 반응하도록 설계했느냐”
라고 생각했고,
그 고민의 결과가 지금의 리스트+순회 기반 Failover Cleaner 구조다.
이 구조로 한 이유 (확장성 / 테스트 / 운영 관점)
이걸 굳이 이렇게까지 나눠서 설계한 이유는 단순히
“멋있어 보이려고”가 아니라, 실제로 운영할 때 이점이 많기 때문이다.
1. 확장성: 새로운 파서를 붙이기 쉽다
나중에 “새 HTML 파서를 한 번 써보고 싶다”라는 요구가 생기면 어떻게 될까?
- NewFancyHtmlTagCleaner 구현체를 하나 더 만들고
- 설정에서 이렇게만 바꿔주면 된다.
@Bean
public HtmlTagCleaner htmlTagCleaner() {
return new FailoverHtmlTagCleaner(
new JsoupHtmlTagCleaner(),
new JerichoHtmlTagCleaner(),
new NewFancyHtmlTagCleaner(), // 추가
new RegexHtmlTagCleaner()
);
}
서비스 코드(ArticleService, IngestionPipeline 등)는
여전히 HtmlTagCleaner 인터페이스만 알고 있고,
뒤에서 어떤 서드파티들이 줄 서 있는지는 전혀 모른다.
라이브러리를 교체하거나 순서를 바꾸는 일도
구현체 리스트만 건드리면 끝 나기 때문에,
개발자 입장에서 유지보수가 훨씬 편해진다.
테스트: 가짜 Cleaner로 쉽게 시뮬레이션 가능
이 구조의 또 다른 장점은 테스트가 쉽다는 점이다.
예를 들어
- “첫 번째 파서가 실패하고, 두 번째 파서가 성공할 때” 케이스를 테스트하고 싶다면
- 간단하게 이런 가짜 구현체들을 만들어서 넣어줄 수 있다.
class AlwaysFailCleaner implements HtmlTagCleaner {
@Override
public String clean(String html) {
throw new RuntimeException("항상 실패");
}
}
class AlwaysSuccessCleaner implements HtmlTagCleaner {
@Override
public String clean(String html) {
return "cleaned";
}
}
그리고 테스트에서는
HtmlTagCleaner cleaner = new FailoverHtmlTagCleaner(
new AlwaysFailCleaner(),
new AlwaysSuccessCleaner()
);
String result = cleaner.clean("<p>hello</p>");
// result == "cleaned"
이렇게 시나리오별로 조합해서 넣을 수 있기 때문에,
- failover 로직이 제대로 동작하는지
- 모든 후보가 실패했을 때 raw HTML을 돌려주는지
같은 것들을 쉽게 검증할 수 있다.
운영: 장애가 나도 “어디서, 얼마나”가 보인다
마지막으로, 이 구조는 운영과 모니터링 관점에서도 이점이 있다.
Jsoup에서 예외가 나면 로그에 JsoupHtmlTagCleaner 실패 이유=...
Jericho에서 또 예외가 나면 JerichoHtmlTagCleaner 실패 이유=...
처럼 정확히 어느 레벨에서 실패했는지 남는다.
나중에 로그/메트릭을 보면,
- “전체 요청 중 몇 %가 메인 파서에서 처리되었는지”
- “얼마나 자주 failover가 발생하는지”
- “특정 뉴스레터 발행처에서만 유난히 자주 깨지는 HTML이 들어오는지”
같은 것들을 파악할 수 있다.
무엇보다 중요한 건,
어떤 서드파티 하나가 의도대로 동작하지 않더라도,
메일 저장과 검색 파이프라인 전체가 같이 쓰러지지 않게 막는 구조
라는 점이다.
마무리
정리해 보면, 이번 작업은 단순히 “HTML 태그를 한 번 지워보자” 수준의 문제가 아니었다.
뉴스레터 본문이 대부분 HTML로 들어오고 검색 인덱스 용량과 응답 속도에 직접적인 영향을 주고
서드파티 라이브러리가 언제든지 예상치 못한 방식으로 실패할 수 있기 때문에
“무엇을 쓰느냐” 못지않게 “어떻게 설계하느냐”가 중요했다.
HTML 태그를 지우는 동작이 실패하면 인덱스 10배는 더 들어가고 만약 실패하면 검색기능이 제대로 되지 않기 때문에 어떻게든 동작해야했다.
그래서 봄봄에서는
`Jsoup` 같은 검증된 라이브러리를 1순위로 쓰되 필요하면 다른 파서로 자연스럽게 넘어갈 수 있는 failover 구조를 만들고
서비스 코드에서는 `HtmlTagCleaner`라는 인터페이스만 바라보게 해서 성능·안정성·확장성 사이의 균형을 맞추려고 했다.
앞으로도 새로운 HTML 파서나 텍스트 추출 도구를 도입하게 되더라도,
기존 코드를 뜯어고치기보다는 이 체인에 “후보 하나 추가”하는 수준에서
유연하게 대응할 수 있을 거라고 기대하고 있다.
HTML 태그를 어떻게 지울지 고민하고 있다면,
“정규식으로 한 방에” 대신 어떤 서드파티를 쓸지
그리고 그 서드파티가 실패했을 때 어떻게 다룰지
까지 같이 고민해보면 좋을 것 같다.
'서비스 운영 일지 > 봄봄' 카테고리의 다른 글
| 봄봄 서비스에 맞게 검색 성능 개선기 (4) | 2025.11.17 |
|---|---|
| 저장이 왜 안되는 거지? (0) | 2025.11.15 |