BLOG ARTICLE 엔진구조 | 2 ARTICLE FOUND

  1. 2008/07/02 루씬 고속 색인 방법 : 토큰을 재사용하라 (3)
  2. 2008/05/16 구글 빅테이블(Google Bigtable)에 관한 오해

사용자 삽입 이미지

"버스 토큰"
from 네이버 기차여행카페 - 바이트레인


오픈소스를 가져다 쓸다면 "유지보수와 튜닝 문제"에 골치 아파질 때가 많다. 오픈소스 덕분에 빠르게 시작할 수 있지만 내부에 대한 이해 부족으로 해법을 찾기 어렵기 때문이다. 혹자는 오픈소스에는 개발자들이 많아 질문만 던지면 즉시 해결될 것이란 유언비어에 빠지는 분도 있는데, 실무에서 만나게 되는 시급한 문제에 대해서는 즉시 응대와 해결은 꿈도 꾸질 말아야 한다. 특히 국내 실정은 더하다.

루씬도 역시 오픈소스인 것 같다. 루씬 2.3.2 소스를 보고 있는데 색인어 추출 루틴들인 Analyzer 계열 클래스들의 복잡도가 만만치 않다. Payload란 실험적인 개념, PositionIncrement 개념, 수많은 클래스들 등이 주석과 함께 코드에 뒤범벅이 되어 있어 웬만한 내공이 아니면 이해하기 어렵다.

내부 이해야 믿으면 그만이지만 검색엔진 적용 시에 대용량 문서를 접하게 되면 속도 튜닝이 아주 힘들다. 루씬 색인속도 향상에 관해서는 Job tech blog를 한번 읽어보라. 이 블로그에 나와있는 5가지 팁 중에 4번째에 이 글 주제인 토큰 재사용에 관한 언급이 있다(나머지 팁들도 유용하다.)

루씬에서는 색인어를 토큰(Token)이라 부른다. 검색엔진의 색인속도에 영향을 주는 여러 요인들이 있지만 토큰을 추출할 때 마다 토큰 문자열을 매번 재 생성하지 않고 토큰 버퍼(Token Buffer)를 만들어 재사용하는 방식으로 6-7배 이상의 속도 향상을 기대할 수 있다(전체 색인 속도 향상을 말하는 것은 아님에 주의하자.) 루씬도 2.3 버전부터 토큰 버퍼 개념을 적용하고 있다. 토큰 버퍼를 사용하면 추출속도를 높일 수는 있으나 다음 추출 시에 토큰버퍼 내용이 지워지므로 사용법에 주의해야 한다. 아래 실험코드가 있다.

