ONTOLOGICAL DB 문서 원문 보기

스키마 변경 벤치마크 — 매핑 수준 변경 vs 물리 마이그레이션

이 문서가 답하는 질문

  • 데이터가 이미 들어 있는 그래프에서 스키마를 바꾸면 얼마나 걸리고, 몇 행을 다시 쓰는가?
  • Ontological 이 스키마 변경을 카탈로그(매핑) 수준에서 끝내는 것이 실제로 얼마나 차이 나는가?
  • 같은 변경을 Neo4j 5 와 Apache AGE 는 어떻게, 얼마에 하는가?
  • 어디서 지는가?

측정 스크립트: bench/schema_evolution.py · 원자료: bench/results/schema_evolution.json · 기능 설명: 스키마 진화 매뉴얼, ADR-028


0. 요약 — 100만 노드 / 200만 관계

# 변경 Ontological (카탈로그) Ontological (물리 마이그레이션) Neo4j 5 Apache AGE 1.5
S1 속성 이름 변경 3.9 ms · 10행 2.63 s · 100만 3.18 s · 200만 측정 안 함¹
S2 노드 타입(라벨) 이름 변경 3.1 ms · 71행 124 s · 1,600만 3.39 s · 200만 17.7 s · 600만
S3 관계 타입 이름 변경 2.7 ms · 63행 60.3 s · 1,000만 39.5 s · 400만 2.65 s · 200만
S4 상위 타입 도입 5.3 ms · 86행 (같은 경로) 2.15 s · 100만 표현 불가²
S5 속성 삭제 48.6 ms · 71행 (같은 경로) 3.36 s · 100만 측정 안 함¹
S6 속성 타입 변경 (string→int) 5.91 s · 100만 (같은 경로) 2.01 s · 100만 측정 안 함¹
S7 유니크 제약 추가 1.54 s · 32행 (같은 경로) 0.54 s · 0 측정 안 함³

"행" = 그 변경이 쓴 행 수. PostgreSQL 계열은 pg_stat_database 의 insert+update+delete (카탈로그·임시 테이블 포함), Neo4j 는 결과 카운터(속성 설정 + 라벨 추가/제거 + 관계 생성/삭제).

  • 이름과 계층을 바꾸는 변경(S1–S4)은 데이터 양과 무관하게 3–5 ms 입니다. 1만 노드에서도 100만 노드에서도 같습니다. 같은 변경을 Neo4j 는 데이터에 비례해 1–40 초, 같은 저장소 위의 물리 마이그레이션은 3–124 초 걸립니다. 100만 노드에서 Neo4j 대비 약 400–14,600 배입니다(S4 405 배 … S3 14,600 배).
  • 차이의 원천은 저장 엔진이 아니라 접근 방식입니다. 같은 Ontological 저장소에서 "새 구조로 복사 → 참조 재작성 → 옛 것 삭제" 를 하면(물리 마이그레이션 열) 셋 중 가장 느립니다. 식별자가 타입을 품고 있어 라벨 이름을 바꾸려면 모든 노드의 id, 그 노드를 가리키는 모든 간선 끝점과 인접 배열을 다시 써야 하기 때문입니다. 카탈로그 경로는 이 중 아무것도 하지 않습니다.
  • 값의 표현이 바뀌는 변경(S6)과 인덱스를 만드는 변경(S7)에서는 Neo4j 가 빠릅니다. S6 은 2.9 배, S7 은 2.9 배 느립니다. 이유는 §3.

¹ AGE 의 Cypher SET/REMOVE 는 데이터에 대해 선형보다 가파르게 늘어납니다 (1만 → 10만에서 S1 이 2.66 s → 324 s, 122 배). 10만 노드에서 S1 324 s, S5 83 s, S6 136 s 였으므로 100만에서는 실행하지 않고 "skipped" 로 기록했습니다. S7 은 S1(fullName 생성)에 의존하므로 함께 건너뛰었습니다. ² AGE 의 정점은 라벨을 정확히 하나 가집니다.


1. 무엇을 쟀나

시나리오

