Engineering · Retrieval · RAG

시간 상한은 탐색에만, 합성 지연은 스트리밍으로: 근거 누출 사고에서 시작한 검색 지연 재설계

질문당 총시간을 보장하려고 건 25초 하드 상한이 합성 LLM에 4초만 남겨, 내부 근거가 답변으로 새는 사고로 이어졌습니다. 로그와 단계별 계측으로 지연의 대부분이 합성 대기임을 확인하고, 상한은 탐색에만 두고 체감 지연은 합성 입력의 문자 예산과 답변 스트리밍으로 다뤘습니다.

기간
2026.06 – 2026.08
소속
플래티어 · XGEN
역할
원인 분석·설계·구현

Key Results

25.7 s vs 0.3 s
LLM 합성 대 그래프·벡터 검색개발 환경 온톨로지 검색 1회 단계별 실측
86%
검색 1회 비용 중 LLM 합성 비중공공기관 문서 그래프(2,000+ 노드) 단계별 실측, 합성 21,461 ms
4.16 s
결함 당시 합성에 남은 시간 예산총 상한 25 s에서 시드 소요를 뺀 값, 서버 로그

2026년 6월, XGEN 온톨로지 검색을 원샷 구조(병렬 인덱스 시드 4종을 동시에 실행하고 답변은 한 번만 합성)로 바꾼 직후 질문당 총시간을 보장하려고 25초 하드 상한을 걸었습니다. 나흘 뒤 검색 답변에 클래스 전수 목록, 원시 트리플, 시드 안내문 같은 내부 스캐폴딩이 그대로 출력되기 시작했고, "시간 상한 때문에 답변을 생성할 수 없다"는 오류가 거의 매번 떴습니다. 두 달 뒤에는 개발 서버에서 검색이 25~33초 걸려 클라이언트의 30초 타임아웃에 걸린다는 보고가 들어왔습니다.

두 사건은 같은 질문으로 이어져 있었습니다. 검색 한 번의 시간은 어디에 쓰이고, 시간 상한은 어디에 걸어야 하는가. 첫 사건에서는 상한의 위치가 틀려 합성 LLM이 굶었고, 두 번째 사건에서는 상한이 아니라 기다리는 방식이 문제였습니다.

이 글은 두 사건의 원인을 좁힌 과정과, 상한은 탐색에만 두고 최종 답변은 완주시키되 사용자가 느끼는 지연은 합성 입력 크기와 스트리밍으로 다룬다는 판단에 이른 경위를 적습니다.

원샷 검색과 25초 상한

원샷 검색은 질문 하나에 대해 벡터 검색, 그래프 라벨 링킹, 클래스 전수, 양방향 관계 조회를 동시에 실행하고, 모인 근거로 LLM이 답을 한 번 씁니다. 6월 22일에 넣은 시간 상한은 이 흐름 전체를 하나의 예산으로 묶는 설계였습니다.

질문 ─ 총 예산 25 s (asyncio.wait_for)
  ├─ 시드 탐색: 벡터 ∥ 라벨 ∥ 클래스 전수 ∥ 관계     ← 소요 t
  └─ 합성 LLM: timeout = 25 s − t (최소 4 s)
       └─ 초과 시: 모은 근거를 결정적으로 그대로 제시하는 폴백

배경에는 꼬리 지연이 있었습니다. 원샷 구조를 고르기 직전 A/B 평가에서 합성 단계를 별도로 둔 판은 지연 중앙값이 7.2초였지만 100문항 중 1건이 30.2초로, 게이트웨이의 30초 한계를 넘길 위험이 있었습니다. 꼬리를 줄이는 것이 후속 과제로 남아 있던 상태에서, 질문당 총시간을 보장하는 장치로 이 상한을 넣었습니다.

의도는 분명했습니다. 어떤 질문도 25초 안에 끝나고, 합성이 늦어도 빈 답 대신 근거라도 보여 주자는 것이었습니다. 같은 날 MMR 다양성 재랭킹, 답변에 실제 등장한 노드만 강조하는 근거 노드 반환, LLM 호출 자체의 타임아웃도 함께 들어갔습니다. 합성 프롬프트도 손봤는데, 조건이 있는 질문에 클래스 전체를 덤프하지 말고 관련된 항목만 근거로 설명하게 하고, 완전한 목록은 "전부", "몇 개"처럼 명시적으로 물을 때만 내게 했습니다. 그런데 정작 폴백은 그 클래스 전수 목록과 원시 근거를 가공 없이 내보내는 경로였습니다.

나흘 뒤의 두 증상과 세 원인

6월 26일, 답변 첫 줄에 "빠른 응답(시간 상한 내 근거 직제시)"라는 문구가 표시되고 그 아래로 클래스 전수 나열, 원시 트리플, 시드 안내문이 쏟아졌습니다. 다른 질문에서는 "답변을 생성할 수 없습니다(시간 상한)"가 반복됐습니다.