GeSHi © 2004, Nigel McNie
  1. import java.io.BufferedReader;
  2. import java.io.IOException;
  3. import java.io.InputStreamReader;
  4. import java.io.StringReader;
  5. import java.net.URL;
  6.  
  7. import org.apache.lucene.analysis.Token;
  8. import org.apache.lucene.analysis.TokenStream;
  9. import org.apache.lucene.analysis.standard.StandardAnalyzer;
  10.  
  11. enum Interface {
  12.   OLD_INTERFACE, NEW_INTERFACE
  13. }
  14.  
  15. public class ReusableTokenTest {
  16.   int textLength = 0;
  17.   int numTokens = 0;
  18.   long elapsedTime = 0;
  19.  
  20.   public void tokenInterface(Interface which) {
  21.     try {
  22.       StringReader reader = new StringReader(getLargeString());
  23.       TokenStream tokens = new StandardAnalyzer().tokenStream(new String(
  24.           "text"), reader);
  25.       long startTime = System.currentTimeMillis();
  26.       if (which == Interface.OLD_INTERFACE)
  27.         this.useOldInterface(tokens);
  28.       else
  29.         this.useNewInterface(tokens);
  30.       this.elapsedTime = System.currentTimeMillis() - startTime;
  31.       this.output(which);
  32.     } catch (Exception e) {
  33.       e.printStackTrace();
  34.     }
  35.   }
  36.  
  37.   private void output(Interface which) {
  38.     StringBuffer sb = new StringBuffer();
  39.     if (which == Interface.OLD_INTERFACE)
  40.       sb.append("Old Interface");
  41.     else
  42.       sb.append("New Interface");
  43.     sb.append("\t");
  44.     sb.append(this.textLength);
  45.     sb.append(" (chars)\t");
  46.     sb.append(this.numTokens);
  47.     sb.append(" (tokens)\t");
  48.     sb.append(this.elapsedTime);
  49.     sb.append(" (msec)");
  50.     System.out.println(sb.toString());
  51.   }
  52.  
  53.   private String getLargeString() throws IOException {
  54.     URL url = new URL("http://insidesearch.tistory.com/");
  55.     BufferedReader in = new BufferedReader(new InputStreamReader(url
  56.         .openStream()));
  57.     StringBuffer sb = new StringBuffer();
  58.     String str;
  59.     while ((str = in.readLine()) != null) {
  60.       sb.append(str);
  61.     }
  62.     this.textLength = sb.length();
  63.     return sb.toString();
  64.   }
  65.  
  66.   private void useOldInterface(TokenStream tokens) throws IOException {
  67.     Token token;
  68.     while ((token = tokens.next()) != null) {
  69.       useToken(Interface.OLD_INTERFACE, token);
  70.     }
  71.   }
  72.  
  73.   private void useNewInterface(TokenStream tokens) throws IOException {
  74.     Token token = new Token();
  75.     while ((token = tokens.next(token)) != null) {
  76.       useToken(Interface.NEW_INTERFACE, token);
  77.     }
  78.   }
  79.  
  80.   private void useToken(Interface m, Token token) {
  81.     this.numTokens++;
  82.     if (m == Interface.NEW_INTERFACE) {
  83.       String s = new String(token.termBuffer(), 0, token.termLength());
  84.     }
  85.     else {
  86.       String s = token.termText();
  87.     }
  88.   }
  89.  
  90.   public static void main(String[] args) {
  91.     new ReusableTokenTest().tokenInterface(Interface.OLD_INTERFACE);
  92.     new ReusableTokenTest().tokenInterface(Interface.NEW_INTERFACE);
  93.   }
  94. }
  95.  
Parsed in 0.125 seconds


위 코드는 http://insidesearch.tistory.com에서 비교적 큰 문자열을 받아 StandardAnalyzer를 사용해 토큰을 만든다. 이 코드를 돌려보면 새로운 인터페이스를 사용하면 6~7배 정도 속도가 향상됨을 볼 수 있다(아래 결과 참조). 이 정도면 수억개의 토큰을 다루는 대용량 문서들을 색인할 때는 수 십분 가량의 속도 향상을 기대할 수 있을 것이다.

Old Interface 56012 (chars) 9105 (tokens) 39 (msec)
New Interface 56012 (chars) 9105 (tokens) 6 (msec)


요약하면 새로운 토큰 인터페이스를 사용하여 추출 속도를 높이려면 기 정의된 토큰 객체를 재사용한다는 점만 기억하면 된다. 내부 토큰버퍼 크기 재조정 작업은 알아서 Token 클래스 내부에서 척척 해 준다. 외부에서 getTermText()를 사용해 내부에서 만들어준 문자열 객체를 사용하지 말고  termBuffer()를 호출해 내부 토큰버퍼(char [])에서 termLength() 만큼 복사해서 사용하면 된다.

@webJOY

참고로 토큰버퍼 내부 동작원리에 대해서는 DEV 용식님이 블로그을 참고하라.


사용자 삽입 이미지

최근 몇몇 블로그 글을 읽다 보면 구글 빅테이블(Google BigTable)을 관계형 데이터베이스의 대체 저장소로 오해하고 있는 듯한 글들이 눈에 띈다. 이런 오해의 근원은 빅 테이블이 몇몇 구글 서비스의 저장소로 사용되었음을 "Bigtable: A Distributed Storage System for Structured Data"란 논문에서 언급하였기 때문인 것 같다. 또, 구글 빅 테이블의 오픈소스 클론 프로젝트인 HyperTable 개발자인 더그 주드(Doug Judd)가 기업용 소프트웨어 개발 커뮤니티인 인포큐과의 인터뷰에서 현재 MySQL이 차지하고 있는 웹 콘텐트 저장소로 자리매김하고 싶다란 견해를 밝혔다. 이런 측면만 보면 구글 빅테이블과 관계형 데이터베이스를 직접 비교하는 우를 범할만도 하다.

