본문 바로가기
Lucene/Lucene In Action

[루씬 인 액션] 3. 색인(2) - 고급 색인 기법

by 잭피 2020. 11. 18.

준실시간 검색

루씬 2.9이상 핵심 기능 중 하나인 준실시간(Near real time) 검색 기능이 추가되었습니다

준실시간이란 색인한 문서를 거의 즉시 검색할 수 있는 기능입니다

일반적인 검색 엔진에서는 제대로 지원하지 못하는 경우가 많습니다

루씬에서는 IndexReader.getReader() 메소드를 통해 준실시간 검색을 지원합니다

IndexReader 객체는 기존에 열려있던 세그먼트 정보를 그대로 사용하며, 새로 추가되거나 변경된 세그먼트만 새로 읽어 들이기 때문에 매우 효율적으로 빠르게 생성합니다

색인 최적화

수많은 세그먼트로 구성된 색인의 경우 최적화 과정을 거쳐 세그먼트의 개수를 최소화하면 검색 속도를 향상시킬 수 있습니다

최적화 작업은 검색속도를 향상시켜주지만, 색인 속도에는 영향이 없다

IndexWriter 클래스의 최적화 관련 메소드

optimize()

색인의 세그먼트를 하나로 병합해 최적화하며, 병합 작업이 끝날 때까지 리턴하지 않습니다

 

optimize(int maxNumSegments)

부분 최적화라고도 부르고, 세그먼트를 maxNumSegments 개수 이하로 병합합니다

maxNumSegments를 5로 주면, 5개의 세그먼트로 최적화합니다

(일반적으로 마지막 단 1개의 세그먼트로 생성할 때 시간이 많이 소모되는데, 이렇게 5개의 세그먼트로 최적화하면 병합 시간을 최소화할 수 있습니다)

 

optimize(boolean doWait)

optimize()와 동일하지만, doWait에 false값을 지정하면 병합 작업을 백그라운드 스레드에서 진행하고 optimize(boolean doWait) 메소드는 즉시 리턴해 다른 작업을 처리할 수 있습니다

(doWaite - false를 지정하려면 ConcurrentMergeScheduler처럼 병합 작업을 백그라운드로 실행하는 스케줄러를 사용하고 있어야 합니다)

 

optimize(int maxNumSegments, boolean doWait)

병합 작업을 백그라운드 스레드로 실행하면서, 최대 세그먼트 개수도 지정할 수 있습니다

 

색인 최적화 작업은 상당한 CPU 자원과 디스크 입출력 대역폭을 소모하기 때문에 꼭 필요하다고 판단될 때 최소한으로만 사용합시다!

(검색 사용량이 적은 밤 시간, 주말에 진행해 검색 속도에 영향을 주지 않도록 하자)

또한, 디스크 공간도 주의합시다

최적화를 진행하면서 세그먼트를 병합할 때 기존 세그먼트를 그대루 두고 병합한 내용을 새로운 세그먼트로 만들고, 새로운 세그먼트가 모두 만들어지고 나면 기존 세그먼트를 삭제합니다

색인 최적화 작업은 진행하는 도중에는 임시로 최대 세 배의 디스크 공간을 사용합니다
최적화 작업이 끝나면 최적화 이전 수준으로 회복됩니다

여러 종류의 Directory

Directory 클래스는 간단한 파일 형식의 저장 공간을 나타내는 API이며, Directory를 상속받은 하위 클래스 내부에 색인을 저장하는 구현 내용을 담고 있습니다

루씬에서 색인의 파일을 읽거나 쓰려는 경우 항상 Directory의 메소드를 통해 처리합니다

FSDirectory 추상 클래스를 상속받은 클래스들

SimpleFSDirectroy

java.io.* 패키지의 API를 사용합니다 (임의의 위치에서 내용을 읽어오는 기능을 충분히 지원하지 않아 내부적으로 락을 사용합니다)

내부적으로 락을 사용하여 여러 쓰레드에서 동시에 읽기 작업을 처리하려는 경우 성능이 떨어집니다

 

NIOFSDirectory

java.nio.* 패키지에서 제공하는 파일 내부 위치 지정을 사용하기 때문에 락을 사용하지 않습니다

따라서 여러 스레드에서 동시에 읽기 작업을 진행해도 성능이 저하되지 않습니다

하지만 마이크로소프트 윈도우용 기본 JRE에 들어있는 버그 때문에 윈도우에서는 성능이 크게 떨어지며, 심지어 SimpleFSDirectroy보다 성능이 떨어지는 경우도 있습니다

 

