Engineering · Delivery · Operations

재색인을 요청-응답에서 백그라운드 작업으로: 30초 타임아웃 뒤에 있던 UI와 백엔드의 계약 불일치

요청 하나 안에서 전 청크 재임베딩까지 끝내던 재색인 API가 클라이언트 30초 타임아웃에 걸려 실패처럼 보이고 중복 실행되던 문제를, 202 응답과 상태 조회, Redis 사본과 SET NX로 다중 파드에서도 컬렉션당 한 작업만 도는 구조로 바꿨습니다. 두 파드 시나리오 테스트가 상태 조회 순서 결함을 잡아냈습니다.

기간
2026.09 – 2026.09
소속
플래티어 · XGEN
역할
원인 분석·백엔드·프런트엔드 구현

Key Results

0.09 s
재색인 시작 응답(202)로컬 사이드 컨테이너 API 실측, 종전에는 재임베딩 완료까지 동기 대기
10 · 19 · 7
단위·API·두 파드 시나리오 통과 수사이드 컨테이너 2개 실측, 두 파드 시나리오가 작업 반환 순서 결함 1건을 검출

XGEN의 지식 컬렉션은 문서를 청크로 나눠 임베딩한 벡터 컬렉션입니다. 임베딩 모델을 바꾸거나 컬렉션의 색인 모델이 시스템 기본 모델과 달라지면, 관리자는 재색인으로 전 청크를 새 모델로 다시 임베딩합니다. 플랫폼은 컬렉션의 색인 모델과 시스템 설정이 어긋나면 UI에 알림을 띄우고 재색인을 조치로 안내하므로, 재색인은 운영 중 자주 쓰는 복구 경로입니다.

제품 기본 라인의 배포 환경에서 이 재색인이 두 가지 증상을 보인다는 제보가 들어왔습니다. 재색인을 시작하면 30초 뒤 Request timeout: 30000ms 토스트가 떴고, 같은 모달의 사전 점검은 항상 "사전 점검에 실패했습니다 / Error"로만 표시됐습니다. 요청은 임시방편이 아니라 제대로 고쳐 달라는 것이었습니다.

이 글은 타임아웃이라는 증상 뒤에 있던 실제 문제, 즉 UI가 약속한 비동기 동작과 동기로 구현된 백엔드 사이의 계약 불일치를 어떻게 확인했고, 다중 파드 환경에서 컬렉션당 한 작업만 실행되도록 백그라운드 작업을 어떻게 설계했는지 다룹니다.

증상: 실패로 보이지만 실제로는 끝나는 재색인

타임아웃 토스트가 뜬 뒤에도 서버는 작업을 계속했고, 실제로 재색인은 끝까지 완료됐습니다. 문제는 UI가 이를 실패로 보여 준다는 점이었습니다. 사용자는 자연스럽게 재색인 버튼을 다시 눌렀고, 그러면 같은 컬렉션에 재색인이 두 개 실행됐습니다.

두 번째 증상은 성격이 달랐습니다. 사전 점검은 재색인 전에 새 임베딩 모델과 현재 청크로 작업이 가능한지 미리 확인하는 API인데, 결과와 무관하게 항상 실패 문구와 함께 "Error" 한 단어만 남겼습니다. 오류 메시지가 비어 있다는 것 자체가 단서였습니다.

두 증상을 코드로 따라가 원인을 확정했습니다.

원인: 동기 API, 30초 클라이언트, 그리고 어긋난 약속

재색인 API는 요청 하나 안에서 모든 단계를 끝내야 응답했습니다.

POST /collections/remake  (응답은 아래가 전부 끝난 뒤)
  1. 벡터 DB에서 컬렉션 전체 청크 읽기
  2. 새 컬렉션 생성
  3. 전 청크 재임베딩
  4. 원본 컬렉션 삭제 → 재생성 → 새 벡터 복사
  5. 컬렉션 메타 갱신

문서가 수십 건이면 수 분이 걸리는 작업입니다. 반면 프론트엔드 API 클라이언트의 POST 기본 타임아웃은 30초였고, 재색인 호출은 이를 늘리지 않았습니다. 브라우저가 먼저 연결을 끊고 실패 토스트를 띄우는 동안 서버는 그대로 진행한 것입니다. 동시 실행을 막는 장치도 없어서, 다시 누르면 두 작업의 원본 삭제·재생성 단계가 겹칠 수 있었습니다. 가장 위험한 것은 이 겹침이었습니다.

