SW/알고리즘

초대형 그래프의 초소형 표현: TerminusDB에서 대용량 데이터를 처리하는 방법

얇은생각 2024. 8. 19. 07:30
반응형

 

데이터 과학과 빅데이터의 시대에, 항상 더 크고 복잡한 데이터셋을 처리하는 방법을 고민합니다. 특히 그래프 데이터베이스는 이러한 대규모 데이터를 다루는 데 있어 강력한 도구로 자리 잡고 있습니다. 그러나, 모든 데이터가 반드시 거대한 하드웨어 자원이나 대규모 분산 시스템을 필요로 하는 것은 아닙니다. 실제로, 데이터의 크기와 복잡성을 줄이면서도 동일한 성능을 유지할 수 있다면 이는 혁신적인 변화를 가져올 수 있습니다. 이 글에서는 TerminusDB를 활용해 170억 개의 트리플(triple)을 단일 머신에서 효율적으로 로드하고 쿼리할 수 있는 방법을 소개합니다.

 

초대형 그래프의 초소형 표현: TerminusDB에서 대용량 데이터를 처리하는 방법

 

TerminusDB의 시작: 초대형 그래프 데이터와의 만남

TerminusDB가 처음 시작될 때, 폴란드 경제에 대한 공공 정보를 하나의 거대한 지식 그래프로 로드하는 프로젝트를 진행했습니다. 이 프로젝트는 30억 개의 트리플로 구성된 데이터셋을 효율적으로 검색할 수 있도록 압축된 그래프 표현으로 병합하는 데 많은 맞춤형 코드를 필요로 했습니다. 하지만 이 과정은 하루 이상 소요되었고, 상당한 컴퓨팅 자원을 필요로 했습니다.

이후 TerminusDB를 더욱 사용자 친화적으로 만들기 위해 노력했습니다. JSON 문서를 쉽게 로드할 수 있도록 하고, 스키마 검사를 포함시켜 잘못된 데이터를 사전에 방지하는 등, 대다수의 사용 사례에서 요구되는 데이터베이스 크기가 2GB 미만임을 감안한 최적화 작업을 진행했습니다. 이러한 노력 덕분에 많은 실사용 사례에서 대규모 데이터셋이 필요하지 않다는 결론에 도달했고, 결과적으로 사용 편의성을 높이는 것이 더 중요하다는 결정을 내렸습니다.

 

거대한 지식 그래프

물론, 때로는 경제 데이터처럼 거대한 데이터셋이 필요할 때가 있습니다. 최근, TerminusDB의 한 커뮤니티 멤버가 OpenAlex 데이터셋의 Authors 컬렉션을 로드할 수 있는지 문의했습니다. OpenAlex는 과학 출판에 대한 방대한 정보를 포함하고 있는 대규모 데이터셋입니다.

TerminusDB의 내부 구조는 매우 압축된 그래프 표현을 저장하도록 설계되어 있습니다. 이에 우리는 몇 가지 계산을 통해 OpenAlex의 상당 부분을 단일 지식 그래프로 구축할 수 있을 것이라는 결론에 도달했습니다. 그리고 이를 가능하게 하기 위해 일부 TerminusDB의 기능을 수정하고 Python으로 작성된 OpenAlex 로직을 활용하여 이를 실현했습니다.

 

병렬 처리: 500개의 데이터베이스로 나누어 처리하기

데이터 로드를 병렬화하기 위해 500개의 별도 데이터베이스를 생성하고, 각 데이터베이스가 데이터 입력의 일부를 담당하도록 했습니다. 이를 위해 데이터를 500개로 분할하고, 64개의 프로세스를 동시에 시작하여 각 프로세서가 하나의 데이터베이스-청크 쌍을 처리하도록 했습니다. 이렇게 하면 모든 프로세서가 데이터 로드 작업을 완료할 때까지 포화 상태를 유지하게 됩니다.

데이터 로드의 조각 크기는 대략적으로 최종 데이터베이스의 메모리 크기의 10배 정도의 메모리를 필요로 한다는 계산을 기반으로 하여 결정되었습니다. 이렇게 계산된 입력 데이터를 조각으로 나누어 모든 프로세서를 포화 상태로 유지하며 데이터를 로드할 수 있었습니다.

이 작업의 주요 부분은 다음과 같은 간단한 Python 코드로 구성됩니다:

