Html Parser는 검색엔진 개발에 있어 가장 기본적이면서도 문자열의 논리적 구성을  담당하는 역할을 하는 중요한 모듈이므로, 속도, 안정성, 정확성등 여러가지 섬세하게 고려해야 될 부분이 많다.
 학생시절 부터 관심이 있어서 Html Parser를 어떻게 만들면 잘 만들수 있을까 참 많은 고민을 한 것 같다.
많은 세월이 흘렀음에도 아직도 해답을 찾지는 못했고, 대략적인 실패한 경험들을 토대로 개발시 고려사항들을 정리해 보고자 한다. (참고로 Version 6 정도의 Html Parser가 현재 개발중이다.)


1. HTML파서와 XML파서의 제작은 차원이 틀리다.
 
 XML 문서와 같은 경우 Well-Formed check를 통해 Tag의 열고닫음, XML의 문법적인 유효성을 강하게 체크하지만,
HTML은 그렇지 못하다. 대표적으로 <BR> 과 같은 Tag는 </BR>과 같이 닫기Tag가 명시적으로 사용되고 있지는 않으므로,
HTML Dom Tree 생성시 계층구조 구성을 위한 별도의 처리가 필요하다.
 
 즉, HTML은 모호성(Ambiguous)을 아직까지는 문법적으로 허용하며, 문법의 정확성을 보장할수 없는 변칙적인 언어이다.
대부분의 HTML DOM 파서는 1Pas 시 Well-Formed 조건에 맞게 XML 파서로 파싱이 가능한 형태의 문서를 생성하고, 
2Pass시 Dom Tree를 생성하는 구조로 되어있다. 



2. 속도와 공간사용량 타협이 필요하다.

 대부분의 Parser가 아마도 최단 시간내에 Parsing을 완료하는 것을 목표로 할 것이다. 물론 정확하게 파싱해 내는 것은 말 할 것도 없다. 하지만 파서의 속도가 아무리 빠르다 할지라도 Web 상의 정보, 소스를 Network을 통해 가져오는 Html Parser의 특성상 극단적으로 속도를 빠르게 하는 것이 무의미한 작업일 수도 있다.

 하지만 속도가 극단적으로 중요한 상황이라면 네트웍에서 데이터를 가져오는 동안 소스의 부분을 여러부분으로 나누어 독립적인 쓰레드의 병렬 파싱을 하도록 한는것도 가능하지만, 가장 간단하면서도 빠른 방법은 일단 데이터를 웹에서 다운받아 StringBuffer나 Char의 배열에 미리 저장해 두고 parsing하는 것이 속도 측면에서는 더 효율적이 이었다.

 파싱을 위한 초기작업으로 HTML 문서에서 의미의 단위별로 텍스트의 조합, 즉 Token을 추출하는 Lexing 작업이 선행된다. 파서자체, 객체지향 프로그램 언어적 시각으로 보았을때 파싱의 대상이 되는 Source라는 커다란 객체를 문법으로 정의된 의미의 객체단위로 분리하는 작업이라 할 수 있다. 보통의 경우 Token의 Text와 Token Object가 최소한 1:1의 관계를 이루게 된다. 모든 Parser가 그러하듯 Lexing의 가장 작은 단위동작은 char, 즉 문자 하나를 소스로 부터 가져와 비교하고 그 문자 하나하나가 모여서 하나의 문자열 Token을 이루게 된다. Char단위의 문자열 연산이 상당히 많으며, String 연산 또한 많다. Token의 Size는 예측할 수 없으므로 결국 Token값의 최종결과는 정적으로 할당된 배열에 저장하게 되는데, 토큰 생성시 마다 정적배열의 생성은 극단적으로 H/W power가 약한 시스템에서는 무리가 될 수 있으며, 배열의 전체공간이 Pull인 경우 배열공간을 갱신하는(ex, StringBuffer) 자료구조를 쓸 경우 속도적인 측면에서 손해를 보게 된다.

언제나 그렇듯, 속도와 공간의 타협점을 찾아야 한다.



3. 사용언어의 추출 : UTF-8, EUC-KR

 다양한 언어가 공존하고, Charset이 다양하게 존재하는 웹의 구조적 특성상, 웹에서 어떤 데이터를 취득했을때, 그 데이터가 어떠한 Charset을 사용했는지 알아내는 것이 HTML 문서 파싱의 선행 작업이라 할 수 있다. 어떠한 Charset을 사용하는지는 아래와 같은 단서를 이용해서 알 수 있다.

a. Http Protocol Header의 Charset attribute 값에서 추출
b. Html Header 내의  <meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
c. <Html lang="ko"> Tag의 lang 속성

