그래프 서버가 없는 곳에서 빌드는 트리플 0개로 완료되고 있었습니다
폐쇄망 고객에게 별도 그래프 서버는 반입 심사와 승인, 운영 인력이 따로 드는 구성 요소였습니다. 지식그래프의 정본을 이미 있는 PostgreSQL 테이블 세 개로 옮기고, 같은 빌드 결과를 두 저장소에 이중 기록해 대조한 과정입니다.
현장에서 다시 잰 것들 시리즈 · 2/6전체 목차 펼치기
지식그래프는 Apache Jena Fuseki라는 별도 그래프 서버에 저장해 왔습니다. 표준 SPARQL로 조회할 수 있어 설계상으로는 자연스러운 선택이었지만, 고객 환경에서는 그 서버 하나를 반입하고 운영하는 부담이 따로 생겼습니다. 2026년 8월에 그래프 정본을 노드, 간선, 청크 근거 세 개의 PostgreSQL 테이블로 옮긴 이유와, 옮긴 뒤 무엇을 확인했는지 정리합니다.
오류 없이 비어 있던 그래프
계기는 개발 서버였습니다. Fuseki에 만들어 둔 데이터셋 이름과 애플리케이션 설정의 이름이 달라 모든 SPARQL 요청이 405로 거절되고 있었고, 그래프 계층의 트리플은 통틀어 0개였습니다.
나중에 은행 폐쇄망의 배포 구성을 확인해 보니 Fuseki가 아예 없었습니다. 이 경우 기존 경로는 오류 없이 빈 그래프를 200으로 반환하고, 빌드도 트리플 0개로 완료 처리했습니다.
Fuseki가 기동된 환경에서도 문제는 있었습니다. 빌드 중에는 조회가 막혔고, 진행 상태 관리가 불안정해 빌드가 늦어지는 원인이 됐습니다.
그래프 서버가 하나 더 있다는 것은 설정이 어긋날 곳, 빠질 곳, 따로 지켜볼 곳이 하나씩 더 있다는 뜻이었습니다.
비용은 질의 성능보다 반입에 있었습니다
고객은 대부분 폐쇄망입니다. 새 구성 요소 하나를 도입하려면 보안 점검과 승인, 그리고 그것을 운영할 사람이 필요합니다. Fuseki는 별도 JVM 프로세스라 경량 배포에서도 구성 부담이 컸습니다.
그래서 질문을 "어떤 저장소가 그래프 질의를 잘하는가"에서 "고객이 추가로 반입하지 않아도 되는 저장소는 무엇인가"로 바꿨습니다. 기준은 범용성, 운영 안정성, 규모 순서로 두었습니다.
세 선택지를 같은 기준으로 봤습니다
| 선택지 | 범용성 기준에서 본 것 | 판단 |
|---|---|---|
| Fuseki 유지 | 고객마다 서버 반입과 운영이 남음 | 기각 |
| 그래프 DB 도입 | 반입할 서버의 이름만 바뀜 | 기각 |
| PostgreSQL 테이블 세 개 | 이미 모든 고객사에 있고 백업·권한·모니터링이 표준화돼 있음 | 채택 |
규모도 따져 봤습니다. 컬렉션당 노드는 수만에서 15만 개 수준이고, 실제로 쓰는 질의는 1~2홉 이웃, 클래스 전체 목록, 집계였습니다. 이 정도는 인덱스와 SQL로 충분하다고 봤고, 한국어 라벨 검색은 pg_trgm으로 처리했습니다.
잃는 것도 분명했습니다. 표준 SPARQL 엔드포인트, 추론 설정, jena-text 같은 생태계입니다. 그래서 Fuseki는 기본 비활성화한 미러 옵션으로 남기고, 빌드 라이브러리에는 SPARQL 1.1 저장소라면 어디든 읽고 쓰는 백엔드를 두어 롤백 경로를 열어 두었습니다.
테이블 세 개로 옮긴 그래프
ontology_nodes (collection_id, uri, kind, label)
ontology_edges (collection_id, subject_uri, predicate, object_uri, edge_kind, edge_weight)
ontology_node_chunks (collection_id, uri, chunk_id)세 번째 테이블이 노드에서 원문 청크로 역추적하는 경로입니다. 검색은 벡터로 찾은 청크에서 이 테이블을 거쳐 개체로 넘어가고, 그 개체의 이웃을 근거로 붙입니다. 간선 테이블에는 주어, 목적어, 술어 방향으로 조회하는 인덱스와 중복을 막는 유니크 인덱스를, 라벨에는 trigram 인덱스를 두었습니다.
전환은 한 번에 하지 않았습니다. 먼저 같은 빌드 결과를 Fuseki와 PostgreSQL에 이중 기록하게 해서 기존 배포의 동작을 바꾸지 않았습니다. 대조가 끝난 뒤 Fuseki 쓰기를 기본 비활성화하고, 예전 컬렉션을 읽는 폴백만 남겼습니다. 그 대가로 모든 환경에서 재빌드가 필요해졌습니다.
Fuseki가 없을 때 멈추던 곳들
기본값을 바꾸자 Fuseki가 있다는 전제에 묶여 있던 경로가 드러났습니다.
- 개념 이름을 바꾸는 반복이 개념마다 SPARQL 갱신을 보내며 연결 실패(약 3.7초)를 수백 번 기다렸습니다. 연결 실패 시 호출을 차단하고 60초 뒤 재시도하는 서킷 브레이커를 두자, 첫 실패 4.2초 뒤로는 0.005초에 끝났습니다.
- DB 적재 작업의 완료 판정이 Fuseki 쪽 완료 마커에 묶여 있어 Fuseki 없이는 끝나지 않았습니다. 작업 행의 조건부 갱신이 그 마커를 대신하게 했습니다.
- PostgreSQL 쓰기 실패가 조용히 삼켜지고 있었습니다. 예외로 전파하고, 미러가 비활성화돼 있으면 빌드를 실패로 처리합니다.
실제 문서로 빌드를 실행하고 나서야 잡힌 결함도 있었습니다. LLM이 클래스 목록에 선언하지 않은 클래스를 도메인이나 레인지로만 언급하거나, xsd:string을 관계의 레인지로 지정해 존재하지 않는 클래스를 가리키는 간선이 생겼습니다. 합성 테스트 데이터는 인스턴스 끝점만 검사해 이를 놓쳤습니다. 끝점을 플레이스홀더 노드로 실체화한 뒤 끊긴 간선은 0개가 됐습니다.
옮긴 뒤 큰 그래프를 다루면서 PostgreSQL 쪽 비용도 만났습니다. subject = uri OR object = uri 조인은 인덱스를 타지 못해 5만 노드에서 전체 스캔이 됐고, 주어와 목적어를 따로 집계해 합치도록 바꿨습니다. 백만 행이 넘는 테이블에서는 자동 통계 갱신이 늦어 새 컬렉션의 행 수를 1행으로 추정했고, 조인이 100초를 넘었습니다. 느린 조회를 고치는 코드가 또 다른 느린 조회를 넣지 않는지 실행 계획으로 확인해야 했습니다.
무엇을 확인했고 무엇이 남았는가
비정형 업무 문서 22개(청크 1,636개)를 한 번 빌드해 두 저장소에 동시에 쓰고 비교했습니다. 추출은 같으니 저장 충실도만 보는 비교입니다. 청크 근거는 Fuseki 39,488건, PostgreSQL 39,395건으로 99.8%가 일치했습니다. 인스턴스는 PostgreSQL 쪽이 9,247개로 Fuseki의 5,245개보다 많았는데, Fuseki의 이름 기반 URI가 이름이 같은 서로 다른 개체를 합치고 있었기 때문입니다.
속도는 같은 컬렉션의 그래프 뷰어 조회 API(노드 500개, 엣지 643개)가 PostgreSQL에서 0.6초였고, Fuseki 개수 질의는 콜드 상태에서 30초, 경합 중에는 87초 뒤 오류였습니다. 두 질의가 같은 연산은 아니어서 배수로 읽기보다는, 뷰어 조회가 그래프 서버의 상태에 묶이지 않게 됐다는 쪽으로 해석합니다.
남은 것도 있습니다. 추론과 SHACL 검증은 두지 않았고, 속성값이 타입 없는 문자열이라 수치 비교는 SQL과 원문에 맡깁니다. 간선마다 출처를 두지 않아 어느 청크가 어느 관계를 뒷받침하는지는 구분하지 못합니다.
그래프 저장소를 고를 때는 질의 언어보다 먼저 고객이 무엇을 더 반입해야 하는지를 물어볼 만하다고 봅니다. 저희에게는 그 질문이 저장소를 바꾼 가장 큰 이유였습니다.