처음에는 추론 서버가 느려 합성이 25초를 넘긴다고 짐작했습니다. 그런데 로그에 찍힌 합성 예산은 4.16초였습니다. 합성 타임아웃이 "총 상한에서 시드 소요를 뺀 값"으로 계산되는데, 시드 탐색이 예산을 거의 다 쓰면 합성에는 최소값 언저리만 남습니다. 긴 근거 위에서 4초 안에 답을 쓸 수 있는 모델은 없으니 합성은 거의 항상 시간 초과였고, 그 실패가 폴백으로 넘어가면서 프롬프트에 넣으려고 만든 근거 블록이 답변 자리에 그대로 출력됐습니다. 두 증상은 원인이 하나였습니다. 시간 상한을 탐색과 합성에 통째로 건 구조 자체가 합성을 굶기고 있었습니다.

같은 날 모델 404 오류도 섞여 있었고, 이것은 별개의 원인이었습니다. 관리 설정에서 모델을 새 버전으로 바꿔 PostgreSQL에는 저장됐지만, 문서 서비스가 실제로 읽는 Redis 설정 캐시는 옛 값(이미 없어진 모델 ID)으로 남아 있었습니다. 마침 그 옛 값이 코드에 박혀 있던 기본 모델과 같아서 "하드코딩 탓"으로 오인하기 쉬웠습니다. 의미 검색 결과가 0건이던 것은 임베딩 API 키의 할당량 소진(429)이라 코드 문제가 아니었습니다. 증상 세 개가 한꺼번에 보였지만, 로그로 하나씩 떼어 보니 원인은 각각 달랐습니다.

이 사건에서 남긴 교훈은 대부분 진단 순서에 관한 것이었습니다.

  • 처음 한 되돌림은 개발 서버가 실제로 실행 중인 제품 기본 라인이 아니라 기능 라인에 들어가 아무 효과가 없었습니다. 고치기 전에 그 코드가 어디서 실행되고 있는지부터 확인해야 했습니다.
  • "추론 서버가 느리다", "하드코딩 탓이다"는 둘 다 로그를 보기 전의 단정이었습니다. 예산, 경과 시간, 예외, 실제 모델 값까지 로그로 확정한 뒤에 원인을 말해야 했습니다.
  • 설정 값은 PostgreSQL이 아니라 서비스가 실제로 읽는 소스(Redis)에서 확인해야 했습니다.
  • 합성 실패가 시간 초과가 아닌데도 "시간 상한"이라는 라벨이 남아 있으면, 다음 사람이 같은 곳을 다시 잘못 짚습니다.

상한을 어디에 둘 것인가

선택지 장점 단점 판단
총시간 상한 유지, 합성 최소 예산 같은 숫자만 조정 변경이 작음 시드가 길어지면 다시 예산이 부족해짐, 숫자 조정으로 덮는 구조 기각
시간 초과 시 모은 근거를 그대로 보여 주는 폴백 유지 빈 답이 없음 조건만 맞으면 프롬프트용 내부 텍스트가 답변으로 노출 삭제
탐색에만 상한, 합성은 완주(LLM 클라이언트 자체 타임아웃 600 s를 안전망) 답변이 끝까지 생성됨, 비용이 예측 불가능한 탐색만 통제 질문당 총시간 보장은 포기 채택

상한의 목적은 "예측할 수 없는 비용이 시스템을 붙잡지 않게 하는 것"이라고 다시 정의했습니다. 그래프 탐색은 질문과 그래프 모양에 따라 비용이 크게 달라지므로 상한이 필요합니다. 반면 최종 답변은 사용자가 기다리는 결과물 자체이고, 생성을 중간에 자르면 남는 것은 오류 메시지나 누출뿐입니다. 그래서 시드 탐색에만 상한을 두고 합성은 마감 없이 실행했으며, LLM 클라이언트의 자체 타임아웃을 안전망으로 남겼습니다.

수정 후
질문
  ├─ 시드 탐색: 상한 유지 (질문과 그래프 모양에 따라 비용이 달라지는 단계)
  └─ 합성 LLM: 마감 없음, LLM 클라이언트 자체 타임아웃 600 s가 안전망
       └─ 실패 시: "LLM 응답 오류" 메시지 (내부 근거 블록은 출력하지 않음)