흥미로운 것은 성공 토스트의 문구였습니다. "재색인을 시작했습니다. 시간이 걸릴 수 있습니다"라는 문구는 이미 비동기 동작을 약속하고 있었습니다. UI는 작업을 시작하고 기다리지 않는 계약을 전제로 만들어졌는데, 백엔드는 끝날 때까지 응답을 붙잡고 있었습니다. 타임아웃은 증상이고, 원인은 UI와 백엔드의 계약이 어긋난 것이었습니다.

사전 점검의 "Error"는 다른 경로였습니다. 사전 점검 함수가 컬렉션 관리 권한을 확인하는 헬퍼를 호출했는데, 그 헬퍼는 고객 배포 라인에만 있고 제품 기본 라인에는 정의된 적이 없었습니다. 코드를 옮기는 과정에서 헬퍼가 빠진 것입니다. 파이썬 NameError가 500 평문 응답이 됐고, HTTP/2에서는 상태 문구(statusText)도 비어 있어, 프론트엔드의 err.message || String(err)가 "Error"만 남겼습니다. UI에 보인 단어 하나가 정확히 이 경로의 결과였습니다.

설계 판단: 작업은 백그라운드로, 상태는 조회로

선택지를 비교했습니다.

선택지 장점 단점 판단
재색인 호출의 클라이언트 타임아웃 연장 변경이 가장 작음 수 분짜리 작업을 HTTP 연결에 계속 묶어 두고, 중복 실행과 원본 단계 겹침은 그대로 임시방편이라 기각
동기 유지 + 컬렉션 잠금 중복 실행은 막음 타임아웃 뒤 실패로 보이는 문제가 그대로 남음 기각
백그라운드 작업 + 상태 조회 + 진행 중 409 UI 문구가 약속한 계약과 일치, 플랫폼에 같은 모양의 선례 존재 상태 저장소와 다중 파드 대응 필요, 프론트엔드와 동시 배포 필요 채택

세 번째 안은 새로 발명한 구조가 아닙니다. 플랫폼의 온톨로지 빌드 작업이 이미 비동기 태스크 생성, 상태 조회, 진행 중 409라는 같은 모양을 쓰고 있었고, 재색인도 이 모양을 따라 새 테이블 없이 구현했습니다. 같은 플랫폼 안에서 오래 걸리는 작업이 같은 계약을 가지면 프론트엔드와 리뷰어가 익힐 것이 줄어듭니다.

더 까다로운 결정은 상태를 어디에 둘지였습니다. 서비스는 여러 파드로 실행될 수 있고, 시작 요청을 받은 파드와 상태 조회를 받는 파드가 다를 수 있습니다. 그래서 작업 상태의 정본은 작업을 실행하는 파드의 메모리에 두고, 업로드 SSE 세션이 이미 쓰고 있던 Redis 백엔드에 사본을 둬서 다른 파드도 조회에 답하게 했습니다. 새 인프라를 추가하지 않고 기존 세션 저장소를 재사용한 것이고, 키는 컬렉션 이름 단위로 잡아 컬렉션당 하나라는 규칙이 키 구조에 그대로 드러나게 했습니다.

시작 경합은 Redis SET NX로 처리했습니다. 키가 없을 때만 쓰는 연산이므로 두 파드가 동시에 시작해도 한쪽만 이깁니다. 이를 위해 세션 백엔드에 "없을 때만 저장" 메서드를 추가했습니다. 실행 중 상태의 TTL은 15분이고, 갱신이 15분 끊긴 작업은 죽은 것으로 보고 실패로 표시한 뒤 재시작을 허용합니다. 끝난 상태(완료 결과나 오류)는 1시간 보관합니다. Redis가 없는 환경에서는 메모리 가드만 동작하므로 단일 파드에서만 중복이 막힙니다.

오류 계약은 바꾸지 않았습니다. 실패한 작업의 상태에는 종전 동기 응답과 같은 구조(message, code, details)를 싣고, 임베딩 컨텍스트 초과는 종전 422 본문(최대 토큰, 권장 청크 크기, 재청킹 필요 힌트)과 동일하게 담았습니다. 프론트엔드의 예외 처리 코드를 바꾸지 않아도 되도록 하기 위해서였습니다.

구현: 백엔드 작업 저장소와 프론트엔드 폴러

백엔드에는 재색인 작업 저장소를 새로 만들었습니다. 시작 요청은 asyncio 태스크를 띄우고 즉시 202로 작업을 반환합니다.

