콘텐츠로 이동

Bluetape4k Graph Part 4: 워크숍 시나리오와 서비스 통합

로봇 작업자들이 어뷰저 탐지, 추천, 지식 그래프, 소셜 네트워크 보드를 살펴보는 3D 작업대 일러스트
실행 가능한 예제는 기술 선택 기준을 저비용으로 검증하는 실험 환경입니다.

Part 1부터 Part 3까지는 그래프 데이터베이스 선택, 핵심 API, 그래프 입출력을 살펴봤습니다. 이번 글에서는 bluetape4k-workshop의 실행 가능한 예제를 바탕으로 실제 서비스 문제를 그래프로 표현하는 방법을 살펴봅니다. 목적은 특정 구현을 그대로 복사하는 것이 아니라, 자신의 서비스가 비슷한 관계 문제를 가졌는지 판단할 기준을 제공하는 것입니다. 각 예제가 모델링하는 관계, 조회 경로, 운영 전 보완할 경계를 함께 설명합니다.

예제그래프 패턴확인할 내용
어뷰저 탐지사용자와 식별자를 잇는 신원 그래프기기·IP·전화번호·결제 수단·추천인 공유 관계 탐색
추천구매 그래프와 팔로우 그래프공동 구매자와 2단계 연결 후보 계산
지식 그래프문서·엔터티·개념 관계의미 관계와 다단계 탐색
소셜 네트워크사람·회사 관계와 다단계 탐색지인의 지인, 공통 지인, 최단 경로

어뷰저 탐지는 그래프가 적합한 대표 사례입니다. 한 사용자를 시작점으로 기기, IP 주소, 전화번호 해시, 결제 토큰, 추천인 같은 식별자를 따라가고, 해당 식별자를 공유하는 다른 사용자를 찾습니다. 관계형 데이터베이스로도 구현할 수 있지만 식별자 종류와 탐색 단계가 늘어나면 조인과 조건 분기가 빠르게 복잡해집니다.

기준 사용자에서 공유 식별자, 관련 사용자, 의심 근거, 위험 순위로 이어지는 어뷰저 탐지 흐름
공유 식별자를 거쳐 관련 사용자를 찾는 구조는 관계형 조인보다 그래프 탐색으로 관계를 명확하게 표현할 수 있습니다.

AbuserDetectionService.findAbuseCluster()는 기준 사용자가 없으면 빈 클러스터를 반환합니다. 사용자가 있으면 먼저 OUTGOING 식별자 간선을 따라가고, 각 식별자에서 INCOMING 방향으로 사용자를 다시 찾습니다. 마지막에는 기준 사용자를 제외하고 같은 식별자를 공유한 사용자와 식별자를 클러스터로 반환합니다.

사용자, 기기, IP 주소, 전화번호, 결제 수단, 추천 사용자 관계를 표현한 어뷰저 탐지 엔터티 그래프
식별자를 별도 정점으로 모델링하면 같은 식별자를 공유하는 사용자 집합을 탐지 클러스터로 구성할 수 있습니다.
val identifiers = IdentifierEdgeLabel.all.flatMap { edgeLabel ->
ops.neighbors(seedUserId, NeighborOptions(edgeLabel.value, Direction.OUTGOING, maxDepth = 1))
}

explainSuspicion()은 기준 사용자에서 시작하는 간선 경로를 수집해 의심 근거를 설명합니다. detectReferralLoops()는 순환 탐지로 추천인 순환을 찾고, rankSuspiciousUsers()는 PageRank 결과로 검토 대상을 정렬합니다. 전화번호는 E.164 형식 값의 SHA-256 해시로, 결제 수단은 PCI 안전 토큰으로 저장해야 합니다. 예제의 규모와 관계없이 민감 정보 경계는 동일하게 적용해야 합니다.

추천 예제는 상품 추천과 팔로워 추천을 함께 다룹니다.

공동 구매 상품 후보와 2단계 팔로우 후보를 생성하는 추천 흐름
후보를 생성하는 탐색과 후보를 정렬하는 점수 계산을 분리하면 서비스 규칙을 명확하게 검증할 수 있습니다.
사용자, 상품, 팔로우 대상, 점수가 계산된 후보 관계를 표현한 추천 엔터티 그래프
PURCHASED는 상품 후보를 만들고, FOLLOWS는 팔로워 후보를 만듭니다. 후보 생성과 점수 계산은 별도 단계로 둡니다.

RecommendationService.recommendProducts()는 기준 사용자가 구매한 상품, 해당 상품을 구매한 다른 사용자, 공동 구매자가 구매한 다른 상품을 차례로 찾습니다. 기준 사용자가 이미 구매한 상품은 제외하고, 서로 다른 공동 구매자 수를 후보 점수로 사용합니다.