데이터: Person 노드 N 개(속성 i, name, age(문자열), status), 노드마다 나가는 KNOWS 관계 2 개(결정적 난수, 총 2N). N = 1만, 10만, 100만. 시나리오는 한 데이터셋에 순서대로 적용됩니다 (S2 이후 라벨은 Individual, S3 이후 관계는 FOLLOWS).

# 변경 Ontological (카탈로그) Ontological (물리) Neo4j AGE
S1 name → fullName rename_property 컬럼 추가 → UPDATE 복사 → 옛 컬럼 삭제 SET p.fullName = p.name REMOVE p.name 같은 Cypher
S2 Person → Individual rename_type 새 타입 생성, 새 id 로 행 복사, og_edge/e_* 끝점·og_adj 배열 재작성, 옛 행 삭제 SET p:Individual REMOVE p:Person 라벨 이름 변경 문이 없어 AGE 자체 SQL 대량 경로: 새 라벨 테이블에 새 graphid 로 복사, 간선 끝점 재지정, 옛 라벨 삭제, 인덱스
S3 KNOWS → FOLLOWS rename_type 새 관계 타입, 새 id 로 간선 복사, 인접 배열 재작성, 옛 것 삭제 관계 타입은 바꿀 수 없어 재생성: CREATE (a)-[:FOLLOWS]->(b) DELETE k SQL: 새 라벨로 복사, 옛 라벨 삭제, 끝점 인덱스
S4 모든 Individual 을 Agent 로도 추상 Agent 생성 + add_parent (카탈로그와 같음) SET p:Agent 표현 불가
S5 status 삭제 drop_property (같음) REMOVE p.status 같은 Cypher
S6 age string → int alter_property_type (expand/migrate/validate/contract, og_schema_apply) (같음) SET p.age = toInteger(p.age) 같은 Cypher
S7 fullName 유니크 add_unique (같음) CREATE CONSTRAINT … IS UNIQUE 표현식 유니크 인덱스

Neo4j 는 문서가 권하는 대로 CALL { … } IN TRANSACTIONS OF 10000 ROWS 로 배치 처리했습니다. AGE 의 Cypher 에는 배치 구문이 없어 한 문장입니다.

S4–S7 에는 Ontological 안에서 "더 싼/비싼 고전적 형태"가 따로 없어(상위 타입 도입은 이 저장소에선 본질적으로 카탈로그 작업, 컬럼 삭제는 PostgreSQL 에서 메타데이터 작업, 타입 변경과 유니크 인덱스는 모든 시스템에서 물리 작업) 물리 열도 같은 경로를 탑니다.

정확성 게이트

각 시나리오 직후, 모든 시스템에서 그래프가 올바르게 답하는지 검사합니다 — 예: S1 뒤 모든 노드의 fullName 이 채워져 있고 name 이 없음, S2 뒤 (:Individual {i: 7})-[:KNOWS]->() 가 정확히 2 건, S6 뒤 age > 60 인 노드 수가 기대값과 일치(문자열 비교가 아닌 숫자 비교), S7 뒤 중복 삽입이 실패. 한 번이라도 틀린 시스템의 시간은 무효("WRONG")로 기록합니다 — bench/harness.py 와 같은 규칙입니다. 최종 결과에 WRONG 은 없습니다. (측정 도중 AGE 100만 S7 이 WRONG 으로 나온 적이 있는데, S1 을 건너뛰어 fullName 이 없는 상태에서 인덱스를 만든 탓이라 그 시나리오도 건너뛰었습니다.)

환경

항목 값
호스트 Apple M4 Max, 16 코어, 128 GB (Docker 에 16 CPU / 31 GB)
PostgreSQL 16.14 (aarch64), shared_buffers 128 MB (pgrx 기본값) — Ontological 과 AGE 가 같은 서버
Ontological 이 저장소, cargo pgrx install --release
Apache AGE 1.5.0, 같은 컨테이너에서 소스 빌드 (arm64 네이티브. amd64 이미지는 에뮬레이션이라 쓰지 않음)
Neo4j 5.15.0 community (arm64), heap 2 GB, page cache 2 GB
클라이언트 호스트의 Python (psycopg2, neo4j 드라이버 4.4) — 경과 시간은 클라이언트 측 벽시계
반복 1만·10만: 새로 적재해 3 회, 중앙값. 100만: Ontological 1 회, Neo4j 2 회, AGE 1 회

