Bluetape4k Graph Part 4: workshop 시나리오와 서비스 통합

Part 1부터 Part 3까지는 graph db 선택, core API, graph I/O를 봤습니다. 이번 글에서는 bluetape4k-workshop의
실행 가능한 예제로 실제 서비스 문제를 graph로 어떻게 표현하는지 보겠습니다. 여기서는 깊이 파고드는 구현 안내보다
“내 서비스에도 이런 모양의 문제가 있나?”를 빠르게 판단하는 지도에 가깝습니다. 예제 이름만 던지고 끝내면 결국 독자가
소스 트리를 다시 뒤져야 합니다. 그건 블로그라기보다 숙제 배포에 가깝죠. 그래서 각 예제가 어떤 관계를 모델링하고,
어떤 판단 기준을 주는지 글 안에서 같이 보겠습니다.
| 예제 | graph pattern | 배울 점 |
|---|---|---|
| abuser detection | 사용자와 identifier를 잇는 identity graph | 공유 device/IP/phone/payment/referral 탐색 |
| recommendation | purchase graph와 follow graph | co-buyer, friends-of-friends 후보 계산 |
| knowledge graph | document, entity, concept 관계 | 의미 관계와 hop 탐색 |
| social network | Person/Company 관계와 다중 hop | FOAF, mutual connection, shortest path |
어뷰저 탐지
섹션 제목: “어뷰저 탐지”어뷰저 탐지는 graph가 잘 맞는 전형적인 문제입니다. 한 사용자를 시작점으로 device, IP address, phone hash, payment token, referral 같은 identifier를 따라가고, 다시 그 identifier를 공유하는 다른 사용자를 찾습니다. relational DB로도 구현할 수 있지만 identifier 종류가 늘수록 join과 조건 분기가 빠르게 지저분해집니다. 처음에는 query 하나였는데, 나중에는 “이 조건은 왜 여기 있고, 이 join은 누가 추가했나”를 추적하는 시간이 더 길어지기 마련입니다.

AbuserDetectionService.findAbuseCluster()는 seed user가 없으면 빈 cluster를 반환합니다. seed가 있으면 먼저 OUTGOING
identifier edge를 따라가고, 각 identifier에서 INCOMING 방향으로 user를 다시 찾습니다. 마지막에는 seed user를 빼고
같은 identifier를 공유한 다른 사용자를 cluster로 반환합니다.

val identifiers = IdentifierEdgeLabel.all.flatMap { edgeLabel -> ops.neighbors(seedUserId, NeighborOptions(edgeLabel.value, Direction.OUTGOING, maxDepth = 1))}explainSuspicion()은 seed user에서 출발하는 edge path를 모아 “왜 의심스러운가”를 설명합니다.
detectReferralLoops()는 cycle detection으로 referral loop를 찾고, rankSuspiciousUsers()는 PageRank로 review queue에
올릴 후보를 정렬합니다. phone은 hash로, payment는 PCI-safe token으로 넣어야 합니다. 예제는 작아도 보안 경계는 작지
않습니다.
추천 서비스
섹션 제목: “추천 서비스”추천 예제는 상품 추천과 팔로워 추천을 함께 다룹니다.


PURCHASED는 상품 후보를 만들고, FOLLOWS는 팔로워 후보를 만듭니다. 후보 생성과 점수 계산은 별도 단계로 둡니다.RecommendationService.recommendProducts()는 seed user가 산 상품을 찾고, 그 상품을 산 다른 사용자를 찾은 뒤, 그
co-buyer가 산 다른 상품을 후보로 올립니다. 이미 seed user가 산 상품은 제외하고, 점수는 distinct co-buyer 수로
계산합니다.
val purchased = findPurchasedProducts(seedUserId)val coBuyers = purchased.flatMap { product -> findProductBuyers(product.id) }val candidates = coBuyers.flatMap { buyer -> findPurchasedProducts(buyer.id) }recommendFollows()는 direct follow를 찾은 뒤 2-hop follow candidate를 만들고, mutual follow count로 정렬합니다.
자기 자신과 이미 follow한 사람은 후보에서 제외합니다. 이 예제는 추천 알고리즘의 완성본이 아닙니다. 더 정확히는
“candidate generation과 scoring을 graph traversal로 어떻게 분리할까?”를 보여주는 작은 뼈대입니다.
큰 graph에서는 이 구현을 그대로 운영에 올리면 N+1 traversal 문제가 생길 수 있습니다. 운영 graph가 커지면 native Cypher나 Gremlin query로 내려가는 편이 맞습니다. 공통 API는 시작점과 테스트를 단순하게 만들기 위한 장치입니다. 무거운 query까지 공통 API에 가두면, 추상화가 돕는 대신 병목을 예쁘게 포장하게 됩니다.
Knowledge graph
섹션 제목: “Knowledge graph”Knowledge graph 예제는 문서, entity, concept의 관계를 다룹니다. 문서가 어떤 entity를 언급하는지, entity끼리 어떤 의미 관계를 갖는지, entity가 어떤 concept에 속하는지를 graph로 표현합니다.

