Engineering · Knowledge Graph

DB 원본과 지식 그래프를 맞추는 법: 증분 키를 버리고 행 지문으로 추가·변경·삭제를 감지하기

외부 DB 테이블을 LLM 없이 그래프로 만드는 기능에서, 사용자가 고르는 증분 키는 값만 바뀐 행·시각이 되돌아간 행·지워진 행을 놓쳤습니다. PK keyset 전수 읽기와 sha256 행 지문 비교로 원본이 진실인 동기화를 만들고, 실패한 회차의 지문이 그래프보다 앞서지 않도록 지문을 먼저 비우고 가리켜지는 행은 자리 노드로 남겨 실패 경로를 닫았습니다.

기간
2026.06 – 2026.10
소속
플래티어 · XGEN
역할
설계·구현(백엔드·프론트엔드)

Key Results

24초 / 4초
첫 동기화 / 변경 없는 재동기화로컬 Oracle 샘플 6개 테이블 1,610행, PK keyset 전수 읽기와 sha256 행 지문 비교
0 → 1,600
실제 행 노드에 닿은 FK 관계로컬 Oracle 샘플 4개 테이블 970행, 계약→영업점·상품. 수정 전 원본 함수로는 계약→영업점 0/800
0.0~0.3초
200만 행 테이블 미리보기SQL 내부 행 수 제한 적용 후. 이전에는 전체 fetch로 프로세스 메모리가 229MB에서 738MB로 증가

XGEN은 등록된 외부 DB의 테이블을 온톨로지 그래프로 만듭니다. 표는 컬럼 타입과 PK, FK가 명시돼 있어 행을 해석하는 데 LLM이 필요 없습니다. 어려운 것은 추출이 아니라 두 가지였습니다. 그래프의 노드가 원본의 어느 행인지 정하는 정체성, 그리고 원본이 바뀌었을 때 그래프가 따라가는 동기화입니다.

정체성 문제는 6~7월에 다뤘고 시리즈 글에 자세히 정리했습니다. 이 글은 그 위에서 10월에 동기화를 다시 설계한 과정을 다룹니다. 사용자가 고르는 증분 키(watermark)를 버린 이유, 행 지문으로 변화를 감지하는 구조, 그리고 자동 리뷰가 세 차례에 걸쳐 찾아낸 "실패할 때만 틀리는" 결함들을 어떤 원칙으로 막았는지입니다.

정형 데이터의 정체성은 이름이 아니라 키입니다

6월에는 CSV 10개로 빌드한 그래프에 클래스가 4개만 남는 문제가 있었습니다. 처음에는 API 통계만 보고 다른 컬렉션이라 데이터가 다르다고 판단했다가, 서버에서 파일과 DB, 그래프를 직접 대조해 판단을 뒤집었습니다. 빌드는 클래스 10개를 만들었고, 빌드 마무리의 개념 중복 제거(같은 라벨 병합과 임베딩 코사인 0.7 유사 병합)가 테이블당 하나인 정형 클래스까지 의미가 가깝다는 이유로 합친 것이었습니다. 살아남은 주문 클래스가 다른 두 테이블의 라벨을 함께 갖고 있던 것이 결정적 단서였습니다. 정형 클래스에는 스키마 레코드의 source 컬럼으로 출처를 남겨 병합 대상에서 보호했습니다. 메모리 표식이나 설명 문자열 형식으로 추론하는 방법을 먼저 시도했지만 임시방편이라 버렸습니다.

7월에는 DB 조회 결과를 직접 그래프로 적재하는 경로를 만들면서 같은 문제를 행 수준에서 만났습니다. 인스턴스 URI를 라벨 컬럼 값에서 만들었더니 이름이 같은 두 행이 한 노드로 합쳐져 조용히 사라졌습니다. 행 URI를 연결, 테이블, PK 컬럼, 원문 PK 값의 결정적 해시로 바꿨고, 재실행 때 옛 값과 지워진 행이 남지 않도록 모든 페이지를 스테이징 그래프에 적재한 뒤 한 번에 교체하게 했습니다. 잡은 조건부 UPDATE로 가져가고, DB 서버 시간 기준 lease와 heartbeat, 토큰 펜싱으로 중단된 잡을 회수합니다.