2. 전체 결과

중앙값. 괄호 안은 쓴 행 수. L 은 그 시스템 측정이 끝났을 때의 호스트 1 분 부하 평균(§5).

1만 노드 / 2만 관계

# Ontological 물리 마이그레이션 Neo4j AGE
S1 3.4 ms (10) 25.6 ms (10,101) 40.1 ms (20,000) 2.66 s (20,029)
S2 3.0 ms (71) 543 ms (160,337) 37.9 ms (20,000) 90.3 ms (60,174)
S3 2.5 ms (63) 245 ms (100,190) 196 ms (40,000) 22.5 ms (20,164)
S4 5.3 ms (86) 5.9 ms (86) 24.6 ms (10,000) 표현 불가
S5 3.7 ms (71) 4.2 ms (70) 38.8 ms (10,000) 808 ms (10,000)
S6 43.3 ms (10,081) 44.6 ms (10,080) 30.0 ms (10,000) 963 ms (10,000)
S7 14.0 ms (32) 15.6 ms (32) 25.0 ms (0) 18.3 ms (8)
L 9.6 L 9.6 L 11.8 미기록

10만 노드 / 20만 관계

# Ontological 물리 마이그레이션 Neo4j AGE
S1 3.2 ms (10) 220 ms (100,101) 333 ms (200,000) 324 s (200,029)
S2 3.1 ms (71) 6.14 s (1,600,337) 311 ms (200,000) 1.26 s (600,174)
S3 2.8 ms (63) 3.46 s (1,000,190) 3.20 s (400,000) 209 ms (200,164)
S4 5.1 ms (86) 5.5 ms (86) 185 ms (100,000) 표현 불가
S5 7.8 ms (71) 8.2 ms (70) 312 ms (100,000) 82.9 s (100,007)
S6 422 ms (100,081) 469 ms (100,080) 195 ms (100,000) 136 s (100,003)
S7 172 ms (32) 320 ms (32) 63.4 ms (0) 166 ms (8)
L 10.0 L 11.8 L 9.3 미기록

100만 노드 / 200만 관계

# Ontological 물리 마이그레이션 Neo4j AGE
S1 3.9 ms (10) 2.63 s (1,000,101) 3.18 s (2,000,000) skipped¹
S2 3.1 ms (71) 124 s (16,000,344) 3.39 s (2,000,000) 17.7 s (6,000,174)
S3 2.7 ms (63) 60.3 s (10,000,190) 39.5 s (4,000,000) 2.65 s (2,000,164)
S4 5.3 ms (86) 9.7 ms (86) 2.15 s (1,000,000) 표현 불가
S5 48.6 ms (71) 52.4 ms (71) 3.36 s (1,000,000) skipped¹
S6 5.91 s (1,000,090) 5.63 s (1,000,090) 2.01 s (1,000,000) skipped¹
S7 1.54 s (32) 1.52 s (32) 0.54 s (0) skipped³
L 6.5 L 9.3 L 8.5 미기록

³ S1 에 의존.

규모에 따른 증가 (1만 → 100만, 데이터 100 배)

# Ontological 물리 마이그레이션 Neo4j
S1 속성 이름 1.1 배 103 배 79 배
S2 타입 이름 1.0 배 228 배 89 배
S3 관계 이름 1.1 배 247 배 202 배
S4 상위 타입 1.0 배 — 87 배
S5 속성 삭제 13 배⁴ — 87 배
S6 타입 변경 137 배 — 67 배

⁴ DROP COLUMN 자체는 메타데이터 작업이지만, og_schema_plan 이 "잃게 될 값 수" 를 보고하려고 전체를 한 번 셉니다(§3). 그 스캔이 48 ms 의 대부분입니다.


3. 왜 이렇게 나오나

