황현동 블로그 개발, 인생, 유우머

260215 LiteDB 1000건 페이징 순회 성능예측

Tags:

⚡ 260215 LiteDB 1000건 단위 페이징 순회 로딩 성능 예측

💡 이전 문서 LiteDB byte[] 변환 시 Full Query 성능 변화의 후속 리서치로, 50만건 MyMesh를 1,000건 단위로 페이징 순회할 때의 수행시간과 메모리 사용량을 예측한다.


📌 1. 전제 조건

항목
테이블 MyMesh
총 행 수 500,000건
정점 수/건 1,000개 (vertexList: float×3,000, indexList: int×1,000, triIndexList: int×3,000)
저장 방식 byte[] 변환 저장 (BsonBinary)
문서 크기 ~28 KB/건 (byte[] 변환 후)
배치 크기 1,000건
배치 수 500회
DB 파일 크기 ~13 GB
환경 Windows 11, Unity, IL2CPP/Mono, SSD

⚡ 2. 페이징 방식별 성능 차이: Skip/Offset vs Keyset

LiteDB에서 페이징을 구현하는 방법은 크게 두 가지다. 어떤 방식을 선택하느냐에 따라 성능이 252배 차이난다.

🔹 2.1 Skip/Offset 방식 (느림)

// 전통적 Skip/Offset 방식
for (int skip = 0; skip < 500_000; skip += 1000)
{
    var batch = col.Find(Query.All(), skip, 1000).ToList();
    ProcessBatch(batch);
}

LiteDB의 Skip은 인덱스를 건너뛰지 않고 실제 문서를 순차 읽기하며 버린다. 즉 O(skip + limit) 비용이 발생한다.

배치  1: skip=0      → 0 + 1,000 = 1,000 문서 순회
배치  2: skip=1000   → 1,000 + 1,000 = 2,000 문서 순회
배치  3: skip=2000   → 2,000 + 1,000 = 3,000 문서 순회
  ...
배치 250: skip=249000 → 249,000 + 1,000 = 250,000 문서 순회
  ...
배치 500: skip=499000 → 499,000 + 1,000 = 500,000 문서 순회

총 순회량 = Σ(k=0→499) (k×1000 + 1000)
         = 1000 × Σ(k=0→499)(k+1)
         = 1000 × (500 × 501 / 2)
         = 125,250,000 회 (약 1.25억 회)

💡 실제 데이터 50만건에 대해 1.25억 번 순회 — 250배 중복 읽기

🔹 2.2 Keyset Pagination 방식 (빠름, 권장)

// Keyset(커서) 방식 - Id 기반 범위 쿼리
long lastId = 0;
while (true)
{
    var batch = col.Find(x => x.Id > lastId, limit: 1000).ToList();
    if (batch.Count == 0) break;

    ProcessBatch(batch);
    lastId = batch[^1].Id;  // 마지막 Id 기억
}

_id 인덱스를 통해 정확한 위치로 점프 후 1,000건만 읽는다. 배치마다 O(log n + limit) 비용.

배치  1: Id>0       → 19 step(인덱스) + 1,000 문서 = 1,019 순회
배치  2: Id>1000    → 19 step(인덱스) + 1,000 문서 = 1,019 순회
  ...
배치 500: Id>499000 → 19 step(인덱스) + 1,000 문서 = 1,019 순회

총 순회량 = 500 × 1,019 = 509,500 회 (약 51만 회)

💡 50만건에 대해 51만번 순회 — 거의 1:1

⚖️ 2.3 비교

항목 Skip/Offset Keyset (Id 범위) 차이
총 순회량 1.25억 회 51만 회 252배
첫 번째 배치 1,000건 읽기 1,019건 읽기 동일
마지막 배치 500,000건 읽기 1,019건 읽기 491배
배치당 시간 (일정?) 점점 느려짐 일정  

💡 참고: LiteDB 인덱스 문서, Skip/Limit 순서 이슈 #718


📌 3. 수행 시간 예측

🗂️ 3.1 문서당 읽기 비용 (byte[] 변환 저장, ~28KB/건)

이전 문서에서 분석한 BsonBinary 역직렬화 비용:

단계 비용
인덱스 탐색 (Skip List) ~0.3 μs/step
페이지 읽기 (캐시 히트) ~1~2 μs
페이지 읽기 (디스크) ~50~100 μs
BSON 역직렬화 (28KB, BsonBinary) ~35~70 μs
문서 1건 총 비용 ~40~80 μs (캐시 히트 시)

🔹 3.2 Skip/Offset 방식 예상 시간

배치별 소요시간:
  배치   1 (skip=0):      1,000 × 60μs = ~60ms
  배치  50 (skip=49000):  50,000 × 60μs = ~3초
  배치 100 (skip=99000):  100,000 × 60μs = ~6초
  배치 250 (skip=249000): 250,000 × 60μs = ~15초
  배치 500 (skip=499000): 500,000 × 60μs = ~30초