10월의 동기화 재설계는 "행의 정체성은 키"라는 원칙을 원본 추종까지 넓힌 작업이었습니다. 그사이 그래프 저장소는 Fuseki에서 PostgreSQL 테이블로 바뀌었습니다.

[DB 연결하기]  카탈로그(ALL_* / information_schema) → 테이블 선택
               → 적재 계획(PK · 라벨 컬럼 · FK · 순서) → 일괄 색인(가리켜지는 표 먼저)
 
[지금 동기화]  workflow: 원본을 PK keyset 으로 끝까지 읽기
                 행 지문 비교 ⇄ core: db_ontology_row_states(row_hash, pending_hash, seen_run)
                 새 행 · 바뀐 행 → documents page: replace_rows
                 끝까지 읽은 회차에 못 본 PK → documents page: deleted_pks
                 세션 완료 → 지문 확정 / 실패 → 다음 회차가 다시 보냄

증분 키가 놓친 세 가지 변화

10월 초 DB 연결 기능은 SQL을 손으로 쓰는 방식이었습니다. PK는 표본 10행의 유일성으로 짐작했고(계약 테이블에서 후보가 6개 나왔습니다), DB 코멘트는 보지 않았고, FK를 지정할 곳이 없었습니다. 연결에 설정한 금지 테이블이 적재 미리보기로는 그대로 읽혔습니다. 에이전트 노드는 테이블 가드로 막고 있었는데 적재 경로에는 그 검사가 없었습니다.

정체성도 마지막 한 단계가 이어지지 않았습니다. PostgreSQL 저장이 URI를 라벨로 만들어, 계약에서 영업점을 가리키는 관계의 목적어가 "BRANCH_B009" 같은 자리 노드에 머물고 실제 영업점 행 노드와 이어지지 않았습니다. 원본 함수로 재 보니 800건 중 0건이 실제 행에 닿았습니다.

가장 큰 문제는 증분 방식이었습니다. 처음 만든 방식은 사용자가 수정 시각 같은 컬럼을 증분 키로 고르면 UPDATED_AT >= 마지막 값인 행만 다시 읽는 것이었습니다. 이 방식은 값만 바뀐 행, 수정 시각이 과거로 간 행, 지워진 행을 놓칩니다. 실제로 원본을 바꿔 시험했을 때도 두 경우가 반영되지 않았습니다. 전량 다시 가져오기도 해결책이 아니었습니다. PostgreSQL 경로가 원본 교체 옵션을 무시하고 합쳐 쓰고 있어서, 전량 적재 뒤에도 지워진 행과 NULL이 된 값, 끊긴 FK가 그래프에 남았습니다.

사용자의 반대는 분명했습니다. 변경 기준을 사람이 고르게 하면 안 되고, 동기화가 변화를 스스로 알아채야 한다는 것이었습니다. 반영 버튼이 두 페이지에 다섯 종류(재빌드, DB 연결하기, 다시 실행, 지금 적재, 전량 다시 가져오기)로 흩어져 있던 것도 같은 문제의 다른 얼굴이었습니다.

원본이 진실이라는 정의에서 설계를 시작했습니다

먼저 동기화의 의미를 정의했습니다. 원본이 진실이고, 그래프는 동기화가 끝난 시점의 원본과 같아야 합니다. 이 정의를 놓고 선택지를 비교했습니다.

선택지 장점 단점 판단
증분 키(watermark) 유지 바뀐 행만 읽어 저렴 값만 바뀐 행·역행·삭제를 놓침, 사용자가 컬럼을 골라야 함 기각
매번 전량 재적재 개념상 원본과 같아짐 매번 모든 행을 다시 씀, 당시 경로는 교체를 무시 기각
PK keyset 전수 읽기 + 행 지문 비교 삭제까지 감지, 바뀐 행만 씀, 사용자 선택 불필요 매 회차 원본 전체를 읽음 채택

읽기는 전수로 하되 쓰기는 바뀐 것만 하는 방식입니다. 원본을 PK keyset으로 끝까지 읽으면서 행마다 지문을 계산하고, 코어 테이블에 저장한 지문과 비교합니다.