수정은 네 커밋으로 나눴습니다. 근거를 그대로 쏟던 폴백을 지우고 실패 시에는 정제된 메시지를 내게 했고, 합성의 마감을 없앴고, 합성 시간 초과가 더는 없으므로 실패 라벨을 "LLM 응답 오류"로 바꿨고, 코드 안의 기본 모델 폴백을 지워 관리 설정의 LLM을 유일한 권위로 삼았습니다(미설정이면 옛 모델로 덮지 않고 "모델 없음"으로 드러냄). Redis 설정 캐시 동기화는 모든 서비스가 같은 방식으로 설정을 읽는 플랫폼 공통 문제라, 검색 코드에 끼워 넣지 않고 별도 과제로 분리했습니다. 수정 후 로컬 스택에서 항목이 112개인 목록형 질문이 Claude Sonnet 계열 모델로 23~29초에 정상 합성됐고, 내부 스캐폴딩 누출은 사라졌습니다.

두 달 뒤, 30초 타임아웃과 단계별 계측

8월 말, 개발 서버에서 온톨로지 검색이 25~33초 걸려 프론트엔드의 30초 타임아웃에 걸린다는 보고가 들어왔습니다. 6월의 수정은 질문당 총시간 보장을 의도적으로 포기한 것이었으니, 이 보고는 그 선택의 비용이 돌아온 셈이었습니다. 그렇다고 합성에 다시 마감을 거는 것은 6월의 사고를 되풀이하는 일이라, 상한 말고 다른 손잡이를 찾아야 했습니다. 이번에는 짐작 대신 단계별 시간부터 쟀습니다. 검색 한 번에서 그래프·벡터 검색은 0.3초, LLM 합성은 25.7초였습니다.

이 비율은 6월에 같은 원샷 검색을 공공기관 문서 그래프(노드 2,000개 이상)에서 잰 결과와도 같은 그림이었습니다.

단계 시간
클래스 전수 194 ms
라벨 링킹 353 ms
관계 조회(정·역방향) 464 ms · 454 ms
인스턴스 개수 105 ms
벡터 시드 1,667 ms
LLM 합성 21,461 ms
합계 25,039 ms

비용의 86%가 합성이었고, 그래프 시드는 인덱스 범위 조회라 벡터 시드보다도 쌌습니다. 당시 "검색이 노드를 너무 많이 본다"는 인상도 있었는데, 실제로 무거운 것은 검색이 아니라 그래프 뷰어가 시각화 데이터를 불러오는 단계(이 그래프에서 6~20초)였습니다. 체감과 계측이 가리키는 곳이 달랐던 셈입니다. 이미 0.3초인 검색 단계를 더 줄여도 체감 지연은 거의 바뀌지 않는다는 뜻이었습니다. 합성 시간은 답의 길이를 따라갔습니다. 282자 답은 6.2초, 1,825자 답은 31초였습니다. 어떤 모델을 쓰든 긴 답을 다 쓸 때까지 기다리는 구조라면 30초를 넘는 질문은 계속 나옵니다.

여기서 지연을 두 갈래로 나눴습니다. 하나는 합성 입력의 크기입니다. 근거를 많이 넣을수록 생성 전 처리와 생성 자체가 길어지므로, 합성 프롬프트를 문자 예산으로 묶었습니다. 다른 하나는 기다리는 방식입니다. 사용자가 느끼는 지연은 전체 생성 시간이 아니라 첫 토큰이 보이기까지의 시간이므로, 답변을 토큰 단위로 스트리밍하게 했습니다. 이렇게 하면 30초 타임아웃은 "응답이 오지 않음"이 아니라 "응답이 흐르는 중"이 되고, 체감 지연이 모델 종류와 무관해집니다.

스트리밍 전환과 자체 검수에서 나온 결함

스트리밍을 머지한 뒤 바로 자체 검수를 실행했고, 결함 두 건이 나왔습니다. 첫째, OpenAI 계열 API는 스트리밍 응답에서 토큰 사용량을 기본으로 돌려주지 않아, 토큰 집계가 오류 없이 조용히 0이 되고 있었습니다. 스트리밍 사용량 옵션을 클라이언트에 바인딩해 해결했습니다. 둘째, 첫 토큰을 기다리는 데 상한이 없어 추론 서버가 응답하지 않으면 요청이 무한히 대기할 수 있었습니다. 첫 토큰 대기 상한을 넣었습니다. 이 상한은 6월의 교훈과 같은 원리입니다. 응답이 시작되지 않는 상황은 예측할 수 없는 비용이므로 상한을 두고, 일단 흐르기 시작한 생성은 끝까지 둡니다. 두 결함을 고치면서 관측 로그도 보강했습니다.

배포 경로에서도 문제가 하나 나왔습니다. 데스크톱 앱용 정적 빌드(Tauri)에서 스트리밍이 동작하지 않았습니다. 스트림 요청이 상대 경로와 쿠키 직접 파싱에 기대고 있었는데, Next.js의 rewrites 프록시가 없는 정적 빌드에서는 둘 다 성립하지 않았습니다. 스트림 URL을 절대 경로로 만들고 인증 토큰을 플랫폼 헬퍼로 주입하는 방식으로 바꿨습니다. 프론트엔드에서는 스트리밍과 함께 추정치로 보여 주던 비용 표시를 걷어내고 실측값으로 대체했습니다.

