2026년 5월, 운영 중인 AI Agent의 크레딧 차감 API에서 간헐적으로 타임아웃이 발생했습니다. 이 경로는 MCP Tool 사용 비용을 조직 크레딧에서 차감하고, 정산과 감사에 필요한 근거를 기록하는 곳입니다.
단건 호출에서는 문제가 드러나지 않았습니다. 그러나 한 사용자 요청 안의 여러 MCP Tool이 비슷한 시점에 끝나자 같은 조직의 차감 요청이 한꺼번에 몰렸고, 일부는 잔액 변경을 직렬화하는 Redis lock을 얻기 전에 실패했습니다.
가장 단순한 대응은 lock wait timeout을 늘리는 것이었습니다. 하지만 기다리는 시간만 키우면 원인은 그대로 남습니다. Redis 연결, 락 안의 DB 작업, 순간적인 요청 집중을 나눠 보기 위해 같은 Agent 실행과 조직의 로그를 한 흐름으로 묶었습니다.
이 글에서 쓰는 말
- Redis lock: 같은 조직의 잔액을 여러 요청이 동시에 바꾸지 못하도록 순서를 정하는 장치
- micro-batch: 같은 서버에 짧은 시간 동안 들어온 요청을 락 앞에서 잠시 모으는 방식
- ledger: 어떤 요청에서 얼마의 비용이 발생했는지 남기는 개별 차감 기록
- idempotencyKey: 같은 요청이 다시 들어와도 중복으로 차감되지 않게 구분하는 값
같은 timeout, 다른 원인
겉으로는 모두 같은 서버 오류였지만, 어느 단계에서 멈췄는지에 따라 대응은 달라집니다. 로그를 Redis 연결 전, lock 대기 중, lock 획득 후의 세 구간으로 나눠 읽었습니다.
Redis 연결 전
연결 오류와 네트워크 예외를 먼저 봤습니다. 같은 시간대에 반복된 연결 실패는 없었습니다.
락을 기다리는 동안
같은 실행과 조직의 요청에서 락 획득 제한 시간 초과가 반복됐습니다.
락을 얻은 뒤
차감 규칙과 DB 저장 예외를 확인했지만, 이번 요청은 그 단계에 도달하기 전에 끝났습니다.
세 구간의 로그를 함께 놓고 보니 요청은 Redis 연결에는 성공했지만, 실제 차감과 DB 저장을 시작하기 전에 멈췄습니다. 락 대기 제한은 2초였고 같은 Agent 실행과 조직에서 이 시간을 넘긴 요청이 이어진 반면, Redis 연결 오류나 DB 저장 예외는 같은 시점에 나타나지 않았습니다.
먼저 락을 얻은 요청은 잔액과 구독 정보를 조회한 뒤 usage 통계와 ledger를 저장했습니다. 그동안 뒤의 요청은 같은 조직 락 앞에서 기다렸습니다. 즉 Redis가 전반적으로 느린 문제라기보다, 한 실행에서 나온 요청이 각각 락을 잡으려 한 구조가 병목에 가까웠습니다.
느린 락보다 길어진 대기열
기존 credit consume 경로는 요청마다 조직 단위 Redis lock을 잡았습니다. 단건 처리에는 잘 맞았지만 Tool A, B, C가 거의 동시에 끝나면 세 요청이 같은 락을 차례로 기다리게 됩니다.
그렇다고 락을 없앨 수는 없었습니다. 잔액 확인과 차감, ledger 저장이 겹치면 중복 차감이나 기록 불일치가 생길 수 있기 때문입니다. lock wait timeout을 늘리거나 실패 요청을 retry하는 방법도 줄의 길이는 그대로 둔 채 기다리는 시간만 바꾸는 선택이었습니다.
따라서 줄여야 할 대상은 락 자체가 아니라 락 진입 횟수였습니다. 같은 Agent 실행과 조직에서 짧은 시간 안에 들어온 요청을 먼저 모으고, batch마다 Redis lock을 한 번만 잡도록 범위를 좁혔습니다.
금액이 아니라 락 진입 횟수를 묶었다
micro-batch의 범위는 일부러 좁게 잡았습니다. A, B, C의 총 차감액을 한 transaction에서 처리할 수는 있어도, 비용이 생긴 근거까지 하나로 합쳐서는 안 됐습니다. 총액이 맞는 것과 각 요청을 나중에 설명할 수 있는 것은 다른 문제였습니다.
락과 transaction 진입은 batch 단위로 줄였습니다. 다만 ledger row, idempotencyKey와 payload는 요청별로 보존했고, batchId는 같은 처리 흐름을 로그에서 찾는 용도로만 사용했습니다. 중복 차감 여부와 정산 근거는 기존 요청 식별자로 판단했습니다.
이 경계 덕분에 락 진입을 줄이면서도 비용의 출처를 다시 확인할 수 있었습니다. 처리 속도를 위해 감사 근거를 지우는 방식은 선택하지 않았습니다.
왜 더 큰 구조를 택하지 않았나
대안은 네 가지였습니다. 각각 줄일 수 있는 문제와 새로 생기는 부담을 비교했습니다.
| 대안 | 얻는 점 | 포기하기 어려운 비용 |
|---|---|---|
| lock 대기 시간 증가 | 코드 변경이 작음 | 경합은 그대로이고 실패만 늦게 드러남 |
| Redis lock 제거 | lock 대기가 사라짐 | 동시 차감으로 잔액과 사용 내역이 어긋날 수 있음 |
| 공유 메시지 큐 도입 | 여러 서버의 요청까지 한 흐름으로 모을 수 있음 | 응답 시점, 재처리, 중복과 부분 실패 기준을 다시 설계해야 함 |
| 서버 내부의 짧은 묶음 처리 | 기존 차감 의미를 유지하며 lock 진입 횟수를 줄임 | 같은 서버의 요청만 묶이며 짧은 대기 시간이 추가됨 |
관찰된 실패는 한 번의 에이전트 실행에서 요청이 순간적으로 몰리는 구간에 한정돼 있었습니다. 따라서 기존 차감 경로와 Redis lock은 유지하고, 동일한 실행과 조직의 요청을 lock 진입 직전에만 잠깐 모았습니다.
이 버퍼는 처리를 나중으로 미루는 메시지 큐가 아닙니다. 첫 요청이 들어온 뒤 50~200ms 범위의 짧은 시간만 기다리고, 최대 batch size에 도달하면 즉시 처리합니다. 같은 조직에서는 동시에 두 개의 flush가 실행되지 않게 했지만, 서로 다른 조직은 독립적으로 처리할 수 있게 했습니다.
초기값은 maxBatchSize 10~50건과 조직별 최대 대기 요청 약 100건을 기준으로 잡았습니다. 큐가 가득 차면 무한히 기다리지 않고 fail-fast로 실패를 드러냈습니다. 비용 차감 경로에서는 조용히 대기가 길어지는 상태가 명시적인 실패보다 찾기 어렵기 때문입니다.
각 요청은 flush가 끝난 뒤 기존처럼 동기 응답을 받습니다. 최대 대기 시간과 batch size를 제한해 사용자 지연과 lock 점유 시간이 함께 커지지 않도록 했으며, 여러 서버를 가로지르는 경합은 이 구조의 범위 밖으로 남겨 두었습니다.
아래 그림에서 바뀐 지점은 크지 않습니다. 모든 차감 구조를 다시 만든 것이 아니라, 같은 실행에서 나온 요청이 lock 앞에 따로 서지 않도록 한 단계만 줄였습니다.
코드에서 나눈 락 안팎의 책임
적용 범위는 문제가 반복된 Agent 사용량 차감 경로로 한정하고, 기존 단건 호출은 그대로 남겼습니다. 묶음 여부에 따라 차감 결과가 달라져서는 안 됐기 때문입니다.
코드에서는 useCredit()에 함께 들어 있던 락 획득과 실제 차감 로직부터 분리했습니다. 묶음 처리에서 기존 함수를 그대로 여러 번 호출하면 겉으로만 batch일 뿐, Redis lock은 요청 수만큼 다시 잡히게 됩니다.
아래 코드는 설계 문서에 남아 있는 클래스와 메서드 구조를 바탕으로 흐름만 줄인 개념 코드입니다.
class CreditConsumeBatcher(
private val organizationCreditService: OrganizationCreditService,
) {
private val buffers = ConcurrentHashMap<String, OrgBatchBuffer>()
fun enqueue(item: CreditUseItem): CreditConsumeResult {
val buffer = buffers.computeIfAbsent(item.organizationId) { organizationId ->
OrgBatchBuffer(organizationId, organizationCreditService)
}
return buffer.enqueueAndAwait(item)
}
}CreditConsumeBatcher는 같은 조직의 요청을 짧게 모은 뒤 각 요청이 flush 결과를 기다리게 합니다. 실제 차감 단계에서는 조직 단위 락을 한 번만 잡고, 요청별 로직을 그 안에서 순서대로 실행했습니다.
fun useCredit(item: CreditUseItem) {
organizationCreditLockService.withCreditLock(item.organizationId) {
useCreditWithinLock(item)
}
}
fun useCreditsBulk(
organizationId: String,
items: List<CreditUseItem>,
) {
organizationCreditLockService.withCreditLock(organizationId) {
items.forEach { item ->
// idempotency 검사와 ledger 저장은 요청별로 유지
useCreditWithinLock(item)
}
recordUsageAggregated(items)
}
}이렇게 하면 기존 단건 경로는 유지하면서, 묶음 경로만 Redis lock 1회 → 요청별 차감과 ledger 저장 순서로 처리할 수 있습니다. 한 항목이 실패했을 때는 우선 묶음 전체를 되돌렸습니다. 부분 성공을 허용하면 재시도와 보상 처리, 감사 상태를 다시 정의해야 하므로 기존 트랜잭션 의미를 먼저 지켰습니다.
batchId는 같은 flush를 로그에서 찾는 용도에만 썼습니다. 정산과 중복 차감은 각 요청의 idempotencyKey와 ledger를 기준으로 판단합니다. A, B, C를 한 번의 락 안에서 처리하더라도 기록은 세 건으로 유지되므로, 비용의 출처와 실패한 요청을 다시 추적할 수 있습니다.
작은 변경이 닿은 범위
그대로 지킨 것
- flush가 끝난 뒤 응답하는 기존 동기 계약
- 조직 단위 Redis lock과 transaction
- 요청별 ledger row, payload와 idempotencyKey
- 기존 BackOffice 정산과 감사 기준
새로 관리한 것
- 락 대기 대신 생긴 micro-batch 내부의 짧은 대기
- usage upsert와 캐시 무효화의 반복 횟수
- process 재시작과 graceful shutdown에서 대기 요청을 끝내는 방식
바꾼 곳은 락 앞의 짧은 구간이었지만, 영향은 응답 시간과 정산 기록, usage 통계, 배포 절차까지 이어졌습니다. 그래서 성능만 좋아졌는지가 아니라 기존 차감 계약이 그대로 남아 있는지도 함께 살폈습니다.
대기가 다른 곳으로 옮겨가지는 않았을까
요청을 모으면 락 앞의 대기는 줄지만 버퍼 안에서 기다리는 시간이 새로 생깁니다. batch가 커질수록 transaction과 lock hold time도 길어질 수 있으므로, 병목이 사라진 것이 아니라 자리를 옮긴 것은 아닌지 살펴야 했습니다.
그래서 batch size와 가장 오래 기다린 요청의 시간, lock acquire time과 hold time, DB 저장 시간, 실패 사유를 한 흐름으로 남겼습니다. 이 지표를 함께 보면 Redis 연결 문제와 락 경합, 느린 DB 저장, queue full을 구분할 수 있습니다. 반면 요청 전문과 사용자 이메일은 원인 분석에 필요하지 않아 기록 대상에서 제외했습니다.
문제가 다시 생기면 버퍼 대기부터 보고, 길어졌다면 micro-batch 설정을 확인합니다. lock hold time이 늘었다면 ledger와 usage 저장으로 범위를 좁히고, 두 구간이 정상일 때 Redis 연결이나 다중 pod 경합까지 살펴봅니다.
속도보다 먼저 본 정합성
이 변경은 평균 처리 시간 하나만으로 판단할 수 없었습니다. 비용을 다루는 경로이므로 차감 결과와 추적 가능성이 먼저 유지돼야 했습니다. 적용 전후에는 다음 항목을 같은 요청 식별자로 비교했습니다.
| 검증 항목 | 확인한 기준 | 근거 |
|---|---|---|
| 단건 차감 | 기존 결과와 사용 내역이 같음 | 단건 회귀 테스트와 저장 기록 |
| 동일 조직의 묶음 차감 | lock은 묶음당 한 번 획득하고 기록은 요청 수만큼 남음 | lock 획득 로그와 요청별 사용 내역 |
| 중복 요청 | 요청별 중복 방지 기준이 유지됨 | 요청 식별자 기준 재호출 테스트 |
| 서로 다른 조직 | 각 조직의 lock과 차감 흐름이 분리됨 | 조직별 lock 키와 병렬 요청 로그 |
| 대기 이동 여부 | lock 대기가 줄어도 버퍼 최장 대기와 lock 점유 시간이 늘지 않음 | batch size, 최장 대기, lock 대기와 저장 시간 |
이 결과를 모든 환경에 적용되는 고정 개선율로 표현하지는 않았습니다. 구조적으로는 요청마다 발생하던 lock 획득이 batch 단위로 줄었고, 운영 효과는 batch size와 최장 대기 시간, lock 획득 및 점유 시간을 함께 보며 판단했습니다.
락 앞의 줄을 줄인 다음
이번 변경이 직접 다룬 범위는 같은 process 안에서 같은 조직의 요청이 짧게 몰리는 상황입니다. Redis 자체 지연이나 느린 DB 작업, 여러 pod를 가로지르는 전역 경합까지 해결한 것은 아닙니다.
앞서 본 버퍼 대기와 lock hold time이 안정적이라면 process-local micro-batch로 충분합니다. 구조를 다시 검토할 신호는 그다음에 나타납니다.
- process 종료 때 대기 요청을 이어받지 못하는 경우
- 여러 pod에서 같은 조직의 lock 경합이 계속되는 경우
- batch 전체 rollback으로 정상 요청까지 반복해서 실패하는 경우
- 응답 전에 연결이 끊겨 같은 idempotencyKey로 재호출되는 경우
이런 문제가 실제 운영 범위로 커질 때 shared queue, item별 결과 또는 보상 처리를 검토하는 편이 변경 비용을 줄일 수 있습니다.