Engineering · Retrieval · RAG

컬렉션 데이터 소스: 휴면 스케줄러를 살리지 않고 provider 두 메서드로 원천을 늘린 증분 인덱싱

내부 파일 저장소 폴더를 컬렉션에 연결하면 변경분만 자동 인덱싱되는 데이터 소스를 바인딩·durable 작업 큐·해시 diff 골격으로 만들고, 휴면 상태의 저장소 동기화 기능을 되살리는 대신 API 도구를 provider로 흡수했습니다. DB 연결 창이 2분 넘게 멈추던 원인도 접속 시간 제한 누락과 이벤트 루프 차단으로 찾아 고쳤습니다.

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

Key Results

indexed 1 · updated 1 · skipped 1
레코드 변경 후 수동 동기화 결과목 API를 도구로 등록한 11단계 E2E, 레코드 1건 변경·1건 신규, content_hash 증분
20초
원천 3종 첫 동기화개발 환경 시험 컬렉션, 파일 5개·API 레코드 100건·DB 200행 각각
60.1초 → 5.1초
응답 없는 DB의 카탈로그 실패 판정가짜 무응답 PostgreSQL, 연결 접속 시간 제한 5초, 수정 전후

XGEN의 지식 컬렉션은 문서를 올리면 파싱, 청킹, 임베딩을 거쳐 PostgreSQL의 문서 메타데이터, Qdrant 벡터 인덱스, MinIO 원본 사본에 게시되고, 워크플로우의 정보검색 노드가 그 컬렉션을 검색합니다. 지금까지 문서를 넣는 방법은 수동 업로드뿐이었습니다. 내부 파일 저장소의 폴더를 컬렉션에 연결해 두면 새로 들어오거나 바뀐 파일만 자동으로 인덱싱되고, 원본에서 지운 파일은 정책에 따라 검색에서도 빠지는 기능이 필요했습니다.

이 글은 그 기능을 "데이터 소스"라는 운영 객체로 설계한 과정과, 같은 골격에 등록된 API 도구의 조회 결과를 주기적으로 수집하는 원천을 provider로 추가한 과정, 그리고 몇 달 뒤 개발 환경에서 DB 원천의 연결 창이 2분 넘게 멈추던 문제를 서버 로그로 추적한 과정을 다룹니다. 파일과 API 원천은 바인딩, 작업 큐, 해시 diff, 인제스천으로 이어지는 같은 골격을 쓰고, 원천마다 다른 부분은 provider의 두 메서드에만 있습니다.

출발점: 수동 업로드와 휴면 상태의 동기화 기능

설계의 첫 원칙은 결과물의 게시 경로를 바꾸지 않는 것이었습니다. 데이터 소스가 만든 문서도 수동 업로드와 같은 경로(문서 메타데이터 테이블, Qdrant, MinIO 사본)에 게시하면, 정보검색 노드는 컬렉션을 고르는 기존 방식 그대로 새 문서를 검색합니다. 검색 쪽에 별도의 소스 선택기를 둘 필요가 없습니다.

비슷한 기능이 이미 있는지도 확인했습니다. 저장소 동기화 스크래퍼라는 기능이 있었고, 실체는 GitLab 저장소 동기화였습니다. 테이블과 컨트롤러 라우팅은 남아 있었지만 스케줄러는 정의만 있고 서비스 진입점 어디서도 기동되지 않았으며, 동기화 서비스는 존재하지 않는 모듈을 import해 실행 자체가 불가능했습니다. 사실상 휴면 상태였습니다.

첫 구현은 범위가 넓었습니다. 7월 15일 구현은 소스, 문서 manifest와 세대(generation), 작업과 작업 항목, 이벤트까지 다섯 모델과 명시적 마이그레이션, PostgreSQL advisory lock, FOR UPDATE SKIP LOCKED와 lease 버전 기반 펜싱, 멱등 키와 If-Match 조건부 요청, 검색 경로 전반의 게시 상태 가드까지 포함했고, 오후 체크포인트 시점에 변경 경로가 172개였습니다. 같은 기록에는 실제 PostgreSQL에서의 마이그레이션·롤백, 여러 워커 간 lease 인계와 펜싱, MinIO에서 Qdrant까지의 종단 검증이 아직 실행되지 않았다고 적혀 있습니다. 이 구현은 로컬 브랜치로만 남았고, 다음 날 최신 원격 기준에서 기능을 새로 설계했습니다. 제품에 들어간 것은 이 재구현입니다.