결과와 남은 과제

지표 이전 이후 측정 조건
합성에 주어진 시간 4.16 s 마감 없음(클라이언트 600 s 안전망) 6월 서버 로그, 총 상한 25 s에서 시드 소요를 뺀 값
목록형 질문(항목 112개) "시간 상한" 오류 23~29 s에 정상 합성 로컬 스택, 같은 질문, Claude Sonnet 계열
내부 스캐폴딩 누출 답변에 그대로 출력 없음 근거 직제시 폴백 삭제 후 같은 질문
검색 1회 시간 구성 25~33 s, 30 s 타임아웃 합성 25.7 s · 검색 0.3 s로 분해, 답변 스트리밍 8월 개발 서버 단계별 실측

같은 구조는 9월 고객 문서 비교 평가에서도 다시 확인됐습니다. 워크플로우 한 번의 응답은 질의 분할 LLM(3~5초), 질의 2~4개의 병렬 검색(유휴 3~5초, 여러 평가 런이 동시에 실행될 때 10~20초), 답변 생성으로 이뤄졌습니다. 개발 서버 유휴 상태에서 측정해 보면 질의 분할 2.6초와 검색 3.2초 뒤 나머지 약 40초는 답변 모델이 6만 자 안팎의 근거로 답을 쓰는 시간이었습니다. 문항당 검색이 2~3초인 조건에서 평가 시간은 답변 LLM과 채점 LLM이 지배했고, 평가 런 네 개를 동시에 돌리면 추론 서버 하나를 나눠 써 문항당 시간만 늘고 전체 처리량(분당 약 3문항)은 런 두 개일 때와 같았습니다. 같은 시기에 근거 문자 예산이 모델 컨텍스트에서 역산돼 12.8만 토큰 모델에서는 검색 한 번에 약 20만 자까지 커지던 문제도 찾아 80,000자 상한으로 묶었고, 상한 적용 뒤 근거 40개짜리 답변의 입력은 중앙값 73,932자, 최대 79,883자로 예산 안에 머물렀습니다. 계측과 체감이 어긋나는 경우도 한 번 더 있었습니다. 평가 기록에는 사전 검색 모드의 문항당 응답이 54초로 찍혔는데, 서버의 노드 타임라인은 질의 분할 0.9초, 검색 7.0~7.9초, 답변 3~5초로 11~14초였습니다. SSE 이벤트가 클라이언트에 도착한 시각을 기록해 보니 완료 이벤트가 40~63초 뒤에 닿았고, 원인은 평가 루프가 채점 LLM을 동기 HTTP로 직접 호출해 프로세스의 이벤트 루프를 5~40초씩 멈춘 것이었습니다. 채점 호출을 별도 스레드로 옮기자 평가 런 세 개를 동시에 실행한 같은 조건에서 문항당 시간이 54초에서 16.4초가 됐습니다. 노드가 남긴 시간만 보면 이 지연은 보이지 않았습니다. 검색 지연을 볼 때 먼저 확인할 것은 검색 단계가 아니라 합성 입력의 크기와 생성 시간이라는 점이 반복해서 드러난 셈입니다.

돌아보면 첫 설계의 실수는 "총시간을 보장한다"는 목표를 가장 비싸고 가장 중요한 단계에까지 적용한 것이었습니다. 상한은 비용을 예측할 수 없는 곳에 걸고, 결과물 자체인 생성은 완주시키되 기다림을 사용자에게 보이게 하는 편이 맞았습니다. 그리고 시간 초과를 숨기려고 만든 폴백이 가장 위험한 경로였습니다. 실패 경로는 성공 경로보다 덜 검증되기 쉬운데, 그 경로가 내부 텍스트를 사용자에게 내보내는 구조였습니다.

남은 것도 있습니다. 관리 설정과 Redis 캐시의 동기화는 플랫폼 공통 인프라 과제로 분리한 채 남겨 두었고, 로컬에서는 Redis 값을 수동으로 맞춰 우회했습니다. 코어 설정의 기본 모델 값과 선택 목록도 이미 없어진 모델을 가리키고 있어, 별도의 작은 수정 후보로 기록했습니다. 스트리밍과 문자 예산의 효과는 변경 시점의 실측 표로 확인했을 뿐 별도의 회귀 테스트나 스크립트는 두지 않았습니다. 첫 토큰 대기 상한과 사용량 집계처럼 조용히 실패하는 지점은 관측 로그로만 지켜보고 있습니다.