모든 데이터베이스는 레코드 단위로 데이터를 저장하는 로우 기반 데이터베이스와 필드 단위로 데이터를 저장하는 컬럼 기반 데이터베이스로 구분할 수 있다. 제목, 작성일, 본문 필드를 가진 사용자가 작성한 글을 글 단위로 저장한다면 로우 기반 데이터베이스가 되고 제목, 작성일, 본문 필드 각각을 따로 저장하면 컬럼 기반 데이터베이스가 된다. 구글 빅테이블은 컬럼 기반 데이터베이스에 해당된다. 반면에 최신 관계형 데이터베이스는 이 두가지를 혼용하여 사용한다고 한다.

저장 구조가 다르기 때문에 이 두가지 형태의 데이터베이스는 사용처에 따라 각자의 성능을 최대로 발휘된다. 데이터를 레코드 단위로 저장, 인출하는 관계형 데이터베이스는 한 레코드 전체를 읽어 사용해야 하는 경우에 한번에 읽을 수 있어 더 유리하다. 반면에 컬럼 데이터베이스는 컬럼 단위로 쓰거나 읽어야 하는 경우에 더 유리하다. 구글의 빅테이블은 웹 크롤러가 저장하고 분산 인덱서가 인출하는 웹 저장소로 시작되었기 때문에 컬럼 기반 데이터베이스 형태로 설계되었다. 이런 선택은 특정 색인필드만을 대량 고속으로 인출해야 하는 분산 웹 색인기의 사용 패턴에 기인한다.

컬럼 기반 데이터베이스는 컬럼 데이터형이 동일하기 때문에 이질 데이터형을 가진 필드를 가진 로우 기반 데이터베이스에 비해 압축 효율이 더 좋기 때문에 저장공간 효율면에서 우월하다. 특히, 컬럼 값이 띄엄띄엄 있는 대용량 데이터를 다룰 경우에 더 좋아진다. 반대로 무작위 인출 연산이 필요한 경우에는 압축해제 시간과 여러 컬럼을 인출해 합쳐야 하는 부담으로 로우 기반 데이터베이스 비해 성능이 떨어진다.

이와 같은 구조적인 차이로 인해 구글 빅테이블은 MySQL과 같이 주 저장구조로 로우 기반 데이터베이스인 관계형 데이터베이스와는 다른 목적으로 설계되어 대치물로 비교되어서는 절대 안된다. 컬럼 기반 데이터베이스가 컬럼의 합인 레코드를 모두 읽어야 하는 경우에 성능이 떨어짐에도 불구하고 저장 효율이 높고 컬럼 단위 인출 속도가 높다는 특징으로 단순한 구조의 대용량 웹 콘텐트의 저장소와 같은 특수한 경우에만 적합하기 때문이다. 한 개의 큰 테이블만을 사용자에게 제공하는 구글 베이스가 이와 같은 특수 사용예다. 논문에 언급한 구글 서비스들도 단순한 구조의 데이터만을 구글 빅테이블에 올리고 있다.

구글 서비스에서 구글 빅테이블을 웹 콘텐트 저장소로 일부 채택했다고 해서 모든 웹 서비스의 데이터 저장소로 구글 빅테이블을 사용할 수 있을 것이라고 오해하면 안된다. 잘 알려진 구글 인프라들은 대용량 검색인프라에 뿌리를 두고 설계되었기 때문에 설계 중심에는 오프라인 처리가 기본 사상이다. 비록 구글 빅테이블과 같이 온라인 처리를 지원하는 인프라라도 오프라인 대용량 처리가 기본 사상이기 때문에 온라인 처리는 특수한 경우에만 효율적이게 된다. 따라서 관계형데이터베이스가 충분히 처리 가능한 수 백만개의 데이터를 다루는 곳에서는 부적합한 경우 또는 사용할 수 없는 경우가 더 많다. 사용자 계정정보, 구매 기록과 같이 트랜잭션과 완벽한 관계성 준수가 필요한 경우는 부적합하고 문자열이 위주인 게시판 또는 정적 웹 페이지를 많이 저장하고 인출할 때 유용하다. 특히, 수억개로 누적될 수 있는 있는 웹 로그, UCC, 크롤된 텍스트를 저장하고 인출하는데 적합하다.

[1] 구글 빅테이블 논문: http://labs.google.com/papers/bigtable.html
[2] 인포큐 인터뷰: http://www.infoq.com/news/2008/04/hypertable-interview
[3] 하이퍼테이블: http://hypertable.org/
[4] 컬럼 기반 데이터베이스: http://en.wikipedia.org/wiki/Column-oriented_DBMS

@webJOY