3 BUILDING KNOWLEDGE GRAPHS · CHAPTER 3

그래프 데이터베이스

Graph Databases — Cypher, Query Patterns, and Neo4j Internals

현대 그래프 기술은 지식 그래프를 학계의 전유물이 아닌, 많은 조직의 실질적 사정권 안으로 가져왔다. 이 장은 실무자 기초 수준에서 그래프 데이터베이스를 소개한다 — Cypher 질의 언어로 지식 그래프를 저장·질의하는 방법, 그리고 그래프 데이터베이스가 지식 그래프 워크로드에서 다른 데이터 기술보다 훨씬 높은 성능을 내는 이유를 다룬다.

Jesús Barrasa · Jim Webber O'Reilly Media, 2023 Part I. Graph Fundamentals pp. 27–50
The Cypher Query Language

Cypher — 그림 그리듯 질의하는 언어

지식 그래프의 개념은 그래프 데이터베이스라는 용어가 만들어지기 전, 시맨틱 웹과 함께 등장했다. 최근에는 그래프 데이터베이스와 그래프 처리가 현대 비즈니스 시스템의 중요한 트렌드가 되었고, 지식 그래프에 대한 관심의 재점화와 그래프 데이터베이스·그래프 데이터 과학 도구의 광범위한 등장 사이에는 상관관계가 관찰된다.

대부분의 지식 그래프 개발자(그리고 일부 사용자)에게 데이터베이스와의 상호작용 대부분은 Cypher 질의 언어를 통해 이루어진다. Cypher는 선언적 패턴 매칭 질의 언어로, 원래 Neo4j가 만들었지만 현재 여러 다른 시스템에도 구현되어 있으며, 집필 시점 기준 ISO(국제표준화기구)에서 GQL(Graph Query Language, 비공식적으로 "그래프를 위한 SQL")로 표준화가 진행 중이다.

Cypher의 일차 설계 목표는 인간 친화적(humane)이고 표현력 있는 언어가 되는 것이었다. Cypher의 창시자들은 사람들이 Microsoft Visio 같은 드로잉 도구에서 레이블 달린 화살표와 상자로 다이어그램을 그리는 데 매우 익숙하고, 더 중요하게는 그런 다이어그램을 읽는 데 익숙하다는 점에 주목했다. SQL과 SPARQL 같은 질의 언어에서 얻은 뼈아픈 경험을 바탕으로, 좋은 그래프 질의 언어는 드로잉 도구처럼 지배적으로 시각적이어야 한다고 판단했다. 그에 따라 그래프를 위한 시각적 표기법이 Cypher의 심장부에 자리한다.

Note — Neo4j 실습 환경

성숙도와 확산 면에서 가장 대중적인 그래프 데이터베이스는 Neo4j이며, 이 책의 모든 기술 예제는 Neo4j와 관련 도구를 기반으로 한다(저자들은 집필 시점에 Neo4j 소속이다). Neo4j는 https://neo4j.com 에서 무료로 받을 수 있다. 로컬 실행이 가장 쉬운 방법은 Neo4j Desktop 앱이고, 설치 없이 쓰려면 Neo4j Sandbox(그래프 입문용 무료 학습 도구)와 Neo4j AuraDB(서비스형 그래프 데이터베이스, 프로토타이핑용 무료 티어 제공)를 고려하면 된다. 이 장은 포괄적 안내서가 아니라 책의 나머지 솔루션을 따라가기에 충분한 노출을 제공하는 것이 목적이며, 더 깊은 내용은 Graph Databases(Ian Robinson, Jim Webber, Emil Eifrem, O'Reilly) 같은 책을 참고하라.

Creating Data

지식 그래프에 데이터 생성하기 — 그린 것이 곧 저장되는 것

시각적이라고 해서 그래픽에 능해야 하는 것은 아니다. 시각적이라는 말은 질의의 구조가 지식 그래프의 구조를 직관적으로 닮는다는 뜻이다. 1장에서 본 친구·거주지 그래프의 Rosa 노드를 보자. "Rosa는 베를린에 산다"의 시맨틱스는 충실도의 손실 없이 ASCII 아트로 포착할 수 있다.

(:Person {name:'Rosa'})-[:LIVES_IN {since:2020}]->(:Place {city:'Berlin'}) ( ) 괄호 = 노드 ASCII 아트의 원(circle)처럼 보인다 -[ ]-> = 방향 있는 관계 LIVES_IN 타입 · 저장 시 방향은 필수 :Person, :Place = 레이블 노드가 그래프에서 맡는 역할 · 0개 이상 가능 {key: value} = 속성 노드와 관계 양쪽에 저장 가능한 키-값 쌍
패턴 해부도 · "Rosa는 베를린에 산다" — 이 패턴 앞에 CREATE만 붙이면 유효한 Cypher 질의가 된다
Example 3-1 · CREATE로 서브그래프 삽입Cypher
CREATE (:Person {name:'Rosa'})-[:LIVES_IN {since:2020}]->
        (:Place {city:'Berlin', country:'DE'})