def ingest_json(args):
    start = time.time()
    filename = args[0]
    number = args[1]
    schema = args[2]
    db_name = f"openalex_{number}"
    db = f'admin/{db_name}'
    with open(f"log/{db_name}.log", 'w') as f:
        subprocess.run(f"{TERMINUSDB_COMMAND} doc insert {db} -g schema --full-replace < {schema}", shell=True, stdout=f, stderr=f)
        subprocess.run(f'{TERMINUSDB_COMMAND} doc insert {db} < {filename}', shell=True, stdout=f, stderr=f)
        end_insert = time.time() - start
        f.write(f"\n\nEND TIME: {end_insert}\n")
 

이 코드는 주어진 데이터베이스에 대한 terminusdb doc insert 명령을 실행하며, 이 프로세스를 적절한 개수로(예: 프로세서 수만큼) 실행할 수 있습니다.

with Pool(args.threads) as p:
    # JSON 로드
    p.map(ingest_json, args_process)
 

이 로드 프로세스는 약 7시간이 소요되었습니다.

 

 

데이터베이스 결합: 500개의 데이터베이스를 하나로 합치기

500개의 데이터베이스를 결합하기 위해 새로운 방법이 필요했습니다. 여러 베이스 레이어를 읽고 이를 하나의 새로운 베이스 레이어로 결합하는 새로운 결합 작업을 작성했습니다.

TerminusDB는 불변성이기 때문에, 업데이트는 데이터베이스의 변경 사항을 포함하는 새로운 레이어를 추가함으로써 수행됩니다. 첫 번째 레이어는 베이스 레이어라고 불리며, 이를 결합하는 작업이 필요했습니다. 이는 델타 인코딩으로 업데이트를 처리하기 때문에, 전체 데이터베이스를 새로운 베이스 레이어로 결합하는 과정이 필요했습니다.

이를 위해 표준 입력으로 데이터베이스 목록을 받아들이는 형태의 명령어를 사용했습니다. 명령어는 다음과 같습니다:

$ echo "admin/db1 admin/db2 ... admin/dbn" | terminusdb concat admin/final
 

여기서 데이터베이스 목록은 공백으로 구분된 데이터베이스 목록이며, 이 명령어를 통해 모든 입력 데이터베이스를 결합할 수 있습니다.

 

 

메모리 절약

500개의 데이터베이스를 결합하는 과정에서는 메모리 사용에 특히 주의를 기울여야 했습니다. TerminusDB는 매우 인덱싱이 잘 되어 있음에도 불구하고 메모리 오버헤드가 매우 낮은 편입니다. 이는 간결한 데이터 구조를 사용하기 때문입니다. 최종적으로 170억 개의 트리플을 포함하는 데이터베이스는 트리플당 13.57 바이트의 메모리만을 차지했습니다.

이 과정에서 중요한 것은, 입력을 스트리밍하면서 메모리 요구량을 최소화하는 것이었습니다. 이를 위해 레이어 작성 코드를 다시 작성하여 모든 입력을 스트림으로 받아들이도록 했습니다. 베이스 레이어는 여러 세그먼트로 구성되어 있으며, 이러한 세그먼트는 모두 정렬되어 있기 때문에 병합 정렬의 두 번째 절반(즉, "정복" 부분)을 수행하여 이를 정렬된 순서로 병합할 수 있습니다.

 

 

대용량 데이터 통합

최종 레이어는 약 212GB 크기로, 500GB의 머신에서 매우 안정적으로 작동합니다. GraphQL을 사용하면 이 데이터를 빠르게 쿼리할 수 있으며, 단일 머신에 많은 양의 데이터를 통합할 수 있어 그래프 성능을 최적화할 수 있습니다.

결합 작업은 약 5시간이 소요되며, 전체 과정은 JSON 파일에서 쿼리가 가능한 레이어로의 변환을 12시간 이내에 완료할 수 있습니다.

 

 

결론: 더 작은 것이 더 좋다

그래프 데이터는 메모리 접근 성능이 매우 중요합니다. 따라서 가능한 모든 데이터를 메모리에 저장할 수 있도록 하는 것이 중요합니다. TerminusDB는 이미 메모리 성능 면에서 우수하지만, 더 많은 데이터를 단일 머신에 저장할 수 있는 방법을 계속해서 연구하고 있습니다.

미래에는 특정 링크에 대한 인덱싱을 제한하거나, 대안적인 인덱싱 전략을 사용하여 메모리 사용량을 더욱 줄일 수 있는 가능성을 모색하고 있습니다. TerminusDB의 모토인 "더 작은 것이 더 좋다"를 바탕으로, 우리는 그래프 데이터의 한계를 계속해서 탐구할 것입니다.

반응형