1. 배경: 분석 품질과 응답 시간의 충돌
KT Enterprise의 B2B 영업 조직을 위해, 영업 대상 기업의 정보를 자동으로 수집·분석해 Account Plan 초안을 만들어주는 파이프라인을 개발하고 있습니다. 공시(DART)·조달(나라장터)·뉴스처럼 서로 다른 출처에서 데이터를 모아 여러 관점으로 분석한 뒤 하나의 문서로 묶어내는 구조입니다.
분석은 하나의 거대한 프롬프트로 하지 않고 관점별로 나눴습니다. 재무를 보는 관점, 사업 동향을 보는 관점, 조달 이력을 보는 관점처럼 성격이 다른 분석 8종을 각각 별도 에이전트로 만들었습니다.
이유는 품질 때문이었습니다. 하나의 프롬프트에 "이것도 보고 저것도 보고 그것도 봐라"라고 시키면, 모델이 앞부분에 집중하고 뒷부분은 대충 훑는 현상이 있었습니다. 관점을 분리하니 각각의 결과가 훨씬 충실해졌습니다.
다만 다 만들고 실행해보니 2분이 넘게 걸렸습니다.
2. 문제 정의: 순차 실행의 지연 누적
사용자가 버튼을 누르고 2분을 대기하는 것은 실질적으로 사용 불가능한 기능입니다. 게다가 이는 한 회사에 대한 처리 시간입니다.
원인은 단순했습니다. 8개 에이전트를 순서대로 돌리고 있었습니다.
에이전트1 (15초) → 에이전트2 (18초) → 에이전트3 (14초) → ... → 에이전트8
총 소요: 각 에이전트 시간의 합 = 2분+각 에이전트가 LLM을 호출하고 응답을 기다리는데, 그 시간이 그대로 누적되고 있었습니다.
3. 기술적 난점
문제 자체는 명백했는데, 병렬로 바꾸려고 보니 확인할 게 있었습니다. 정말 순서가 상관없나?
병렬화가 안전한지는 에이전트들이 서로 의존하는지에 달려 있습니다. 만약 3번 에이전트가 1번의 결과를 참고해야 한다면 순서를 깰 수 없습니다.
다행히 이 부분은 걱정할 필요가 없었습니다. 설계 단계에서 각 에이전트를 이렇게 만들어뒀기 때문입니다.
- 역할이 격리됨 각자 자기 관점만 봅니다
- 프롬프트가 독립적 다른 에이전트의 프롬프트를 참조하지 않습니다
- 출력 스키마가 따로 정의됨 각자 정해진 JSON 형태로만 반환합니다
- 입력이 동일함 모두 같은 수집 데이터를 봅니다
8개 에이전트는 같은 원본을 보고 각자 다른 관점으로 해석하는 구조였습니다. 서로 주고받는 게 없으니 순서가 의미 없었습니다.
돌아보면 이는 운이 아니라, 관점을 분리한 결정이 만든 결과였습니다. 품질을 위해 나눴던 것이 성능 최적화의 전제조건이 되어 있었습니다.
4. 대안 검토
1안. 에이전트 수를 줄여서 합친다
8개를 3~4개로 묶는 방식입니다.
- 장점: 호출 수가 줄어 확실히 빨라집니다.
- 단점: 애초에 나눈 이유를 되돌리는 것입니다. 합치면 분석 품질이 다시 떨어집니다. 속도를 위해 결과물의 가치를 포기하는 셈이라 받아들이기 어려웠습니다.
2안. 비동기(async)로 전환합니다
I/O 대기를 이벤트 루프로 처리하는 방식입니다.
- 장점: LLM 호출은 대부분 네트워크 대기라서 async와 잘 맞습니다. 리소스 효율이 좋습니다.
- 단점: 파이프라인 전체를 async로 바꿔야 하는데, 기존 동기 코드와 섞이는 지점에서 손이 많이 갑니다.
3안. 스레드 풀로 병렬 실행합니다
ThreadPoolExecutor로 8개를 동시에 던지는 방식입니다.
- 장점: 기존 코드를 거의 안 고치고 적용됩니다. LLM 호출은 I/O 대기라 GIL의 영향을 받지 않습니다.
- 단점: 동시 호출이 늘어 API 레이트 리밋에 걸릴 수 있습니다.
5. 설계 결정 및 근거
3안, 스레드 풀을 골랐습니다.
핵심 판단은 이것이었습니다. 이 작업은 CPU를 쓰는 게 아니라 기다리는 작업입니다. 8개 에이전트가 하는 일의 대부분은 LLM에 요청을 보내고 응답을 기다리는 것입니다. 파이썬의 GIL은 CPU 연산에서 병목이 되지만, I/O 대기 중에는 GIL이 풀립니다. 그래서 스레드만으로도 충분히 동시에 기다릴 수 있었습니다.
2안(async)이 이론적으로 더 우아하지만, 얻는 이득 대비 바꿔야 할 코드가 많았습니다. 8개 정도의 동시 실행에서는 스레드 풀로도 같은 효과가 나왔습니다. 더 나은 도구보다 지금 문제에 충분한 도구를 골랐습니다.
1안은 처음부터 고려하지 않았습니다. 속도 문제를 품질을 깎아서 해결하는 건 순서가 틀렸다고 봤습니다.
실패 처리 설계
병렬로 바꾸면서 실패 처리를 신경 썼습니다. 8개를 동시에 던지면 그중 하나가 실패할 확률도 그만큼 늘어납니다.
하나가 죽어도 전체가 죽지 않게 · 했습니다. 외부 API가 타임아웃이 나거나 오류를 뱉으면, 오류 처리와 재시도 노드를 거치도록 워크플로에 결합했습니다. 한 관점의 분석이 실패해도 나머지 7개 결과로 문서를 만들 수 있게 한 것입니다. 전부 성공해야만 결과가 나오는 구조였다면 병렬화가 오히려 실패율을 높였을 것입니다.
LLM 공급자 장애에 대비해 대체 공급자로 자동 전환되는 폴백도 넣었습니다.
6. 실험 결과
| 측정 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 일괄 생성 지연 | 2분대 (순차) | 15.7초 |
| 단축률 | - | 87%↓ |
| 분석 관점 수 | 8종 | 8종 (유지) |
품질을 깎지 않고 속도만 87% 줄였습니다. 병렬 실행에서는 전체 시간이 합계가 아니라 가장 느린 하나에 수렴하기 때문에, 에이전트를 더 추가해도 전체 시간이 크게 늘지 않는 구조가 됐습니다. 관점을 늘릴 여지가 생긴 것입니다.
비용도 함께 잡았습니다
속도를 잡고 나니 호출 비용이 눈에 들어왔습니다. 화면을 열 때마다 생성이 돌면 같은 결과를 반복 생성하며 과금이 누적됩니다. 이에 따라 세 가지를 적용했습니다.
- 조회 경로에서는 LLM을 호출하지 않도록 차단, 이미 생성된 결과를 읽기만 합니다
- 스케줄러 기반 생성 화면 갱신이 아니라 정해진 주기에만 생성합니다
- 영구 번역 캐시 한 번 번역한 것은 다시 호출하지 않다
생성 시점과 조회 시점을 분리한 것이 핵심이었습니다.
7. 결론 및 시사점
병렬화는 실행의 문아니라 설계의 문제입니다. ThreadPoolExecutor로 바꾼 작업 자체는 몇 줄이었습니다. 진짜 작업은 그 전에 있었습니다. 에이전트를 역할·프롬프트·출력 스키마가 격리되도록 설계해뒀기 때문에 병렬화가 안전했습니다. 만약 에이전트들이 서로의 결과를 참조하는 구조였다면 이 최적화는 불가능했을 것입니다.
모듈이 독립적이면 나중에 선택지가 생깁니다. 품질 때문에 관점을 나눴는데, 그 결정이 나중에 성능 카드로 돌아왔습니다. 반대로 처음부터 하나로 합쳐뒀다면 품질도 낮고 병렬화도 못 하는 상태였을 겁니다. 결합도를 낮춰두면 그때는 몰랐던 방식으로 이득을 봅니다.
동시 실행을 늘리면 실패 처리를 함께 설계해야 합니다. 8개를 동시에 던진다는 건 실패 지점이 8개라는 뜻이기도 합니다. "하나 실패하면 그것만 빼고 진행"이라는 규칙을 정해두지 않았다면, 빨라진 대신 자주 실패하는 파이프라인이 됐을 것입니다.