데이터를 저장할 때 관계의 방향은 필수다. 방향이 "Rosa가 베를린에 산다"와 "베를린이 Rosa 안에 산다"를 구별하는 정확하고 충실도 높은 모델을 만들기 때문이다. Neo4j에 저장하려면 콘솔에 그대로 입력하면 된다 — 그린 것이 곧 저장되는 것(what you draw is what you store)이다.

데이터를 적재한 뒤에는 Cypher의 MATCH 키워드로 질의한다. MATCH의 목적은 사용자 정의 패턴을 기저 지식 그래프와 비교하는 것이다. MATCH (n) RETURN n은 아무 노드나 찾아 변수 n에 바인딩해 호출자에게 돌려 달라는 요청으로, "그래프 전체를 보여줘"에 해당한다. 기본 화면은 매치된 노드 사이의 관계까지 펼쳐 보여준다. 큰 그래프에서는 비실용적이므로, SQL처럼 LIMIT과 양의 정수를 붙여 반환 레코드 수를 제한하는 것이 보통이다.

Note — 레이블과 인덱스

레이블은 노드를 역할별로 묶는다. 레이블과 함께 노드를 생성하면 그 레이블의 인덱스에 노드가 추가된다. 평소에는 이 인덱스를 의식하지 못하지만, 질의 플래너가 이를 이용해 질의를 빠르게 만든다. 노드 레이블은 모델의 충실도와 성능을 함께 높인다. 좋은 격언이 있다 — 도메인에 대해 아는 것이 있다면 지식 그래프에 넣어라.

Note — 양방향 순회는 같은 상수 비용

관계는 어느 방향으로든 같은 상수 비용으로 순회할 수 있다. 이것은 레이블 속성 그래프 모델의 근본 신조다. 실무적으로 질의 지연시간은 지식 그래프를 얼마나 탐색하는가에 비례하며, 그래프 크기의 단순 함수가 아니라는 뜻이다.

Avoiding Duplicates

중복 없이 그래프 키우기 — MERGE의 미묘한 시맨틱스

그래프를 키우려면 노드와 관계를 더 만들어야 한다. 순진하게는 CREATE만으로 큰 그래프를 만들 수 있다고 생각하겠지만, CREATE는 언제나 새 데이터를 생성한다. 같은 CREATE를 다시 실행하면 Rosa 노드도 Berlin 노드도 하나 이상 생긴다. 새 레코드를 원할 때도 있지만, 대개는 중복을 원하지 않는다.

Example 3-2 · DELETE와 DETACH DELETECypher
MATCH (n) DELETE n            // 노드 삭제. 관계가 매달리게 되면 깔끔하게 중단

MATCH ()-[r:LIVES_IN]->()      // 임의 노드 간 LIVES_IN 관계 삭제
DELETE r

MATCH (n) DETACH DELETE n     // 모든 노드와 부착된 관계 삭제 = 그래프 전체 삭제

DELETE는 매치된 레코드를 제거하되, 관계가 매달리게(dangling) 되는 경우 깔끔하게 중단한다. DETACH DELETE는 매치된 노드와 부착된 관계를 함께 제거하는 특수형이다. MATCH (n) DETACH DELETE n으로 데이터베이스를 리셋하고, 이번에는 MERGE를 사용한다. MERGE는 제공된 패턴 전체가 이미 존재하지 않을 때만 레코드를 삽입한다.

먼저 MERGE (:Person {name:'Karl', age:64})-[:LIVES_IN {since:1980}]->(:Place {city:'London', country:'UK'})를 실행하면, 빈 데이터베이스에서 MERGE는 CREATE처럼 동작해 노드 둘과 관계 하나가 저장된다. 이제 런던에 사는 Fred를 MERGE하면 어떻게 될까. 기존 London 노드에 새 Fred 노드가 연결되리라 기대하겠지만, 놀랍게도 그렇지 않다.

기대한 결과 실제 결과 — MERGE는 부분 매치를 하지 않는다 Karl Fred London London 하나에 두 사람이 연결 Karl Fred London London 원치 않는 London 중복 · Fred–Karl 연결 없음
그림 3-6 · MERGE는 기존 데이터에서 패턴을 전체로 매치할 수 없으면 CREATE처럼 동작한다