행 지문 = sha256(매핑 서명 + 열 값)        매핑 서명 = 열 · PK · 라벨 · FK 구성
 
비교 결과
  지문 없음              → 새 행     → replace_rows
  지문 다름              → 바뀐 행   → replace_rows (속성 키와 그 행 근거 관계를 한 트랜잭션에서 걷어내고 다시 씀)
  끝까지 읽었는데 못 봄   → 지워진 행 → deleted_pks (사본 제거 후 감분)
  매핑 서명이 바뀜        → 전 행 재작성
세션 완료 뒤에만 pending → row_hash 로 확정, 실패하면 다음 회차가 다시 보냄

삭제는 "끝까지 읽은 회차에 보지 못한 PK"로만 판정합니다. 중간에 끊긴 회차에서 못 본 행을 지우면 안 되기 때문입니다. 요약(읽은 행, 추가, 변경, 그대로, 삭제, 완주 여부)은 작업 행에 남겨 UI에 "추가 a · 변경 b · 제거 c"로 보여 줍니다.

테이블 선택도 같은 원칙으로 바꿨습니다. DB 딕셔너리(Oracle·Tibero의 ALL_*, PostgreSQL의 information_schema와 pg_catalog)에서 테이블을 고르면, 적재 계획이 PK는 제약에서, FK 대상은 카탈로그에서 가져오고 가리켜지는 테이블을 먼저 적재하도록 순서를 정합니다. 이름 컬럼은 문자열로 선언된 비 FK 컬럼 중에서 표본 200행으로 제안해 작업에 고정합니다. 실행마다 받은 행으로 이름 컬럼을 고르던 때는 발급일(ISO 표기의 T 때문에 글자로 판정됐습니다)이나 FK인 계약번호가 이름으로 잡혔고, 증분 3행만 받은 회차에서는 계약상태가 뽑혀 노드 이름이 덮이기도 했습니다. 금지 테이블은 카탈로그에서 숨기고 상세 조회는 403, 직접 SQL로 JOIN해도 400으로 막습니다.

FK는 정체성 키(dbrow_<sha256>)로 실제 행 노드에 잇습니다. 대상 행이 아직 없으면 자리 노드를 만들고, 행이 들어오면 자리 노드의 이름을 갱신합니다.

실패한 회차가 남기는 지문이 가장 위험했습니다

기능이 동작한 뒤 자동 리뷰 에이전트가 네 개 MR을 실제로 실행해 검토하고 머지 전 수정 사항 13건을 보냈습니다. 하나씩 코드로 확인하니 12건이 맞았고, 공통점은 정상 경로가 아니라 실패, 전환, 동시성에서만 조용히 틀린다는 것이었습니다.

가장 위험한 것은 영구 누락이었습니다. 문서 서비스는 삭제 페이지를 받는 즉시 그래프에 반영하는데, 그 뒤 세션이 실패하면 워크플로우는 지문을 확정하지 않으므로 옛 지문이 남습니다. 이후 원본에서 그 행이 같은 값으로 돌아오면 지문이 같으니 "변경 없음"으로 판정되고, 그래프에서 지워진 행은 다시 들어오지 않습니다. 이 결함에서 원칙 하나를 세웠습니다. 지문은 그래프가 실제로 가진 것보다 앞서면 안 됩니다. 지우려는 행의 지문을 페이지를 보내기 전에 먼저 비웁니다(invalidate). 지문이 빈 행은 다음 회차에 새 행처럼 다시 쓰고, 여전히 없는 행은 다시 지웁니다.

2차 리뷰는 같은 구멍을 바뀐 행에서 찾았습니다. pending 기록이 전송 뒤에 있어, 바뀐 행을 보내다 실패하고 원본이 옛 값으로 돌아오면 확정 지문과 같아져 그 행이 영영 빠졌습니다. 기록을 전송 앞으로 옮기고 그때 확정 지문도 비웠습니다. 쓰기 실패가 아예 보이지 않는 문제도 있었습니다. SDK의 갱신 함수가 실패를 로그로만 남기고 None을 반환해서, 간선 INSERT가 실패해도 페이지는 성공하고 지문이 확정돼 FK가 영구히 사라졌습니다. 그래프 쓰기와 행 삭제의 모든 쓰기 결과를 검사해 예외로 올렸고, 그러면 페이지 실패, 세션 중단, 작업 실패, 지문 미확정으로 이어집니다.