MMapDirectroy

메모리 맵 I/O 기능을 사용합니다

따라서 락을 사용하지 않기 때문에 여러 스레드에서 동시에 사용하려 해도 성능에 영향이 적습니다

하지만 색인의 크기만큼 메모리 공간을 할당할 수 있어야 합니다

 

3가지 모두 읽기 부분에서만 차이가 있으며, 쓰기 부분은 모두 SimpleFSDirectory처럼 java.io.* 패키지의 기능을 사용합니다

가장 좋은 방법은 FSDirectory.open 이라는 정적 메소드를 사용하는 방법입니다

병렬 처리, 스레드 안전성, 락

3가지 주제

1. 다수의 JVM에서 하나의 색인을 동시에 사용하는 문제

2. IndexRedaer와 IndexWriter를 동시에 사용할 때 스레드 안정성 문제

3. 루씬에서 몇 가지 규칙을 적용하고자 사용하는 락

스레드와 다중 JVM 안정성

특정 색인에 대해 읽기 전용의 IndexReader는 몇 개라도 열어 사용할 수 있습니다

(서로 JVM 또는 장비가 달라도 상관없습니다)

하지만, 성능과 자원 활용 관점에서는 하나의 IndexReader 인스턴스를 생성한 다음 여러 스레드에서 공유해서 사용하는 편이 좋습니다

즉, 여러 스레드에서 하나의 IndexReader 인스턴스를 통해 검색할 수 있습니다

색인 하나에 대해 IndexWriter는 하나만 열 수 있습니다 (쓰기 락을 사용)

IndexWriter 인스턴스가 생성되자마자 쓰기 락을 확보하며, 닫으면 쓰기 락이 해제됩니다

 

IndexReader는 IndexWriter가 색인의 내용을 변경하고 있는 도중이라도 언제든지 열어 사용할 수 있습니다 (IndexReder는 변경 작업과 관계없이 열리는 시점의 색인을 나타냅니다)

 

IndexReader나 IndexWriter 인스턴스는 여러 스레드에서 얼마든지 공유해 사용해도 좋습니다

 

스레드 안전성을 확보하고 있을 뿐만 아니라, 다중 스레드로 사용하기 좋게 구현돼 있습니다

 

원격 파일 시스템의 색인 공유

여러 대의 장비, 여러 개의 JVM에서 단 하나의 색인을 공유하려면 해당 색인을 원격 파일 시스템으로 열어줘야 합니다

하지만 로컬 디스크에 저장된 색인과 비교할 때 다른 장비에서 검색하는 성능은 별로 좋지 않을 것입니다

성능을 최대한 확보하려면 원본 색인의 사본을 각 검색 서버에 확보하는 방법이 좋습니다

Ex) 솔라(엔터프라이즈 검색 엔진)는 색인 복제 기능을 기본적으로 지원

 

하지만, 이렇게 색인을 복제하는 대신 원격 파일 시스템의 색인을 그대로 사용해야 한다면?

→ 여러가지 제약이 있을 수 있습니다

ex) 캐시 문제로 변경 사항을 반영한 직후 원격 장비에서 최근 반영된 내역을 보지 못할 수 있습니다

 

NFS는 특히 다른 컴퓨터에서 열고 있는 파일을 로컬 장비에서 삭제하려 할 때 문제가 발생합니다

대부분 윈도우 운영체제는 열려있는 파일을 삭제되지 않게 막아줍니다

하지만 NFS는 원본 파일을 그대로 삭제하며, 파일을 열고 있던 원격 장비에서 색인 파일에 I/O 기능을 요청하면 IOException이 발생합니다

 

NFS로 검색 기능을 서비스하면서 이러한 익셉션을 방지하려면,

직접 IndexDeletionPolicy 클래스를 직접 작성하고, 해당 색인을 열고 있는 모든 IndexReader가 새로운 버전을 열기 전까지 이전 버전의 색인을 삭제하지 않도록 통제해야합니다

루씬은 색인에 접근할 때 최대한의 병렬성을 지원합니다 여러 IndexReader가 하나의 색인을 공유할 수 있고, 여러 스레드에서 하나의 IndexReader, IndexWriter를 공유할 수도 있습니다 중요한 점은 하나의 색인을 대상으로 2개 이상의 IndexWriter를 사용할 수 없다는 점입니다

색인 락

색인 하나에 하나의 IndexWriter만 접근할 수 있게 루씬은 파일 기반의 락을 사용합니다