MERGE는 미묘하다. 그 시맨틱스는 MATCH와 CREATE의 혼합으로, 패턴 전체를 매치하거나, 패턴 전체에 부합하는 새 레코드를 생성한다. 부분적으로 MATCH하고 부분적으로 CREATE하는 일은 결코 없다. Fred 패턴을 전체로 매치할 수 없었으므로 패턴 전체를 새로 생성했고, 그 결과 원치 않는 London 중복 노드가 생겼다(그리고 Fred와 Karl 사이의 연결은 없다).

제약 조건 + 다단계 MERGE — 올바른 해법

전체를 지우고 이번에는 데이터 모델을 더 신중히 생각한다. 분명히 London, UK는 하나여야 하고 여러 사람이 살 수 있어야 한다. 또한 캐나다 온타리오의 London처럼 다른 London의 존재도 허용되어야 한다. 데이터 모델에 제약을 걸어 단일한 London, UK 노드만 존재하게 하고, 동일 노드를 추가로 만들려는 갱신은 (깔끔하고 안전하게) 거부되도록 할 수 있다.

복합 노드 키 제약 조건Cypher
CREATE CONSTRAINT no_duplicate_cities
FOR (p:Place) REQUIRE (p.country, p.city) IS NODE KEY

이 문장은 Place 노드가 city와 country 속성의 조합으로 이루어진 유일 복합 키를 가져야 한다고 선언한다(복합 제약은 Neo4j Enterprise Edition 기능이며, Neo4j Desktop의 비프로덕션 용도로는 무료로 쓸 수 있다). 이로써 London, UK와 London, Ontario의 공존은 보장하되 각각의 중복은 방지된다. 이제 Karl과 Fred를 London, UK 노드에 연결하는 문제를 세 단계로 분해한다 — ① London을 나타내는 노드를 생성 또는 조회, ② Karl 노드를 생성 또는 조회하고 London에 연결, ③ Fred 노드를 생성 또는 조회하고 London에 연결.

Example 3-3 · 다중 행 MERGE (단일 트랜잭션, 원자적 적용)Cypher
MERGE (london:Place {city:'London', country:'UK'})  // London, UK 노드 생성 또는 매치
                                                     // 변수 "london"에 바인딩
MERGE (fred:Person {name:'Fred'})                 // Fred 노드 생성 또는 매치

MERGE (fred)-[:LIVES_IN]->(london)              // fred와 london 사이 LIVES_IN 생성/매치

MERGE (karl:Person {name:'Karl'})                 // Karl 노드 생성 또는 매치

MERGE (karl)-[:LIVES_IN]->(london)              // karl과 london 사이 LIVES_IN 생성/매치

이 MERGE 문들은 단일 트랜잭션의 일부로 실행되어 원자적으로 적용(또는 거부)된다. 유일성 제약 덕분에 데이터베이스는 중복 Place 노드를 받아들이지 않고, 만들려는 트랜잭션을 중단시킨다. 같은 MERGE 패턴을 반복하면 도메인을 고충실도로 포착하는 큰 지식 그래프를 만들 수 있다. 데이터베이스와의 상호작용이므로 생성 후의 갱신·보강도 자유롭다.

SET과 REMOVE — 속성과 레이블의 갱신Cypher
MATCH (p:Person) WHERE p.name = 'Rosa' SET p.dob = 19841203   // 생년월일 속성 추가

MATCH (p:Person) WHERE p.name = 'Rosa' REMOVE p.dob            // 속성 제거 (노드는 유지)

MATCH (p:Person) WHERE p.name = 'Rosa' REMOVE p:Person         // 레이블 벗겨내기
Note — 배포 아키텍처는 다른 DB와 같다

지식 그래프 개발자는 Cypher를 상당한 수준으로 이해해야 한다. 명령줄 입력은 대개 프로토타이핑이나 일상 워크로드 밖의 임시(ad hoc) 질의를 위한 것이고, 대부분의 질의는 지식 그래프와 상호작용하는 앱 안에 호스팅된다. 질의는 매개변수화되어 네트워크를 거쳐 데이터베이스로 전송·평가된다 — 여느 관계형·NoSQL 데이터베이스와 똑같다. 그래프 데이터베이스를 다른 기술과 구별 짓는 것은 배포 아키텍처가 아니라 데이터 모델이다.

Graph Local Queries

그래프 로컬 질의 — 아는 노드에 닻을 내린 탐색