a의 경우 서버 자체적으로 제공해 주는 기능이라 최초 Lexing전 charset의 정보를 취득할 수 있으며, b, c의 경우 일단 데이터를 저장하고 pre-reading을 통해 해당정보를 추출해야 한다.(Parsing 속도 측면에서 좋은 방법은 아니며, 초기 ASCII 모드형태로 데이터를 읽으면서 해당정보가 추출된 이후 charset 형태의 맞추어 문자 읽기를 동적으로 변환하는 방법으로 가능하다)

저러한 정보를 얻을 수 없는 최악의 상황은 byte연산을 통해 현재 어떠한 type의 문자를 포함하고 있는지 별도의 연산과정이 더 필요하다.



4. 무한 루프의 가능성

 앞서 언급한 바와 같이 html 언어는 모호성을 허용하며, 변칙적인 문법의 사용이나, 정확하지 않은 문법을 사용한 문서들도 상당히 많음을 항상 염두해 두어야 한다. 아마도 모든 파서가 데이터로 부터 커서나 포인터를 증가시켜 문자를 읽어들이면서 파싱이 진행되는 형태를 취하고 있을 것으로 생각된다. 
 
 커서의 진행방향이 consume만 하는, 값이 증가만 하는 단방향성만을 가진 다면 큰 문제는 없으나, 경우에 따라서는 커서의 backward가 필요한 경우가 종종 발생한다. 생각해 볼수 있는 가장 심각한 시나리오는 커서가 진행을 못해 파싱 과정에서 파서 자체의 프로세스가 무한루프에 빠지는 경우이다.

 커서가 지속적으로 진행하는 단방향성을 가지는 것이 최고의 시나리오이나 그렇지 못한 문법구조를 가졌다면 무한루프의 가능성을 항상 염두해 두어야 되겠다. (불행히도 수많은 Case에 대한 Test 필요)



5. 주석처리상의 문제

 위에서도 언급한 바와 같이 HTML parser의 문자처가 단방향성을 가진다면 더할 나위 없이 좋겠지만, <script>, <style> <!-- 주석 -->등 과 같은 요소로 인해 사실상 커서의 backward나 미리 읽기버퍼를 현재 문자이후에 등장하는 문자열을 검사해야 하는 경우가 발생한다. 

 <script>나 <style> 태그의 경우 Value값의 경우 HTML과는 다른 별개의 언어의 문법을 사용하므로 HTML Parsing 모듈이 해당 언어를 파싱할 수 있는 모듈에게 제어권을 넘기거나 혹은 </script> 나 </style>이라는 문자열이 나타날때 까지 모든 문자열을 무시해야 한다. 주석의 경우에도 마찬가지 인다.
문자 단위로 커서를 이동하면서 미리읽기를 통해 이후에 뒤따르는 문자열을 분석하는 작업이 필요하다. 속도 측면에서 상당한 마이너스가 아닐까 생각된다.



6. DOM Object의 경량화

 앞서 언급한 바와 같이 Token 과 Token의 Object는 1:1 관계를 이룬다. 일반적으로 Java Application이 실행시 부하량(시간지연)에 가장 많이 영향을 주는 요소는 객체의 생성 시간이다. 객체의 생성량이 크지 않는 경우라면 시스템 성능에 미치는 영향이 미미하겠지만 대형 포털 사이트 메인페이지를 파싱하면 보통 2000개 이상의 Token이 생성된다. Token Object 객체도 똑같이 2000개 이상 생성된다는 소리이며, Tree 생성시 구조에 따라서 생성 객체수는 2배이상 증가 될 수 있다. 

 일반적인 프로그램에 비해 객체의 생성량이 많고, 페이지의 특성에 따라 생성되는 수를 예측할 수 없기 때문에, 일반적으로 Class 생성시 DOM을 구성하는 단위 객체는 최대한 경량화 되어야한다. C의 구조체처럼 데이터만을 포함될 수 있는 형태가 적절하며 연산이 들어간 method의 사용은 최소화 하는 것이 바람직해 보인다.

 전체 디자인적으로는 Parsing 을 하는 행위모듈과 파싱후 생성되는 객체, 데이터 모듈을 Class 단위로 철저히 분리해야 한다. 또한 Parsing의 행위모듈의 경우에도 최대한 static 하거나 single object에서 실행되도록 하여, 객체의 생성 자체를 최소화 하는 것이 바람직한 방향으로 보인다. 

 객체의 생성이 과도하다면 Object의 메모리 영역을 clone copy를 통해 새로운 객체를 생성하는 Prototype Design Pattern을