삭제의 부작용도 같은 부류였습니다. 다른 행이 가리키는 행을 지우면 노드 정리 과정에서 들어오는 간선도 사라지는데, 가리키던 행은 바뀌지 않았으니 다시 전송되지 않습니다. 원본에서 그 행이 복구돼도 FK는 돌아오지 않습니다. 들어오는 관계가 있는 행 노드는 정리하지 않고 자리 노드({표}_{PK}, 타입·속성·청크 링크 제거)로 되돌려, 행이 돌아오면 자리 노드 이름 규칙에 따라 원래 행이 되게 했습니다.

검증 중에 하나를 더 찾았습니다. 로컬에서 다른 판의 코어가 재기동하며 SDK가 모델에 없는 동기화 컬럼을 지웠다가 다시 만들자 회차 번호가 0부터 다시 셌고, "못 본 행" 판정이 seen_run < run이라 지워진 행을 찾지 못했습니다. 판정을 seen_run <> run으로 바꿨습니다. 배포 순서도 코드로 강제했습니다. 새 워크플로우와 옛 문서 서비스가 만나면 삭제 페이지가 매번 400으로 실패하므로, 세션 시작 응답에 지원 기능 목록을 싣고 없으면 원본을 읽기 전에 멈춥니다.

3차로는 높은 강도의 자동 검수를 세 브랜치에 실행해 이식성 결함을 찾았습니다.

  • PostgreSQL에서 user라는 열이 접속 계정 이름으로 읽혔습니다. 따옴표 없이 쓴 식별자가 함수로 해석된 것이라 식별자를 항상 따옴표로 감쌉니다. 앞선 리뷰 반영 때 Oracle에서 "card_no"처럼 따옴표를 쳐 ORA-00904가 난 적이 있어, 드라이버가 반환한 결과 컬럼 이름에서 원래 표기를 되돌리는 규칙(Oracle이 소문자로 반환한 단순 이름은 대문자로, 섞이거나 한글이면 따옴표)을 함께 씁니다.
  • "user" 같은 열이 있는 테이블은 계획 단계부터 막혔습니다. SQL 안전 검사가 따옴표 안의 이름까지 금지어로 봤기 때문이라, 문장 키워드는 따옴표 식별자를 가린 문장에서 찾게 했습니다.
  • Oracle 11g는 FETCH FIRST를 지원하지 않아 ROWNUM으로 행 수를 제한합니다.
  • 미리보기와 계획 표본, PK 사전 검증이 테이블 전체를 가져왔습니다. psycopg는 fetchmany로도 결과를 통째로 받기 때문에 200만 행 테이블에서 프로세스 메모리가 229MB에서 738MB로 늘었습니다. SQL 서브쿼리에 max_rows+1 행 제한을 넣었습니다.
  • 행을 언급한 문서를 지우면 행끼리의 FK가 사라졌습니다. 두 행이 같은 청크에 함께 나오지 않으니 공출현 기준으로는 근거가 없다고 본 것입니다. 지운 청크 밖에 출처가 남은 관계는 두게 했습니다.

이 단계에서 처음 고친 것 중 세 가지는 다시 따져 보니 다른 경우를 실패하게 만들어 되돌렸습니다. 예를 들어 첫 적재에서 사본 교체를 끄는 조건은, 작업을 다시 만들면 지문이 비어 옛 사본이 남는 경우를 놓쳤습니다.

결과