| Vertex | 의미 |
|---|---|
Document | 기사, 문서, 노트 같은 원문 단위 |
Entity | 사람, 회사, 제품, 장소처럼 추출된 대상 |
Concept | entity를 묶는 상위 개념 |
| Edge | 방향 | 의미 |
|---|---|---|
MENTIONS | Document -> Entity | 문서가 entity를 언급합니다. confidence 같은 속성을 둘 수 있습니다. |
RELATED_TO | Entity -> Entity | entity 사이의 의미 관계를 표현합니다. relationType으로 관계 종류를 구분합니다. |
IS_A | Entity -> Concept | entity가 어떤 concept에 속하는지 표현합니다. |
이 예제의 핵심은 검색 결과를 단순 문자열 목록으로 끝내지 않는 것입니다. findMentionedEntities(documentId)로 문서가
언급한 entity를 찾고, findRelatedEntities(entityId, depth)로 여러 hop의 의미 관계를 따라갑니다.
findConceptsForEntity(entityId)는 entity가 속한 concept을 찾고, inferRelationshipPaths(from, to)는 두 entity 사이의
관계 경로를 설명합니다.
이 구조는 RAG나 검색 시스템에서도 자주 필요합니다. vector search가 “비슷한 문서”를 잘 찾는다면, knowledge graph는 “왜 연결되는지”를 edge path로 설명하는 데 강점이 있습니다. 모든 검색을 graph로 바꾸자는 뜻은 아닙니다. 모든 문제를 graph로 풀기 시작하면 graph가 아니라 새로운 만능 망치가 됩니다. 문서 본문 검색은 전문 검색이나 vector index에 맡기고, 의미 관계와 설명 가능한 연결은 graph가 맡는 식으로 나누는 편이 현실적입니다.
Social network
섹션 제목: “Social network”Social network 예제는 graph 교과서에 가까운 문제를 서비스 코드로 옮긴 것입니다. Person과 Company vertex를 만들고,
KNOWS, FOLLOWS, WORKS_AT edge로 사람과 회사의 관계를 표현합니다. 쉬워 보이는 예제일수록 운영 코드에 넣으면
제외 조건이 늘어납니다. 자기 자신 추천, 이미 연결된 사람 추천, 너무 먼 경로 추천 같은 것들입니다.

