그래프 데이터베이스 — Neo4j 와 Cypher · 순회와 경로 · 이론
순회는 질의가 아니라 걷기다
한 줄 요약
Cypher 의 MATCH 는 패턴을 걷는다. 몇 걸음 걸을지(*1..3), 어느 쪽으로 걸을지(방향), 길이 없을 때 어떻게 할지(OPTIONAL MATCH), 걸어 모은 것을 어떻게 세울지(WITH) — 네 가지를 정하면 순회 질의는 끝난다. 그리고 첫 걸음을 어디서 떼는지가 성능의 전부다.
왜 이게 필요했나
"계좌 8 에서 나간 돈이 세 다리 안에 어디까지 갔나" 를 물으면 답이 두 가지로 갈린다. MATCH (a:Account {id: 8})-[:TRANSFER*1..3]->(b) RETURN count(b) 는 33 이고 count(DISTINCT b) 는 32 다. 앞은 경로를 센 것이고 뒤는 닿은 계좌를 센 것이다. 같은 계좌에 두 길로 닿으면 경로는 둘, 계좌는 하나다. 이 차이를 모르고 보고서를 쓰면 숫자가 매번 다르게 나온다.
가변 길이 패턴에는 상한을 꼭 둔다. * 만 적으면 그래프 끝까지 걷고, 송금처럼 촘촘한 관계에서는 경로 수가 걸음마다 곱으로 늘어난다. 실습 환경의 급여 계좌는 230곳으로 송금했다 — 거기서 세 걸음이면 경로가 수만 개다. Neo4j 는 한 경로 안에서 같은 관계를 두 번 밟지 않으므로 순환 그래프에서도 끝은 나지만, 끝나기 전에 메모리가 먼저 끝날 수 있다.
어떻게 동작하나
방향. (a)-[:TRANSFER]->() 는 a 가 보낸 것, (a)<-[:TRANSFER]-() 는 받은 것, (a)-[:TRANSFER]-() 는 둘 다다. 같은 계좌를 세 가지로 물으면 보낸 수 + 받은 수 = 방향 없는 수가 된다. 돈이 흐른 방향이 중요한 질문(순환 송금)과 연결 자체가 중요한 질문(같은 무리인가)은 화살표가 다르다.
최단 경로. shortestPath((p1)-[:OWNS|TRANSFER*..8]-(p2)) 는 두 노드 사이의 가장 짧은 길 하나를 준다. 같은 길이의 길이 여럿이면 어느 것이 나올지는 정해져 있지 않다 — 전부 필요하면 allShortestPaths. 관계 종류를 | 로 여럿 적을 수 있고, 방향을 무시한 최단 길이와 방향을 따른 최단 길이는 대개 다르다.
OPTIONAL MATCH. MATCH (p:Person)-[:USES]->(d:Device) 는 기기가 없는 사람을 아예 내놓지 않는다. 그 사람들이 몇 명인지 알려면 OPTIONAL MATCH 로 걷고 d 가 NULL 인 행을 센다. SQL 의 LEFT JOIN 과 같은 자리이고, 같은 실수를 같은 자리에서 한다.
WITH. Cypher 는 절을 위에서 아래로 흘려보내는 파이프라인이다. WITH d, count(DISTINCT p) AS users WHERE users >= 4 처럼, 집계한 값으로 거르려면 WITH 로 한 번 끊어야 WHERE 가 그 값을 볼 수 있다. ORDER BY · LIMIT 도 WITH 뒤에 붙여 중간에서 자를 수 있다.
실무에서 진짜 중요한 것
첫째, 시작점을 인덱스로 잡는다. PROFILE MATCH (p:Person {email: 'x'}) 를 인덱스 없이 돌리면 NodeByLabelScan 이 나오고 db hits 가 사람 수의 두 배쯤 찍힌다. 인덱스를 만들면 한 자리 수다. 그래프가 빠른 것은 걷기이지 찾기가 아니다 — 첫 노드를 찾는 일은 여전히 인덱스의 몫이다.
둘째, 경로 위에서 계산할 수 있다. reduce(s = 0, r IN relationships(p) | s + r.amount) 는 경로의 관계 속성을 누적한다. "두 단계 송금 사슬 중 합이 가장 큰 것" 은 이 한 줄로 끝난다. nodes(p) · relationships(p) · length(p) 가 경로를 다루는 기본 도구다.
셋째, PROFILE 을 읽는 눈을 기른다. db hits 는 저장소를 몇 번 건드렸는지다. 같은 질의를 인덱스 전후로 재 보면 자릿수가 바뀐다. 그 숫자를 재 보지 않고 "그래프라서 빠르다" 고 말하면 안 된다. 다음 실습이 그 숫자를 직접 재게 한다.