Engineering · Knowledge Graph

빌드 진행률은 어디서 읽어야 하는가: 그래프 역산을 걷어내고 PostgreSQL 카운터를 정본으로

진행률을 RDF 그래프 역질의로 복원하던 구조 때문에 74초 걸리는 진행률 API, 좀비 잡, 빌드 중 전체 API 무응답이 겹쳤습니다. 정본을 PostgreSQL로 옮겼다가 되돌리고 다시 옮긴 경위와, 진행 상태와 빌드 결과를 분리한 네 가지 원칙, 이벤트 루프 오프로드의 실측을 정리합니다.

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

Key Results

74초 → 0.2초
빌드 진행률 API 응답개발 서버, PG 카운터 fast-path 재배포 전후 실측(74.19s → 0.17~0.20s)
평균 12ms
빌드 중 잡 폴링 응답로컬 스택, 2만 6,934청크 컬렉션 빌드 준비 스캔 25.6초 동안 폴링 50회, 최대 57ms. 수정 전에는 같은 구간 전체 블로킹
500행 → 9,551행
스키마 조회 결과클래스 5,408개 컬렉션, SDK 조회 기본 limit=500 암묵 절단 제거 전후

온톨로지 빌드는 문서 청크를 LLM으로 추출해 그래프에 적재하는 장시간 백그라운드 작업입니다. 큰 컬렉션은 수십 분에서 한 시간 넘게 걸리고, 그동안 프론트엔드는 진행률을 폴링하고 사용자는 중단하거나 이어서 빌드합니다. 5월 말 개발 서버에서 이 빌드를 둘러싼 증상이 하루 사이에 여러 개 보고됐습니다. 진행률 요청이 타임아웃으로 끊기고, 이미 끝난 빌드가 "진행 중"으로 남아 새 빌드를 막았고, 빌드 버튼을 누르면 빈 페이지가 수십 초 이어졌습니다.

증상은 제각각이었지만 결국 하나의 설계 결함으로 모였습니다. 실행 중인 작업의 상태(진행률, 단계)와 작업의 결과(그래프)를 같은 저장소에 두고, 상태를 결과에서 거꾸로 계산하고 있었습니다. 이 글은 그 결함을 좁혀 간 과정, 진행률의 정본을 PostgreSQL로 옮겼다가 되돌리고 다시 옮긴 경위, 그리고 빌드 골격은 그대로 두고 상태 계층만 고친 설계와 실측을 다룹니다.

진행 상태와 빌드 결과가 같은 그래프에 있었습니다

문서 서비스는 단일 uvicorn 이벤트 루프 위에서 동작하는 API 프로세스이고, 온톨로지 빌드는 그 프로세스 안의 asyncio 백그라운드 태스크로 실행됩니다. 빌드 결과는 RDF 트리플스토어(Apache Jena Fuseki)에, 청크와 임베딩은 Qdrant에, 빌드 잡 이력은 PostgreSQL에 있었습니다.

잡 저장소는 그보다 2주 앞서 한 번 정리한 상태였습니다. 처음에는 잡이 파드별 메모리에만 있어서 다른 파드가 조회하면 404가 났습니다. Redis 동기화를 넣었다가 바로 다음 커밋에서 걷어내고 PostgreSQL 잡 테이블 영속화로 바꿨습니다. 캐시를 동기화하는 구조보다 정본 하나를 두는 쪽이 장애 지점이 적다고 판단했습니다.

문제는 진행률이었습니다. 진행률 API는 호출될 때마다 그래프에서 SELECT DISTINCT chunk_id로 처리된 청크 ID를 전부 받아 오고, Qdrant를 끝까지 scroll해서 전체 청크 ID와 비교했습니다. 빌드 워커는 그룹을 처리할 때마다 "이 청크들을 시도했다"는 마커 트리플을 콘텐츠 그래프에 넣었고, 이어서 빌드할 때마다 그래프 전체를 다시 훑었습니다. 게다가 빌드 워커와 조회 엔드포인트가 앱 전역의 같은 HTTP 클라이언트를 공유해서, 트리플스토어가 쓰기 락을 잡는 동안 읽기가 줄을 섰습니다.

