데이터베이스 조회 결과를 온톨로지로 만들려면 CSV로 내보낸 뒤 파일 빌드 경로를 다시 거쳐야 했습니다. 그 과정에서 컬럼 타입과 키 정보가 약해졌습니다. 직접 읽기로 하고 나서 처음 부딪힌 것은 추출이 아니라 정체성이었습니다. 사람 이름으로 만든 식별자는 동명이인을 한 사람으로 합치고, 개명한 사람을 다른 사람으로 만듭니다.
이미 갖고 있는 것을 버리고 다시 추측하고 있었습니다
데이터베이스 연결 등록과 읽기 전용 조회 기능은 이미 있었습니다.
그 결과를 온톨로지로 만들려면 CSV로 내보낸 뒤 파일 빌드 경로를 다시 타야 했습니다.
이 경로에서 잃는 것이 있었습니다. 컬럼 타입과 기본키, 외래키 정보가 약해집니다. CSV는 전부 문자열이니 타입을 다시 추정해야 하고, 어느 컬럼이 키인지도 다시 판정해야 합니다. 운영 데이터가 바뀔 때마다 파일 스냅샷을 다시 관리해야 하는 부담도 붙습니다.
데이터베이스가 이미 정확하게 알려주는 것을 버린 다음, 그것을 다시 추측하고 있었습니다.
타입과 키가 있으면 행마다 모델을 부를 이유가 없습니다. 그래서 연계의 초점을 조회 실행이 아니라 행의 정체성과 스냅샷 교체로 옮겼습니다.
두 시스템을 연결할 때 한쪽의 출력 형식을 다른 쪽의 입력 형식으로 맞추는 방법이 가장 빠릅니다. 그 변환에서 무엇이 떨어지는지는 나중에 드러납니다. 대체로 떨어지는 것은 구조 정보이고, 뒤에서 그것을 복원하는 비용이 변환 비용보다 큽니다.
읽기 좋은 식별자가 서로 다른 두 행을 한 노드로 만들었습니다
행을 노드로 만들려면 식별자가 필요합니다. 사람 이름이나 상품명을 쓰면 화면에서 읽기 좋습니다.
두 가지가 깨집니다.
같은 이름을 가진 두 행이 하나의 노드로 합쳐집니다. 그리고 이름이 바뀌면 같은 행이 새 노드가 됩니다.
이름을 식별자로 동명이인 두 행 → 한 노드 같은 사람이 개명 → 두 노드
기본키를 식별자로 동명이인 두 행 → 두 노드 같은 사람이 개명 → 한 노드
데이터베이스에서 행의 정체성은 표시 라벨이 아니라 기본키입니다. 라벨은 그 행에 대해 사람이 읽는 설명이고, 바뀔 수 있습니다.
그래서 행 식별자를 연결과 테이블, 기본키 컬럼, 원본 기본키 값을 조합해 결정적으로 만들었습니다. 라벨은 별도 속성으로 저장합니다.
identity = connection + table + pk_column + pk_value
label = 사용자가 고른 표시 컬럼
기본키가 42인 상품 행은 이름이 바뀌어도 같은 식별자를 씁니다. 증분 조회에서 이 행을 다시 읽으면 식별자는 유지한 채 그 행이 소유한 값과 나가는 관계를 교체합니다. 이후 전체 스냅샷에서 기본키 42가 사라졌다면 해당 출처의 행과 그 행을 가리키는 관계를 함께 제거합니다.
라벨과 증분 갱신과 삭제가 서로 다른 규칙을 쓰는데 정체성은 하나로 유지됩니다. 그것이 기본키를 쓴 이유입니다.
값이 없거나 중복된 기본키는 적재 전에 거부합니다. 빈 문자열과 공백은 원본 값으로 구분하고, 외래키는 값이 없을 때만 관계 생성을 생략합니다.
외래키도 대상 테이블의 표시 이름이 아니라 명시된 기본키 컬럼을 참조할 때만 관계로 만듭니다. 참조 대상이 아직 없으면 임시 노드를 만들 수 있지만, 실제 행처럼 출처 표시를 붙이지 않습니다. 그래야 나중에 스냅샷을 교체할 때 존재하지 않았던 행으로 오인하지 않습니다.
식별자를 정할 때 읽기 좋음을 기준으로 삼기 쉽습니다. 식별자가 답해야 하는 질문은 "이것과 저것이 같은 것인가" 하나입니다. 사람이 읽는 문제는 라벨이 따로 맡으면 됩니다.
미리보기를 편의 기능으로 만들지 않았습니다
사용자는 등록된 연결과 읽기 전용 조회를 고르고, 미리보기에서 컬럼 타입과 샘플 행, 기본키 후보를 확인합니다.
이 화면을 확인용 편의 기능으로 두지 않았습니다. 여기서 정한 기본키와 라벨 컬럼, 외래키, 증분 키가 그대로 적재 계약이 됩니다.
조회 구문만 허용하고 행 수와 실행 시간은 운영 설정으로 제한합니다. 증분 조건을 넣을 때 쿼리를 문자열로 치환하지 않고 원 쿼리를 서브쿼리로 감쌉니다. 값은 바인딩하고, 컬럼명은 미리보기에서 확인한 식별자만 허용합니다.
행은 모델로 해석하지 않습니다. Python 값과 데이터베이스 타입을 XML Schema 타입으로 매핑하고, 기본키와 외래키로 인스턴스와 관계를 만듭니다. 스키마의 라벨 번역과 요약에 소량의 모델 호출이 남을 수 있지만, 호출 수가 행 수가 아니라 클래스와 속성 수에 비례합니다.
페이지를 바로 넣었더니 실패했을 때 반쯤 바뀐 그래프가 남았습니다
전체 조회 결과는 여러 페이지로 나눠 읽습니다.
각 페이지를 읽는 대로 운영 그래프에 넣는 것이 자연스럽습니다. 마지막 페이지에서 실패하면 새 데이터와 이전 데이터가 섞인 그래프가 남습니다. 어디까지가 새 것인지 알 수 없습니다.
그래서 모든 페이지를 출처별 임시 그래프에 먼저 적재했습니다. 적재 전에 전체 결과의 기본키 누락과 중복 여부를 데이터베이스에서 집계합니다.
모든 페이지가 끝나면 그래프 갱신 구문 한 번으로 네 가지를 처리합니다.
1 같은 출처가 이전에 만든 행과 관계를 제거한다
2 임시 그래프의 새 스냅샷을 운영 그래프에 삽입한다
3 완료 표식을 기록한다
4 임시 그래프를 정리한다
값이 바뀐 행에서 나가는 기존 관계는 교체되고, 조회에서 사라진 행과 그 행을 가리키는 관계도 제거됩니다. 새 스냅샷에도 남아 있는 행을 향한 다른 출처의 관계와 다른 출처가 소유한 개체는 보존합니다.
빈 조회 결과도 성공한 새 스냅샷으로 취급합니다. 그러면 이전 출처 데이터가 정상적으로 삭제됩니다. 결과가 없는 것과 실패한 것을 구분한 것인데, 9편에서 조용한 실패를 고친 것과 같은 구분입니다.
같은 교체 확정 요청이 응답 유실로 다시 들어와도 완료 표식을 보고 이미 끝난 작업인지 판정합니다. 운영 그래프를 직접 수정하는 시간은 마지막 교체 구간으로 제한됩니다.
원자적이라고 부를 수 있는 범위는 생각보다 좁았습니다
여기까지 만들고 저희는 적재가 원자적이라고 말할 수 있게 됐다고 생각했습니다.
경계를 다시 그어야 했습니다.
원자적으로 보호되는 범위는 그래프 저장소 안의 대상 그래프까지입니다. 이후 PostgreSQL 스키마 메타데이터를 확정하는 단계는 다른 트랜잭션 도메인입니다.
여러 워커가 동시에 마무리 단계를 실행하면 메타데이터 행이 중복될 가능성이 남아 있습니다. 그러니까 전체 적재를 하나의 원자적 트랜잭션이라고 부를 수는 없습니다.
트랜잭션 경계를 말할 때 어느 저장소 안에서의 이야기인지 함께 적어야 합니다. 두 저장소를 걸치는 순간 그 단어가 보장하는 범위가 달라지는데, 문장에서는 같은 단어가 그대로 쓰입니다.
수정 시각만 커서로 쓰면 페이지 경계에서 행이 샙니다
전체 실행과 증분 실행 가운데 하나만 남기지 않았습니다.
전체 실행은 현재 조회 결과를 기준 스냅샷으로 다시 만들기 때문에 원본에서 삭제된 행과 오래된 관계까지 정리합니다. 대신 데이터가 클수록 매번 전부 읽는 비용이 큽니다.
증분 실행은 마지막 커서 이후에 추가되거나 변경된 행만 읽습니다. 다만 데이터베이스가 삭제 이력을 제공하지 않으면 조회 결과에 나타나지 않은 삭제 행을 알 수 없습니다. 그래서 증분 경로는 이번에 읽은 개체만 교체하고, 보지 못한 행을 삭제하지 않습니다.
잦은 갱신에는 증분을 쓰고, 삭제와 누적 차이를 다시 맞춰야 할 때는 전체 스냅샷을 실행할 수 있게 두 경로를 함께 뒀습니다.
증분 커서에도 함정이 있었습니다. 수정 시각만 커서로 쓰면 같은 시각 값을 가진 행이 페이지 경계에 걸릴 때 일부가 누락됩니다. 마지막으로 읽은 시각보다 큰 것만 다음 페이지로 가져오면, 그 시각에 걸쳐 있던 나머지 행이 사라집니다.
증분 위치를 수정 시각과 기본키의 튜플로 저장했습니다.
WHERE (watermark, pk) > (:last_watermark, :last_pk)
ORDER BY watermark, pk
데이터베이스별 문법 차이는 어댑터가 처리하되 의미는 같습니다. 다음 페이지는 마지막 행의 두 값을 이어받습니다.
작업 소유권에는 만료 시간을 뒀습니다. 워커는 조건부 갱신으로 대기 중인 작업을 가져와 주기적으로 만료 시간을 연장하고, 소유권 토큰이 일치할 때만 상태와 커서를 갱신합니다. 만료된 작업은 다른 워커가 복구할 수 있고, 늦게 돌아온 이전 워커는 만료된 토큰이 막히므로 완료 상태를 덮어쓸 수 없습니다.
정렬 키가 유일하지 않으면 페이지 경계에서 데이터가 새거나 겹칩니다. 페이지네이션을 붙일 때 정렬 키에 유일한 값을 하나 더 묶어두면 이 부류의 버그를 미리 막을 수 있습니다.
검증은 행 수가 아니라 교체의 의미를 봤습니다
격리된 실제 그래프 저장소에서 전체 교체, 값 변경, 삭제 행 제거, 다른 출처에서 들어오는 관계 보존, 동일 교체 요청 재시도, 증분 교체, 이전 출처 형식의 업그레이드, 빈 스냅샷을 확인했습니다. 프론트엔드 빌드와 서비스별 검증도 통과했습니다.
이 변경은 격리 환경의 통합 검증까지 마쳤으며 아직 운영 배포 전입니다.
남은 경계도 적어두겠습니다. 전체 조회의 여러 페이지를 원본 데이터베이스의 하나의 장기 트랜잭션 스냅샷으로 묶지는 않습니다. 실행 중에 원본이 바뀌면 행 수 변화는 감지하지만 같은 행 수를 유지한 값 교체까지 찾지는 못합니다. 운영에서는 일관 읽기 뷰나 변경이 멈춘 시간대, 또는 데이터베이스가 제공하는 스냅샷 조회가 필요합니다.
일부 어댑터는 실제 서버 대신 공식 구문과 모의 실행까지만 검증했고, 서비스 조합 전체를 한 번에 통과하는 통합 시험은 아직 수행하지 않았습니다.
열 편 내내 무언가가 조용히 사라지고 있었습니다
시리즈를 마치며 돌아보면, 열 편의 문제가 서로 다른 층에 있었는데 형태는 반복됐습니다.
1편 질문 목록 밖의 개념이 그래프에 들어오지 못했다
2편 컨텍스트를 줄이면서 답의 근거가 합성 단계에 도달하지 못했다
3편 한글 문서가 추출되기도 전에 비자연어로 걸러졌다
5편 아직 관계가 안 만들어진 정상 클래스가 자동 삭제됐다
8편 서로 다른 테이블이 유사 개념으로 합쳐졌다
9편 잘린 응답이 빈 결과와 같은 값으로 성공 처리됐다
10편 같은 시각 값을 가진 행이 페이지 경계에서 누락됐다
매번 무언가가 사라졌고, 매번 시스템은 정상으로 보고했습니다. 사라진 것을 발견한 방법도 대체로 같았습니다. 숫자가 아니라 결과물을 직접 열어봤을 때 나왔습니다. 클래스 수를 세어보고, 그래프 화면을 열어보고, 실행 기록을 따라가 봤습니다.
지식그래프를 만드는 일에서 어려운 쪽은 무엇을 넣을지가 아니라 무엇이 조용히 빠졌는지 알아채는 것이었습니다.
그리고 아직 못 한 것이 하나 남아 있습니다.
1편에서 질문을 빌드의 입력에서 빼고 평가 쪽에 남겼습니다. 그런데 도메인의 역량 질문 전체 중 몇 퍼센트에 답할 수 있는지 계산하는 일은 아직 구현하지도 검증하지도 못했습니다. 질문별로 필요한 개념과 관계와 연산을 정의하고 빌드 결과와 자동으로 대조하는 작업입니다.
질문이 빌드의 발견 범위를 제한하지 않게 하면서도, 마지막 평가 기준으로는 반드시 돌아오게 만드는 것. 1편에서 절반만 한 일의 나머지 절반이고, 이 시리즈가 남긴 다음 과제입니다.
이전 편 → 작업은 성공이라고 나왔는데 그래프가 비어 있었습니다 (9편)
시리즈 처음 → 질문이 답의 범위까지 정하고 있었습니다 (1편)