질의도 데이터를 저장할 때와 매우 유사한 패턴으로 작성한다. Cypher 질의의 심장은 MATCH 절이다. 그래프에서 아는 부분을 패턴에 명시해 존재하는 부분에 바인딩하고, 모르는 부분은 데이터베이스가 찾도록 남겨 둔다. "누가 베를린에 사는가?"를 물어보자. 이런 질의는 그래프의 특정 부분 — 여기서는 베를린 노드 — 에 묶여 있으므로 그래프 로컬(graph local) 질의라 부른다. (로컬이란 패턴 일부가 알려진 노드나 관계에 바인딩된다는 뜻일 뿐, 처리 레코드 수가 적다는 뜻은 아니다.)

Example 3-4 · 누가 베를린에 사는가?Cypher
MATCH (p:Person)-[:LIVES_IN]->(:Place {city:'Berlin', country:'DE'})
RETURN (p)

Cypher가 처음이어도 꽤 읽기 쉽다. (:Place {city:'Berlin', country:'DE'})는 베를린 노드를 매치하고, -[:LIVES_IN]->는 그 노드로 들어오는 LIVES_IN 관계를 매치한다. 속성이 지정되지 않은 느슨한(loose) 패턴 (p:Person)은 임의의 Person 노드를 매치해 변수 p에 바인딩한다. 결과는 Rosa 하나다.

이 질의는 길이 1의 경로만 고려하는 얕은 질의다. 대개는 지식 그래프 깊숙이 탐색해 인사이트를 찾고 싶어지며, 다행히 Cypher로 표현하기 쉽고 Neo4j에서 계산적으로 저렴하다. Rosa의 친구의 친구를 찾아보자. 가변 길이 경로 문법 *2..2는 길이 2부터 2까지(즉 정확히 길이 2)의 경로를 지정한다. (:Person)-[:FRIEND]->(:Person)-[:FRIEND]->(:Person)처럼 전체를 풀어 써도 동등하지만, 짧은 쪽이 더 읽기 좋아 선호된다.

Example 3-5 → 3-6 · 순진한 버전과 올바른 친구의 친구Cypher
// 순진한 버전 — Rosa 자신이 결과에 나타난다!
MATCH (:Person {name:'Rosa'})-[:FRIEND*2..2]->(fof:Person)
RETURN (fof)

// 올바른 버전 — WHERE 술어로 Rosa-Karl-Rosa 순환을 배제
MATCH (rosa:Person {name:'Rosa'})-[:FRIEND*2..2]->(fof:Person)
WHERE rosa <> fof
RETURN (fof)

순진한 버전을 실행하면 Rosa가 자기 자신의 친구의 친구로 나타나는 어리둥절한 결과를 얻는다. Rosa는 Karl의 친구이고 Karl은 Rosa의 친구이므로, Rosa → Karl → Rosa라는 깊이 2 경로가 패턴에 매치되기 때문이다. WHERE rosa <> fof 술어가 패턴을 보강해, Rosa 노드와 매치된 노드가 같지 않을 때만 매치되게 한다. 결과: Rosa의 유일한 친구의 친구는 (친구 Karl을 거친) Fred다.

Note — WHERE로 쓸 수 있는 더 많은 술어

불리언 연산, 문자열 매칭, 경로 패턴, 리스트 연산, 속성 검사 등을 적용할 수 있다.

WHERE n.name STARTS WITH 'Ka'
WHERE n.name CONTAINS 'os'
WHERE NOT n.name ENDS WITH 'y'
WHERE NOT (p)-[:KNOWS]->(:Person {name:'Karl'})
WHERE n.name IN ['Rosa', 'Karl'] AND (p)-[LIVES_IN]->(:Place {city:'Berlin'})

같은 질의 구조로 "베를린에 사는 사람의 친구 또는 친구의 친구는 누구인가?"도 쉽게 물을 수 있다 — 패턴에서 관계 타입을 혼합하고 질의 깊이를 늘리면 된다. 지식 그래프 질의에서 이는 일상적이며 표현하기 쉽다.

Example 3-7 · 베를린 거주자의 친구와 친구의 친구Cypher
MATCH (:Place {city:'Berlin'})<-[:LIVES_IN]-(p:Person)<-[:FRIEND*1..2]-(f:Person)
WHERE f <> p
RETURN f

베를린 노드에서 사람으로 향하는 LIVES_IN 관계 하나가 매치되어야 하고(즉 베를린 거주자), 이어서 가변 길이 경로 <-[:FRIEND*1..2]-가 깊이 1의 친구 또는 깊이 2의 친구의 친구를 매치한다. 답은 Karl(Rosa의 친구)과 Fred(Karl의 친구)다. 이 예에서 친구와 친구의 친구가 본인도 베를린에 살 필요는 없는데, 그것을 강제하려면 AND (f)-[:LIVES_IN]->(:Place {city:'Berlin'}) 같은 WHERE 술어 하나만 더하면 된다.