[이전]
빌드 워커 ── 콘텐츠 트리플 + 시도 마커 INSERT ──▶ 그래프 ◀── DISTINCT chunk_id ── 진행률 API
                                     (같은 클라이언트, 같은 커넥션)       + Qdrant 전체 scroll
[이후]
빌드 워커 ── 콘텐츠만 INSERT (쓰기 풀) ──▶ 그래프 ◀── 읽기 풀 ── 통계·그래프 조회
    └── 그룹마다 처리 수 단조 증가 ──▶ PostgreSQL 잡 행 ◀── O(1) 읽기 ── 진행률 API
         재빌드: 그래프 CLEAR와 같은 흐름에서 카운터 0

하루 동안 보고된 증상들

처음 보고는 다섯 가지였습니다. 콘솔에 "signal is aborted without reason"이 반복되고, 빌드 버튼을 누르면 빈 페이지가 수십 초 이어지고, 빌드가 끝난 컬렉션에 온톨로지 배지가 표시되지 않고, 빌드 요청은 409(이미 진행 중)로 막히는데 그 잡 ID를 조회하면 404이고, 중단 버튼을 눌러도 잡이 사라지지 않았습니다. 저녁 재배포 뒤에는 "그래프 데이터 없음"이 표시되고 재빌드를 눌러도 시작되지 않는 증상이 더해졌습니다.

개발 서버에서 엔드포인트별 응답 시간을 직접 쟀습니다.

엔드포인트 개발 서버 판단
잡 목록 0.65초 정상
스키마 프로필 0.43초 정상
그래프 통계 13.95초 느리지만 프론트엔드 30초 타임아웃 안
빌드 진행률 74.19초 타임아웃의 주원인, 로컬 3.21초의 23배

DB를 직접 보니 그 컬렉션의 빌드는 처리 청크 10,354개로 이미 끝나 있었습니다. 진행률 응답이 30초 안에 오지 않아서 프론트엔드가 "그래프 데이터 없음"을 그리고 있었던 것입니다. 2주 뒤에는 별개로 보이는 증상이 하나 더 나왔습니다. 문서가 많은 컬렉션을 빌드하는 동안 잡 폴링, 그래프 통계, 문서 목록까지 모든 API가 응답하지 않았고 문서 수에 비례해 악화됐습니다.

원인을 좁혀 간 과정: 네 번 왕복한 정본

먼저 인프라 장애를 배제했습니다. 다른 컬렉션의 그래프는 정상적으로 그려졌으므로 트리플스토어 자체는 살아 있었습니다.

좀비 잡은 코드에서 바로 설명됐습니다. 잡 목록 조회는 종료되지 않은 잡을 찾아 409를 냈고, 단건 조회는 그 ID를 메모리에서 찾지 못해 404를 냈습니다. 파드가 SIGTERM을 받거나 롤아웃될 때 실행 중인 태스크에는 asyncio.CancelledError가 던져지는데, 이 예외는 BaseException 계열이라 래퍼의 except Exception이 잡지 못했습니다. 실패 처리가 실행되지 않은 잡 행은 영원히 진행 중으로 남았습니다. 취소 예외를 명시적으로 처리하고, 30분 넘게 정체된 잡을 정리하는 sweep을 넣었습니다. 저녁에 발견한 9시간짜리 좀비(단계 init, 진행률 0, 경과 33,123초)는 이 수정이 빌드는 됐지만 배포 동기화가 안 된 상태에서 생긴 것이라, 관리자 취소 API로 정리하고 대시보드 시각이 아니라 응답 모양으로 배포 여부를 확인했습니다.