카탈로그 경로가 상수 시간인 이유. Ontological 에서 속성 이름, 타입 이름, 계층상의 위치는 og_catalog 의 행이고, 물리 저장소(og_data.n_<type_id> 테이블, p_<name> 컬럼)를 가리킬 뿐입니다. 테이블 이름에는 타입 이름이 아니라 타입 id 가, 노드 id 에는 타입 id 가 들어 있고 이름은 없습니다. 그래서

  • 이름 변경은 카탈로그 한두 행 갱신 + 생성 뷰 무효화(스키마 버전 증가) — 10–71 행.
  • 상위 타입 도입은 타입 DAG 의 구간 라벨 재계산(타입 수에 비례, 인스턴스 수와 무관) + 상속 컬럼의 재작성 없는 ADD COLUMN — 86 행. 테스트(08_schema_evolution.sql)가 노드 행의 xmin 이 바뀌지 않음을 단언합니다.

Neo4j 는 라벨과 속성 이름이 각 노드 레코드에 붙어 있어 모든 노드를 방문해야 하고(S1, S2, S4, S5: 노드 수만큼), 관계 타입은 바꿀 수 없어 관계를 지웠다 다시 만듭니다(S3: 관계 수 × 2).

같은 저장소의 물리 마이그레이션이 가장 느린 이유. 식별자가 [shard][type][local] 비트필드라 (ADR-003) 노드를 다른 타입으로 "옮기는" 것은 새 id 를 발급하는 일입니다. S2 는 노드 100만 행 복사

  • 간선 200만 개의 양 끝점 × 2 테이블 + 인접 배열 전부 재작성 + 옛 행 삭제로 1,600만 행을 씁니다. AGE 도 graphid 에 라벨 id 가 들어 있어 같은 구조의 비용(600만 행)을 치릅니다. 이 열은 "카탈로그 경로가 없었다면 이 저장소에서 스키마 변경이 얼마였을지" 를 보여 줍니다.

AGE S3 가 빠른 이유. AGE 의 간선 id 는 다른 곳에서 참조되지 않으므로, 관계 라벨 변경은 INSERT … SELECT 한 번과 라벨 테이블 삭제로 끝납니다(200만 행 복사, 2.65 s). Cypher 가 아니라 AGE 의 SQL 대량 경로를 쓴 결과이며, Ontological 의 물리 경로(60 s)보다 빠른 것은 인접 배열 같은 보조 구조가 없기 때문입니다 — 대신 AGE 는 탐색 성능에서 그 값을 치릅니다(benchmark.md).

어디서 지는가 — S6 (타입 변경), 2.9 배. 값의 표현이 바뀌는 변경은 모든 시스템에서 모든 행을 다시 씁니다. Neo4j 는 한 번 훑으며 바꿉니다. Ontological 은 운영 중 안전을 위해 단계를 나눠서 같은 데이터를 여러 번 읽습니다.

  1. plan — 캐스트할 수 없는 값을 세는 스캔 (변경 전에 문제 행을 보고하려고)
  2. expand — 그림자 컬럼 추가
  3. migrate — 배치 UPDATE (100만 행 재작성, 인덱스 포함)
  4. validate — 옮겨지지 않은 행을 세는 스캔
  5. contract — 옛 컬럼 삭제, 그림자 이름 변경, 인덱스 재구축

그 대가로 얻는 것: 변경 전에 문제 행 id 를 보고, 전환 중에도 쓰기가 계속되며(expand 이후 새 쓰기는 새 타입으로 검사), 캐스트 불가 값이 하나 있어도 전체가 롤백되지 않고, 중단 후 재개할 수 있습니다. Neo4j 의 toInteger() 는 캐스트할 수 없는 값을 조용히 null 로 만듭니다 — 이 벤치마크의 데이터는 전부 숫자라 차이가 드러나지 않았을 뿐입니다.

이 측정 중 한 가지를 고쳤습니다. 처음엔 캐스트 가능 여부를 예외 블록(값마다 서브트랜잭션)으로 검사해 100만 행에서 11.6 s 였는데, PostgreSQL 16 의 pg_input_is_valid() 로 바꿔 5.9 s 가 됐습니다. 남은 차이는 위 단계 구조 자체입니다.