| 기능 | 설명 |
|---|---|
| direct connections | 한 사람과 직접 연결된 사람을 찾습니다. |
| N-degree connections | 2-hop, 3-hop처럼 깊이를 늘려 관계를 탐색합니다. |
| friends-of-friends | 이미 아는 사람을 제외하고 follow 또는 friend 후보를 만듭니다. |
| colleagues | 같은 회사에 다니는 사람을 찾습니다. |
| shortest path / all paths | 두 사람 사이의 연결 경로를 찾습니다. |
| mutual connections | 두 사람 사이의 공통 지인을 계산합니다. |
이 예제는 “graph를 쓰면 멋진 추천이 자동으로 나온다”는 이야기가 아닙니다. 실제로 필요한 것은 방향, depth, 제외 조건입니다.
예를 들어 FOLLOWS는 방향이 중요하지만 KNOWS는 서비스 정책에 따라 양방향으로 취급할 수 있습니다. 2-hop 추천에서는
자기 자신과 이미 연결된 사람을 빼야 합니다. shortest path는 설명 가능한 경로를 주지만, depth 제한을 두지 않으면 큰
graph에서 비용이 빠르게 커집니다.
작은 예제에서는 단순해 보이지만, 실전에서는 이런 제외 조건과 depth 제한을 빼먹는 일이 의외로 많습니다. 그래서 저는 social network 예제를 “graph API의 기본기를 확인하는 smoke test”처럼 사용합니다.
Spring Boot와 Ktor 통합
섹션 제목: “Spring Boot와 Ktor 통합”Spring Boot 4 서비스에서는 graph-spring-boot가 auto-configuration으로 GraphOperations,
GraphSuspendOperations, GraphVirtualThreadOperations bean을 구성합니다. 가장 작은 설정은 TinkerGraph입니다.
bluetape4k.graph.backend를 생략해도 TinkerGraph가 기본으로 선택되므로, 테스트나 로컬 예제에서는 외부 graph db 없이
바로 시작할 수 있습니다.
bluetape4k: graph: backend: tinkergraph서비스에서는 bean을 주입받아 평범한 Spring service처럼 사용합니다.
@Serviceclass AbuseReviewService( private val graph: GraphOperations,) { fun cluster(seedUserId: GraphElementId): AbuseCluster { val service = AbuserDetectionService(graph) return service.findAbuseCluster(seedUserId) }}운영에서 Neo4j를 쓰고 싶다면 property만 graph db에 맞춰 바꿉니다. code path는 그대로 두고, driver와
GraphOperations 구현만 auto-configuration이 바꿉니다.
bluetape4k: graph: backend: neo4j neo4j: uri: bolt://localhost:7687 username: neo4j password: secret register-suspend: true register-virtual-thread: trueKtor 쪽은 GraphPlugin이 lifecycle을 관리합니다. Ktor는 Spring container가 없으므로 application module에서 어떤 graph
storage를 쓸지 직접 설치합니다. 테스트나 작은 예제는 tinkerGraph()가 가장 단순합니다.
fun Application.module() { install(GraphPlugin) { tinkerGraph() }
routing { get("/users/{id}/cluster") { val graph = graphPluginState().graphOperations val service = AbuserDetectionService(graph) val cluster = service.findAbuseCluster(GraphElementId.of(call.parameters["id"]!!)) call.respond(cluster) } }}이미 DI container나 bootstrap 코드에서 GraphOperations와 GraphSuspendOperations를 만들었다면 operations(sync, suspend)로 넣을 수 있습니다. 이 경우 기본값은 caller-owned lifecycle입니다. 누가 만든 객체를 누가 닫을지 애매하게
두면 shutdown 때 꼭 한 번쯤 이상한 일이 생깁니다. Ktor plugin이 driver를 닫아야 한다면 closeOnStop = true를
명시합니다.
install(GraphPlugin) { operations(syncOps, suspendOps, closeOnStop = true)}정리하면 Spring Boot 4에서는 property와 bean 주입으로 통합하고, Ktor 3에서는 plugin install과 application lifecycle로 통합합니다. 두 방식을 동시에 쓰라는 뜻이 아닙니다. 자신의 서비스 runtime에 맞는 쪽을 고르면 됩니다.
relational-only 서비스에서 시작한다면
섹션 제목: “relational-only 서비스에서 시작한다면”처음부터 모든 데이터를 graph로 옮기면 보통 실패합니다. 마이그레이션이 커지고, 운영 리스크도 커지고, 회의도 길어집니다. 먼저 variable-depth traversal, shortest path, shared identity, recommendation 후보 생성처럼 relational query가 급격히 복잡해지는 기능을 찾습니다. 그 기능만 graph로 분리하고, source-of-truth가 relational DB라면 그대로 둡니다. 테스트는 TinkerGraph로 시작하고, 운영 후보는 Neo4j나 Memgraph, AGE, FalkorDB로 옮겨 측정합니다.
이 시리즈의 결론은 단순합니다. graph는 관계가 업무의 중심일 때 씁니다. bluetape4k-graph는 그 선택을 Kotlin/JVM
서비스에서 반복 가능하게 만들기 위해 만든 라이브러리입니다. graph db를 숭배하자는 이야기가 아니라, 관계 문제가 진짜로
커졌을 때 덜 지저분하게 시작하자는 이야기입니다.
참고한 소스
섹션 제목: “참고한 소스”- AbuserDetectionService
- RecommendationService
- knowledge graph workshop
- social network workshop
- Spring Boot graph auto-configuration
- Ktor graph plugin
- GraphProperties
- GraphPluginConfig
시리즈
섹션 제목: “시리즈”- Part 1: 그래프 데이터베이스 선택 지도
- Part 2: core API와 실행 모델
- Part 3: graph I/O와 benchmark 읽기
- Part 4: workshop 시나리오와 서비스 통합
- Part 5: Virtual Threads benchmark 읽기
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.