이 과정에서 틀린 수정을 두 번 했습니다. 첫째, 그래프 통계를 PostgreSQL 잡 행의 개수 우선으로 바꿨습니다. 그런데 잡 행의 개수는 빌드 시점의 스냅샷이고 그래프는 사용자가 관계를 추가하고 지우며 바뀝니다. 로컬에서 관계를 편집한 뒤 5.6% 편차를 확인하고 원복했습니다. 작업 기록에는 이 커밋을 "임시방편을 구조로 포장한 것"이라고 남겼습니다. 둘째, 프론트엔드가 응답을 잃은 빌드를 자동으로 다시 붙잡는 self-healing을 넣었는데, 백엔드가 불안정한 상태에서는 좀비 잡을 계속 다시 붙잡아 중단 버튼이 무의미한 루프를 만들었습니다. 그 부분만 되돌리고, 대신 7곳의 abort 호출에 "Request timeout: 30000ms GET ..."처럼 URL과 시간을 담은 사유를 넣어 증상을 진단 자료로 바꿨습니다. 타임아웃을 늘리는 변경도 임시방편이라 되돌렸습니다.

전환점은 통계와 진행률을 구분한 것이었습니다. 통계는 편집 가능한 그래프가 정본이지만, 진행률은 잡의 메타데이터입니다. 그래프 데이터에서 다시 계산할 이유가 없었습니다. 완료된 잡이 있으면 그래프를 보지 않고 PostgreSQL 한 행으로 답하는 fast-path를 넣었습니다.

# 첫 fast-path: 완료된 잡이 있으면 그래프를 다시 세지 않는다
def has_completed_build(collection_id):
    rows = db.execute_query(
        "SELECT 1 FROM build_jobs WHERE collection_id=%s "
        "AND stage='completed' AND COALESCE(triple_count,0)>0 LIMIT 1",
        (collection_id,))
    return len(rows) > 0

로컬 1만 청크 컬렉션에서 진행률 API는 3.21초에서 두 번째 호출 0.18초, 세 번째 0.11초가 됐습니다. 진행 중인 잡도 처리 수 카운터를 잡 행에 쌓게 했고, 가짜 진행 중 행으로 잰 응답은 0.06초였습니다. 개발 서버에 배포한 뒤 74초가 0.17~0.20초로 줄었습니다.

다음 날 새벽 새 결함이 보고됐습니다. "이어서 빌드" 버튼이 사라지고 "재빌드"만 보였습니다. 전날의 완료 잡 행은 그대로인데, 그 뒤 재빌드들이 그래프를 비워 트리플이 46,139개에서 2,461개로 줄어 있었습니다. fast-path는 완료 행만 보고 "남은 청크 0"이라고 답했습니다. 같은 시각에 좀비 판정 기준을 30초로 잡아 실제로 진행 중이던 잡을 취소한 실수도 있었습니다. LLM 호출 하나가 1~2분 걸리는 것이 정상인데 30초 정체를 좀비로 본 것입니다. 이때는 카운터 컬럼을 되돌리고, 진행률을 그래프의 출처 청크 COUNT 한 줄과 Qdrant 컬렉션 포인트 수로 다시 계산했습니다(warm 0.3초, cold 2.2초). 옛 경로가 느렸던 이유는 정본이 그래프였기 때문이 아니라 ID 전체를 전송했기 때문이었다는 판단이었습니다.

되돌린 뒤 원인을 다시 정리하니, PostgreSQL 카운터가 실패한 이유는 설계가 아니라 한 줄이었습니다. 재빌드가 그래프는 CLEAR하면서 카운터는 0으로 되돌리지 않아 둘이 어긋난 것입니다. "PostgreSQL이 정본"이라는 말 자체도 무엇의 정본인지 빠져 있었습니다. 잡 단위 메타데이터의 정본과 컬렉션의 현재 그래프 상태의 정본은 다른 데이터입니다. 이 구분을 세우고 6월 초 카운터를 다시 도입했습니다. 정본은 PostgreSQL에서 그래프로, 다시 PostgreSQL로 네 번 왕복한 셈입니다.