색인 디렉토리에 락 파일(write.lock)이 존재하면 이미 어떤 IndexWriter 인스턴스가 쓰기 권한을 쥐고 있다는 뜻입니다

(write.lock이 존재하는 상황에 다른 IndexWriter 생성 또는 IndexReader에서 변경을 사용하려하면 LockObtainFailedException 발생)

 

일반적으로 어떤 락을 어떻게 사용하는지 별로 알아야 할 필요는 없습니다

루씬이 제공하는 락의 종류

NativeFSLockFactory (FSDirectory 클래스의 기본 락)

SimpleFSLockFactory

SingleInstanceLockFactory (락을 메모리 안에서 생성합니다)

NoLockFactory (락을 사용하지 않습니다)

 

락 생성 클래스를 직접 구현한다면 오류가 없도록 LockStressTest라는 단위 테스트 도구를 사용하자

(LockVerifyServer, VerifyingLockFactory 등을 통해 새로 구현한 락 생성 클래스가 올바르게 동작하는 테스트할 수 있습니다)

락 관련 메소드 2가지

1. IndexWriter 클래스의 isLocked(Directory)

지정한 directory에 들어있는 색인에 쓰기 락이 걸려있는 상태인지 확인합니다

 

2. IndexWriter 클래스의 unlock(Directory)

지정한 directory에 락이 걸려있는 경우 강제로 락을 해제합니다

(주의 : 호출 전 정상적으로 쓰기 락을 확보한 IndexWriter가 있는지 반드시 확인하자)

(혹시라도 다른 IndexWriter가 색인의 내용을 변경하고 있을 때 호출하여 락을 제거하면 색인이 깨지고 사용할 수 없는 상태가 될 수도 있습니다)

그냥 루씬이 제공하는 락 기능을 그대로 사용하는 편이 좋습니다

색인 작업 디버깅

IndexWriter의 setInfoStream 메소드로 색인 과정에서 진행되는 내용을 디버깅할 수 있습니다

루씬이 작업을 진행하는 동안 세그먼트를 디스크에 저장하거나 병합하는 등의 작업이 지정된 스트림으로 출력됩니다

 

고급 색인 기법

IndexReader 클래스를 통해 색인된 문서를 삭제하는 방법,

루씬이 언제 세그먼트를 새로 생성하는지,

루씬에서 트랜잭션을 어떻게 지원하는지,

색인에서 삭제된 문서가 디스크 공간을 쓸데없이 소모하지 않게 관리하는 방법 등을 알아보아요

IndexReader에서 문서 삭제

IndexReader 클래스에는 문서를 삭제하는 메소드도 들어있습니다

IndexWriter에 이미 있는데 왜 있을까요?

두 클래스의 삭제 기능에는 약간의 차이점이 있습니다

 

IndexReader 클래스는 문서 번호로 삭제하고자 하는 문서를 지정할 수 있습니다

IndexWriter에서는 병합 과정을 거치면서 문서 번호가 바뀌기 때문에 문서 번호로 삭제하는 기능을 구현할 수 없습니다

 

IndexReader에서 Term으로 문서를 삭제하면 삭제된 문서 개수를 리턴하지만(Term에 따라 삭제할 문서를 즉시 결정),

IndexWriter에서는 삭제된 문서의 개수를 알 수 없습니다 (삭제할 Term이 버퍼에 쌓여 있다가 나중에 적용되기 때문에 개수를 알 수 없음)

 

IndexReader 하나를 여러 검색 스레드에서 공유해 사용하는 경우 삭제된 문서는 즉시 결과에 반영됩니다

IndexWriter로 삭제한 문서는 IndexReader를 다시 열지 않는 한 검색 결과에 반영되지 않습니다

 

IndexWriter는 Query 객체로 질의에 해당하는 문서를 삭제할 수 있지만,

IndexReader에는 질의로 문서를 삭제하는 기능이 없습니다

(질의를 실행하고 결과를 받아와서 결과에 속한 모든 문서를 삭제할 수는 있습니다)

 

IndexReader 클래스는 undeleteAll 메소드를 제공하며, 지금까지 삭제된 문서를 다시 되살려줍니다

(물론 아직 병합되지 않은 세그먼트의 문서만 되살릴 수 있음)

IndexWriter에서 문서를 삭제할 때는 삭제 표시만 먼저 해두기 때문에 되살리는 기능을 구현할 수 있습니다

(물론 마찬가지로 삭제된 문서가 담긴 세그먼트가 병합 절차를 거쳐 새로운 세그먼트가 생성되면 되살릴 수 없음)

 