총 소요시간 = Σ(평균 250,000건 × 60μs × 500배치의 가중평균)
           ≈ 1.25억 × 60μs
           ≈ 7,500초
           ≈ ~125분 (~2시간)

그러나 LiteDB 캐시 효과와 SSD 순차 읽기 최적화를 고려하면, skip된 문서는 역직렬화 없이 인덱스만 순회하므로 실제로는 더 빠르다.

시나리오 예상 총 시간 근거
최적 (캐시 히트, skip 경량) ~15~25분 skip이 인덱스만 순회한다고 가정
현실적 ~20~40분 일부 디스크 I/O + 캐시 미스
최악 (모든 skip이 역직렬화) ~2시간+ 모든 건을 완전 역직렬화

🔹 3.3 Keyset Pagination 예상 시간

배치별 소요시간 (일정):
  매 배치: 19 step(인덱스) + 1,000건 × 60μs = ~60ms

총 소요시간 = 500배치 × 60ms = 30,000ms = ~30초
시나리오 예상 총 시간 근거
최적 (캐시 히트) ~30초 모든 페이지가 캐시에 있는 경우
현실적 ~1~2분 디스크 I/O 포함 (13GB 순차 읽기 ~26초)
최악 (캐시 미스 빈번) ~3~5분 SSD 랜덤 I/O 포함

⚖️ 3.4 비교표

항목 Skip/Offset Keyset 개선율
현실적 총 시간 ~20~40분 ~1~2분 ~20x
첫 번째 배치 ~60ms ~60ms 동일
마지막 배치 ~30초 ~60ms ~500x
시간 안정성 배치마다 증가 일정  

📌 4. 메모리 사용량 예측

🔹 4.1 배치당 메모리 (1,000건 × 28KB, BsonBinary)

[사용자 데이터]
  byte[] 배열 (vertex 12KB + index 4KB + tri 12KB) × 1000건
    = 28,000 KB = ~27.3 MB
  float[]/int[] 복원 배열 (vertex 12KB + index 4KB + tri 12KB) × 1000건
    = 28,000 KB = ~27.3 MB
  MyMesh 객체 오버헤드 (참조, 헤더 등)
    = ~0.5 MB
  List<MyMesh> 컨테이너
    = ~0.01 MB
  ─────────────────────────────
  사용자 데이터 합계: ~55 MB

[LiteDB 내부]
  MemoryCache PageBuffer (8KB × ~3,500페이지)
    = ~27.3 MB (배치당, 캐시 히트 시 재사용)
  BSON 역직렬화 중간 객체
    = ~5~10 MB (역직렬화 후 GC 대상)
  ─────────────────────────────
  LiteDB 내부 합계: ~32~37 MB

[배치당 피크 메모리]    = ~87~92 MB

🔹 4.2 순회 전체에 걸친 메모리 추이

핵심 문제: LiteDB v5의 MemoryCache는 캐시 상한이 없어, 순회할수록 캐시가 누적된다.

배치   1: 피크 ~90MB,  LiteDB 캐시 ~27MB
배치  10: 피크 ~90MB,  LiteDB 캐시 ~100MB  (캐시 누적)
배치  50: 피크 ~90MB,  LiteDB 캐시 ~300MB  (캐시 누적)
배치 100: 피크 ~90MB,  LiteDB 캐시 ~500MB  (캐시 누적)
  ...
배치 500: 피크 ~90MB,  LiteDB 캐시 ~?? GB  (이론적 무한 증가)

그러나 실제로는 MemoryCache가 오래된 페이지를 _free 큐로 이동시키고, 새 페이지 할당 시 재사용한다. 문제는 _free 큐 자체에 상한이 없다는 점이다.

💡 참고: LiteDB MemoryCache 메모리 누수 #1756, PageBuffer 무한 증가 #2020

🔹 4.3 메모리 예측 요약

시점 사용자 데이터 LiteDB 캐시 Unity 힙 총 메모리
배치 1 ~55 MB ~27 MB ~128 MB ~210 MB
배치 50 ~55 MB ~100~300 MB ~512 MB ~670~870 MB
배치 100 ~55 MB ~200~500 MB ~1 GB ~1.2~1.5 GB
배치 500 (최종) ~55 MB ~500 MB~2 GB ~2 GB ~2.5~4 GB

⚠️ 경고: Unity Boehm GC는 힙을 확장만 하고 축소하지 않는다. 500 배치 순회 후 Unity 관리 힙은 최대 확장 크기를 유지한다.

💡 참고: Unity GC 문서

🔹 4.4 메모리 완화 전략

전략 효과 구현
DB 인스턴스 재생성 (100배치마다) 캐시 완전 해제 db.Dispose()new LiteDatabase()
GC.Collect() (배치 간) 사용자 객체 회수 (힙 크기 유지) 프레임 스파이크 유발 주의
커뮤니티 패치 적용 free 큐 200개 상한 (1.6MB) LiteDb5_Memory
배치 간 yield Unity 프레임 유지 yield return null