설계 판단: 상태 계층만 외과적으로

선택지 장점 단점 판단
그래프 역산(DISTINCT chunk_id + 전체 scroll) 그래프 현재 상태를 그대로 반영 호출마다 ID 전체 전송, 빌드 쓰기와 같은 그래프·커넥션 경쟁, 마커가 콘텐츠를 오염 폐기
그래프 COUNT + 벡터 저장소 포인트 수 가볍고 재빌드가 즉시 반영됨 빌드 중에도 그래프 저장소를 읽고, 시도 마커가 계속 필요 중간 복원 후 대체
PostgreSQL 단조 카운터 O(1) 읽기, 그래프와 무관, 다른 파드·재시작 뒤에도 같은 값 재빌드 리셋을 빠뜨리면 어긋남 리셋을 CLEAR와 같은 흐름에 두고 채택
통계도 PostgreSQL 스냅샷 우선 빠름 관계 편집 후 5.6% 편차 기각, 통계는 그래프가 정본

원칙은 네 가지로 좁혔습니다.

  1. 진행 마커 트리플을 콘텐츠 그래프에서 제거한다.
  2. 진행률은 워커가 단조 증가시키는 PostgreSQL 카운터이고, 폴링은 O(1) 읽기다.
  3. 재빌드의 카운터 리셋은 그래프 CLEAR와 같은 흐름에서 처리한다.
  4. 트리플스토어 클라이언트를 빌드용 쓰기 풀과 조회용 읽기 풀로 나눠 쓰기가 읽기를 막지 않게 한다.

추출, 그룹 처리, 그래프 적재, 중복 제거 같은 빌드 골격은 직전의 대규모 수정으로 이미 정상화돼 있어서 대상에서 뺐습니다. 한 번에 갈아엎지 않고 안전한 축부터 고친다는 판단이었습니다.

구현: 카운터, 풀 분리, 이벤트 루프 오프로드

카운터는 그룹 처리가 끝날 때마다 처리 청크 수를 더하고, 진행률 API는 이 컬럼만 읽습니다. 컬럼 추가 마이그레이션은 재실행해도 결과가 같도록 멱등하게 작성했습니다. 이어서 빌드의 그래프 전체 스캔도 없앴습니다. RDF 적재는 같은 트리플을 다시 넣어도 중복되지 않으므로 다시 처리해도 결과가 늘지 않습니다.

빌드 중 전체 API 무응답은 다른 축이었습니다. 빌드 태스크가 Qdrant scroll, 문서 스트리밍, 그룹마다의 기존 개념 로드, 스키마 저장, CSV를 DB로 옮기는 대량 적재 같은 동기 호출을 이벤트 루프 위에서 직접 실행하고 있었습니다. 이 호출들을 asyncio.to_thread로 워커 스레드에 넘기고, 재시도 헬퍼가 코루틴을 반환하는 람다도 await하도록 확장했습니다. 잡 행 단건 저장(밀리초 단위, 그룹당 2~3회)과 완료 시 1회 점검 조회는 전환 위험이 이득보다 크다고 보고 의도적으로 동기로 남겼습니다. DB 풀(psycopg3 ConnectionPool)과 Qdrant 클라이언트(httpx 기반)가 스레드 안전하다는 것도 확인했습니다. 측정은 바인드 마운트된 작업 트리에서 먼저 하고, 폴링 실측으로 효과를 확인한 뒤 커밋했습니다. 로컬 스택을 다시 올리면 작업 트리 반영분이 초기화되기 때문입니다.

같은 조사에서 암묵 절단 하나를 더 찾았습니다. SDK 조회 함수의 기본값이 limit=500이어서, 클래스가 5,408개인 컬렉션의 스키마 조회·고아 탐지·개념 수가 전부 앞 500행에서 잘렸습니다. 클래스 요약은 119개(2.2%)만 만들어져 있었습니다. 스키마 조회를 전체 페이지네이션으로 바꾸고, 단일 프롬프트에 목록을 싣는 호출부만 의도적으로 500 상한을 유지했습니다.