Note — 조인 폭탄(join bomb)은 없다

관계형 데이터베이스 전문가라면 깊이의 거침없는 증가에 따른 성능 저하를 걱정할 수 있지만, 그럴 필요 없다. 재귀적 조인을 쓰는 관계형 질의와 달리, Neo4j의 질의 지연시간은 데이터베이스 크기가 아니라 방문한 노드와 관계의 수에 비례한다. 걱정할 조인 폭탄 문제가 없고, 관계 하나를 순회하는 비용이 매우 낮으므로(수 마이크로초 수준) 방대한 그래프 탐색 작업을 매우 빠르게 수행할 수 있다.

Graph Global Queries

그래프 글로벌 질의 — 그래프 전체를 내려다보기

지금까지는 Rosa나 베를린처럼 특정 노드에 질의의 닻을 내렸다. 그러나 지식 그래프에서는 그래프 전체를 질의하고 싶은 경우가 많다. 이를 그래프 글로벌(graph global) 질의라 한다. "가장 인기 있는 거주 도시는 어디인가?"라는 단순한 질문에서는 패턴이 특정 노드에 바인딩되지 않고, 모든 도시와 거주 인구를 고려해야 한다. 지식 그래프의 큰 부분(또는 전체)을 처리하고 싶으므로 시작점 노드를 지정하지 않는다.

그림 3-13 · 가장 인기 있는 도시 (집계 + 정렬)Cypher
MATCH (p:Place)<-[l:LIVES_IN]-(:Person)
RETURN p AS place, count(l) AS rels
ORDER BY rels DESC

╭─────────────────┬──────╮
│ place           │ rels │
├─────────────────┼──────┤
│ London, UK      │  2   │   ← 가장 인기 있는 도시
│ Berlin, DE      │  1   │
╰─────────────────┴──────╯

SQL 경험자에게 익숙한 구조다. RETURN p AS place는 p(Place 노드)에 바인딩된 매치를 place라는 친숙한 이름으로 반환하고, count(l) AS rels는 Place 노드에 부착된 LIVES_IN 관계의 수를 rels로 반환한다. ORDER BY rels DESC는 결과를 rels 값 기준 내림차순(가장 높은 것 먼저)으로 정렬한다. 집계 수치가 목적이므로 그래프 시각화 대신 표 형태의 결과가 쓰인다 — 런던 2명, 베를린 1명이다.

작은 네트워크라 필터링이 필요 없었지만, 프로덕션 데이터베이스라면 전체 결과가 훨씬 크고 과도한 데이터는 사용자를 압도한다. Cypher는 avg(평균), max(최대), min(최소), sum(합) 등의 집계 함수SKIP(결과 건너뛰기)·LIMIT(반환 수 제한) 절을 갖추고 있어, 매우 큰 지식 그래프 위를 도는 글로벌 질의도 간결하고 적절한 집계 정보로 압축해 돌려줄 수 있다.

Note — 아는 것은 질의에 명시하라, 관계 타입은 절대 생략하지 마라

이 질의에서 Person 레이블은 엄밀히는 불필요하다 — LIVES_IN이 Place와 Person만 잇는다는 것을 알기 때문이다. 그러나 그래프를 질의할 때, 그래프에 대해 아는 것이 있으면 질의에 명시적으로 넣어라. 익명 노드 () 대신 Person 레이블을 쓰면 질의 플래너가 더 나은 계획을 세워 성능이 올라가고 지연이 줄어든다. 반대로 관계 타입은 절대 빼놓지 마라 — 질의의 탐색 공간이 폭발해 지연시간이 크게 늘어난다.

Functions & Procedures

함수와 프로시저 호출 — 바퀴를 다시 발명하지 마라

Neo4j는 특정 작업을 구현한 함수와 프로시저 호출로 지식 그래프 연산을 지원한다. 호출은 간단하다 — Cypher의 CALL 문법 뒤에 (네임스페이스가 붙은) 프로시저 이름과 괄호 안 인자를 쓴다. 자주 쓰는 프로시저 하나는 그래프의 스키마 확인이다.

그림 3-14 · 지식 그래프의 스키마 시각화Cypher
CALL db.schema.visualization()   // 기저 지식 그래프의 스키마를 드러낸다