세 가지 제약과 그에 맞춘 결정

재구현은 세 테이블로 시작했습니다. 바인딩 정본, 원천 항목과 색인 문서를 잇는 manifest, durable 작업 큐 겸 실행 이력입니다.

스키마 (core)
  vector_db_sources       바인딩 정본: 범위 · 삭제 정책(retain|mirror) · 자동 동기화 · 상태
  vector_db_source_files  manifest: 원천 항목 ↔ 색인 문서 + 내용 해시
  vector_db_source_runs   durable 작업 큐 + 실행 이력
        │
문서 서비스 (API + worker)
  planner       원천 실측 ↔ manifest 순수 diff (DB 무의존)
  repository    3단계 클레임 · heartbeat 만료 복구
  document_ops  항목 1건 색인/삭제 (기존 인제스천 재사용)
  worker        30초 heartbeat · 10분 주기 자동 동기화
        │
프론트엔드: 컬렉션 상세 [문서 | 데이터 소스] 탭

첫째 제약은 플랫폼 SDK의 쿼리 함수가 SELECT에서만 행을 반환한다는 점이었습니다. UPDATE ... RETURNING으로 대기 작업을 원자적으로 가져갈 수 없었고, 워커는 여러 replica로 뜰 수 있었습니다. 그래서 작업 획득을 3단계로 나눴습니다. 후보를 SELECT로 고르고, queued 상태일 때만 바뀌는 조건부 UPDATE로 자기 worker_id를 쓰고, 다시 읽어 worker_id가 자기 것인지 확인합니다. 두 워커가 같은 후보를 골라도 상태 조건 때문에 UPDATE는 한쪽만 성공하고, 다른 쪽은 재확인 단계에서 물러납니다. 소스당 동시 실행 1개, FIFO 순서, heartbeat가 끊긴 실행의 복구(실행 중이던 작업은 한 번 재큐잉 후 실패 처리, 취소 요청된 작업은 되살리지 않고 취소 처리)도 저장소 계층의 시험 13건에 넣었습니다.

둘째 제약은 원본 삭제였습니다. 원본 폴더가 지워졌을 때 인덱스가 조용히 남거나 조용히 사라지면 운영자는 알 수 없습니다. 그래서 바인딩의 저장소·폴더 ID에는 일부러 외래 키를 걸지 않았습니다. 원본 삭제를 동기화 오류로 드러내려면 바인딩이 원본보다 오래 살아야 하기 때문입니다. 같은 이유로 연결 후 범위는 바꿀 수 없게 하고, 바꾸려면 새 소스를 만들게 했습니다. 범위가 바뀌면 이미 색인된 문서의 소유권과 삭제 경계가 흐려지기 때문입니다. 원본에서 사라진 항목은 소스마다 고른 삭제 정책(검색 데이터 유지 또는 함께 삭제)을 따릅니다.

셋째는 실패했을 때의 검색 품질이었습니다. 바뀐 파일을 재색인할 때 새 버전이 성공한 뒤에 이전 문서를 지우게 해, 재색인이 실패해도 기존 검색은 유지됩니다. 문서 삭제는 기존 문서 삭제 엔드포인트와 같은 순서(Qdrant, PostgreSQL, 집계, MinIO 참조 카운트)를 밟습니다. 워커는 서비스 수명주기에 묶인 asyncio 작업이고, core 스키마가 아직 배포되지 않았으면 서비스 기동을 막지 않고 대기합니다.

UI 위치도 같은 관점에서 정했습니다. 데이터 소스는 생성 옵션이 아니라 연결 후 동기화 상태, 실행 이력, 오류를 계속 보는 운영 객체라, 새 컬렉션 생성 모달이 아니라 컬렉션 상세의 문서 | 데이터 소스 탭에 두었습니다. 권한은 배포 라인의 계약을 따랐습니다. 제품 기본 라인은 컬렉션 쓰기 권한이 있으면 소스를 바꿀 수 있고, 고객 배포 라인은 컬렉션 관리를 소유자 전용 등급으로 분리한 계약에 맞춰 소유자만 관리합니다.