POST /collections/remake          → 202 {job: job_id, status, stage, processed, total}
GET  /collections/remake/status   → running(단계·청크 진행) / completed(result) / failed(error)
같은 컬렉션 진행 중 재시작        → 409 {detail: {message, code: REMAKE_IN_PROGRESS, job}}
관리자 경로                       → 같은 계약

재색인 서비스 함수에는 진행 콜백을 추가해 준비(preparing), 임베딩(embedding), 쓰기(writing), 마무리(finalizing) 단계와 청크 진행 수를 보고하게 했습니다. 콜백에서 예외가 나도 경고 로그만 남기고 작업은 계속합니다. 진행 표시가 본 작업을 중단시키면 안 되기 때문입니다.

사전 점검은 헬퍼를 이식하는 대신 본 작업과 같은 소유자 조건(사용자 ID와 컬렉션 이름으로 조회, 없으면 404)으로 바꿨습니다. 사전 점검이 통과시킨 판정이 본 작업에서 뒤집히면 사전 점검의 의미가 없기 때문입니다.

프론트엔드는 API 클라이언트에 작업 타입과 2초 간격 폴링 헬퍼를 추가했습니다. 폴러는 완료 시 결과를 반환하고, 실패 시 종전 오류 계약 그대로 예외를 던지며, AbortSignal로 중단할 수 있습니다. 상태 조회가 404이거나 실패한 경우는 5회 연속일 때만 실패로 처리해 일시적인 네트워크 오류에 흔들리지 않게 했습니다. 409 응답에 실린 진행 중 작업을 꺼내는 헬퍼도 두었습니다.

UI 동작은 다음 원칙으로 정했습니다.

  • 진행 패널은 단계 라벨, 청크 n/m, 퍼센트 바와 "창을 닫아도 서버에서 계속 진행된다"는 안내를 보여 주고, 사용자와 관리자 모달이 공유합니다.
  • 모달을 열 때 이미 재색인 중이거나 시작이 409면 새로 시작하지 않고 그 작업에 이어 붙습니다.
  • 재색인 중에는 창을 닫을 수 있고 폴링은 계속됩니다. 끝나면 토스트와 목록 갱신이 옵니다. 다만 재청킹(1패스) 중에는 종전대로 닫히지 않습니다.
  • 폴러는 컬렉션당 하나(ref)이며, 닫았다 다시 열어도 같은 폴러에 붙습니다.
  • 관리자 일괄 재색인은 컬렉션마다 시작, 완료 대기, 다음 순서로 실행합니다. 한꺼번에 띄우면 임베딩 서버에 컬렉션 수만큼 요청이 몰리기 때문입니다.

두 파드 시나리오가 잡은 결함

검증은 제품 기본 라인 워크트리를 로컬 스택의 사이드 컨테이너 2개로 띄우고, 같은 Redis를 공유시켜 두 파드를 흉내 냈습니다. 프론트엔드는 빌드본을 프록시로 연결해 Playwright로 실측했습니다.

단위 테스트 10건은 완료와 진행률, Redis 사본 TTL, 진행 중 409, 다른 파드 작업의 409, 갱신이 끊긴 작업의 실패 표시와 재시작, 컨텍스트 초과·HTTP 예외·일반 예외의 오류 형식, 콜백 예외 무시, Redis 없는 메모리 가드, 1시간 지난 작업 정리를 다룹니다. API 테스트 19건은 사전 점검(비소유자 404), 시작 202와 즉시 재시작 409(같은 작업 ID), 진행 조회와 완료 결과, 존재하지 않는 모델로 시작했을 때 실패 상태의 code·message, 권한 없음 403, 관리자 경로, Redis 키 TTL을 확인합니다.

가장 값진 것은 두 파드 시나리오 7건이었습니다. 파드 A에서 시작한 작업을 파드 B가 사본으로 읽고(임베딩 단계 진행까지), B의 시작은 409를 받고, A의 작업이 끝난 뒤 B가 새로 시작한 작업을 A가 읽는 흐름입니다. 이 마지막 단계에서 결함이 나왔습니다. A의 메모리에는 끝난 자기 작업이 남아 있었고, 상태 조회가 로컬 메모리를 먼저 보는 바람에 B가 새로 시작한 진행 중 작업 대신 A의 끝난 작업을 반환했습니다. 조회 순서를 "로컬의 진행 중 작업, 없으면 Redis 사본(다른 파드의 더 새 작업), 사본도 없으면 로컬의 끝난 작업"으로 고쳤고, 이 상황을 단위 테스트로도 고정했습니다.