또 하나의 상용 프로시저 묶음은 APOC 라이브러리에서 온다. APOC은 Neo4j와 함께 배포되는 인기 라이브러리로, 지식 그래프를 다루는 유용한 기능과 지름길이 가득하다. 직접 재발명하지 않도록 APOC의 프로시저와 함수를 알아 두는 것이 좋다. Cypher로 복잡할 법한 일에 착수하기 전에, 도와줄 프로시저가 있는지 확인할 가치가 있다 — 라이브러리 활용은 단순 노동을 없애고 오류 가능성을 줄인다.

Example 3-8 · 프로시저: 원자적 문자열 연결Cypher
MATCH (p:Person {name:'Fred'})
// Fred가 Freddy가 된다 (부분 결과 노출 없음)
CALL apoc.atomic.concat(p,"name",'dy')
YIELD oldValue, newValue
RETURN oldValue, newValue;
Example 3-9 · 함수: 날짜/시간 변환Cypher
// 함수는 CALL 없이, 값이 들어갈 자리에 삽입한다
RETURN apoc.date.convert(
  datetime().epochSeconds,
  "seconds", "days"
) as outputInDays;
Supporting Tools

EXPLAIN과 PROFILE — 질의 성능의 나침반

Cypher에 유능해지기 위해 알아야 할 도구가 둘 더 있다. EXPLAINPROFILE 키워드는 개발자가 질의 성능을 이해하도록 돕는데, 지식 그래프가 커질수록 특히 중요해진다.

EXPLAIN — 실행 없이 계획을 본다

질의 계획(query plan)의 시각화를 제공하여, 질의를 관통해 흐르는 데이터량("DB hits")을 줄이는 방향으로 개발자를 안내한다. 실제로 실행하기 전에 큰 질의의 동작을 이해하는 데 특히 유용하다.

PROFILE — 실행하며 실측한다

한 걸음 더 나아가 실행 중인 질의를 계측(instrument)하여 런타임의 실제 동작을 보여준다. 실제 지식 그래프 위에서 질의가 어떻게 움직이는지 보고 그에 맞게 튜닝할 수 있다.

Practice — 무엇을 보고 어떻게 고치는가

EXPLAIN·PROFILE의 신중한 사용은 질의 성능을 크게 끌어올린다. 연산자에서 연산자로 과도한 데이터(DB hits)가 흐른다면, 카디널리티를 더 일찍 줄이도록 질의를 리팩터링할 기회를 찾아라. 거시적으로 스캔 같은 값비싼 작업이 보인다면 유용한 인덱스가 빠져 있다는 신호일 수 있다 — 무차별 스캔을 인덱스 조회로 맞바꿔라. 각 연산자 하단의 주황색 상자는 실행의 시간 비용을 보여준다. DB hits를 희생하더라도 그 시간 비용의 최소화를 항상 추구하라.

Neo4j Internals

Neo4j 내부 — 기계적 공감으로 결을 따라 일하기

레이싱 드라이버가 되기 위해 엔지니어일 필요는 없다. 그러나 기계적 공감(mechanical sympathy)은 있어야 한다.

— 재키 스튜어트(Jackie Stewart), F1 3회 챔피언

데이터베이스 엔지니어가 아니어도 지식 그래프에서 큰 가치를 얻을 수 있다. 그러나 약간의 기계적 공감이 있으면 견고하고 성능 좋은 지식 그래프를 설계해 시스템 사용자를 기쁘게 할 수 있다. 내부 동작을 조금 알면 결을 거스르지 않고 결을 따라 일하게 된다. 최상위 수준에서 그래프 데이터베이스는 사용자를 위해 두 가지 기능을 수행한다 — ① 그래프 데이터를 성능 좋게 질의하고, ② 그래프 데이터를 안전하게 저장한다. (프로덕션 시스템에 필요한 고가용성, 모니터링, 보안, 온라인 업데이트 등은 오늘날 클라우드와 온프레미스의 여러 그래프 데이터베이스에 — 성숙도의 차이는 있으나 — 존재한다.)

질의 처리 — 인덱스 없는 인접성(Index-Free Adjacency)

성능 좋은 질의를 위해 그래프 데이터베이스는 순회(traversal) — 한 노드에서 관계를 건너 다른 노드로 이동하는 행위 — 를 빠르게(저지연) 그리고 저렴하게(높은 동시 처리량) 만들어야 한다. Neo4j의 저장 방식은 디스크에서나 RAM에서나 그래프 순회에 최적화되어 있다. Neo4j는 그래프의 구조(노드·관계)를 속성 데이터와 분리해 저장한다. 구조는 고정 길이 레코드로 저장된다 — 노드용 저장소 하나, 관계용 저장소 하나. 레코드 ID × 레코드 크기(바이트) = 해당 저장 파일 안의 오프셋이다. 이 패턴이 인덱스 없는 인접성(index-free adjacency)이며, Marko A. Rodriguez와 Peter Neubauer의 "The Graph Traversal Pattern"에 기술되어 있다. 포인터 추적(pointer-chasing)을 통한 인덱스 없는 인접성은 현대 컴퓨터에서 매우 효율적이다.