지표 이전 이후 측정 조건
실제 행 노드에 닿은 FK 계약→영업점 0/800 계약→영업점·상품 1,600건 전부, 자리 노드 0 로컬 Oracle 샘플 4개 테이블 970행
첫 동기화 / 변경 없는 재동기화 - 24초 / 4초 Oracle 샘플 6개 테이블 1,610행
변경 없는 재동기화(PostgreSQL) - 3초 쇼핑 도메인 4개 테이블 270행
7가지 변경 유형 반영 증분 키 방식은 두 경우 누락 7가지 모두 그래프 일치, 되돌리면 원상 값·시각 역행·NULL·FK 변경·FK NULL·추가·삭제
200만 행 미리보기 전체 fetch, 메모리 229 → 738MB 0.0~0.3초, 647 → 691MiB SQL 내부 행 수 제한
삭제 후 세션 실패 → 복구 영구 누락 가능 추가 150 · FK 150 복원 완료 UPDATE를 DB 트리거로 막아 재현

Oracle 샘플은 영업점 12, 고객 300, 상품 8, 계약 800, 담보 340, 카드 150행의 6개 테이블입니다. 이름이 같은 DB 행 두 개는 합쳐지지 않았고, 문서 근거 관계는 유지됐으며 고아 출처는 0이었습니다. 첫 동기화에서는 담보→계약 340건, 카드→고객 150건의 관계가 사라지는 것으로 직렬화 결함도 찾았습니다. 이름이 숫자 PK인 행을 '값'으로 보고 FK와 속성을 버리던 것인데, 예전에는 합쳐 쓰기가 옛 값으로 가리고 있었습니다. 정체성 키가 있는 행은 값 모양 판정에서 빼서 고쳤고, 같은 결함을 빌드 라이브러리에서도 재현해 함께 고쳤습니다.

실패 경로는 실제 스택에 DB 트리거를 심어 쓰기를 막는 방식으로 재현했습니다. 간선 INSERT를 막으면 "그래프 쓰기 실패"로 작업이 실패하고 지문이 확정되지 않았으며, 트리거를 풀면 다음 동기화가 변경 1건과 FK 3건을 복원했습니다. 계약 5건과 카드 1건이 가리키는 고객을 지운 경우에는 고객 노드가 자리 노드로 바뀌어 들어오는 FK 6건이 유지됐고, 복구하자 이름과 타입이 돌아왔습니다. 행 지문 의미 5개 시나리오, 식별자 9개 사례, 대기 임대 수명 등 회귀 테스트 26건을 외부 의존 없이 추가했습니다. 제품 기본 라인에는 10월 12일 머지됐고, 고객 배포 라인에도 같은 수정을 올려 같은 Oracle 6개 테이블에서 같은 결과를 확인했습니다.

남은 과제

머지 다음 날 개발 환경에서 두 가지를 더 찾았습니다. 응답하지 않는 원본 DB에 연결하면 테이블 목록 조회가 134초 뒤에야 실패했는데, 저장된 연결의 접속 시간 제한이 전달되지 않아 조회 시간 제한(60초)이 대신 쓰이고 보안 접속 재시도로 두 번 기다린 것이었습니다. 가짜 무응답 DB로 재현해 60.1초를 5.1초로 줄였습니다. 미리보기 처리기가 비동기 핸들러 안에서 원본 조회를 직접 호출해 응답 없는 DB 앞에서 서버 전체가 멈추던 것도 스레드 풀로 옮겼습니다.

Tibero와 Informix 실 DB는 같은 keyset 경로를 쓰지만 아직 검증하지 못했습니다. 원본이 비면 표 전체를 지우는 동작은 정의대로 두고 복구만 보장했으며, 0건일 때 보류할지는 제품 결정으로 남겼습니다. 문서와 DB가 섞인 컬렉션의 재빌드 뒤 자동 동기화는 로컬에 그런 컬렉션이 없어 확인하지 못했습니다. 이 설계는 매 회차 원본을 끝까지 읽는 비용을 받아들인 것이므로, 큰 테이블에서의 동기화 시간은 앞으로 재야 할 항목입니다.

돌아보면 리뷰가 잡은 결함은 대부분 한 질문으로 정리됐습니다. 실패한 직후 시스템이 기억하는 상태가 실제 그래프보다 앞서 있는가. 지문을 먼저 비우고, 쓰기 실패를 예외로 올리고, 지운 행을 자리 노드로 남긴 것은 모두 이 질문에 "아니다"라고 답하게 만든 장치였습니다.