Backend 서비스는 언제 GraphDB를 도입해야 할까?

GraphDB는 관계가 많은 query를 자연스럽게 표현하게 해 준다. MATCH (a)-[*1..N]->(b)는 self join이나
recursive SQL보다 읽기 쉽다. 하지만 문법이 편하다는 이유만으로 도입하면 위험하다. GraphDB를 넣는다는
것은 새로 운영할 저장소, query planner, 데이터 적재 방식, index, monitoring 기준을 받아들이는 일이다.
종종 relational source of truth 옆에 두 번째 저장 모델도 생긴다.
실제 질문은 “graph syntax가 편한가?”가 아니다.
variable-depth path traversal이 충분히 자주, 충분히 큰 규모로 필요해서 graph 전용 storage와 operation 비용을 감수할 만한가?
최근 bluetape4k graph benchmark는 이 질문을 감으로 답하지 않고 측정 결과로 확인하려는 작업이었다.

무엇을 비교했나
섹션 제목: “무엇을 비교했나”처음 아이디어는 abuser/fraud detection이었다. AGE + Exposed, Exposed, JPA를 비교하고 detection latency와 성공률을 보자는 방향이었다. 이 domain은 쓸모가 있지만, fraud detection benchmark는 설계를 조금만 잘못해도 오해를 만든다. 모든 가능한 path를 찾게 만들면 GraphDB도 path explosion을 없애 주지 못한다.
그래서 더 강한 시나리오로 authorization inheritance를 선택했다.
user -> group -> group... -> role -> resource이 workload에는 GraphDB를 검토할 만한 성질이 있다.
- 관계 깊이가 user와 organization 구조에 따라 달라진다.
- deny edge가 allow edge를 override하므로 path 자체가 의미를 가진다.
- cycle을 안전하게 처리해야 한다.
- traversal은 bounded, selective, semantic query다.
- 같은 fixture를 native graph engine과 PostgreSQL relational baseline으로 구현할 수 있다.
비교 대상은 다음과 같았다.
| Candidate | Role |
|---|---|
| Neo4j Cypher | Native persistent GraphDB candidate |
| Memgraph Cypher | Native persistent GraphDB candidate. large fixture load 실패는 별도 evidence로 추적 |
| PostgreSQL AGE/Cypher | PostgreSQL extension 기반 graph syntax |
| PostgreSQL recursive CTE | Relational variable-depth traversal baseline |
| PostgreSQL iterative traversal | Relational batched traversal baseline |
| TinkerGraph | In-memory이므로 이번 adoption decision에서는 제외. API/contract benchmark에는 여전히 유용 |
PostgreSQL을 recursive CTE와 iterative traversal로 나눈 것이 중요했다. “GraphDB vs SQL” 비교는 SQL 쪽 query shape가 약하면 쉽게 불공정해진다. 도입 판단에서는 PostgreSQL도 제대로 된 baseline으로 세워야 한다.
GraphDB 코드는 어떤 모습인가
섹션 제목: “GraphDB 코드는 어떤 모습인가”bluetape4k에서는 application code를 domain에 가깝게 유지하고, backend 구현이 operation을 TinkerGraph,
Neo4j, Memgraph 같은 graph engine에 맞게 mapping한다. social-network workshop은 GraphOperations로
vertex를 만들고, typed edge로 연결한 뒤 bounded traversal operation을 실행한다.
class SocialNetworkService( private val graphName: String, private val ops: GraphOperations,) { fun initialize() { if (!ops.graphExists(graphName)) { ops.createGraph(graphName) } }
fun addPerson(personId: String, name: String): GraphVertex = ops.findVerticesByLabel("Person", mapOf("personId" to personId)).firstOrNull() ?: ops.createVertex("Person", mapOf("personId" to personId, "name" to name))
fun recommendConnections(personVertexId: GraphElementId): List<GraphVertex> { val direct = ops.neighbors( personVertexId, NeighborOptions(edgeLabel = "KNOWS", direction = Direction.OUTGOING, maxDepth = 1), ).map { it.id }.toSet()
return ops.neighbors( personVertexId, NeighborOptions(edgeLabel = "KNOWS", direction = Direction.OUTGOING, maxDepth = 2), ) .filter { it.id != personVertexId && it.id !in direct } .distinctBy { it.id } }
fun findConnectionPath(from: GraphElementId, to: GraphElementId): GraphPath? = ops.shortestPath( from, to, PathOptions(edgeLabel = "KNOWS", direction = Direction.OUTGOING), )}모든 query를 이런 방식으로 작성해야 한다는 뜻은 아니다. Graph API가 유용해지는 지점은 domain 질문 자체가 path-shaped일 때다. “2-hop 밖에 있는 사용자는 누구인가?”, “두 사용자를 연결하는 path가 있는가?”, “mutual connection이 많은 후보는 누구인가?”, “같은 device/IP/account signal로 연결된 계정은 무엇인가?” 같은 질문이 여기에 해당한다.
bluetape4k workshop에는 실행 가능한 GraphDB 예제가 있다.
- Social network: TinkerGraph, Neo4j, Memgraph 위에서 friend-of-a-friend recommendation, shortest path, all paths, colleagues, mutual connection을 다룬다.
- Abuser detection: shared device/IP/account signal과 bounded suspicious relationship traversal을 다룬다.
- Recommendation: collaborative filtering과 relationship 기반 추천을 다룬다.
- Knowledge graph: query 가능한 knowledge structure를 위한 entity/relation modeling을 다룬다.
같은 Traversal을 표현하는 세 가지 방식
섹션 제목: “같은 Traversal을 표현하는 세 가지 방식”이번 benchmark 판단은 GraphDB syntax만 보고 내린 것이 아니다. 같은 authorization inheritance 질문을 native graph traversal, JDBC recursive CTE, JDBC iterative traversal 세 가지 방식으로 표현했다.
GraphDB/Cypher는 path shape를 명시적으로 드러낸다.
MATCH p = (u:User {id: $userId})-[:MEMBER_OF|INHERITS*1..10]->(r:Role)MATCH (r)-[g:GRANTS]->(res:Resource)WHERE all(rel IN relationships(p) WHERE rel.active = true) AND g.active = true AND res.public = trueWITH res, collect(g.effect) AS effectsWHERE NOT 'DENY' IN effectsRETURN DISTINCT res.idJDBC recursive CTE는 PostgreSQL을 source of truth로 유지한 채 SQL이 membership path를 확장하게 한다.
val sql = """ WITH RECURSIVE reachable(subject_id, depth, path) AS ( SELECT ?, 0, ARRAY[?] UNION ALL SELECT e.to_id, reachable.depth + 1, path || e.to_id FROM authz_edges e JOIN reachable ON e.from_id = reachable.subject_id WHERE e.active = true AND reachable.depth < ? AND NOT e.to_id = ANY(path) ) SELECT DISTINCT g.resource_id FROM reachable r JOIN grants g ON g.role_id = r.subject_id WHERE g.active = true AND g.effect = 'ALLOW' AND NOT EXISTS ( SELECT 1 FROM grants deny WHERE deny.resource_id = g.resource_id AND deny.role_id IN (SELECT subject_id FROM reachable) AND deny.effect = 'DENY' )""".trimIndent()
connection.prepareStatement(sql).use { statement -> statement.setString(1, userId) statement.setString(2, userId) statement.setInt(3, maxDepth) statement.executeQuery().use { rs -> generateSequence { if (rs.next()) rs.getString("resource_id") else null }.toSet() }}JDBC iterative traversal은 recursive SQL을 쓰지 않고 frontier를 한 단계씩 확장한다. 각 step이 selective하고 index를 잘 타면 의외로 빠를 수 있다.
var frontier = setOf(userId)val visited = mutableSetOf(userId)val roles = mutableSetOf<String>()
repeat(maxDepth) { if (frontier.isEmpty()) return@repeat
val next = selectActiveTargets(frontier) .filterTo(mutableSetOf()) { it !in visited }
roles += next.filter { isRole(it) } visited += next frontier = next}
val grants = selectGrantsForRoles(roles)val denied = grants.filter { it.effect == "DENY" }.mapTo(mutableSetOf()) { it.resourceId }val allowed = grants.filter { it.effect == "ALLOW" }.mapTo(mutableSetOf()) { it.resourceId }
return allowed - denied그래서 benchmark에서는 CTE와 iterative traversal을 분리했다. Recursive SQL은 compact하고 set-based다. Iterative traversal은 더 단순한 indexed query와 application-side pruning을 쓸 수 있다. Native GraphDB는 path workload가 충분히 길고 선택적일 때만 운영 비용을 상쇄할 가능성이 생긴다.
Small/Medium 데이터는 GraphDB 도입을 정당화하지 못했다
섹션 제목: “Small/Medium 데이터는 GraphDB 도입을 정당화하지 못했다”small/medium authorization inheritance matrix에서 AGE는 latency row를 하나도 이기지 못했다. PostgreSQL recursive CTE와 iterative traversal이 더 강했다.
대표 resolveResources latency, ms/op, 낮을수록 좋다:
| Scenario | Size | AGE/Cypher | PostgreSQL CTE | PostgreSQL iterative | Winner |
|---|---|---|---|---|---|
shallow | small | 6.337 | 0.572 | 0.576 | PostgreSQL CTE |
shallow | medium | 57.382 | 12.085 | 1.056 | PostgreSQL iterative |
deep-inheritance | small | 93.848 | 0.726 | 1.449 | PostgreSQL CTE |
deep-inheritance | medium | 604.833 | 9.385 | 2.102 | PostgreSQL iterative |
wide-groups | medium | 250.083 | 1.521 | 3.658 | PostgreSQL CTE |
이 결과가 중요한 이유는 단순한 결론을 막아 주기 때문이다. Domain이 graph 모양이라고 해서 곧바로 GraphDB가 필요해지는 것은 아니다. 데이터가 작거나, path depth가 짧거나, indexed relational traversal로 충분히 줄일 수 있는 query라면 PostgreSQL이 더 단순하고 더 빠를 수 있다.
AGE는 좋은 반례였다. PostgreSQL 안에서 Cypher syntax를 제공하지만, 이 benchmark에서는 속도만으로 도입을 정당화할 근거를 만들지 못했다. 편한 표현력과 좋은 실행 성능은 다르다.
Path를 길게 잡으니 GraphDB 신호가 나왔다
섹션 제목: “Path를 길게 잡으니 GraphDB 신호가 나왔다”다음 단계에서는 데이터 크기와 traversal 길이를 키웠다. adoption probe는 large fixture에서 다음 시나리오를
사용했다.
long-chain: 강제로 10-hop target chain을 만든다.deep-wide: 더 넓은 fan-out과 cycle을 포함한 12-hop traversal이다.
resolveResources, large fixture, ms/op, 낮을수록 좋다:
| Scenario | Neo4j Cypher | Memgraph Cypher | AGE/Cypher | PostgreSQL CTE | PostgreSQL iterative | Winner |
|---|---|---|---|---|---|---|
long-chain | 12.731 | load failure | timeout >75s | 55.364 | 47.568 | Neo4j Cypher |
deep-wide | 56.467 | load failure | timeout >75s | 11.596 | 27.836 | PostgreSQL CTE |
여기서 처음으로 유의미한 신호가 나왔다. long-chain에서 Neo4j는 PostgreSQL iterative traversal보다
3.74배, PostgreSQL recursive CTE보다 4.35배 빨랐다. 이 정도 결과라면 GraphDB 추가 평가를 계속할 근거가
된다.
하지만 같은 benchmark는 과도한 일반화를 경고한다. deep-wide는 여전히 PostgreSQL CTE가 이겼다. AGE는
large adoption scenario 두 개 모두 timeout이었다. Memgraph는 smoke parity는 통과했지만 local large fixture
loading 중 실패했으므로 아직 reportable latency row가 아니다.
결론은 좁고 실용적이다.
GraphDB 도입은 길고 선택적인 path-shaped traversal에서는 설득력이 있다. 하지만 일반적인 authorization, fraud detection, CRUD, fixed join, shallow relationship query를 위해 도입할 근거는 아니다.
GraphDB가 잘 맞는 곳
섹션 제목: “GraphDB가 잘 맞는 곳”GraphDB는 아래 조건이 여러 개 동시에 맞을 때 평가할 가치가 있다.
- traversal depth가 가변이고, 고정 2-3개 table join을 자주 넘어간다.
- path 자체가 결과이거나 filter의 핵심이다.
- traversal이 bounded이고 selective하다.
- edge type, edge property, time window, risk flag, deny/allow semantics가 path에 영향을 준다.
- aggregate table, materialized projection, indexed relational join으로 깔끔하게 줄이기 어렵다.
- 팀이 GraphDB 운영 비용을 감당할 수 있다.
좋은 후보는 permission inheritance, organization hierarchy exception, dependency/impact analysis, network reachability, path-shaped recommendation, shared identifier 기반 fraud ring exploration이다.
나쁜 후보는 단순 ID 조회, 1-hop join, 고정 2-3단계 join, CRUD-heavy OLTP, star schema aggregation, unbounded all-path search다.
Fraud detection도 마찬가지다. GraphDB가 유용한 경우는 shared device cluster나 short money-flow convergence처럼 bounded, time-windowed, risk-filtered pattern을 찾을 때다. 모든 path를 제한 없이 찾는 query에는 맞지 않는다.
실제 서비스에서 어떻게 판단할까
섹션 제목: “실제 서비스에서 어떻게 판단할까”가장 안전한 도입 절차는 단계적으로 가는 것이다.
- PostgreSQL CTE와 iterative traversal baseline부터 만든다.
- correctness metric과 latency ranking을 분리한다.
- Neo4j 같은 native persistent GraphDB candidate를 추가한다.
- In-memory engine은 production adoption decision에서 제외한다. API test에는 유용할 수 있다.
- 실제 차이가 드러날 만큼 데이터 크기와 path depth를 키운다.
- 성공한 latency row뿐 아니라 timeout, load failure도 기록한다.
마지막 항목이 중요하다. Adoption table에는 AGE timeout과 Memgraph load failure도 보여 줘야 한다. 실패한 candidate를 표 밖으로 숨기면 판단이 실제보다 깔끔해 보인다. 운영 도입은 data loading, query completion, operational stability, performance를 함께 봐야 한다.
다음 과제: Hybrid 성능 개선 작업
섹션 제목: “다음 과제: Hybrid 성능 개선 작업”다음 질문은 “GraphDB냐 relational database냐”만이 아니다. 실제 시스템은 보통 hybrid design이 필요하다.
후속 benchmark에서는 다음 작업을 확인해야 한다.
- Cache-assisted traversal: hot account, hot group, permission closure, frequent path-existence result를 cache한다.
- Materialized views: suspicious aggregate, reachable-resource set, impact-radius summary를 PostgreSQL에 유지한다.
- Incremental graph projections: query 시점에 graph를 재구성하지 않고 ingest 시점에 graph projection을 갱신한다.
- Candidate pruning: relational index로 후보군을 먼저 줄이고, 좁혀진 대상에만 graph traversal을 수행한다.
- Online/offline split: online에서는 얕은 bounded traversal을 쓰고, 더 깊은 graph analysis는 batch job으로 분리한다.
- Hybrid correctness: cache나 materialized answer가 canonical relational 또는 graph oracle과 일치하는지 검증한다.
- Operational cost: load time, index build time, memory footprint, query timeout behavior, recovery behavior를 함께 측정한다.
이 지점에서 GraphDB가 더 설득력 있어질 수 있다. 가장 강한 설계는 보통 “PostgreSQL을 GraphDB로 대체한다”가 아니다. 더 현실적인 방향은 다음에 가깝다.
Transactional truth는 relational storage에 두고, hot decision은 cache와 projection으로 빠르게 처리하며, long path traversal이 핵심인 곳에만 native graph engine을 사용한다.
이 방향은 다음 benchmark로 이어갈 가치가 있다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.