val purchased = findPurchasedProducts(seedUserId)
val coBuyers = purchased.flatMap { product -> findProductBuyers(product.id) }
val candidates = coBuyers.flatMap { buyer -> findPurchasedProducts(buyer.id) }

recommendFollows()는 직접 팔로우한 사용자를 찾은 뒤 2단계 팔로우 후보를 만들고, 공통 팔로우 수로 정렬합니다. 자기 자신과 이미 팔로우한 사용자는 후보에서 제외합니다. 이 예제는 추천 알고리즘의 완성본이 아니라, 후보 생성과 점수 계산을 그래프 탐색으로 분리하는 기본 구조입니다.

현재 구현은 상품별·사용자별로 이웃 조회를 반복하므로 큰 그래프에서는 N+1 탐색 문제가 발생할 수 있습니다. limit은 반환 후보 수만 제한하며 조회 횟수를 제한하지 않습니다. 운영 규모에서는 네이티브 Cypher 또는 Gremlin 질의로 한 번에 처리하고, 공통 API는 계약 테스트와 작은 탐색에 사용하는 편이 적절합니다.

지식 그래프 예제는 문서, 엔터티, 개념의 관계를 다룹니다. 문서가 어떤 엔터티를 언급하는지, 엔터티끼리 어떤 의미 관계를 갖는지, 엔터티가 어떤 개념에 속하는지를 그래프로 표현합니다.

문서, 엔터티, 개념, 관련 엔터티의 관계를 표현한 지식 그래프
검색은 후보 문서를 찾고, 그래프 경로는 엔터티가 연결된 이유를 설명합니다.
정점의미
Document기사, 문서, 노트 같은 원문 단위
Entity사람, 회사, 제품, 장소처럼 추출된 대상
Concept엔터티를 묶는 상위 개념
간선방향의미
MENTIONSDocument -> Entity문서가 엔터티를 언급합니다. confidence 속성으로 추출 신뢰도를 기록할 수 있습니다.
RELATED_TOEntity -> Entity엔터티 사이의 의미 관계를 표현하고 relationType으로 종류를 구분합니다.
IS_AEntity -> Concept엔터티가 어떤 개념에 속하는지 표현합니다.

이 예제의 핵심은 검색 결과를 단순 문자열 목록으로 끝내지 않는 것입니다. findMentionedEntities(documentId)로 문서가 언급한 엔터티를 찾고, findRelatedEntities(entityId, depth)로 여러 단계의 의미 관계를 따라갑니다. findConceptsForEntity(entityId)는 엔터티가 속한 개념을 찾고, inferRelationshipPaths(from, to)는 두 엔터티 사이의 관계 경로를 설명합니다.

이 구조는 RAG나 검색 시스템에서도 활용할 수 있습니다. 벡터 검색이 유사한 문서를 찾는 데 적합하다면, 지식 그래프는 두 엔터티가 연결된 이유를 간선 경로로 설명하는 데 유용합니다. 문서 본문 검색은 전문 검색이나 벡터 색인에 맡기고, 의미 관계와 설명 가능한 연결만 그래프로 분리해야 각 저장소의 역할이 명확해집니다.

소셜 네트워크 예제는 PersonCompany 정점을 만들고 KNOWS, FOLLOWS, WORKS_AT 간선으로 사람과 회사의 관계를 표현합니다. 운영 코드에서는 자기 자신, 이미 연결된 사람, 허용 깊이를 넘는 경로를 제외하는 규칙이 필요합니다.

사람, 회사, 지인, 팔로우, 재직 관계를 표현한 소셜 네트워크 그래프
방향과 탐색 깊이를 명확히 해야 추천 후보와 설명용 경로가 섞이지 않습니다.
기능설명
직접 연결한 사람과 직접 연결된 사람을 찾습니다.
N단계 연결2단계, 3단계처럼 깊이를 늘려 관계를 탐색합니다.
지인의 지인이미 연결된 사람을 제외하고 팔로우 또는 지인 후보를 만듭니다.
동료같은 회사에 재직하는 사람을 찾습니다.
최단 경로·전체 경로두 사람 사이의 연결 경로를 찾습니다.
공통 지인두 사람 사이의 공통 지인을 계산합니다.

FOLLOWS는 방향성이 있지만 현재 SocialNetworkService.connect()KNOWS 관계를 두 개의 방향 간선으로 저장합니다. 따라서 호출자는 같은 관계를 인자 순서를 바꿔 다시 생성하지 않아야 합니다. 2단계 추천에서는 자기 자신과 기존 연결을 제외해야 하며, 최단 경로나 전체 경로에는 깊이 제한을 적용해야 합니다. 이 예제는 그래프 API의 방향·깊이·제외 조건을 검증하는 스모크 테스트로 활용할 수 있습니다.