IndexReader에서 문서를 삭제할 때 해당 색인에 쓰기 락을 확보해야 합니다

따라서 동일한 색인에 IndexWriter가 열려 있다면 닫고 락을 확보해야 합니다

즉, 상황에 따라 색인에 문서를 추가하고 삭제하는 작업을 번갈아가며 빈번하게 호출하면 색인 속도가 떨어진다는 점을 알 수 있습니다
성능을 고려한다면 색인에 문서를 추가하거나 삭제하는 작업은 하나의 IndexWrtier 인스턴스를 통해 최대한 일괄 작업으로 처리하는 편이 좋습니다

삭제된 문서가 차지하는 디스크 공간

일반적인 역파일 색인에는 문서 하나를 텀으로 분리해서색인의 여러 곳에 퍼져있는 형태입니다

그래서 문서를 삭제할 때마다 매번 해당 문서를 색인에서 삭제하려면 효율적이지 않습니다

따라서 시간이 지나면서 자동으로 병합 과정을 거칠 때 실제로 디스크에서 삭제되며,

그 전까지는 색인 안에 그대로 유지됩니다

 

expungeDeletes 메소드를 호출해 삭제된 문서를 실제로 디스크에서 제거할 수 있습니다

(삭제된 문서가 있다고 표시된 세그먼트를 병합합니다)

그래도 여전히 병합은 자원을 많이 소모하는 작업이므로

실제 작업을 일괄로 처리한 다음 한동안은 삭제 작업이 없을 것이라는 판단이 될 때만 실행하는 편이 좋습니다

 

문서 버퍼, 플러시

루씬 색인에 문서를 추가하거나 삭제할 문서가 있다면

이런 변경 사항은 디스크에 즉시 반영하는 대신 일단 메모리 버퍼 안에 보관합니다

 

메모리 버퍼에 변경 사항을 보관해두면 디스크 입출력 횟수를 줄여 성능을 개선시킬 수 있습니다

주기적으로 Directory 안에 새로운 세그먼트를 생성하고 메모리 버퍼 변경 사항을 디스크에 flush 합니다

 

IndexWriter에서 세그먼트를 새로 생성하는 작업은 다음과 같은 3가지 상황에서 발생하고, 검색 애플리케이션에서 제어할 수 있습니다

 

1. 메모리 버퍼에 지정된 용량 이상의 문서가 쌓이면 디스크에 플러시합니다

setRAMBufferSizeMB 메소드로 용량을 지정할 수 있습니다

하지만, JVM 전반적인 메모리 사용량을 측정할 때 관련된 요소가 많기 때문에 버퍼의 용량을 정확하게 지정할 수 없습니다

 

2. setMaxBufferedDocs 메소드로 메모리 버퍼에 보관할 문서의 최대 개수를 지정할 수 있습니다

해당 문서 개수에 다다르면 세그먼트를 생성하고 메모리 버퍼의 내용을 flush합니다

 

3. 텀이나 질의로 삭제할 때, setMaxBufferedDeleteTerms 메소드로 삭제한 텀이나 질의의 최대 개수를 지정할 수 있습니다

질의의 개수가 지정한 값에 도달하면 삭제된 내용을 디스크에 flush합니다

IndexWriter가 색인의 내용을 변경하면 IndexReader를 변경 작업 이후에 열었더라도 commit()이나 close() 메소드 호출하기 전에는 변경 사항을 볼 수 없습니다

색인 커밋

IndexWriter의 commit 메소드를 호출하면 새로운 색인 커밋이 생성됩니다

IndexReader나 IndexSearcher를 새로 생성하면 생성 시점의 최신 색인 커밋을 기준으로 동작합니다

일반적으로 커밋 작업은 시스템 자원을 많이 소모하며, 커밋 작업을 너무 자주 호출하면 색인 성능이 떨어집니다

rollback() 메소드를 호출하면 디스크에 생성된 최근 커밋 이후에 발생한 모든 변경 사항을 무시합니다

 

IndexWriter에서 커밋 작업을 진행하는 절차

1. 메모리 버퍼에 들어있는 모든 새 문서와 삭제된 문서를 플러시

 

2. 새로 생성한 파일과 병합 작업을 통해 생성한 파일을 모두 싱크합니다

IndexWriter는 Directory.sync를 호출하며, 쓰기 작업이 끝날 때까지 대기하고 리턴합니다

 

3. 다음 번호의 segments_N 파일을 생성하고 디스크에 싱크합니다