원천이 늘어도 골격은 그대로: provider 두 메서드

API 조회 결과를 주기적으로 수집하는 요구가 왔을 때, 휴면 상태의 스크래퍼를 되살리는 길과 데이터 소스 구조에 흡수하는 길이 있었습니다.

선택지 장점 단점 판단
저장소 동기화 스크래퍼 부활 테이블과 컨트롤러가 남아 있음 스케줄러 미기동, 서비스 모듈 import 불가. 큐·이력·삭제 정책을 따로 다시 갖춰야 함 제외 (GitLab 원천은 후속 provider로)
데이터 소스의 provider로 흡수 바인딩·큐·diff·인제스천 재사용, provider는 두 메서드만 구현 manifest 키를 파일 ID에서 일반화해야 함 채택

바인딩에서 durable 실행 큐, 워커, 해시 diff, manifest, 기존 인제스천까지는 원천과 무관했습니다. 바뀌는 것은 "항목 목록을 어디서 가져와 어떻게 문서 파일로 만드느냐"뿐이라 provider 인터페이스를 두 메서드로 정했습니다.

SourceProvider
  list_items(source)  -> [{item_key, content_hash, display_name, ...}]
  materialize(item)   -> 로컬 임시 문서 파일 (기존 인제스천에 투입)
 
  xgen_storage : 저장소 파일 나열 / MinIO 다운로드(복호화)
  api_tool     : 도구 호출 → 레코드 추출 → 정제 매핑 → .md 렌더
  gitlab       : (후속) 구 스크래퍼 로직 이식

스키마는 아직 배포 전이라 같은 브랜치에서 진화시켰습니다. manifest의 파일 ID 컬럼을 item_key 문자열로 일반화해 storage:{파일 ID}, api:{레코드 키} 형태로 쓰고, 소스에는 API 설정 JSON과 소스별 동기화 주기를 추가했습니다. 배포 뒤에 manifest 컬럼을 일반화하면 마이그레이션 문제가 되므로, 이 단계를 미푸시 상태에서 먼저 한 것이 순서상 중요했습니다.

{
  "tool_id": 123,
  "params": {"q": "..."},
  "record_path": "data.items",
  "item_key_field": "id",
  "title_field": "title",
  "content_fields": ["body"],
  "format": "markdown"
}

API 동기화는 등록된 API 도구를 그 정의(메서드, 헤더, 타임아웃)대로 호출하고, record_path로 레코드 배열을 꺼낸 뒤 레코드마다 키 필드 값을 item_key로, 정제 결과의 sha256을 content_hash로 삼습니다. diff는 파일 원천의 planner 로직을 그대로 써서 같은 해시는 건너뛰므로, API가 매번 전체 목록을 반환해도 재인덱싱이 일어나지 않습니다. 바뀐 레코드는 제목 템플릿과 선택 필드로 마크다운 문서를 렌더해 기존 인제스천에 넣으므로 개인정보·금칙어 마스킹, 청킹, 임베딩을 그대로 통과합니다. 소스당 레코드 상한은 파일 원천과 같은 2,000건입니다.

API 원천이 드러낸 결함들

목 API를 도구로 등록한 11단계 E2E는 첫날 통과했습니다. 레코드 하나를 고치고 하나를 추가한 뒤 수동 동기화하면 indexed 1, updated 1, skipped 1이 나왔고, 원천에서 사라진 레코드는 mirror 정책에 따라 색인에서 제거됐습니다. 그러나 실제 공공기관 OPEN API를 연결하자 목 API에서는 보이지 않던 결함이 차례로 나왔습니다.

첫 결함은 인코딩이었습니다. 공공기관 API 다수가 JSON을 text/html; charset=euc-kr로 내려주는데, HTTP 클라이언트의 JSON 파싱은 바이트를 UTF-8로만 해석해 "API 응답이 JSON이 아닙니다"로 실패했습니다. 선언된 charset으로 디코딩한 텍스트를 다시 파싱하는 폴백과 EUC-KR 회귀 테스트를 추가했습니다.

