기존 구조에서 확인한 문제점
시나리오 설정
- Cloudflare Queue batch 크기: 최대 100개
- 정상 이벤트 99개
- ClickHouse INSERT 과정에서 실패하는 sentinel 이벤트 1개
- 오류는 일시적인 HTTP 503 또는 network timeout
- Queue는 at-least-once delivery 방식
기존 슈도 코드
async function consumeQueueBatch(batch) {
const preparation = await prepareAnalyticsBatch(
batch.messages.map(message => message.body)
);
try {
await storage.insertCanonicalEvents(preparation.rowsToInsert);
} catch (error) {
for (const item of preparation.accepted) {
item.message.retry();
}
return;
}
await materialize(preparation.affectedSiteIds);
for (const item of preparation.accepted) {
item.message.ack();
}
}
문제점
100개 중 1개만 INSERT 오류를 일으켜도 ClickHouse 요청 전체가 실패하기 때문에, 기존 구현은 유효한 100개 메시지를 모두 retry했습니다.
즉, 실제 결과는 다음과 같았습니다.
ClickHouse bulk INSERT: 실패
ack: 0개
retry: 100개
이 구조에는 다음 문제가 있었습니다.
- 정상 이벤트 99개도 불필요하게 재처리됨
- Queue read/retry operation이 증가해 Free 플랜 비용이 커짐
- 정상 이벤트가 중복 전달되어 ClickHouse dedup 조회가 반복됨
- batch 전체가 반복되면서 ClickHouse 장애 시 요청량이 증가할 수 있음
- retry가 누적되면 정상 이벤트까지 DLQ로 이동할 위험이 있음
- INSERT가 실제로 성공했지만 응답만 유실된 경우에도 전체 메시지가 재전달됨
- handler 전체를 throw하는 방식으로 처리하면 batch 전체가 다시 전달될 가능성이 있음
Cloudflare Queue는 최대 batch 크기가 100이며, 메시지별 ack()와 retry()를 사용할 수 있습니다. 메시지가 설정된 최대 retry 횟수에 도달하면 DLQ로 이동합니다. Cloudflare Queue batching and retries
바뀐 구조
시나리오 설정
동일한 100개 batch를 사용합니다.
- 정상 이벤트 99개
- sentinel 이벤트 1개
- 최초 bulk INSERT는 503으로 실패
- sentinel이 포함된 sub-batch만 계속 실패
- 정상 sub-batch는 성공
- materializer가 성공한 뒤에만 ack
변경된 슈도 코드
async function consumeQueueBatch(batch) {
const preparation = await prepareEachMessage(batch.messages);
const existingRows = preparation.accepted.filter(
item => item.alreadyPersisted
);
const insertItems = preparation.accepted.filter(
item => !item.alreadyPersisted
);
try {
// 정상 경로: bulk INSERT 1회
await storage.insertCanonicalEvents(
insertItems.map(item => item.row)
);
addPending(existingRows);
addPending(insertItems);
} catch (error) {
// 오류 경로: 실패 이벤트 격리
const result = await isolateInsertFailure(
insertItems,
error
);
addPending(existingRows);
addPending(result.persisted);
for (const failed of result.failed) {
failed.message.retry({
delaySeconds: retryDelay(failed.message.attempts)
});
}
}
try {
// 저장 성공 이벤트가 속한 site만 materialize
await materialize(pendingSiteIds());
for (const message of pendingMessages()) {
message.ack();
}
} catch (error) {
// materializer 실패 site의 메시지만 retry
retryFailedSites(error);
ackSuccessfulSites();
}
}
INSERT 오류 격리 슈도 코드
async function isolateInsertFailure(items, initialError) {
if (items.length === 1) {
return {
persisted: [],
failed: [{ item: items[0], error: initialError }]
};
}
if (!isRetryableClickHouseError(initialError)) {
return {
persisted: [],
failed: items.map(item => ({
item,
error: initialError
}))
};
}
const [left, right] = splitInHalf(items);
const leftResult = await tryInsert(left);
const rightResult = await tryInsert(right);
const persisted = [
...leftResult.successful,
...rightResult.successful
];
const failed = [
...isolateFailed(leftResult),
...isolateFailed(rightResult)
];
return { persisted, failed };
}
기존 문제를 해결한 방식
- 정상 경로에서는 기존과 동일하게 ClickHouse bulk INSERT 1회만 사용
- bulk INSERT가 실패한 경우에만 sub-batch 분할 수행
- 성공한 sub-batch의 메시지는 retry하지 않음
- 최종 singleton 실패 이벤트만 retry 또는 DLQ 대상이 됨
site_id + client_event_iddedup 조회를 통해 응답 유실 후 재전달도 안전하게 처리- materializer 성공 이후에만 정상 메시지를 ack
- materializer가 실패한 site의 메시지만 retry
- retry delay는 30초, 120초로 제한
- isolation 요청 수는 최대 16회로 제한해 장애 상황의 요청 폭주 방지
- Queue handler 전체 throw 대신 메시지별 명시적
ack()/retry()사용
동일한 시나리오에서 개선된 결과
로컬 실제 ClickHouseClient HTTP 경계 테스트
초기 bulk INSERT: 100행, HTTP 503
정상 sub-batch INSERT: 99행, HTTP 200
sentinel singleton INSERT: HTTP 503
sentinel 데이터: 서버에 이미 저장됨
결과:
ack: 99개
retry: sentinel 1개
추가 INSERT: 없음
재전달 sentinel: SELECT dedup 후 ack
실제 Cloudflare Queue/DLQ 플랫폼 테스트
disposable Cloudflare Queue와 Worker를 사용해 실제 플랫폼에서 확인했습니다.
첫 batch:
100개 처리
99개 ack
sentinel 1개 retry
재전달:
sentinel 단일 batch, attempts=2
sentinel 단일 batch, attempts=3
최대 retry 도달:
sentinel 1개 DLQ 이동
DLQ payload에는 원본 sentinel body와 함께 CF-sourceQueueName, CF-sourceMessageId, CF-Content-Type 메타데이터가 포함됐습니다. Cloudflare Queue DLQ
결과적으로 정상 이벤트 99개는 재처리하지 않고 완료되며, 실제로 실패한 이벤트만 bounded retry 후 DLQ로 이동하는 구조로 개선되었습니다.