고려해 볼 수 있으나, Prototype 패턴은 사용 class의 구조적 특성에 따라 효율이 오히려 떨어지는 경우도 있다. 객체 생성시 생성자의 구현이 복잡하다면 고려해 볼 수 있으나, 생성자의 내용이 없는 DOM의 Token 혹은 Node Object 생성시에는 시간적 효율은 거의 없거나 증가하는 양상을 보였다.



7. 문법 확장성의 고려
 새로운 문법에 대한 확장성에 대한 고려도 물론 필요하다. 전체 설계시 영향을 주는 부분이고, 지나친 환장성은 속도, 공간적 효율을 저해한다. 이 부분은 현재 개발중인  YGHtmlParser Release시 별도 언급을 하겠다.


- 김영곤(gonni21c@gmail.com) 

Java에서는 문자열 처리를 위해서 String이라는 기본 Class를 제공합니다.

하지만 String은 구조적 특성상 속도상으로는 효율이 떨어지는 편입니다.

아래의 코드를 예로 들면,

String str = "abc" + "def" + "gh";

str에는 "abcdefgh" 라는 문자열이 최종적으로는 저장되지만..

실제 str에 값이 할당되기 전까지 아래의 4개의 String 객체가 생성됩니다.


new String("abc");

new String("def");

new String("gh");

new String("abcdefgh");


new 로 4개의 객체가 생성되는데, 실제 프로그램에서 느껴지는 체감속도에는 크게 문제가 없는것 처럼 느껴지지만,

parser 개발시 추천, 수만의 위와 같은 String연산을 처리했을시 속도에는 치명적이다.

실제 객체지향 프로그램의 Coding에서 new 를 써서 객체를

메모리상에 할당하는 시간의 비중이 프로그램 수행속도에  가장 큰 영향을 미친다고 볼수 있다.

즉, new를 사용하여 객체를 생성하는 것은 비용이 매우 높은 일중에 하나라 볼 수 있다


그래서 자바에서는 이러한 String상의 문제를 해결하기 위해 StringBuffer를 제공한다.


StringBuffer str = new StringBuffer("abc");

str.append("def");

str.append("gh");


실제 위의 코드는 String과는 달리 str 객체를 단한번 생성하고, 문자열을 추가시킨다.

원리는 String과 달리 StringBuffer는 문자열을 저장하기위해 초기배열을 10정도로 메모리상에 할당한다.

그래서 매번 메모리에 고정된 문자 저장 공간을 할당하는 String에 비해  객체생성에 대한 비용이 상당히 줄어들기 때문에,

문자열 처리의 연산속도가 String에 비해 상당히 빠르다.


하지만 StringBuffer도 고정크기 Buffer를 사용하므로, Buffer크기 이상의 문자열이 할당될 경우,

내부적으로 새로운 Buffer크기의 StringBuffer 객체를 생성하므로 자료의 구조에 따라

일시적으로 속도가 느려지는 문제가 생긴다. 궁극의 해결책은 못된다.


String과 StringBuffer의 공통적인 문제는 문자열 저장을 위해 내부적으로는,

고정크기의 char 배열을 사용하는데 있어, 배열보다 큰 공간이 필요할때,

새로운 객체를 생성하기 때문에 시간적인 비효율이 존재한다.


그래서, 고정공간 배열이 아닌 Linked List 구조의 가변공간 기반의 자료형이 있으니,

이름하여 Java Rope이다.


아래의 사이트에 자세한 설명이 있으니 살펴보도록 하자.

http://www.ibm.com/developerworks/kr/library/j-ropes/index.html


아래는 특정 웹사이트의 URL의 내용을 긁어 오는 코드이다.


 private void loadRope(URL url, int timeout) throws IOException
{
  
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
  
String ct = conn.getContentType(), charset = "euc-kr";
if (ct.indexOf("charset=") > 0)
  charset = ct.substring("charset=".length() + ct.indexOf("charset="));

conn.setConnectTimeout(timeout);
conn.setInstanceFollowRedirects(true);
String contentType = conn.getContentType();

if (contentType != null && contentType.startsWith("text/html")) {
InputStreamReader isr = new InputStreamReader(
conn.getInputStream(), charset);
BufferedReader br = new BufferedReader(isr);
String line = null;
//this.sBuf = new StringBuffer();
while ((line = br.readLine()) != null) {
         this.rope.append(line + "\r\n");
}
   br.close();
} else {
throw new IOException("Invalid URL :" + url);
}
}

BufferedReader로 읽은 문자열을,