async def test_finished_local_job_yields_to_newer_job_from_other_pod():
    a, b = make_store(backend), make_store(backend)   # 같은 Redis, 다른 프로세스
    first = await a.start(collection_name="c10", ..., run=run)
    await drain()                                      # A 작업 완료
    second = await b.start(collection_name="c10", ..., run=run_b)
    seen = await a.get("c10")
    assert seen["job_id"] == second.job_id and seen["status"] == STATUS_RUNNING

단일 프로세스 단위 테스트만으로는 드러나지 않는 결함이었습니다. 메모리 정본과 Redis 사본이라는 이중 구조를 택했다면, 두 저장소가 어긋나는 순간을 시나리오로 만들어 확인해야 한다는 것을 보여 준 사례였습니다.

정적 검증도 함께 했습니다. 두 UI의 동작 계약 문서에 위 동작(이어 붙기, 닫아도 계속, 순차 일괄 실행)을 기술하고 계약 lint를 72개 페이지 전체에서 통과시켰습니다. 타입 검사에서 남은 오류는 모두 기존 코드의 것이었고 이번 변경이 만진 줄에는 0건이었으며, 웹 빌드도 통과했습니다.

검증 환경에서도 함정이 있었습니다. 이 머신에서는 프론트엔드 개발 서버가 미들웨어 컴파일에서 멈춰 빌드본으로 실측했고, 코드 재적재 옵션 없이 띄운 사이드 컨테이너는 코드를 바꾼 뒤 재시작해야 했습니다. 한 번은 재시작을 빠뜨려 옛 코드로 검증한 무의미한 결과를 보기도 했습니다.

결과와 남은 과제

지표 이전 이후 측정 조건
재색인 시작 응답 재임베딩 완료까지 대기, 30초에 클라이언트 타임아웃 202 즉시 반환, 0.09초 로컬 사이드 컨테이너 API 테스트
같은 컬렉션 재시작 두 작업이 동시에 실행 409와 진행 중 작업 ID 반환 API 테스트, 관리자 작업 진행 중 사용자 시작도 409
다른 파드의 시작·조회 해당 기능 없음 시작은 409, 조회는 사본으로 단계·진행까지 응답 사이드 컨테이너 2개, 같은 Redis
사전 점검 NameError 500, "Error" 표시 200, 비소유자 404 API 테스트
테스트 통과 해당 없음 단위 10/10, API 19/19, 두 파드 7/7 두 파드 시나리오가 조회 순서 결함 1건 검출 후 수정

UI 실측에서는 사전 점검 정상, 시작 202, 진행 패널, 완료 토스트와 모달 닫힘, 이미 실행 중인 138청크 작업에 이어 붙기, 닫았다 다시 열어도 같은 폴러, 닫은 채로 완료 토스트 수신, 관리자 선택 재색인의 진행 패널과 완료 문구까지 전 경로를 확인했습니다. 시작 응답은 검증 기록상 0.09초였고, 재실행 로그에서는 0.11초로 모두 0.1초 안팎이었습니다.

배운 것은 타임아웃 같은 증상을 만났을 때 시간을 늘리기 전에 계약을 먼저 보는 것입니다. 이번에는 UI 문구가 이미 올바른 계약을 말하고 있었고, 백엔드를 그 계약에 맞추는 것이 수정의 방향이었습니다. 사전 점검 쪽에서는 빈 오류 메시지 하나가 NameError, 500 평문, HTTP/2의 빈 statusText를 거친 결과였다는 점에서, 오류를 사용자에게 보여 주는 경로 자체도 점검 대상이라는 것을 확인했습니다.

남은 한계도 있습니다. Redis가 없으면 메모리 가드만 동작해 단일 파드에서만 중복이 막힙니다. 백엔드와 프론트엔드는 함께 배포해야 합니다. 프론트엔드만 먼저 나가면 상태 조회가 404라 폴링이 5회(10초) 뒤 실패 문구로 끝나고(재색인 자체는 종전처럼 동기로 완료), 백엔드만 먼저 나가면 종전 UI는 202 본문을 무시하고 시작 토스트만 띄웁니다. 검증은 모두 로컬 실측이며, 배포 후 문서가 많은 컬렉션 재색인, 사전 점검, 관리자 일괄 재색인을 각 1회 확인하는 일이 남아 있습니다. 같은 동기 재색인 문제는 고객 배포 라인에도 있어, 재현되면 같은 변경을 옮길 계획입니다. 작업 도중 파드가 재시작될 때 컬렉션이 어느 단계에 남는지는 이번 범위에서 다루지 않았습니다.