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

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 메모리 내에서 안전하게 순회 로딩 가능하다.


🔗 참고 자료