📌 5. 최적 구현: Keyset Pagination + 메모리 관리

public IEnumerator LoadAllMeshesCoroutine(
    ILiteCollection<MyMesh> col,
    System.Action<MyMesh> onMeshLoaded)
{
    long lastId = 0;
    int batchCount = 0;
    int totalLoaded = 0;

    while (true)
    {
        // Keyset Pagination: Id 인덱스로 정확한 위치 점프
        var batch = col.Find(x => x.Id > lastId, limit: 1000).ToList();
        if (batch.Count == 0) break;

        // 데이터 처리
        foreach (var mesh in batch)
        {
            onMeshLoaded(mesh);
        }

        lastId = batch[^1].Id;
        totalLoaded += batch.Count;
        batchCount++;

        // 명시적 참조 해제
        batch.Clear();
        batch = null;

        // Unity 프레임 양보 (매 배치마다)
        yield return null;

        // 100배치(10만건)마다 GC 강제 수집 (선택)
        if (batchCount % 100 == 0)
        {
            System.GC.Collect();
            yield return null;
        }
    }

    Debug.Log($"전체 로딩 완료: {totalLoaded}건, {batchCount}배치");
}

🧪 5.1 예상 성능 (Keyset + byte[] + 위 코드)

항목 예상치
총 소요시간 ~1~2분 (500배치 × ~60ms + yield 오버헤드)
배치당 시간 ~60~120ms (일정)
배치당 피크 메모리 ~87~92 MB
누적 LiteDB 캐시 ~500MB~2GB (재생성 없이)
실용적 총 메모리 ~500MB~1.5GB (GC.Collect 포함)
Unity 프레임 영향 매 배치 1프레임 소비 (16ms 초과 시 프레임 드롭)

🗄️ 5.2 DB 재생성 포함 버전 (메모리 안전)

public IEnumerator LoadAllMeshesWithDbRestart(
    string dbPath, System.Action<MyMesh> onMeshLoaded)
{
    long lastId = 0;
    int totalLoaded = 0;

    while (true)
    {
        // DB 인스턴스 생성 (캐시 초기화)
        using (var db = new LiteDatabase($"Filename={dbPath};Connection=direct"))
        {
            var col = db.GetCollection<MyMesh>("meshes");
            int batchInSession = 0;

            while (batchInSession < 100) // 100배치(10만건)마다 DB 재생성
            {
                var batch = col.Find(x => x.Id > lastId, limit: 1000).ToList();
                if (batch.Count == 0) yield break;

                foreach (var mesh in batch)
                    onMeshLoaded(mesh);

                lastId = batch[^1].Id;
                totalLoaded += batch.Count;
                batchInSession++;

                batch.Clear();
                batch = null;
                yield return null;
            }
        } // db.Dispose() → MemoryCache 완전 해제

        System.GC.Collect();
        yield return null;
    }
}
항목 DB 재생성 없음 DB 100배치마다 재생성
총 소요시간 ~1~2분 ~2~3분 (재생성 오버헤드)
피크 메모리 ~500MB~2GB ~300~500MB
메모리 안정성 계속 증가 안정적

⚖️ 6. 종합 비교표

🗂️ 50만건 MyMesh (byte[] 저장, 28KB/건, 정점 1,000개)

방식 총 시간 피크 메모리 안정성
FindAll().ToList() OOM 불가 OOM (13GB+) 불가
FindAll() 스트리밍 ~1~2분 ~100MB~2GB (캐시 누적) 위험
Skip/Offset 1000건 ~20~40분 ~90MB~2GB (캐시 누적) 느림
Keyset 1000건 ~1~2분 ~90MB~2GB (캐시 누적) 양호
Keyset + DB 재생성 ~2~3분 ~300~500MB 안전

⚖️ 배치 시간 추이 비교

Skip/Offset:                    Keyset:
배치 1:   ▏ 60ms                배치 1:   ▏ 60ms
배치 50:  ████ 3초              배치 50:  ▏ 60ms
배치 100: ████████ 6초          배치 100: ▏ 60ms
배치 250: ████████████████ 15초 배치 250: ▏ 60ms
배치 500: ██████████████████████████████ 30초
                                배치 500: ▏ 60ms

📌 7. 최종 권장사항

우선순위 권장사항
1 (필수) float[]byte[] 변환 저장 (BsonBinary)
2 (필수) Keyset Pagination 사용 (Id > lastId), Skip/Offset 절대 금지
3 (강력 권장) 100배치(10만건)마다 DB 인스턴스 Dispose() + 재생성
4 (권장) Unity 코루틴에서 매 배치마다 yield return null
5 (선택) 10만건 처리 후 GC.Collect() (프레임 스파이크 허용 시점)

이 조합으로 50만건 × 28KB 메시 데이터를 ~2~3분, ~300~500MB 메모리 내에서 안전하게 순회 로딩 가능하다.


🔗 참고 자료