StringBuffer와 Rope를 이용해 수행 속도를 측정한 결과 Rope가 5배 이상 빠른 결과를 보여주었다.


물론,

Rope도 만능은 아니다. 하지만 Cursor의 rollback이 없는 parser(lexer)의 구현이라면, 

사용해볼만한 가치가 충분히 있다고 보여진다.


김영곤(gonni21c@gmail)

제가 그냥 개인적으로 만들어 쓰고 있는 자바 기반 Html 파서 , YGHtml Parser 0.3.3 버전입니다.

이 Parser는 최대한 가벼우면서도 정확히 Token을 추출할 수 있어야 한다는 목적으로 제작되고 있습니다..
타 공개 Parser에 비해 제공 기능은 많이 떨어지지만, 가볍고 빠르고 대부분의 공개 Parser에서 잘못된 처리를 하는 JavaScript나 Comment 부분의 Token을 비교적 정확히 추출하는데 중점을 두었습니다.

이전 버전에 비해 개선사항은 아래와 같습니다.

- String 연산을 StringBuffer로 대체, 처리속도 대폭향상
- 일부 Lexing 오류 제거

** 보고된 문제점
- Style Tag Value 처리 불가

아래는 해당 프로젝트내에 org.yglib.html.ui.NodeViewer를 실행하여 얻은 Google 첫 페이지, HTML DOM Tree Rendering 화면입니다.

사용자 삽입 이미지

국내 대형포탈 첫화면은 대부분 정상적으로 처리하였으나 일부 페이지에서는 여전히 Parse Tree 생성시 모호성 처리상의 문제로 정상적으로 동작하지 않습니다.

사용법은 프로젝트 내의 각 파일의 main method의 코드를 참조하시면 충분하리라 생각됩니다.

프로젝트 다운로드 :

 아직 최종배포를 위한 Interface는 정의되지 않았습니다. DOM 부분은 표준화된 XML 기반의 API를 참조하여 인터페이스를 구현할 예정입니다.

이번이 여기서 배포되는 마지막 버전이 될지도 모를것 같네요. 흠..
 
YGHTML V0.1.1버전에 다음의 기능을 추가하거나 개선하였습니다.

- HTML DOM Tree 생성 기능 추가
- DOM Tree Viewer 추가
- 주석 파싱 오류 제거
- 약간의 리펙토링(-_-;)

보고된 문제점
- Style Tag 처리불가

아래는 Sample 소스를 이용하여, DOM Tree를 생성, Viewer로 출력된 이미지 입니다.

파싱대상 소스(test.html)

<!document html>
<html>
<head>
  <title>가라지하 사바하</title>
</head>
<script>
<!--
  <a href="dcinside.com">아햏햏</a> fdsf <
  adf, < "" <:>
-->
</script>
<body bgcolor=red background="img/bg.jpg">
<br>
<br>
<br>
  <a href="http://kr.yahoo.com">야후</a>
  <a href="kkk"><img src="yahoo.jpg"></img><img src="yahoo.jpg"/></a>
  <table bgcolor=lightgrey width=95%>
  <tr><td>..</td></tr>
  </table>
  <!-- 비정규 파싱 -->
</body>
</html>


사용자 삽입 이미지

아래는 Naver 메인화면 소스를 적용해 본 이미지 입니다. (역시 복잡합니다.)

사용자 삽입 이미지

해당 소스파일 다운로드 ( Eclipse Project )

정확한 요구사항 분석도 없이,
설계에 대한 개념도 없이 HTML Parser가 절실한 상황에서 급조된 코드입니다..
분명히 저같이 Parser가 절실한 사람이 있다고 생각되기에, 저질 코드라도 용감, 무식하게 공개합니다..

소스의 질적인 부분에 대한 지적은 할말 없습니다..
또한 제가 파서에 대한 개념이 많이 부족한 상태라,
해당소스에서 더 괜찮은 아이디어나 제안이 있으시다면 언제든지 리플 부탁해요..

- 김영곤 (gonni21c@gmail.com)

Java로 만들어진 Html Parser는 제법 많이 있다.

- org.htmlparser
- nekoParser

정도가 있는데, htmlParser는 다소 무겁기는 하지만 잘 정의된 구조에, 4만 페이지 정도의 Hard한 환경에서 Parsing Test를 해보았으나, 모든 문서를 Parsing해 내는(파싱 알고리즘 통과) 안정성을 자랑하였다.