Spring Boot 4 서비스에서는 graph-spring-boot 자동 설정이 GraphOperations를 등록합니다. register-suspendregister-virtual-thread가 활성화되어 있으면 GraphSuspendOperationsGraphVirtualThreadOperations도 등록합니다. bluetape4k.graph.backend를 생략하면 TinkerGraph가 선택되므로 테스트와 로컬 예제를 외부 그래프 데이터베이스 없이 시작할 수 있습니다.

bluetape4k:
graph:
backend: tinkergraph

서비스에서는 빈과 그래프 이름을 주입받고, 애플리케이션 시작 단계에서 initialize()를 한 번 호출합니다.

@Service
class AbuseReviewService(
private val graph: GraphOperations,
@Value("\${app.graph-name:abuse-review}") private val graphName: String,
) {
private val service = AbuserDetectionService(graph, graphName)
@PostConstruct
fun initialize() = service.initialize()
fun cluster(seedUserId: GraphElementId): AbuseCluster =
service.findAbuseCluster(seedUserId)
}

운영에서 Neo4j를 사용한다면 저장소 설정을 변경할 수 있습니다. 서비스가 사용하는 공통 계약은 유지되지만, 질의 계획·트랜잭션·오류 동작·성능까지 같아지는 것은 아닙니다. 운영 후보는 해당 백엔드의 통합 테스트와 실제 작업 부하 측정으로 검증해야 합니다.

bluetape4k:
graph:
backend: neo4j
neo4j:
uri: bolt://localhost:7687
username: neo4j
password: secret
register-suspend: true
register-virtual-thread: true

Ktor에서는 GraphPlugin에 사용할 저장소를 애플리케이션 모듈에서 명시합니다. tinkerGraph()가 생성한 동기·코루틴 연산 객체는 같은 위임 객체를 공유하며, 플러그인이 애플리케이션 종료 시 해당 객체를 한 번 닫습니다. 경로 처리기는 블로킹 GraphOperations보다 GraphSuspendOperations를 우선 사용합니다.

fun Application.module() {
install(GraphPlugin) {
tinkerGraph()
}
routing {
get("/users/{id}/cluster") {
val graph = call.graphSuspendOperations()
val service = AbuserDetectionSuspendService(graph, "abuse-review")
service.initialize()
val cluster = service.findAbuseCluster(GraphElementId.of(call.parameters["id"]!!))
call.respond(cluster)
}
}
}

예제는 흐름을 한곳에 보여주기 위해 요청마다 initialize()를 호출합니다. 실제 서비스에서는 애플리케이션 시작 단계에서 초기화를 마치고 서비스를 재사용해야 합니다. 현재 graphExists → createGraph 순서는 원자적이지 않으므로 여러 인스턴스가 동시에 초기화하는 환경에서는 백엔드의 업서트 기능이나 별도 잠금이 필요합니다.

DI 컨테이너나 부트스트랩 코드가 GraphOperationsGraphSuspendOperations를 생성했다면 operations(sync, suspend)로 전달할 수 있습니다. 기본값은 호출자 소유 생명주기입니다. 플러그인이 두 객체를 종료해야 할 때만 closeOnStop = true를 지정하며, 내부 위임 객체를 공유할 때에는 close의 멱등성도 호출자가 보장해야 합니다.

install(GraphPlugin) {
operations(syncOps, suspendOps, closeOnStop = true)
}

Spring Boot 4는 설정 속성과 빈 주입으로, Ktor 3는 플러그인 설치와 애플리케이션 생명주기로 통합합니다. 두 방식 모두 저장소 생성 주체, 초기화 시점, 종료 책임을 명시해야 합니다.

관계형 데이터베이스 중심 서비스에서 시작한다면

섹션 제목: “관계형 데이터베이스 중심 서비스에서 시작한다면”

처음부터 모든 데이터를 그래프로 이전하지 않습니다. 가변 깊이 탐색, 최단 경로, 공유 신원, 추천 후보 생성처럼 관계형 질의가 급격히 복잡해지는 기능을 먼저 찾습니다. 해당 기능만 그래프로 분리하고, 업무 데이터의 원본이 관계형 데이터베이스라면 그 책임을 유지합니다. 공통 계약은 TinkerGraph로 빠르게 검증하되, 운영 후보는 Neo4j·Memgraph·AGE·FalkorDB 각각의 통합 테스트와 실제 작업 부하로 검증합니다.

그래프는 관계 자체가 업무의 중심일 때 선택합니다. bluetape4k-graph는 그래프 선택과 서비스 통합을 Kotlin/JVM에서 반복 가능한 구조로 제공하지만, 백엔드별 운영 특성까지 동일하게 만들지는 않습니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.