스키마 변경 벤치마크 — 매핑 수준 변경 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 은 운영 중 안전을 위해 단계를 나눠서 같은 데이터를 여러 번 읽습니다.
- plan — 캐스트할 수 없는 값을 세는 스캔 (변경 전에 문제 행을 보고하려고)
- expand — 그림자 컬럼 추가
- migrate — 배치
UPDATE(100만 행 재작성, 인덱스 포함) - validate — 옮겨지지 않은 행을 세는 스캔
- 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_buffers128 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" 으로 기록됩니다.