콘텐츠로 이동

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

backend service가 relationship-heavy data를 탐색하는 소개용 일러스트
Graph database는 relationship 자체가 workload가 될 때부터 진지하게 볼 만하다.

GraphDB는 관계가 많은 질의를 자연스럽게 표현하게 해 준다. MATCH (a)-[*1..N]->(b)는 자기 조인이나 재귀 SQL보다 읽기 쉽다. 하지만 문법이 편하다는 이유만으로 도입하면 위험하다. GraphDB를 넣는다는 것은 새로 운영할 저장소, 질의 계획기, 데이터 적재 방식, 인덱스, 모니터링 기준을 받아들이는 일이다. 관계형 원본 데이터 옆에 두 번째 저장 모델이 생기기도 한다.

실제 질문은 “그래프 문법이 편한가?”가 아니다.

가변 깊이 경로 순회가 충분히 자주, 충분히 큰 규모로 필요해서 그래프 전용 저장소와 운영 비용을 감수할 만한가?

최근 bluetape4k 그래프 벤치마크는 이 질문을 감이 아니라 측정 결과로 확인하려는 작업이었다.

대용량 권한 상속 벤치마크에서 long-chain과 deep-wide 시나리오별 Neo4j Cypher, PostgreSQL CTE, PostgreSQL 반복 순회의 지연 시간을 비교한 차트
GraphDB 도입 신호는 길고 선택적인 경로형 순회에서만 나왔다. PostgreSQL은 여러 제한된 관계형 순회 형태에서 여전히 강했다.

처음 아이디어는 악용 사용자·사기 탐지였다. AGE + Exposed, Exposed, JPA를 비교하고 탐지 지연 시간과 성공률을 보자는 방향이었다. 이 도메인은 쓸모가 있지만, 사기 탐지 벤치마크는 설계를 조금만 잘못해도 오해를 만든다. 가능한 모든 경로를 찾게 만들면 GraphDB도 경로 폭발을 없애 주지 못한다.

그래서 더 강한 시나리오로 권한 상속을 선택했다.

user -> group -> group... -> role -> resource

이 작업 부하에는 GraphDB를 검토할 만한 성질이 있다.

  • 관계 깊이가 사용자와 조직 구조에 따라 달라진다.
  • 거부 간선이 허용 간선을 우선하므로 경로 자체가 의미를 가진다.
  • 순환을 안전하게 처리해야 한다.
  • 순회는 범위가 정해져 있고 선택적이며, 도메인 의미를 가진 질의다.
  • 같은 테스트 데이터를 네이티브 그래프 엔진과 PostgreSQL 관계형 기준선으로 구현할 수 있다.

비교 대상은 다음과 같았다.

후보역할
Neo4j Cypher네이티브 영속형 GraphDB 후보
Memgraph Cypher네이티브 영속형 GraphDB 후보. 대용량 테스트 데이터 적재 실패는 별도 근거로 추적
PostgreSQL AGE/CypherPostgreSQL 확장 기반 그래프 문법
PostgreSQL 재귀 CTE관계형 가변 깊이 순회 기준선
PostgreSQL 반복 순회관계형 배치 순회 기준선
TinkerGraph인메모리이므로 이번 도입 판단에서는 제외. API·계약 벤치마크에는 여전히 유용

PostgreSQL을 재귀 CTE와 반복 순회로 나눈 것이 중요했다. “GraphDB 대 SQL” 비교는 SQL 쪽 질의 형태가 약하면 쉽게 불공정해진다. 도입 판단에서는 PostgreSQL도 제대로 된 기준선으로 세워야 한다.

bluetape4k에서는 애플리케이션 코드를 도메인에 가깝게 유지하고, 백엔드 구현이 작업을 TinkerGraph, Neo4j, Memgraph 같은 그래프 엔진에 맞게 매핑한다. social-network 워크숍은 GraphOperations로 정점을 만들고, 타입이 있는 간선으로 연결한 뒤 범위가 정해진 순회 작업을 실행한다.

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),
)
}