잡에 기록된 트리플 수도 정본 문제였습니다. 6월의 대형 빌드에서 잡의 누적값은 109만이었지만 실제 그래프는 16만 1,287개였습니다. 그룹마다 OWL·번역 트리플이 다시 더해진 중복 합산이었습니다. 7월에는 QA 결함("빌드된 컬렉션을 확인할 수 없음")을 조사하다 운영 환경에서 잡 상세 뷰 4,582건, 실제 그래프 2,675건을 확인했습니다. 추출 단계의 누적치를 그대로 보여 준 것이 원인이었고, 완료 직전 그래프에서 실측한 값으로 갱신하고 재빌드로 트리플이 급감하면 경고를 남기게 했습니다.

결과

지표 이전 이후 측정 조건
진행률 API 74.19초 0.17~0.20초 개발 서버, fast-path 배포 전후
진행률 API(로컬) 3.21초 0.11초(warm) 로컬, 1만 354청크 컬렉션
빌드 준비 스캔 중 폴링 구간 전체 블로킹 평균 12ms · 최대 57ms(50회) 로컬, 2만 6,934청크, 25.6초 구간
전체 재빌드 중 폴링 기록 없음 평균 23ms(152회, 1,045ms 1회) 로컬, 669청크 CSV, 156.9초
LLM 그룹 경로 빌드 중 폴링 기록 없음 평균 11ms · 최대 53ms(73회) 로컬, 11청크, 73.5초
스키마 조회 앞 500행 9,551행 전체 클래스 5,408개 컬렉션
잡 트리플 수 4,582(추출 누적) 그래프 실측 2,675으로 정합 운영 환경 실측

669청크 재빌드에서는 진행률 92%에서 완료까지 143초 동안 무거운 마무리(요약·중복 제거·CSV 적재)가 이어졌는데도 폴링이 전부 즉시 응답했습니다.

회고와 남은 과제

가장 크게 남은 교훈은 "정본"이라는 말에 대상을 붙여야 한다는 것입니다. 잡의 진행률과 컬렉션의 현재 그래프는 둘 다 "빌드 상태"처럼 보이지만 수명과 갱신 주체가 다릅니다. 성능을 위해 정본을 우회하면 곳곳에 정리 코드가 필요해지고 결국 동기화가 어긋납니다. 정본은 그대로 두고 더 빠르게 읽는 방법을 찾거나, 정본 자체를 데이터의 성격에 맞게 다시 정해야 했습니다. 되돌린 커밋도 이력으로 남겨 두었고, 그 덕분에 두 번째 도입 때 실패한 한 줄을 정확히 짚을 수 있었습니다.

남은 문제도 있습니다. 온톨로지 빌드가 API 프로세스 안에서 실행되는 구조라, 이후 HPA가 파드를 줄이며 빌드 중인 파드를 종료해 좀비 잡(409)이 생긴 사례가 있었습니다. 빌드 워커를 별도 프로세스로 분리하는 것은 인프라 과제로 남아 있습니다. 같은 원칙은 10월의 후속 작업으로 이어졌습니다. 메모리 플래그와 잡 행 삭제로 하던 취소가 다른 파드의 빌드에 닿지 않아, 취소 후에도 120초 동안 모델 호출이 21건 늘어나는 것을 2파드 E2E로 확인했습니다. 잡 행의 단계를 compare-and-set으로 바꾸고 모델 호출마다 DB의 종료 상태를 확인하게 하자, 진행 중이던 배치 8~9건 뒤 호출이 0이 됐습니다. 재기동 시 정리하는 대상도 빌드가 주기적으로 갱신하는 생존 신호가 오래된 잡으로 한정했습니다. 실행 상태의 정본은 DB 행 하나라는 원칙을 끝까지 적용한 셈입니다.