하지만, HTML Parser를 한번이라도 개발한 경험이 있는 사람들은 알겠지만..
HTML Parser의 최대 난제는 아무래도 script value element 처리를 비롯한 html 문법을 무시해야 하는 영역의 구현이 아닐까 싶다.
불행히도 대부분의 Open Source 파서는 이러한 부분에 대한 정확한 처리를 하지 못한다. Script 내부의 Text까지 HTML 문법을 적용하여 Parsing하고, Source 구조상 이부분의 수정이 쉽지가 않았다.

Parser의 생명은 Parsing의 정확도와 속도다.

 정확도에 있어서, HTML은 XML과 틀리게 상당히 변칙적인 문법을 허용한다. 이것은 Parse 트리 구축시 모호성이 발생한다는 것을 의미한다. IE를 비롯한 대부분의 Browser에 내장된 HTML Parser의 경우 HTML 문서의 전처리를 거쳐 well-formed 문서로 만들고(1Pass), XML Parser등을 적용, DOM Tree를 생성한다.(2Pass)
 최소 2Pass를 거쳐야 하므로 속도는 궁극적으로 떨어지며, 일반적으로 구현시 고성능의 ReadBuffer의 구현으로 하거나 1Pass시 불완전 DOM Tree 구축후 2Pass 단계에서 보정방식을 사용하기도 한다.

 속도에 있어 또다른 문제는, SCRIPT부분의 처리이다. SCRIPT 내부 value 처리시 Script 처리 Parser로 동적 전환되고, </SCRIPT>라는 문자열이 발견되기 이전 시점에 Html Parser로 전환되는 과정을 거쳐야 한다. 이것은 Parser 구현시 미리읽기 Buffer를 구현하거나 혹은 Read Cursor가 Lexing 과정중 Back 될 수도 있음을 의미한다. 이런 구현은 속도에는 치명적이며 구현 복잡도는 매우 증가한다.
(그런데 이문제에 대해 별다른 해결책은 없어 보인다, 물론 돈과 시간이 적절히 주어진다면 대체할수 있는 구현은 가능 하겠지만.. 하드웨어가 저질코드의 성능을 충분히 커버해 주는 시대이므로 거기 까지 갈일은 없을것 같다..ㅋㅋ)

 주말 이틀 반납해가며, SCRIPT 부분에 대해 Parsing이 되는 HTML Parser를 만들어 보았다.

 Version은 0.1 정도로 보면 되겠다. 가장 문법이 변태적인 Naver정도는 정확하게 Parsing을 해냈으나 아직까지 많은 테스트와 수정, 추가가 필요한 상황이다. 연구용 정도로 쓰기는에는 불편함이 없을듯 하며, 아직 DOM Tree를 생성하는 부분의 구현은 안되었다.(시간 부족)

 Lexer가 어느정도 안정화 되었으므로 Stack 정도만 적절히 쓴다면 DOM Tree 정도는 쉽게 구현이 가능할 것이다.

소스코드(Eclipse Project) 다운로드


아래는 대략적인 사용법이다.

파싱대상 소스(test.html)

<html>
<head>
  <title>가라지하 사바하</title>
</head>
<script>
<!--
  <a href="dcinside.com">아햏햏</a> fdsf <
  adf, < "" <:>
-->
</script>
<body bgcolor=red background="img/bg.jpg">
  <a href="http://kr.yahoo.com">야후</a>
</body>
</html>

출력결과

tag>html
tag>head
tag>title
latest Tag :title
text>가라지하 사바하
tag>/title
tag>/head
tag>script
text>
<!--
  <a href="dcinside.com">아햏햏</a> fdsf <
  adf, < "" <:>
-->

tag>/script
tag>body
 -bgcolor=red
 -background=img/bg.jpg
tag>a
 -href=http://kr.yahoo.com
latest Tag :a
text>야후
tag>/a
tag>/body
tag>/html


Lexer Code

File file = new File("d:\\test.html");
   Lexer lexer = new Lexer(file);
   Node node = null;
   while((node = lexer.getNextToken()) != null)
   {
    if(node instanceof TagNode)
    {
     String isClosed = "";
     TagNode tag = (TagNode)node;
     if(tag.isClosedTag()) isClosed = "/";
     System.out.println("tag>" + tag);
    }
    else if(node instanceof TextNode)
    {
     TextNode txNode = (TextNode)node;
     System.out.println("text>" + txNode.getParsedText());
    }
   }

막 구현된 소스라 리펙토링이 전혀 안된 상태이며, 구현과 설계를 동시에 하는 본인의 업무 특성상,
스파게티 코드를 보고 충격을 받지 말길 바란다. 차츰차츰 바로 잡아갈테니.. ㅋㅋ

+ Recent posts