둘째는 위저드의 호출 테스트에서 "apiType 변수가 필요합니다"가 나온 건이었고, 원인은 세 겹이었습니다. 플랫폼의 도구 호출 테스트는 GET 요청에 쿼리를 만들어 주지 않고, 도구 등록의 정본은 쿼리를 URL에 직접 쓰는 방식입니다. 그런데 워커의 HTTP 클라이언트에 params=를 넘기면 URL에 이미 있는 쿼리를 병합하지 않고 대체해, URL 직접 쿼리 도구를 소스 동기화가 호출하면 쿼리 전체가 사라졌습니다. 증상은 위저드에서 먼저 드러났지만 동기화 워커에도 같은 결함이 있었던 셈입니다. URL 쿼리를 보존해 병합하도록 고치고 회귀 테스트 2건을 넣었습니다. 위저드 쪽은 도구 테스트가 text/html JSON을 문자열로 반환해 경로 추천과 미리보기가 비어 있던 것을, 문자열이면 JSON 파싱을 시도하게 고쳤습니다.

셋째는 JSON이 아닌 응답이었습니다. 도구 테스트는 파일·바이너리 응답을 객체 저장소로 넘기고 메타데이터만 반환하는데, 위저드가 이를 데이터로 오인해 내부 필드를 레코드 후보로 보여 줬습니다. 비JSON 응답은 안내와 함께 차단했고, 이후 요구에 따라 XML 응답을 지원했습니다. 이때 핵심 계약은 위저드와 워커가 같은 변환 규칙(반복 태그는 배열, 속성은 @ 접두, 텍스트 리프는 문자열)을 쓰는 것이었습니다. 위저드에서 고른 레코드 경로가 워커에서 같은 위치를 가리켜야 하기 때문입니다. 백엔드는 표준 라이브러리만 써서 폐쇄망 반입 의존성을 늘리지 않았습니다.

넷째는 사용자 UI에 찍힌 "추가 1 · 건너뜀 59"였습니다. 공공 통계 API의 환율 시계열 60행이 모두 같은 통계 코드를 갖고 있었는데, 그 필드를 고유 키로 고르자 59건이 키 중복으로 제외된 것이었습니다. 날짜 필드를 키로 잡아야 날짜별 문서 60건이 됩니다. 결함은 아니지만 이유를 알 수 없는 숫자였으므로, 중복으로 제외된 건수와 "항목마다 값이 다른 필드를 고유 키로 선택하라"는 안내를 UI에 표시하게 했습니다.

마지막은 "성공 · 추가 1"의 정체였습니다. 인증키가 아직 플레이스홀더인 도구를 레코드 경로 없이 연결해, 미등록 인증키 오류 응답 전체가 문서 1건으로 인덱싱된 것이었습니다. 단일 객체를 반환하는 API도 정당한 패턴이라 이런 응답을 일괄 차단하는 가드는 넣지 않았습니다. 반대로 레코드 경로를 지정한 소스에서는 오류 응답에 레코드 배열이 없어 추출 단계가 예외를 내고 실행이 실패로 끝나므로, 호출 한도를 넘긴 날에도 mirror 정책이 문서를 대량 삭제하지 않습니다. 삭제 정책이 있는 원천에서는 실패를 "빈 목록"으로 해석하지 않는 것이 특히 중요했습니다.

DB 연결 창이 2분 14초 동안 멈춘 이유

10월에는 DB 원천이 더해진 상태에서, 개발 환경의 DB 연결 창이 "테이블 목록을 읽는 중"으로 2분 넘게 멈추고 [취소]도 눌리지 않는다는 제보가 왔습니다. 서버 로그에서 카탈로그 요청은 134초 뒤 실패로 끝나 있었고, 사용자가 고른 연결은 예전 개발 서버의 DB를 가리키고 있었습니다. 그 DB는 접속을 받아 놓고 응답하지 않다가 끊었습니다.

응답 없는 DB 자체는 연결 설정 문제였지만, 그 앞에서 2분 넘게 기다리는 것은 코드 문제였습니다. 연결마다 접속 시간 제한(기본 30초)을 설정할 수 있는데, 저장된 연결을 해소하는 함수가 이 값을 접속 정보에 싣지 않아 접속 대기에 조회 시간 제한 60초가 쓰였고, 보안 접속 재시도로 그 대기가 두 번 일어났습니다. 저장 연결의 다른 조회 경로와 같게 접속 시간 제한을 싣도록 고쳤습니다. 접속을 받고 응답하지 않는 가짜 PostgreSQL 서버로 재현하자 실패 판정이 60.1초에서 5.1초(연결 설정 5초)로 줄었습니다.

