
Perplexity 스타일의 Q&A 에이전트를 개인 개발 앱에 통합 — 스케일링 시 실행 모델을 어떻게 변경할 것인가
요약
Perplexity 스타일의 Q&A 에이전트를 개인 앱에 통합하며 겪은 아키텍처 설계와 스케일링 전략을 다룹니다. 현재의 단일 워커 구조에서 발생할 수 있는 한계를 분석하고, 사용자 증가에 따른 리트라이 및 폴백 설계의 필요성을 설명합니다.
핵심 포인트
- 6단계 공정(세이프티 체크부터 답변 생성까지)을 통한 에이전트 워크플로우 설계
- 현재는 단일 워커 태스크 내 연속 함수 호출로 구현된 심플한 구조
- 스케일링 시 공정별 실패 패턴에 따른 차별화된 리트라이 및 폴백 전략 필요
- 중간 결과의 영속화 및 단계별 재개 가능성 확보가 핵심 과제
서론
외부 웹 검색과 애플리케이션 내에서 사전에 AI 분석을 마친 독자적인 내부 기사를 결합하여, 출처가 포함된 답변을 반환하는 — 그런 "Perplexity 스타일의 Q&A 에이전트"를, 개인 개발 중인 해외 테크 뉴스 수집·분석 앱 『Vector』에 통합했습니다.
전편에서는 이 에이전트를 개발하면서 고안한 공정별 설계에 대해 작성했습니다.
한편, 그 실행 모델은 현재의 운영 규모에 맞춰 상당히 타협한 설계로 되어 있습니다.
이 기사에서는 사용자가 늘어나 스케일링(Scaling)될 때, 아키텍처를 어떻게 변경해야 하는지에 대해 고찰해 보겠습니다.
소스 코드도 공개하고 있으니, 봐주시면 감사하겠습니다.
GitHub 리포지토리: https://github.com/yook11/Vector -
실제 서비스 사이트: https://vectorbrief.online
(※ 실제 동작을 확인하실 수 있지만, 사용자 등록 및 이용은 초대제로 운영됩니다)
전제: 현재 에이전트의 구성
현재는 초대제 애플리케이션으로 운영하고 있으며, 사용자당 하루 10회까지의 실행 제한을 두고 있습니다.
사용자의 질문은 백그라운드의 worker에서 비동기(Asynchronous)로 처리되며, 다음 6가지 공정을 순서대로 거칩니다.
그 진행 상황(현재 공정이나 검색 중인 키워드) 및 생성 중인 답변 텍스트는 Redis Streams(본업은 ElastiCache Valkey)와 SSE를 통해 브라우저로 순차적으로 스트리밍(Streaming)되는 구조입니다.
세이프티 체크 (Safety Check) — 입력의 안전성을 판정하는 공정. 정책 위반이 없는지 사전에 체크하여 부정한 요청을 조기에 차단합니다. -
컨텍스트 정리 (Context Organization) — 문맥을 보충하는 공정. 과거의 대화 이력으로부터 "사용자가 정말로 알고 싶은 것(질문 문장)"과 답변 조건을 추출 및 재구축합니다. -
플래닝 (Planning) — 조사 방침을 결정하는 공정. 검색 필요 여부를 판단하고, 필요한 경우 검색 쿼리와 조사 목표를 구성합니다. -
에비던스 수집 (Evidence Collection) — 근거 정보를 모으는 공정. 내부 데이터베이스와 외부 웹 검색을 사용하여 답변에 필요한 정보를 병렬로 수집합니다. -
에비던스 리뷰 (Evidence Review) — 수집한 정보를 정밀 조사하는 공정. 수집한 정보에서 유효한 근거만을 선별하고, 부족한 정보가 없는지 특정합니다. -
답변 생성 (Answer Generation) — 최종 답변을 작성하는 공정. 정밀 조사된 근거를 바탕으로, 근거 링크(인용)를 명시한 Markdown 형식의 답변을 생성합니다.
실행 모델
이 6가지 공정은 세밀하게 큐(Queue)로 나누지 않고, 모두 하나의 worker 태스크 내의 연속된 함수 호출로서 구현되어 있습니다.
상태 전달은 메모리 상에서 완결되는 심플한 설계입니다.
현재 아키텍처의 제약 사항은 다음과 같습니다.
1 run = 1 worker 태스크 (큐에 들어가는 것은 최초의 기동 트리거 1건뿐임) -
중간 결과는 메모리 상에만 존재 (각 공정에서 생성한 플랜이나 채택된 에비던스 등을 저장·영속화하는 메커니즘이 없음) -
도중에 실패하면 "모두 처음부터 다시" (도중에 실패했을 경우, 거기서부터 재개할 수 없으며 run 단위로 실패 처리됨)
도중에 실패했을 때의 처리 (리트라이 설계)
사용자가 늘어나면 가장 먼저 직면하게 되는 것이 에러 발생 시의 리트라이(Retry) 설계입니다.
여기서 중요한 점은, 공정마다 실행하는 처리가 다르며 그에 따라 실패의 성질도 달라진다는 점입니다.
공정별로 "몇 번 다시 시도할 것인가"뿐만 아니라, "재시도해도 실패했을 때 최종적으로 어떻게 폴백(Fallback)할 것인가"까지 결정해야 합니다.
| 실패 패턴 | 대응 방침 (설계 포인트) |
|---|---|
| 안전성 판정 불능 (에러 또는 타임아웃) | 횟수 상한을 정하여 재시도. 그래도 판정할 수 없다면 안전한 방향으로 기울여 거부(에러 종료)한다 |
| LLM의 출력 에러 (JSON 깨짐·내용 모순 등) | 에러의 종류(포맷 위반인지, 로직의 미비인지)에 따라 리트라이 방법을 변경한다 |
| 외부 API의 통신·일시적 에러 (타임아웃·과부하 등) | 짧은 시간 대기 후 재시도할지, 아니면 해당 검색을 스킵하고 진행할지(폴백)를 판단한다 |
각각의 실패에 대해 어떻게 대처해야 하는지 정리합니다.
1. 안전 판정을 할 수 없을 때 (페일 세이프)
세이프티 체크는 판정하지 못한 채로 후속 공정으로 진행해서는 안 되는 공정입니다.
다른 공정에서는 '에러가 발생해도 전체를 멈추지 않고 진행한다'는 방침을 취하고 있지만, 여기만은 반대로 '판정이 불가능하면 후속으로 넘기지 않고 처리를 멈춰야' 합니다. '재시도 상한에 도달했으니 일단 통과시킨다'는 것은 무검사로 통과시키는 것과 같아져 버리기 때문입니다.
2. LLM의 출력이 부정확할 때
LLM의 출력이 항상 지정된 스키마를 따르는 것은 아니므로, 예상치 못한 응답에 대한 에러 핸들링은 필수적입니다.
이때, 에러의 '종류'에 따라 적절한 재시도 방침은 달라집니다.
- 스키마(JSON 등)가 손상된 경우:
포맷의 불비는 단순 재시도로 복구할 수도 있는 경우가 많으므로, 허용 가능한 비용이나 대기 시간 범위 내에서 재시도를 실행하는 것이 효과적입니다. -
구조는 맞지만 내용이 모순되는 경우:
(예: '검색 불필요'로 판단했는데 검색 태스크가 포함되어 있거나, 존재하지 않는 출처 번호를 인용하는 등)
이 경우에는 직전의 에러 내용을 프롬프트에 피드백하여, 실패 이유를 고려한 후 재시도시키는 접근 방식이 고려될 수 있습니다.
또한, 단순히 그 자리에서 에러를 재시도할 뿐만 아니라, LLM의 응답을 모니터링하고 평가하여 프롬프트를 조정하거나 지속적인 개선으로 연결하는 메커니즘 역시 중요하다고 생각합니다.
3. 외부 API가 일시적으로 실패했을 때 (UX와의 트레이드오프)
외부 검색 API의 에러에 대해서도 적절한 분리가 필요합니다.
타임아웃이나 500번대 에러, 레이트 리미트 등은 재시도로 복구될 가능성이 있지만, 인증 에러(401/403)나 요청 부적정(400)은 몇 번을 시도해도 해소되지 않습니다.
외부 검색이 도저히 성공하지 못하는 경우, '내부의 스톡 정보만으로 답변을 생성한다'는 폴백(fallback)도 선택지가 됩니다.
여기서 중요해지는 것이, '본 앱이 대화형 채팅 UI이다'라는 제약입니다.
백그라운드에서의 재시도 시간은 그대로 '사용자를 기다리게 하는 시간'에 직결됩니다.
재시도 방침은 기능 단위로 결정하는 것이 아니라, '시스템 전체로서 사용자를 어디까지 기다리게 할 수 있는지(허용 대기 시간)'라는 제약과 세트로 설계해야 합니다.
완료된 공정의 성과를 재활용하기
현재 구성에는 또 하나의 큰 과제가 있습니다.
그것은, '도중에 에러가 발생했을 경우, 그때까지 완료했던 공정의 성과도 모두 버려진다'는 현재 사양입니다.
인메모리(in-memory)로 공정 간 상태를 전달하고 있기 때문에, 도중에 실패하면 모든 공정이 처음부터 다시 시작됩니다.
여기서 가장 큰 문제는 비용 낭비입니다.
이미 성공했던 공정의 LLM이나 검색 API 요금을 또다시 지불해야 하게 됩니다.
따라서, 실패했을 때 '도중에 진행된 상태에서 처리를 재개할 수 있는' 메커니즘의 중요성을 강하게 느낍니다.
재개를 위해 필요한 3가지 요소
도중부터 재개하려면 다음 3가지가 필요합니다.
공정별 성과물을 저장하는 것공정이 어디까지 완료되었는지 기록하는 것완료된 공정을 이어받아 재개하는 것
가장 큰 문제는 1번의 '성과물'로, 이는 현재 어느 곳에도 저장되어 있지 않습니다.
공정 간 데이터 전달을 모두 메모리 상에서 진행하고 있기 때문에, 프로세스가 다운되면 중간 성과물은 모두 소실됩니다.
취할 수 있는 접근 방식은 다음과 같습니다.
- 메모리 계층에서의 데이터 보장 수준 높이기 — 중간 결과를 worker 프로세스의 외부에 있는 인메모리(in-memory) 데이터 스토어로 옮기고, 레플리케이션(replication, 복제)이나 디스크 쓰기 설정으로 보장 수준을 높이는 접근 방식입니다. 다운되었을 때 손실될 가능성을 0으로 만들 수는 없지만, 어디까지 허용할지 조정할 수 있습니다. 다만, 레플리카를 늘리면 그만큼 메모리를 가진 노드가 늘어나고, 쓰기 완료를 기다리도록 설정하면 대기 시간도 길어집니다. 특히 메모리는 디스크에 비해 단가가 높기 때문에, 남기는 데이터가 많아질수록 운영 비용으로 작용합니다. - DB(Postgres)에 영구 저장하기 — 프로세스와 완전히 독립적으로 데이터를 남기는 접근 방식입니다. 내구성을 확보하기 쉽다는 장점이 있는 반면, 공정마다 DB로 쓰기 레이턴시(latency, 지연)를 지불하게 됩니다.
현 시점에서는 2번째의 'DB에 기록하는' 접근 방식이 가장 간단하다고 생각합니다.
다만, 실제로 채택할 수 있을지는 다음 2가지를 측정 및 검증하여 판단해야 합니다.
- 처리 속도에 미치는 영향: 지금까지 메모리상에서 전달하던 중간 데이터를 DB 쓰기로 대체함으로써, 1 run 전체의 처리 시간에 어느 정도 영향을 주는지 확인할 필요가 있습니다.
- 커넥션 풀 (Connection Pool)과의 관계: 공정의 경계마다 쓰기 작업을 삽입하기 때문에 DB 접속 빈도가 증가합니다. 사용자가 늘어나 동시에 실행되는 run이 증가했을 때, 커넥션 풀의 상한에 여유를 유지할 수 있는지, 또한 DB 부하 및 비용 증가가 허용 범위 내에 들어오는지 확인할 필요가 있습니다.
숨겨진 대가: 동일한 run이 두 번 실행될 가능성
또 하나 잊어서는 안 될 트레이드오프 (Trade-off)가 있습니다. 중간부터 재개할 수 있도록 한다는 것은, "동일한 run이 두 번 실행될" 가능성을 받아들인다는 것입니다.
프로세스가 중단된 run을 재개하려면, 해당 run을 다시 실행해야 합니다.
접근 방식으로는 메시지 큐 (Message Queue) 측의 재시도 (Retry)를 이용하거나, 중단된 run을 앱 측에서 감지하여 다시 투입하는 방법 등을 생각할 수 있습니다.
다만, 어떤 방법을 선택하더라도 동일한 벽에 부딪히게 됩니다.
"정말로 중단된 run"과 "살아있지만 응답이 늦어지고 있을 뿐인 run"을 확실하게 구분할 방법이 없다는 점입니다.
그렇게 되면 다음과 같은 설계가 새로 필요해집니다.
- 외부 API의 이중 호출: 2개의 run이 동시에 동작하면, LLM이나 검색 API도 그만큼 추가로 호출됩니다. 이는 API 비용의 이중 발생뿐만 아니라, 프로바이더의 레이트 리밋 (Rate Limit)에도 영향을 미칩니다.
- SSE의 이중 전달: 2개가 동일한 run의 스트림 (Stream)에 쓰게 되면, 서로 다른 생성 결과가 화면에 전달되어 텍스트가 섞여서 표시됩니다.
- 중간 결과의 이중 쓰기: 어느 run에 의한 쓰기를 정답으로 할지 정하고, 이전 세대에서 늦게 도착한 쓰기 작업을 차단하는 메커니즘이 필요해집니다.
즉, "재개 가능한 구성"을 선택한다는 것은 "동일한 run은 실질적으로 1개만 실행된다"는 현재의 단순함을 포기하는 것이기도 합니다.
현재도 메시지 큐의 배송 자체는 At-least-once (최소 1회 배송) 방식이지만, 재배송까지의 유예 시간(10분)이 태스크 (Task)의 최대 타임아웃 (Timeout, 3분)에 비해 충분히 길게 설정되어 있습니다. 따라서 설령 동일한 메시지가 재배송되더라도, 이미 완료된 run은 실행하지 않고 스킵 (Skip)되는 구조입니다.
그렇기 때문에 2개가 동시에 실행될 가능성은 극도로 낮습니다.
하지만 이는 상태에 의한 배타적 제어가 아니라 시간적 여유로 성립하고 있는 것일 뿐이며, 재개를 전제로 하면 이 전제 자체가 무너집니다.
향후 스케일 (Scale)에 맞춰 "중간 재개가 가능한 구성"으로 이행한다면, "이중 실행에 따른 API 비용의 이중화"에 대한 대책과 "상태 관리 및 배타 제어"와 같은 복잡한 구현 도입에 힘써야 합니다.
실행 단위의 분할: 태스크의 성격에 따라 큐를 나눌 것인가
현재는 6개의 공정 모두를 하나의 워커 태스크 (Worker Task) 안에서 순차적으로 실행하고 있습니다.
이는 성격이 다른 공정들이 동일한 큐와 실행 환경에서 동작하고 있음을 의미합니다.
모든 공정에 "동일한 타임아웃", "동일한 네트워크 환경", "동일한 워커의 실행 프레임"이 일률적으로 할당되기 때문에 다음과 같은 문제가 발생합니다.
- 특정 태스크의 정체가 다른 태스크를 휘말리게 함: 외부 API의 지연 등으로 처리가 길어지면, 해당 run은 워커의 동시 실행 프레임을 계속 점유합니다. 프레임이 모두 차게 되면, 나중에 도착한 run은 프레임이 빌 때까지 대기해야 합니다.
- 필요한 부분만 스케일링할 수 없음: "외부 검색이 따라가지 못하니 그 공정의 워커만 늘린다"는 식의 대응이 불가능합니다. 스케일링의 단위가 run 전체가 되어 버립니다.
- 신뢰 경계와 리소스를 공유하게 됨: run 전체가 하나의 워커 프로세스 안에서 동작하기 때문에, API 키나 DB 접속 정보, 네트워크 도달 범위 등을 해당 프로세스가 일괄적으로 보유하게 됩니다. 모든 태스크가 동일한 권한으로 동작하므로, 문제가 발생했을 때 영향 범위를 공정 단위로 격리할 수 없습니다.
언제 분할을 결단해야 하는가?
여기서 분할했을 때의 장점과 단점을 정리한 후, "그렇다면 어느 타이밍에 분할을 결단해야 하는가?"에 대해 생각해 보겠습니다.
분할하는 장점 (얻을 수 있는 것)
- 단위별 독립적 제어: 태스크의 성격에 맞춰 타임아웃 (Timeout), 재시도 (Retry) 전략, 병렬도를 최적화할 수 있다.
- 정체 시 영향 범위 억제: 특정 태스크가 막히더라도 전용 워커 (Worker)를 할당해 두면 다른 태스크를 휘말리게 하지 않는다.
- 필요한 권한만 부여 가능: 공정마다 해당 처리에 필요한 API 키나 접속 대상만을 전달할 수 있다. 제3자의 콘텐츠를 수집하는 태스크를 분리해 두면, 그곳에서 문제가 발생하더라도 다른 공정에 미치는 영향을 억제할 수 있다.
분할하는 단점 (잃는 것)
- 레이턴시 (Latency) 증가: 큐 (Queue)를 넘을 때마다 메시지의 인큐 (Enqueue)와 다음 워커로의 전달이 발생합니다. 이 왕복 처리 자체는 LLM 호출 시간에 비하면 무시할 수 있는 수준이지만, 문제가 되는 것은 "다음 워커에 여유가 없을 경우의 대기 시간"입니다.
현재는 하나의 run이 한 번 실행 프레임을 확보하면 처음부터 끝까지 단번에 완료할 수 있지만, 분할하면 공정의 경계마다 매번 워커의 여유를 기다리는 상황이 발생할 가능성이 있습니다. -
- 제어의 복잡화: 지금은 하나의 태스크 내에서 보호되고 있는 "실행 취소"나 "중복 실행 방지 (배타 제어)"를, 분산된 각 태스크에서 협조하여 지켜야 할 필요가 생깁니다. -
- 태스크 상실 리스크: 이전 태스크가 완료되고 다음 태스크를 큐에 넣는 찰나의 순간에 프로세스가 정지하면, 처리가 어느 큐에도 존재하지 않는 "유실 (Lost) 상태"가 됩니다. 이를 감지하고 회복하는 메커니즘이 새로 필요합니다.
지금까지의 정리를 바탕으로, 분할을 결단하는 시점은 "특정 태스크의 문제가 run 전체를 휘말리게 하고 있다"고 확인된 단계라고 생각합니다.
- 특정 태스크만 시간이 오래 걸림: 워커를 점유하여 다른 run의 대기를 발생시키고 있음 -
- 특정 태스크만 막힘: 해당 공정만을 독립적으로 스케일링 (Scaling) 하고 싶은 상태가 됨 -
- 특정 태스크만 실패가 많음: 재시도 (Retry)의 연쇄로 인해 다른 정상적인 공정까지 휘말리고 있음
이러한 실질적인 피해가 없는 상태에서 분할하면, 단점이 더 커질 것이라고 생각합니다.
어디서 분할해야 하는가?
분할한다고 하더라도, 단순히 공정별로 나누기만 하면 되는 것은 아닙니다.
태스크의 특성을 파악한 뒤, 어디에 경계를 그을지 결정할 필요가 있다고 생각합니다.
이 애플리케이션에서는 태스크 성격의 차이가 어디에 있는지 살펴보겠습니다.
유일하게 성격이 확연히 다른 것은 외부 웹 검색 API를 호출하는 "에비던스 (Evidence) 수집"입니다.
다른 공정이 "프롬프트 (Prompt)를 모델로 보내고, 정해진 형식의 응답을 받는" 것과 달리, 이 공정은 "검색 API를 호출하여 제3자가 작성한 콘텐츠를 가져오는" 처리입니다.
실패 원인 또한 LLM의 출력 불량이라기보다 "상대방의 API가 응답하지 않음"과 같은 네트워크 너머의 장애가 중심이 됩니다.
이 애플리케이션에서 분할한다면, 에비던스 수집과 그 외의 LLM 공정 사이에 경계를 그어 성격이 다른 에비던스 수집을 분리해 내는 형태가 될 것이라고 생각합니다.
마치며
여기까지 읽어주셔서 감사합니다.
기사를 작성하며 사고를 정리함으로써 저 자신에게도 많은 깨달음이 있었습니다.
재시도 (Retry) 설계, 중간 결과물 유지, 실행 단위 (워커의 경계)를 나누는 방법 등 마주해야 할 과제는 여전히 많이 남아 있습니다.
앞으로는 근본적인 데이터 구조 등에 대해서도 다시 학습할 필요가 있다고 느끼고 있습니다.
앞으로는 근본적인 데이터 구조나 분산 시스템 (Distributed System)의 기초에 대해서도 다시금 학습을 심화할 필요성을 실감하고 있습니다.
이번에 확인된 과제와 지식은 향후 앱 개발에도 적극적으로 활용할 계획입니다. 계속해서 직접 손을 움직이며 시행착오를 이어가겠습니다.
의견이나 피드백, 조언 등을 주시면 큰 힘이 됩니다!
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기