NODE STORE (고정 길이 레코드) id=0 id=1 id=2 id=3 offset = id × record_size 인덱스 조회 없이 산술 연산만으로 위치 결정 → O(1) RELATIONSHIPS = 노드의 로컬 "인덱스" n₀ n₂ n₅ pointer-chase O(1) pointer-chase O(1) PROPERTY STORE (연결 리스트) name dob city 노드 → 속성 리스트의 머리(head) 참조 읽기 O(N) · 쓰기 O(1) (head에 추가) 원칙: 구조를 먼저 순회하고(O(1)), 대상을 찾은 뒤에 속성을 읽어라(O(N))
Neo4j 저장 구조 · 구조와 속성의 분리 — 순회는 상수 시간, 속성 접근은 선형 시간

순회에는 통상 인덱스가 개입하지 않는다(질의 옵티마이저가 인덱스를 쓰는 지름길을 발견하는 경우 제외). 노드에 붙은 관계들이 다음에 (합법적으로) 갈 수 있는 곳을 안내하는 로컬 "인덱스" 역할을 한다. 인덱스 없는 인접성이 강력한 이유는 O(1), 즉 상수 시간 순회를 제공하기 때문이다. 그래프 질의 지연시간이 그래프 크기가 아니라 순회한 그래프의 양에 비례하므로, 복잡한 질의도 빠르고 낮은 계산 비용으로 실행되는 경향이 있다.

속성 저장은 다르다 — 데이터 유연성을 위해 설계되었다. 속성은 문자열, 숫자, 리스트 등 여러 형태를 취하므로 저장 포맷이 수용적이어야 한다. Neo4j에서 속성은 기술적(descriptive) 속성 레코드의 리스트로 유지되며, 리스트의 머리(head)가 연관 노드·관계에서 참조된다. 노드·관계 저장소와 달리 속성 저장소의 읽기는 O(N), 선형 시간이다 — 접근 비용이 그 노드·관계에 저장된 속성 수에 비례한다. 쓰기는 새 속성이 체인의 머리에 추가되므로 O(1)을 유지한다.

지식 그래프 개발자가 유의할 것은 속성 접근의 누적 비용이다. 각 접근은 (특히 RAM에 캐시되어 있으면) 저렴해도, 순회 중 많은 속성에 접근하면 쌓인다. 큰 순회에서 빈번한 속성 접근은 지연시간을 크게 — 일부 실무자에 따르면 최대 두 배까지 — 늘릴 수 있다. 가능하면 그래프 구조를 먼저 순회하고, 대상 노드·관계를 찾은 뒤에 속성 데이터를 조회하라. 대부분의 작업을 빠르고 저렴한 O(1) 연산으로 처리하고, 더 비싼 O(N) 연산을 줄이는 것이다.

속성이 순회 방식 자체에 영향을 주어 많은 속성 조회를 피할 수 없는 경우도 있다. 이론적 비용은 어쩔 수 없어도, 실무에서는 워킹 셋을 메모리 계층의 빠른 곳에 유지함으로써 영향을 크게 줄일 수 있다. 현대 컴퓨터의 메모리 계층은 잘 알려져 있다 — 가장 느린 디스크(SSD 포함)에서 RAM을 거쳐 가장 빠른 CPU 캐시까지. 다음에 필요할 레코드가 (대부분의 경우) 디스크가 아닌 메모리에 있도록 보장하는 것은 DBMS의 일이며, 지역성 인지 캐싱 전략으로 뜨거운 데이터를 빠른 계층에 유지하려 한다. 이를 지속 가능하게 하려면 RAM 대 데이터 크기의 건강한 비율을 유지해야 하고, 가장 까다로운 사례에서는 지식 그래프 전체가 RAM에 들어가야 한다. 그래서 느린 워크로드의 가장 싸고 빠른 처방이 그냥 RAM을 더 다는 것일 때도 있다.

Sidebar — 현대적인 하드웨어를 써라

회전 디스크(spinning disk)를 쓰지 마라. 빠른 회전 디스크의 탐색 시간은 5ms인데, 이는 컴퓨터에게 매우 긴 시간이다. 그 시간 동안 데이터베이스는 I/O를 기다리는 대신 수천 번의 순회를 완료할 수 있었을 것이다. 진지한 프로젝트라면 좋은 디스크와 데이터를 담을 충분한 RAM에 투자할 가치가 있다.

