Memgraph는 Cypher 쿼리 언어를 지원하며 초고속 ACID 트랜잭션을 보장한다고 홍보하는 인-메모리(in-memory) 그래프 데이터베이스다. 이것이 마케팅 문구다. 하지만 현실은 훨씬 미묘하며, 더 흥미롭다. 공식 문서를 깊이 파고든 후, Memgraph를 검토 중이거나 실제 프로덕션 환경에서 격렬하게 굴리고 있는 사람들에게 진짜로 중요한 내용들을 정리해 보았다.
스토리지 모드: 트레이드오프를 선택하라
Memgraph에는 세 가지 스토리지 모드가 있으며, 여기서 내리는 선택이 이후의 모든 시스템 동작을 결정짓는다.
IN_MEMORY_TRANSACTIONAL은 기본 모드이자 대부분의 엔지니어들이 Memgraph를 도입하는 이유다. 완전한 ACID를 보장한다. 지속성(durability)을 위해 WAL(Write-Ahead Log) 파일과 주기적인 스냅샷을 사용한다. 트랜잭션 격리 및 롤백을 지원하기 위해 모든 데이터 변경 사항은 델타(Delta) 객체로 추적된다. 대가는 명확하다. 모든 데이터 변경마다 델타(개당 56바이트)가 생성되므로 대규모 벌크 임포트 시 메모리가 빠르게 소모된다.
IN_MEMORY_ANALYTICAL은 델타 추적 메커니즘을 완전히 제거한 모드다. ACID를 보장하지 않는다. WAL도 없고, 주기적인 스냅샷도 없다. 대신 최대 6배 빠른 임포트 속도와 데이터 적재 중 극적으로 낮은 메모리 소비를 얻을 수 있다. 여러 트랜잭션이 충돌 없이 동일한 노드에 동시에 쓸 수도 있다. 단점은 트랜잭션이 실패해도 변경 사항이 롤백되지 않는다는 것이다. 다른 트랜잭션이 커밋되지 않은 데이터를 읽을 수도 있다. 지속성은 수동으로만 유지되므로, 필요할 때 직접 CREATE SNAPSHOT을 실행해야 한다.
이 모드는 매우 특정한 유스케이스를 위해 존재한다. 슈퍼노드가 포함된 거대한 고밀도 그래프가 있고, 이를 매우 빠르게 적재해야 하며, 적재가 끝난 뒤 다시 트랜잭션 모드로 전환할 계획인 경우다. 데이터 일관성이 전혀 상관없는 특수한 상황이 아니라면 프로덕션 쿼리를 분석 모드로 돌려서는 안 된다.
ON_DISK_TRANSACTIONAL은 RocksDB를 백엔드 스토리지로 사용한다. 데이터는 디스크에 저장되고, 핫(hot) 객체만 인-메모리 캐시에 올라간다. 스냅샷 격리(snapshot isolation) 방식으로만 ACID를 지원한다. 당연하게도 성능은 인-메모리보다 떨어지지만, 물리 RAM 용량을 초과하는 대규모 데이터셋을 다룰 수 있다. 아직 실험적(experimental) 단계로 분류되어 있다. 이 모드를 진지하게 고려하고 있다면, 다른 데이터베이스를 알아보거나 Memgraph 엔지니어링 팀과 먼저 상의하는 것이 좋다.
인-메모리 모드 간의 전환(STORAGE MODE IN_MEMORY_ANALYTICAL;)은 간단하다. 반면 온-디스크 모드에서 벗어나려면 빈 데이터베이스가 필요하므로 사전에 계획을 잘 세워야 한다.
메모리 계산법 (Memory Math)
Memgraph의 생명줄은 RAM이다. 트랜잭션 모드에서 필요한 스토리지 메모리를 추정하는 공식은 다음과 같다.
StorageRAM = 정점(Vertices) × 204B + 간선(Edges) × 154B
이것은 대략적인 추정치다. 실제 세부 내역을 뜯어보면 이렇다. 정점 하나는 최소 136바이트(객체 80B + 델타 56B)이고, 간선 하나는 최소 88바이트(객체 32B + 델타 56B)이며, 여기에 스킵리스트(SkipList) 컨테이너 오버헤드(노드 및 포인터당 약 24B)가 추가되고, 마지막으로 속성(properties) 데이터가 더해진다.
속성 크기 계산이 특히 중요하다. 불리언(Boolean)은 2바이트를 차지한다. 정수(Integer)는 값의 크기에 따라 310바이트가 든다. 문자열(String)은 기본 4바이트에 ASCII 문자당 1바이트가 추가된다. 시간/날짜 타입은 1214바이트를 먹는다. 쿼리 내에서 propertySize() 함수를 사용하면 정확한 속성 크기를 확인할 수 있다.
예를 들어 마블 코믹스 유니버스 데이터셋(정점 21,723개, 간선 682,943개)은 약 117MB의 스토리지 메모리를 소비한다. 아무 데이터도 없는 빈 Memgraph 인스턴스조차 기동하는 것만으로 약 75MB를 차지한다. 쿼리 실행 오버헤드를 감안하여 전체 데이터셋 크기의 약 2배에 달하는 RAM 용량을 산정해 두어야 안전하다.
메모리를 절약하는 세 가지 팁이 있다.
- 관계에 별도 속성이 없다면 간선 속성을 비활성화한다(
--storage-properties-on-edges=false). 간선 객체 자체가 생성되지 않는다. - 경량 간선(lightweight edge)을 활성화한다(
--storage-light-edge=true). 스킵리스트 대신 풀(pool) 할당 방식을 사용하여 간선당 약 24바이트를 아낄 수 있다. - zlib을 이용한 속성 압축을 켠다(
--storage-property-store-compression-enabled=true). CPU를 소모하는 대신 메모리를 아낀다. 압축 수준은low,mid(기본값),high로 조절 가능하다.
부동소수점 해상도 설정도 있다. --storage-floating-point-resolution-bits=32를 쓰면 8바이트 double 대신 4바이트 float으로 저장하여 정밀도를 희생하는 대신 부동소수점당 4바이트를 절약한다. 단, WAL과 스냅샷은 언제나 전체 64비트로 직렬화되므로, 한 번 기록된 정밀도 손실은 영구적이다.
인덱스: 복합 인덱스의 함정
Memgraph의 인덱스는 동시성 스킵리스트(concurrent skip list)를 기반으로 구축되어 삽입, 삭제, 검색 모두 O(log n)의 시간 복잡도를 가진다. 인덱스는 자동으로 생성되지 않는다(자동 생성을 활성화하지 않는 한).
기본 인덱스 유형으로는 라벨 인덱스, 라벨-속성 인덱스, 엣지 타입 인덱스, 포인트 인덱스가 있다. 이들은 직관적이다. 주의해야 할 것은 **복합 인덱스(Composite Index)**다.
:Person(name, age, occupation)에 설정된 복합 인덱스는 ’최좌측 접두사 규칙(leftmost prefix rule)’을 따른다. 즉, name 단독 필터, name + age 필터, 혹은 세 가지 모두를 포함하는 쿼리에는 효율적으로 동작한다. 하지만 age 단독 필터나 occupation 단독 필터에는 인덱스를 탈 수 없다. 이는 관계형 SQL 데이터베이스의 복합 인덱스와 동일한 동작이지만, 쿼리 패턴의 변칙성이 큰 그래프 환경에서는 많은 엔지니어들이 쉽게 간과하는 부분이다.
두 가지 실전 원칙:
- 카디널리티(고유값의 수)가 가장 높은 속성을 맨 앞에 배치하라.
- 특정 속성 하나로 자주 쿼리한다면, 해당 속성이 복합 인덱스에 포함되어 있더라도 별도의 단일 속성 인덱스를 만들어라. 복합 인덱스가 단일 인덱스만큼의 성능을 내지 못할 수 있다.
내림차순 인덱스(WITH CONFIG {"order": "DESC"})는 ORDER BY ... DESC 쿼리를 위해 제공되며, 특히 LIMIT과 함께 쓸 때 유용하다. 정렬 후 자르는 연산을 O(N)의 즉각적인 인덱스 읽기로 변환해 준다. 단, IN_MEMORY_TRANSACTIONAL 모드에서만 작동한다.
데이터를 적재하고 인덱스를 생성한 후에는 반드시 ANALYZE GRAPH를 실행해야 한다. 이는 속성값의 분포를 계산하여, 쿼리 플래너가 단순 노드 수가 아니라 평균 그룹 크기와 카이제곱 통계량을 기반으로 최적의 인덱스를 선택하도록 돕는다. 반복 실행할 필요 없이 한 번만 실행하면 된다.
제약 조건: 세 가지 유형, 모두 커밋 시점에 검증
Memgraph는 존재(existence) 제약 조건, 유일성(uniqueness) 제약 조건, 데이터 타입(data type) 제약 조건을 지원한다.
존재 제약 조건은 특정 라벨을 가진 모든 노드에 해당 속성이 반드시 존재하도록 강제한다. 유일성 제약 조건은 라벨-속성 조합이 고유함을 보장하며, 복합 유일성(여러 속성의 조합)도 지원한다. 데이터 타입 제약 조건은 속성이 특정 타입(STRING, INTEGER 등)만 갖도록 강제한다.
중요한 점은 모든 제약 조건이 쓰기 시점이 아니라 **커밋 시점(commit time)**에 검사된다는 사실이다. 낙관적(optimistic) 접근 방식을 취하기 때문에, 여러 쿼리로 구성된 트랜잭션은 최종 커밋 전까지 제약 조건 검사 없이 그대로 진행된다. 만약 제약 조건 위반이 발생하면 트랜잭션 전체가 롤백된다. 대량 배치 임포트 파이프라인을 구축할 때 이를 반드시 이해해야 한다. 10,000건의 배치 중 단 하나의 잘못된 레코드가 전체 배치를 날려버릴 수 있기 때문이다.
또한 유일성 제약 조건이 인덱스를 자동으로 생성해 주지 않는다는 점을 명심하라. 유일성 강제와 빠른 조회가 둘 다 필요하다면 제약 조건과 인덱스를 각각 따로 생성해야 한다.
트랜잭션: 기본값은 스냅샷 격리 (Snapshot Isolation)
Memgraph의 기본 격리 수준은 스냅샷 격리다. 트랜잭션은 데이터베이스의 일관된 특정 스냅샷을 바라보며, 해당 스냅샷 시점 이후로 충돌하는 업데이트가 발생하지 않았을 때만 성공적으로 커밋된다. 더티 리드(dirty read), 반복 불가능한 읽기(non-repeatable read), 팬텀 리드(phantom read)를 방지한다.
필요하다면 격리 수준을 READ COMMITTED나 READ UNCOMMITTED로 낮출 수 있다(후자는 읽기 전용 쿼리에만 권장된다). 세션이나 트랜잭션 범위에서 격리 수준을 변경할 수 있다.
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
명시적 트랜잭션은 BEGIN, COMMIT, ROLLBACK으로 제어한다. 데이터를 가상으로 수정하고 그래프 알고리즘을 돌린 뒤 롤백하는 식의 ’What-if 분석’에 매우 유용하다.
BEGIN;
MATCH (e:E)-[r:CONNECTED_TO]->(f:F) SET r.flow = 17;
MATCH (a:A), (f:F) CALL max_flow.get_flow(a, f, "flow") YIELD max_flow RETURN max_flow;
ROLLBACK;
SHOW TRANSACTIONS로 현재 활성 트랜잭션을 모니터링할 수 있다. 이때 진행 중인 스냅샷(transaction_id = "snapshot")이나 가비지 컬렉션(transaction_id = "gc")을 나타내는 가상 행들도 표시된다. 이들은 백그라운드 프로세스이므로 강제 종료할 수 없다. 만약 gc 행의 elapsed_ms가 매우 크고 exclusive_lock: true 상태라면, 가비지 컬렉션이 사용자 트랜잭션을 블로킹하고 있다는 신호다.
지속성: 스냅샷과 WAL의 결합
Memgraph는 주기적 스냅샷과 WAL이라는 두 가지 메커니즘으로 지속성을 보장한다. 가장 결정적인 규칙은 스냅샷 없이 WAL만 단독으로 사용할 수는 없다는 점이다. 스냅샷 주기를 0으로 설정한 상태에서 WAL을 활성화하면 Memgraph는 기동을 거부한다.
서버가 시작될 때 Memgraph는 가장 최근의 스냅샷으로부터 데이터를 복구한 뒤, 해당 스냅샷 이후에 기록된 WAL 파일들의 변경 사항을 순차적으로 재생(replay)한다. WAL 파일은 새로운 스냅샷이 성공적으로 생성되면 자동으로 삭제된다.
v3.12부터 WAL 파일에 트랜잭션별 CRC32 체크섬이 도입되었다. 복구 과정에서 손상이 감지되면 손상된 지점에서 재생을 중단하므로 깨진 데이터가 DB에 반영되지 않는다. 스냅샷 파일에는 아직 체크섬이 적용되지 않았다.
복구 실패 처리 방식에도 큰 변화가 있었다. 기본 설정에서는 DB 손상 시 전체 프로세스가 비정상 종료(crash)된다. 하지만 --storage-allow-recovery-failure=true를 설정하면 손상된 데이터베이스만 빈 상태로 비활성화되고 다른 DB들은 정상 동작하며, 이후 RECOVER SNAPSHOT으로 복구를 시도할 수 있다. 고가용성(HA) 클러스터 환경에서는 손상된 레플리카가 메인 노드로부터 자동으로 복구된다.
병렬 스냅샷 생성(--storage-parallel-snapshot-creation=true)을 켜면 대용량 데이터셋의 스냅샷 생성 시간을 크게 단축할 수 있다. 권장 기준은 전체 아이템 수 ≈ 4 × 스레드 수 × 배치 크기다. 디스크 I/O 대역폭이 포화 상태에 도달하면 스레드를 더 늘려도 속도가 올라가지 않는다.
트리거: 이벤트 기반 Cypher
트리거는 CREATE, UPDATE, DELETE 이벤트에 반응하여 openCypher 구문을 실행한다. 트랜잭션의 일부로 커밋 직전에 실행되는 BEFORE COMMIT과, 비동기로 실행되는 AFTER COMMIT 중 선택할 수 있다.
사전 정의된 변수들이 핵심이다. createdVertices, updatedObjects, deletedEdges 등이 제공된다. 이벤트 유형별로 노출되는 변수가 다르다. 특히 UPDATE 이벤트는 매우 세분화되어 있어 setVertexProperties, removedVertexProperties, setVertexLabels 및 모든 변경을 포괄하는 updatedObjects를 활용할 수 있다.
트리거는 기본적으로 SECURITY DEFINER 권한으로 실행된다. 즉, 트리거를 생성한 사용자의 권한으로 돌아간다. 이벤트를 발생시킨 사용자의 권한으로 검증하고 싶다면 SECURITY INVOKER로 전환해야 한다.
실전 활용처: 노드 타임스탬프 자동 갱신, 파생 데이터 동기화 유지, MAGE의 pagerank_online.update()를 활용한 페이지랭크 등 그래프 알고리즘의 실시간 증분 업데이트.
스토리지 접근: 3단계 동시성 제어
모든 쿼리는 스토리지 접근자(storage accessor)를 필요로 한다. 공유(Shared) 접근은 병렬 읽기 및 쓰기를 허용한다. 읽기 전용(Read-only) 접근은 병렬 읽기는 허용하지만 쓰기는 차단한다. 고유(Unique) 접근은 배타적 락(exclusive lock)을 쥐며 인덱스/제약 조건 DDL, DROP GRAPH, RECOVER SNAPSHOT 등에 사용된다.
접근자 유형은 쿼리 파싱 시점에 자동으로 결정된다. 주의해야 할 단 한 곳은 명시적 트랜잭션이다. 명시적 트랜잭션은 기본적으로 쓰기 공유 접근을 잡기 때문에 다른 트랜잭션을 불필요하게 대기시킬 수 있다. 읽기 전용 명시적 트랜잭션이라면 반드시 읽기 전용으로 명시하여 동시성을 확보해야 한다.
결론
Memgraph는 기본 모드에서 초고속 성능, Cypher 네이티브 지원, ACID 보장을 모두 갖춘 뛰어난 솔루션이다. 인-메모리 아키텍처의 특성상 데이터셋 크기는 물리 RAM에 엄격하게 종속된다. 메모리 계산은 예측 가능하지만 순수 스토리지 외에도 델타 오버헤드, 스킵리스트 컨테이너, 쿼리 실행 메모리를 반드시 함께 고려해야 한다.
스토리지 모드 선택은 이후의 모든 동작을 좌우하는 첫 번째 관문이다. 일반 프로덕션에는 트랜잭션 모드를, 대규모 벌크 적재에는 분석 모드를 쓰며, 온-디스크 모드는 정말 다른 방법이 없을 때만 고려해야 한다.
인덱스는 철저한 전략이 필요하다. 복합 인덱스의 컬럼 순서가 치명적으로 중요하고, ANALYZE GRAPH는 선택이 아닌 필수이며, 내림차순 인덱스는 훌륭하지만 종종 간과되는 최적화 기법이다. 제약 조건은 쓰기가 아니라 커밋 시점에 검증된다. 그리고 지속성을 위해서는 스냅샷과 WAL이 반드시 함께 작동해야 한다.