시험 중에 더 큰 문제가 나왔습니다. 응답 없는 DB로 SQL 미리보기를 누르면 서버 전체가 다른 요청에도 응답하지 않았습니다. 미리보기 엔드포인트가 비동기 처리기 안에서 동기 조회를 직접 호출해 이벤트 루프를 막고 있었습니다. 조회를 스레드풀로 옮기고, 같은 패턴이 더 있는지 AST로 코드를 훑어 이 한 곳뿐임을 확인했습니다. 미리보기가 매달린 동안 다른 요청은 0.37초에 응답했습니다.

UI 쪽은 직접 작성한 코드의 결함이었습니다. 창이 목록, 적재 계획, 미리보기를 읽는 동안에도 닫기를 잠갔습니다. 읽기와 만들기를 나눠, 작업을 만드는 요청이 나간 동안에만 닫기를 막고, 읽는 중에도 연결을 바꾸거나 창을 닫을 수 있으며 앞 연결의 늦은 응답은 UI를 덮어쓰지 않게 했습니다. 접속 실패 때 드라이버 원문과 내부 주소가 그대로 노출되고 접속 시간 초과가 "조회 범위를 줄이라"로 안내되던 것도, 접속 실패를 조회 시간 초과보다 먼저 분류해 "DB에 연결하지 못했습니다"로 바꿨습니다.

결과

수정 후 개발 환경의 시험 컬렉션에 원천 세 종류를 연결해 문서 임베딩과 온톨로지 빌드까지 확인했습니다.

지표 이전 이후 측정 조건
증분 동기화 없음 (수동 업로드만) indexed 1 · updated 1 · skipped 1 목 API 도구, 레코드 1건 변경·1건 신규 후 수동 동기화, 11단계 E2E
백엔드 테스트 - 61 → 88 → 96건 통과 실제 PostgreSQL scratch DB, provider 일반화 후 88, API 원천 보강 후 96
응답 없는 DB의 카탈로그 실패 판정 60.1초 5.1초 가짜 무응답 PostgreSQL, 접속 시간 제한 5초
미리보기가 매달린 동안 다른 요청 서버 멈춤 0.37초 같은 조건
원천 3종 첫 동기화 - 각 20초 개발 환경 시험 컬렉션, 파일 5개·API 레코드 100건·DB 200행(FK로 이어진 표 2개 포함)
온톨로지 자동 빌드 - 청크 179/179 반영, 트리플 1,594 · 관계 296 같은 컬렉션

카드 네 개에서 [지금 동기화]를 모두 실행했을 때 API 오류는 0건이었고, 문서 중복 없이 반영 상태가 "최신 상태"로 표시됐습니다.

회고와 남은 과제

돌아보면 이 기능의 수명을 결정한 것은 처음의 경계 설정이었습니다. 게시 경로를 기존 업로드와 같게 두었기 때문에 검색 노드를 한 줄도 바꾸지 않았고, 원천 의존 부분을 provider 두 메서드로 가두었기 때문에 API 원천이 들어와도 큐와 diff는 그대로였습니다. 반대로 결함은 대부분 경계 바깥, 즉 실제 API의 인코딩, 쿼리 처리 방식, 응답 형식에서 나왔습니다. 목 API로 통과한 E2E는 골격을 검증했을 뿐 원천의 다양성까지 검증하지는 못했고, 그 간극은 실제 API를 하나씩 연결하며 메웠습니다.

남은 과제도 있습니다. API 도구 원천은 컬렉션 공용 도구(소유자 없음, 컬렉션 지정)를 권한 거부(403)로 막는 반면, 워크플로우 쪽은 같은 도구를 모두에게 보여 주고 실행을 허용해 두 권한 모델이 어긋나 있습니다. 그 컬렉션을 읽을 수 있는 사용자에게만 허용하는 안을 권장안으로 결정을 기다리고 있습니다. 인증 프로필을 자동 주입하는 도구의 백그라운드 동기화, 진짜 증분 API를 위한 커서 변수 치환, GitLab provider는 기록상 후속 과제로 남아 있고, 빌드 진행 API의 "남은 청크" 값이 DB 동기화 뒤 틀리게 나오는 문제도 확인만 해 두었습니다.