ACID 트랜잭션 — 안전한 저장

데이터를 안전하게 저장하기 위해 Neo4j는 — 다른 많은 데이터베이스처럼 — 내결함성 쓰기를 제공하는 트랜잭션 쓰기 채널을 갖는다. 고전적인 선행 기입 로그(write-ahead log, WAL)에 기반한다. 의도된 갱신을 지식 그래프에 적용하기 전에 먼저 순서 있게 디스크에 영속화하는 방식이다. 이로써 데이터베이스는 전원 차단·재시동을 견딜 수 있다 — 전원이 복구되면 로그에 깨끗하게 영속화된 트랜잭션은 복구하고, 부분적으로만 저장된 것은 안전하게 폐기한다.

데이터베이스를 내결함성 있게 만드는 일은 쉽지 않고 엣지 케이스도 많다. 그러나 지식 그래프 사용자의 관점에서 데이터는 손상으로부터 안전하게 지켜지고, 소프트웨어·하드웨어 결함 앞에서도 고가용성을 유지한다. 신뢰할 수 있고 내결함성 있는 그래프 데이터베이스는 엔터프라이즈 규모(그 이상까지)의 지식 그래프를 받치는 올바른 기반이다. "운영상 지루한(operationally boring)" 그래프 데이터베이스가 있어야 지식 그래프에서 가치를 끌어내는 일에 집중할 수 있다.

Note — 클러스터에서도 ACID: Raft와 인과적 장벽

Neo4j는 단일 서버뿐 아니라 서버들에 걸쳐서도 ACID(원자성·일관성·고립성·지속성) 트랜잭션을 유지한다. 이를 위해 개별 트랜잭션 로그들을 "묶어(tied)" 주는 Raft 알고리즘(이름의 유래)을 사용한다. Raft 위에 Neo4j는 인과적 장벽(causal barrier)을 제공한다 — 서버들이 광역 네트워크(WAN)에 흩어져 있어도 사용자는 언제나 최소한 자신의 쓰기를 볼 수 있다. 이 인과성 기능은 옵트인이며 투명해서, 데이터베이스 클러스터를 다루는 일이 프로그래밍 언어의 변수 하나를 추론하는 것만큼 단순해진다.

Summary

요약 — ASCII 아트 위의 고성능 플랫폼

이 장에서 Cypher 질의가 ASCII 아트에 기반한 쾌적한 패턴 매칭 문법임을 보았다. 그 아래의 그래프 데이터베이스는 지식 그래프 데이터의 저장과 질의를 위한 고성능 플랫폼을 제공한다. 배운 것으로 지식 그래프를 성능 좋게 질의하고 안전하게 갱신할 수 있다.

1

Cypher = 시각적 패턴 매칭. ( )는 노드, -[ ]->는 방향 있는 관계, { }는 속성이다. 그린 것이 곧 저장되는 것이며, ISO에서 GQL로 표준화가 진행 중이다.

2

CREATE는 항상 만들고, MERGE는 전부-매치 아니면 전부-생성. 부분 매치는 없다. 유일성 제약(NODE KEY)과 다단계 MERGE의 조합이 중복 없는 그래프 성장의 정석이다.

3

로컬 질의와 글로벌 질의. 아는 노드에 닻을 내리면 로컬, 그래프 전체를 돌면 글로벌이다. 가변 길이 경로(*1..2)와 WHERE 술어로 깊은 탐색을 표현하고, 집계 함수와 ORDER BY·SKIP·LIMIT으로 결과를 다듬는다.

4

아는 것은 명시하되, 관계 타입은 절대 생략 금지. 레이블 명시는 플래너를 돕고, 관계 타입 생략은 탐색 공간을 폭발시킨다. EXPLAIN·PROFILE로 DB hits와 시간 비용을 관찰하며 튜닝한다.

5

기계적 공감. 인덱스 없는 인접성이 O(1) 순회를 주고, 속성 읽기는 O(N)이다 — 구조 먼저, 속성은 나중에. RAM을 넉넉히, 회전 디스크는 금지. WAL 기반 ACID와 Raft·인과적 장벽이 클러스터에서도 데이터를 안전하게 지킨다.

Next — 4장 예고

지식 그래프의 데이터 중 적어도 일부는 이미 다른 시스템에 존재하는 경우가 많다. 4장에서는 지식 그래프로 데이터를 가져오는(import) 방법 — 특히 지식 그래프의 전체 수명주기에 걸쳐 유용한 대량 적재(bulk import) — 를 다루며, 이를 추가 보강과 가치 있는 인사이트의 기반으로 삼는 길을 논한다.