- Published on
Pinecone 업서트 429·타임아웃, 배치·재시도 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Pinecone에 벡터를 대량 업서트(upsert)하다 보면 두 가지가 가장 자주 발목을 잡습니다. 하나는 429(rate limit / too many requests)이고, 다른 하나는 네트워크·서버 처리 지연으로 인한 타임아웃입니다. 문제는 둘이 단독으로 오지 않는다는 점입니다. 타임아웃이 늘면 재시도가 폭증하고, 재시도가 폭증하면 429가 더 자주 터지며, 결국 적재 파이프라인이 스스로를 공격하는 형태가 됩니다.
이 글은 “일단 재시도” 수준을 넘어, 배치 크기, 동시성, 재시도 정책(지수 백오프·지터), 부분 실패 처리, **관측(Observability)**까지 포함해 Pinecone 업서트를 안정화하는 실전 설계를 다룹니다.
관련 사고 패턴(타임아웃/데드라인)과 재시도 설계는 아래 글도 함께 보면 연결이 잘 됩니다.
429·타임아웃이 나는 구조적 이유
1) 업서트는 네트워크 + 인덱스 쓰기 비용
업서트는 단순 HTTP 호출이 아니라, 서버 측에서 벡터 저장 + 메타데이터 저장 + 인덱스 갱신 비용을 동반합니다. 특히 다음 조건이 겹치면 지연이 급격히 증가합니다.
- 벡터 차원(dimension)이 큼
- 메타데이터가 크거나 필드 수가 많음
- 한 요청에 담는 벡터 수가 많음(배치 과대)
- 동시 요청 수가 많음(동시성 과대)
- 네임스페이스가 많아 분산이 깨짐(핫 파티션)
지연이 증가하면 클라이언트 타임아웃이 발생하고, 타임아웃 재시도는 다시 서버 부하를 올려 429를 유발합니다.
2) 429는 “서버가 바쁘니 천천히”라는 신호
429는 단순 오류가 아니라 스로틀링(throttling) 신호입니다. 즉, “지금 속도로 계속 보내면 더 망가진다”는 의미이므로, 재시도는 반드시 속도 제어와 결합해야 합니다.
3) 타임아웃은 실패가 아닐 수도 있다
타임아웃은 “서버가 처리 실패”가 아니라 “클라이언트가 응답을 못 받음”일 수 있습니다. 업서트는 보통 idempotent하게 설계할 수 있지만(동일 id면 덮어쓰기), 다음을 조심해야 합니다.
- 업서트가 실제로는 성공했는데 재시도로 중복 트래픽 증가
- 부분 성공/부분 실패가 섞였는데 전체를 재시도해 불필요한 부하 증가
따라서 부분 실패를 분리하고, 재시도는 실패한 항목만 대상으로 해야 합니다.
목표: “안정적인 처리량”을 만드는 4가지 레버
업서트 안정화는 아래 4개 레버를 함께 조정하는 일입니다.
- 배치 크기(한 요청에 몇 개 벡터)
- 동시성(동시에 몇 요청)
- 재시도(어떤 조건에서, 얼마나, 어떤 간격으로)
- 속도 제한(토큰 버킷/리키 버킷 등)
핵심은 “최대 처리량”이 아니라 지속 가능한 처리량입니다.
배치 전략: 크게 보낼수록 좋은가?
권장 접근: 작은 배치로 시작해 점진적으로 늘리기
초기값을 정할 때는 다음 원칙이 안전합니다.
- 배치 크기:
50~200정도로 시작 - 동시성:
1~4로 시작 - 성공률이
99%이상 유지되면 배치 또는 동시성을 조금씩 증가
왜냐하면 큰 배치는 요청 수를 줄여주는 장점이 있지만, 한 번 실패했을 때 재시도 비용이 폭발하고, 타임아웃 시 **불확실성(성공했는지 실패했는지)**이 커지기 때문입니다.
“배치 과대”의 전형적 증상
- 평균 지연은 괜찮은데
p95/p99가 급증 - 간헐적으로 타임아웃이 터지고, 이후
429가 연쇄 발생 - 실패 시 재시도 트래픽이 순간적으로 몰림(스파이크)
이 경우 배치를 줄이고, 대신 동시성을 약간 늘리는 편이 더 안정적일 때가 많습니다.
재시도 설계: 지수 백오프 + 지터는 기본
재시도 대상 분류
업서트에서 재시도는 모두에게 같은 정책을 적용하면 망합니다. 최소한 아래처럼 나눕니다.
429: 반드시 재시도하되, 백오프를 크게 + 동시성/속도를 낮추는 피드백 필요408/504또는 타임아웃: 재시도 가능. 단, 배치를 쪼개는 전략이 효과적5xx: 재시도 가능. 다만 연속 발생 시 서킷 브레이커 고려4xx중429가 아닌 것: 대개 입력 문제(메타데이터 타입, 크기, 필수 필드 등)라서 재시도 금지
지수 백오프(Exponential Backoff) + 지터(Jitter)
동시에 여러 워커가 재시도하면 “재시도 폭풍(thundering herd)”이 생깁니다. 지터가 없으면 모두 같은 간격으로 다시 때려 429를 더 악화시킵니다.
- 기본 백오프:
base * 2^attempt - 최대 대기: 예를 들어
30s또는60s - 지터:
full jitter(0~backoff 랜덤) 또는equal jitter
재시도 횟수와 실패 처리
429: 최대8~10회까지도 의미가 있을 수 있음(트래픽 상황에 따라)- 타임아웃/
5xx:3~6회 정도에서 멈추고, 실패 레코드를 별도 큐에 격리
“무한 재시도”는 결국 장애를 영구화합니다. 실패를 남기고 다음 배치로 넘어가는 설계가 필요합니다.
부분 실패 처리: 실패한 벡터만 재시도하기
Pinecone 클라이언트/SDK에 따라 응답 형태가 다를 수 있지만, 개념적으로는 아래처럼 처리해야 합니다.
- 배치 업서트 요청
- 결과에서 실패한 항목만 추출
- 실패 원인별로 분기(
429면 대기 후 재시도,4xx면 드롭/수정) - 타임아웃이면 배치를 반으로 쪼개 재시도
이 방식은 “전체 배치 재시도” 대비 트래픽과 비용을 크게 줄입니다.
구현 예시 (Node.js/TypeScript): 배치 + 동시성 + 재시도
아래 코드는 Pinecone 업서트를 할 때 자주 쓰는 형태를 의도적으로 단순화해 보여줍니다.
- 배치 처리
- 제한된 동시성
429/타임아웃/5xx재시도- 타임아웃이면 배치 분할
주의: Next.js MDX 빌드에서 < >가 노출되면 에러가 날 수 있으므로, 제네릭/비교 연산자 등은 모두 인라인 코드로 감싸거나 엔티티로 치환해야 합니다.
import pLimit from "p-limit";
type VectorRecord = {
id: string;
values: number[];
metadata?: Record<string, unknown>;
};
type UpsertFn = (batch: VectorRecord[]) => Promise<void>;
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function isRetryable(err: any) {
const status = err?.status ?? err?.response?.status;
if (status === 429) return true;
if (status >= 500 && status <= 599) return true;
// SDK에 따라 타임아웃 에러 형태가 다릅니다.
const msg = String(err?.message ?? "");
if (msg.includes("timeout") || msg.includes("ETIMEDOUT")) return true;
return false;
}
function isRateLimited(err: any) {
const status = err?.status ?? err?.response?.status;
return status === 429;
}
function backoffWithJitter(attempt: number, baseMs: number, capMs: number) {
const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * exp); // full jitter
return jitter;
}
async function upsertWithRetry(
upsert: UpsertFn,
batch: VectorRecord[],
opts: {
maxRetries: number;
baseBackoffMs: number;
maxBackoffMs: number;
timeoutSplitThreshold?: number;
}
): Promise<void> {
let attempt = 0;
let currentBatch = batch;
while (true) {
try {
await upsert(currentBatch);
return;
} catch (err: any) {
if (!isRetryable(err) || attempt >= opts.maxRetries) {
throw err;
}
// 타임아웃이면 배치를 쪼개서 재시도(큰 배치가 원인인 경우가 많음)
const msg = String(err?.message ?? "");
const isTimeout = msg.includes("timeout") || msg.includes("ETIMEDOUT");
if (isTimeout && currentBatch.length >= 2) {
const mid = Math.floor(currentBatch.length / 2);
const left = currentBatch.slice(0, mid);
const right = currentBatch.slice(mid);
// 왼쪽/오른쪽을 순차로 처리(동시로 하면 다시 부하를 키울 수 있음)
await upsertWithRetry(upsert, left, opts);
await upsertWithRetry(upsert, right, opts);
return;
}
const waitMs = backoffWithJitter(attempt, opts.baseBackoffMs, opts.maxBackoffMs);
// 429면 더 길게 쉬는 편이 안전
const extra = isRateLimited(err) ? Math.floor(waitMs * 0.5) : 0;
await sleep(waitMs + extra);
attempt += 1;
}
}
}
function chunk(records: VectorRecord[], size: number) {
const out: VectorRecord[][] = [];
for (let i = 0; i < records.length; i += size) {
out.push(records.slice(i, i + size));
}
return out;
}
export async function upsertAll(
records: VectorRecord[],
upsert: UpsertFn,
opts: {
batchSize: number;
concurrency: number;
}
) {
const batches = chunk(records, opts.batchSize);
const limit = pLimit(opts.concurrency);
await Promise.all(
batches.map((b) =>
limit(() =>
upsertWithRetry(upsert, b, {
maxRetries: 8,
baseBackoffMs: 250,
maxBackoffMs: 30_000,
})
)
)
);
}
위 코드에서 중요한 포인트
429/타임아웃/5xx만 재시도합니다. 그 외4xx는 입력 문제일 가능성이 크므로 빠르게 실패시켜야 합니다.- 타임아웃이면 배치를 반으로 쪼개 재시도합니다. “큰 요청 1개”가 “작은 요청 2개”로 바뀌면 성공하는 케이스가 많습니다.
full jitter로 재시도 타이밍을 분산합니다.
동시성 제어: 워커 수는 곧 부하 스위치
동시성을 올리면 처리량이 늘어날 것 같지만, Pinecone 업서트는 서버 측 쓰기 비용이 크기 때문에 임계점 이후에는 실패율만 증가하는 경우가 흔합니다.
권장 운영 방식:
- 동시성은
2~8사이에서 먼저 최적점을 찾기 429가 발생하면 동시성을 자동으로 1단계 낮추는 적응형 제어 도입- 장시간 적재 작업(백필, reindex)은 “최대 속도”가 아니라 “안정 속도”로 고정
적응형 제어를 더 엄밀히 하려면, 429 발생률이나 p95 latency를 피드백으로 삼아 토큰 버킷의 refill rate를 낮추는 방식이 효과적입니다.
타임아웃 튜닝: 클라이언트 데드라인을 짧게 잡지 말기
타임아웃을 너무 짧게 잡으면 “실제로는 처리 중인데” 클라이언트가 먼저 끊어 재시도를 발생시킵니다. 업서트는 읽기보다 느릴 수 있으므로, 다음처럼 접근합니다.
- 기본 요청 타임아웃을
30s수준에서 시작 p95가10s인데도 타임아웃이 난다면 네트워크/프록시(게이트웨이, NAT, LB) 타임아웃을 점검- 타임아웃이 특정 시간대에만 증가하면, 해당 시간대 트래픽과 함께 스로틀링이 걸리는지 확인
타임아웃을 다루는 사고 패턴은 아래 글과도 결이 같습니다.
운영 팁: 관측 지표가 없으면 튜닝이 불가능
업서트 파이프라인에 최소한 아래 지표를 남겨야 합니다.
- 배치당 레코드 수
- 요청 지연(
avg/p95/p99) - 상태 코드별 카운트(
429,5xx, 타임아웃) - 재시도 횟수 분포(0회, 1회, 2회…)
- 최종 실패 레코드 수(및 샘플)
로그에는 반드시 다음을 포함하세요.
- 네임스페이스
- 배치 크기
- 동시성(워커 id)
- 재시도 attempt
- 에러 코드/메시지
이렇게 해두면 “배치가 커서 문제인지”, “동시성이 과해서 문제인지”, “특정 네임스페이스만 핫한지”가 빠르게 보입니다.
흔한 안티패턴 5가지
429를 즉시 재시도(대기 없이)- 모든
4xx를 재시도(입력 오류를 영구 재시도로 바꿈) - 타임아웃이 나면 전체 배치를 그대로 재시도(부하 폭증)
- 동시성을 올려서 해결하려고 함(대부분 악화)
- 재시도에 지터가 없음(재시도 폭풍)
특히 1번과 5번은 429를 “연쇄 장애”로 만드는 지름길입니다. 재시도 설계의 기본 원칙은 다른 API에서도 동일합니다.
정리: 안전한 기본값(Starting Point)
환경마다 다르지만, 처음 적용하기 좋은 기본값은 아래와 같습니다.
- 배치 크기:
100 - 동시성:
4 - 타임아웃:
30s내외(프록시 포함) - 재시도: 최대
8회 - 백오프:
250ms시작, 최대30s,full jitter - 타임아웃 발생 시: 배치 반으로 분할 후 재시도
429발생 시: 대기 시간을 더 주고(백오프 확대), 가능하면 동시성도 한 단계 하향
이 조합은 “빨리 끝내기”가 아니라 “끝까지 안정적으로 끝내기”에 최적화되어 있습니다. Pinecone 업서트는 결국 쓰기 파이프라인이므로, 배치·동시성·재시도를 하나의 제어 시스템으로 묶어 운영하는 것이 가장 확실한 해법입니다.