S7 (유니크 제약), 2.9 배. plan 과 validate 가 각각 계층 전체의 중복을 GROUP BY 로 세고, contract 가 인덱스를 만듭니다 — 세 번의 전체 스캔. Neo4j 는 인덱스를 만들며 중복을 검사합니다(한 번). 중복 값을 변경 전에 보고한다는 이점이 있지만, 이미 깨끗한 데이터에서는 순수한 비용입니다.

AGE 의 Cypher 쓰기. 1만 → 10만에서 S1 이 122 배, S5 가 103 배, S6 이 141 배로 늘었습니다 (데이터는 10 배). 선형보다 가파릅니다. 원인은 이 측정의 범위 밖이라 조사하지 않았습니다.


4. 이 결과가 말하지 않는 것

  • 전환 중의 동시성. 각 변경을 혼자 실행한 시간입니다. 변경 도중 다른 세션의 읽기·쓰기가 얼마나 막히는지는 재지 않았습니다. og_schema_apply 는 한 트랜잭션이라 S6 같은 변경은 끝날 때까지 테이블 잠금을 쥡니다. 운영 중에는 단계별 호출을 권합니다(매뉴얼 §7).
  • 변경 이후의 질의 성능. 이름을 바꾼 뒤 질의가 빨라지거나 느려지지 않는지는 이 스크립트가 재지 않습니다. 카탈로그 경로에서는 저장소가 그대로이므로 달라질 이유가 없지만, 측정한 것은 아닙니다.
  • 한 가지 데이터 모양. 노드당 속성 4 개, 나가는 관계 2 개. 속성이 많거나 관계가 조밀하면 물리 경로와 Neo4j 의 비용은 늘고, 카탈로그 경로는 그대로일 것으로 예상되지만 측정하지 않았습니다.
  • 메모리 설정이 같지 않습니다. PostgreSQL 은 shared_buffers 128 MB(기본값), Neo4j 는 page cache 2 GB. 이 차이는 PostgreSQL 쪽에 불리하게 작용합니다(물리 마이그레이션, S6, S7).
  • TypeDB 는 측정하지 않았습니다.

5. 측정 조건에 대한 기록

한 차례 측정 도중 호스트의 부하 평균이 88 까지 올라간 적이 있습니다(같은 기계의 다른 프로젝트 컨테이너). 그 구간의 Ontological 재측정은 적재 시간이 7 배로 늘고 시간 제한에 걸려 중단됐으며, 그 결과는 쓰지 않았습니다. 이후 스크립트가 각 시스템 측정이 끝날 때의 1 분 부하 평균을 loadavg_1m 으로 기록하도록 했고, 위 표의 Ontological 두 열과 Neo4j 열은 부하 평균 6.5–11.8 에서 다시 잰 값입니다. AGE 열은 그 이전에 잰 값이라 부하가 기록되어 있지 않습니다. 다만 AGE 의 반복 측정값은 서로 가깝고(예: 10만 S6 = 134.6 / 135.8 / 135.6 s), 다른 열과의 차이가 수십~수천 배라 결론에 영향을 줄 정도는 아닙니다.


6. 재현

# 비교 대상: Neo4j (arm64 이미지)
docker run -d --name og-bench-neo4j -p 27687:7687 -e NEO4J_AUTH=neo4j/benchpass123 \
  -e NEO4J_server_memory_heap_max__size=2G -e NEO4J_server_memory_pagecache_size=2G \
  neo4j:5.15.0-community

# Apache AGE 1.5 를 Ontological 과 같은 PostgreSQL 에 설치 (개발 컨테이너 안)
docker exec og bash -lc 'cd /tmp && git clone --depth 1 --branch PG16/v1.5.0-rc0 \
  https://github.com/apache/age.git && cd age && \
  make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config && \
  sudo make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config install'

# 측정 (호스트에서; psycopg2 와 neo4j 드라이버 필요)
python3 bench/schema_evolution.py --sizes 10000,100000 --repeat 3
python3 bench/schema_evolution.py --sizes 1000000 --skip "age:S1,S5,S6,S7"

AGE Cypher 시나리오는 AGE_TIMEOUT_S(기본 900 초)를 넘으면 "timeout" 으로 기록됩니다.

이 페이지는 docs/schema-evolution-benchmark.md 에서 만들어졌습니다 · python3 web/build_docs.py