모든 질의를 이런 방식으로 작성해야 한다는 뜻은 아니다. Graph API가 유용해지는 지점은 도메인 질문 자체가 경로형일 때다. “2-hop 밖에 있는 사용자는 누구인가?”, “두 사용자를 연결하는 경로가 있는가?”, “공통 연결이 많은 후보는 누구인가?”, “같은 기기·IP·계정 신호로 연결된 계정은 무엇인가?” 같은 질문이 여기에 해당한다.

bluetape4k workshop에는 실행 가능한 GraphDB 예제가 있다.

  • Social network: TinkerGraph, Neo4j, Memgraph 위에서 친구의 친구 추천, 최단 경로, 모든 경로, 동료, 공통 연결을 다룬다.
  • Abuser detection: 공유 기기·IP·계정 신호와 범위가 정해진 의심 관계 순회를 다룬다.
  • Recommendation: 협업 필터링과 관계 기반 추천을 다룬다.
  • Knowledge graph: 질의 가능한 지식 구조를 위한 엔터티·관계 모델링을 다룬다.

같은 Traversal을 표현하는 세 가지 방식

섹션 제목: “같은 Traversal을 표현하는 세 가지 방식”

이번 벤치마크 판단은 GraphDB 문법만 보고 내린 것이 아니다. 같은 권한 상속 질문을 네이티브 그래프 순회, JDBC 재귀 CTE, JDBC 반복 순회 세 가지 방식으로 표현했다.

GraphDB/Cypher는 경로 형태를 명시적으로 드러낸다.

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 = true
WITH res, collect(g.effect) AS effects
WHERE NOT 'DENY' IN effects
RETURN DISTINCT res.id

JDBC 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

그래서 벤치마크에서는 CTE와 반복 순회를 분리했다. 재귀 SQL은 간결하고 집합 기반이다. 반복 순회는 더 단순한 인덱스 질의와 애플리케이션 측 가지치기를 쓸 수 있다. 네이티브 GraphDB는 경로형 작업 부하가 충분히 길고 선택적일 때만 운영 비용을 상쇄할 가능성이 생긴다.

Small/Medium 데이터는 GraphDB 도입을 정당화하지 못했다

섹션 제목: “Small/Medium 데이터는 GraphDB 도입을 정당화하지 못했다”

소형·중형 권한 상속 매트릭스에서 AGE는 지연 시간 항목을 하나도 이기지 못했다. PostgreSQL 재귀 CTE와 반복 순회가 더 강했다.

대표 resolveResources 지연 시간, ms/op, 낮을수록 좋다:

ScenarioSizeAGE/CypherPostgreSQL CTEPostgreSQL iterativeWinner
shallowsmall6.3370.5720.576PostgreSQL CTE
shallowmedium57.38212.0851.056PostgreSQL iterative
deep-inheritancesmall93.8480.7261.449PostgreSQL CTE
deep-inheritancemedium604.8339.3852.102PostgreSQL iterative
wide-groupsmedium250.0831.5213.658PostgreSQL CTE

이 결과가 중요한 이유는 단순한 결론을 막아 주기 때문이다. 도메인 관계가 그래프 모양이라고 해서 곧바로 GraphDB가 필요해지는 것은 아니다. 데이터가 작거나, 경로 깊이가 짧거나, 인덱스가 있는 관계형 순회로 충분히 줄일 수 있는 질의라면 PostgreSQL이 더 단순하고 더 빠를 수 있다.

AGE는 좋은 반례였다. PostgreSQL 안에서 Cypher 문법을 제공하지만, 이 벤치마크에서는 속도만으로 도입을 정당화할 근거를 만들지 못했다. 편한 표현력과 좋은 실행 성능은 다르다.

Path를 길게 잡으니 GraphDB 신호가 나왔다

섹션 제목: “Path를 길게 잡으니 GraphDB 신호가 나왔다”

다음 단계에서는 데이터 크기와 순회 길이를 키웠다. 도입 탐색은 large 테스트 데이터에서 다음 시나리오를 사용했다.

  • long-chain: 강제로 10-hop target chain을 만든다.
  • deep-wide: 더 넓은 fan-out과 cycle을 포함한 12-hop traversal이다.