이 작업이 끝나고 IndexReader를 다시 열면 변경된 내용을 볼 수 있습니다

 

4. IndexDeletionPolicy로 지정한 설정에 따라 기존의 커밋을 제거합니다

IndexDeletionPolicy을 직접 구현하면 어떤 커밋을 언제 제거할 것인지 상황에 맞게 변경할 수 있습니다

 

2단계 커밋

루씬의 색인과 데이터베이스 등 외부 다른 저장소를 함께 트랜잭션으로 연동할 수 있습니다

루씬에서는 prepareCommit() 메서드를 지원합니다

예를 들어 디스크 공간 부족 등의 오류가 발생한다면 commit() 메소드 대신 prepareCommit() 메소드에서 발생합니다

 

IndexDeletionPolicy

IndexDeletionPolicy는 IndexWriter에게 기존에 커밋했던 색인을 삭제해도 좋은지 알려주는 기능을 담당합니다

기본 정책은 KeepOnlyLastCommitDeletionPolicy이며, 최신 커밋 작업이 완료되면 이전에 커밋했던 내용은 모두 삭제합니다

특별한 경우가 아니라면 기본 설정을 사용하는 편이 좋습니다

 

다수의 커밋을 관리하는 API

일반적으로 루씬의 색인에는 최신 커밋 하나만 존재합니다

하지만 IndexDeletionPolicy를 직접 구현해 적용하는 경우 색인 하나에 여러 개의 커밋을 보관할 수 있습니다

 

ACID 트랜잭션과 색인의 일관성

루씬은 ACID 트랜잭션 모델을 구현하며,

다만 한 번에 단 하나의 트랜잭션(IndexWrtier)만 진행할 수 있습니다

Atomic : 모두 반영되거나 하나도 반영되지 않는다
Consistency : 일관성을 유지한다
Isolation : 추가 및 삭제 작업을 진행하고 있더라도 커밋 전에는 변경 사항을 볼 수 없다
Durability : 컴퓨터가 비정상적으로 종료되더라도 색인은 항상 정상적인 상태를 유지한다

병합

색인에 세그먼트 개수가 너무 많아지면 IndexWriter에서 몇 개의 세그먼트를 골라 하나의 세그먼트로 병합합니다

세그먼트를 병합하면 어떤 장점이 있을까요?

 

1.세그먼트의 개수가 줄어듭니다

병합해 하나의 큰 세그먼트를 색인에 추가하고, 병합한 몇 개의 세그먼트는 모두 삭제합니다

세그먼트 개수가 줄어들면 검색 질의를 실행해야 할 횟수가 줄어들기 때문에 검색 성능이 빨라집니다

 

2. 색인이 차지하는 디스크 공간이 줄어듭니다

삭제했다고 표시된 문서가 병합 과정에서 실제로 디스크에서 제거됩니다

병합된 하나의 세그먼트가 동일한 문서를 담고 있는 몇 개의 세그먼트보다 약간 적은 용량을 차지합니다

 

병합은 언제 필요할까요?

그 기준을 정해주는 클래스가 바로 MergePolicy입니다

MergePolicy는 병합 작업에 해당하는 대상 세그먼트를 뽑아주고,

병합 작업을 실행하는 일은 MergeScheduler 클래스가 맡고 있습니다

 

MergePolicy

병합 작업이 처리해야 할 시점을 판단합니다

IndexWriter에서 기본적으로 LogByteSizeMergePolicy를 사용합니다

LogByteSizeMergePolicy 클래스는 특정 세그먼트에 해당하는 모든 색인 파일의 바이트 단위 크기에 따라 동작합니다

 

mergePolicy 클래스에서 주기적으로 자동 진행되는 병합 과정을 제어하기도 하지만 optimize 메소드나 expungeDeletes 메소드를 호출했을 때도 MergePolicy 클래스의 설정을 참조해 세그먼트를 병합합니다

 

MergeScheduler

실제 병합 작업을 진행합니다

기본 설정으로 IndexWriter는 ConcurrentMergeScheduler 클래스를 사용하며,

백그라운드 스레드를 활용해 병합 작업을 처리합니다

 

일반적으로 MergePolicy 설정을 변경하거나 별도의 MergePolicy 또는 MergeScheduler 하위 클래스를 직접 작성하는 일은 거의 없다

매우 특이한 경우로 고급 최적화 작업이 필요할 때나 해볼만 한 일이다

기본 설정으로도 매우 훌륭하게 동작한다

 

 

이 글은 “Lucene In Action” 책 내용을 요약한 글입니다.

만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

 

댓글