resolveResources, large 테스트 데이터, ms/op, 낮을수록 좋다:

ScenarioNeo4j CypherMemgraph CypherAGE/CypherPostgreSQL CTEPostgreSQL iterativeWinner
long-chain12.731load failuretimeout >75s55.36447.568Neo4j Cypher
deep-wide56.467load failuretimeout >75s11.59627.836PostgreSQL CTE

여기서 처음으로 유의미한 신호가 나왔다. long-chain에서 Neo4j는 PostgreSQL iterative traversal보다 3.74배, PostgreSQL recursive CTE보다 4.35배 빨랐다. 이 정도 결과라면 GraphDB 추가 평가를 계속할 근거가 된다.

하지만 같은 벤치마크는 과도한 일반화를 경고한다. deep-wide는 여전히 PostgreSQL CTE가 이겼다. AGE는 대용량 도입 시나리오 두 개 모두에서 시간 초과가 났다. Memgraph는 스모크 호환성은 통과했지만 로컬 대용량 테스트 데이터 적재 중 실패했으므로 아직 보고 가능한 지연 시간 항목이 아니다.

결론은 좁고 실용적이다.

GraphDB 도입은 길고 선택적인 경로형 순회에서는 설득력이 있다. 하지만 일반적인 권한 확인, 사기 탐지, CRUD, 고정 조인, 얕은 관계 질의를 위해 도입할 근거는 아니다.

GraphDB는 아래 조건이 여러 개 동시에 맞을 때 평가할 가치가 있다.

  • 순회 깊이가 가변이고, 고정 2-3개 테이블 조인을 자주 넘어간다.
  • 경로 자체가 결과이거나 필터의 핵심이다.
  • 순회 범위가 정해져 있고 선택적이다.
  • 간선 유형, 간선 속성, 시간 창, 위험 플래그, 거부·허용 의미가 경로에 영향을 준다.
  • 집계 테이블, 구체화 투영, 인덱스가 있는 관계형 조인으로 깔끔하게 줄이기 어렵다.
  • 팀이 GraphDB 운영 비용을 감당할 수 있다.

좋은 후보는 권한 상속, 조직 계층 예외, 의존성·영향도 분석, 네트워크 도달성, 경로형 추천, 공유 식별자 기반의 사기 연결망 탐색이다.

나쁜 후보는 단순 ID 조회, 1-hop 조인, 고정 2-3단계 조인, CRUD 비중이 높은 OLTP, 스타 스키마 집계, 범위 제한이 없는 모든 경로 탐색이다.

사기 탐지도 마찬가지다. GraphDB가 유용한 경우는 공유 기기 군집이나 짧은 자금 흐름의 수렴처럼 범위와 시간 창, 위험 조건이 정해진 패턴을 찾을 때다. 모든 경로를 제한 없이 찾는 질의에는 맞지 않는다.

실제 서비스에서 어떻게 판단할까

섹션 제목: “실제 서비스에서 어떻게 판단할까”

가장 안전한 도입 절차는 단계적으로 가는 것이다.

  1. PostgreSQL CTE와 반복 순회 기준선부터 만든다.
  2. 정확성 지표와 지연 시간 순위를 분리한다.
  3. Neo4j 같은 네이티브 영속형 GraphDB 후보를 추가한다.
  4. 인메모리 엔진은 운영 도입 판단에서 제외한다. API 테스트에는 유용할 수 있다.
  5. 실제 차이가 드러날 만큼 데이터 크기와 경로 깊이를 키운다.
  6. 성공한 지연 시간 항목뿐 아니라 시간 초과, 적재 실패도 기록한다.

마지막 항목이 중요하다. 도입 판단 표에는 AGE 시간 초과와 Memgraph 적재 실패도 보여 줘야 한다. 실패한 후보를 표 밖으로 숨기면 판단이 실제보다 깔끔해 보인다. 운영 도입은 데이터 적재, 질의 완료, 운영 안정성, 성능을 함께 봐야 한다.

다음 과제: 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을 남길 수 있습니다.