처음에는 timeout 값을 보면 될 문제처럼 보였습니다. 크레딧 차감 요청 일부가 실패했고, 로그에는 락을 제때 얻지 못한 흔적이 남아 있었습니다. 숫자만 보면 대기 시간을 조금 늘리는 쪽이 가장 빠른 답처럼 보였습니다.

그런데 계속 찜찜했습니다. timeout을 늘리면 실패는 덜 보일 수 있지만, 왜 그 순간에 같은 락을 기다리는 요청이 많아졌는지는 설명하지 못합니다. Redis가 느린 것인지, 락 안에서 처리하는 일이 오래 걸린 것인지, 아니면 락 앞에 서는 요청 수가 갑자기 늘어난 것인지가 모두 같은 오류처럼 보였습니다.

AI Agent 실행에서는 이 차이가 더 잘 드러났습니다. 여러 도구 호출이 비슷한 시점에 끝나고, 각 호출이 사용량을 만들면서 크레딧 차감 요청도 함께 생깁니다. 요청 하나만 보면 평범한 차감이지만, 같은 조직 기준으로 보면 짧은 순간에 여러 요청이 같은 락 앞에 줄을 섭니다.

그래서 질문을 바꿨습니다. “얼마나 더 기다리게 할까?”가 아니라, “왜 같은 실행에서 생긴 요청들이 락 앞에 따로 서고 있을까?”를 먼저 봤습니다.

같은 timeout이어도 원인은 달랐습니다

운영 로그에서 보이는 결과는 비슷했습니다. 크레딧 차감이 실패했고, 일부 요청은 서버 오류처럼 보였습니다. 하지만 같은 timeout이라도 원인은 꽤 달랐습니다.

Redis에 닿지 못한 경우라면 저장소나 네트워크를 먼저 봐야 합니다. Redis는 정상인데 락을 기다리다 실패했다면, 그 순간 같은 락 앞에 요청이 얼마나 몰렸는지를 봐야 합니다. 락을 얻은 뒤에 실패했다면 차감 계산이나 DB 저장 과정이 의심 지점이 됩니다.

이 구분 없이 대기 시간만 늘리면 증상은 잠시 줄어 보일 수 있습니다. 하지만 실패가 늦게 드러날 뿐, 같은 실행에서 요청이 몰리는 구조는 그대로 남습니다.

이번 경우는 Redis 자체 장애나 차감 계산 실패보다는 같은 조직의 요청이 짧게 몰린 상황에 가까웠습니다. 락을 잡은 요청 하나가 처리되는 동안 다른 차감 요청들이 뒤에서 기다렸고, 그 대기가 실패로 이어질 수 있었습니다.

락이 느린 게 아니라 줄이 길어진 것에 가까웠습니다

기존 구조에서는 각 크레딧 차감 요청이 따로 분산락을 기다렸습니다. 요청 A, B, C가 같은 조직에 대해 거의 동시에 들어오면 세 요청 모두 같은 락 앞에 섭니다. A가 잔액 확인, 차감, 사용 내역 저장을 처리하는 동안 B와 C는 기다립니다.

크레딧 차감은 순서대로 처리되어야 하므로 락 자체는 필요합니다. 다만 AI Agent 실행에서는 요청이 만들어지는 방식이 조금 달랐습니다. 한 번의 사용자 작업 안에서 여러 도구가 동시에 끝나고, 각 도구가 사용량을 만들면서 차감 요청도 함께 늘어났습니다.

이때 병목은 “락이 너무 느리다”보다 “락 앞에 서는 요청이 갑자기 많아졌다”에 가까웠습니다. 대기 시간을 늘리면 실패 시점은 늦출 수 있습니다. 실패 요청을 재시도하면 같은 요청을 다시 락 앞으로 보낼 수도 있습니다. 그렇다고 락을 없애면 잔액과 사용 내역이 어긋날 수 있습니다.

그래서 더 오래 기다리게 만들기보다, 같은 실행에서 나온 요청들이 락 앞에 따로 서지 않도록 바꾸는 쪽이 더 맞다고 봤습니다. 대신 차감 기록과 중복 차감 방지 기준은 요청별로 남겨야 했습니다.

합친 것은 금액이 아니라 기다리는 횟수였습니다

요청을 묶는다고 해서 차감 기록까지 한 줄로 합칠 수는 없었습니다. 크레딧 차감은 비용과 연결됩니다. 총액이 맞더라도, 나중에 어떤 요청에서 비용이 생겼는지 설명할 수 없으면 다시 문제가 됩니다.

예를 들어 도구 호출 A, B, C가 각각 비용을 만들었다면 잔액 차감은 한 번에 처리할 수 있습니다. 하지만 사용 내역은 A, B, C로 따로 남아야 합니다. 그래야 특정 요청이 중복 처리됐는지, 어떤 호출에서 비용이 발생했는지, 이후 정산에서 어떤 근거로 금액이 계산됐는지 확인할 수 있습니다.

그래서 합친 것은 금액의 근거가 아니라 락을 기다리는 횟수였습니다. 락 앞에 서는 줄은 줄이고, 차감 기록과 중복 차감 방지 기준은 요청별로 유지했습니다.

이 경계가 중요했습니다. 성능을 위해 기록을 흐리면 안 됐습니다.

큰 구조를 바꾸지 않은 이유

락을 없애면 대기 실패는 줄어 보일 수 있습니다. 하지만 크레딧 차감은 잔액을 바꾸고 사용 내역을 남기는 작업입니다. 동시에 처리되면 안 되는 구간은 여전히 필요했습니다.

모든 차감 처리를 나중에 처리하는 대기열로 넘기는 방법도 바로 선택하지 않았습니다. 그렇게 하면 사용자에게 언제 성공으로 보여 줄지, 실패한 차감을 어떻게 다시 처리할지, 일부 요청만 실패했을 때 기록을 어떻게 남길지까지 다시 정해야 합니다.

이번에 확인한 것은 전체 차감 구조가 아니라, 같은 실행에서 요청이 짧게 몰리는 지점이었습니다. 그래서 기존 차감 경로와 분산락은 유지했습니다. 대신 같은 실행에서 나온 요청을 락에 넣기 전에 아주 짧게 모았습니다.

이 대기열은 오래 쌓아두는 큐가 아닙니다. 사용자 응답을 몇 초씩 늦추거나, 실패 처리를 나중으로 미루기 위한 구조도 아닙니다. 거의 동시에 들어온 요청을 잠깐 모아 락 앞의 줄을 줄이기 위한 장치였습니다.

대기 시간은 짧아야 했고, 한 번에 묶는 요청 수도 제한해야 했습니다. 크레딧 차감처럼 비용과 연결된 경로에서는 조용히 오래 밀리는 상태가 더 위험하기 때문입니다.

아래 그림에서 바뀐 지점은 크지 않습니다. 모든 차감 구조를 다시 만든 것이 아니라, 같은 실행에서 나온 요청이 락 앞에 따로 서지 않도록 한 단계만 줄였습니다.

락을 잡는 일과 차감하는 일을 나눴습니다

처음부터 모든 차감 경로를 바꾸지는 않았습니다. 실패가 반복된 경로부터 확인했습니다. 단건 차감이 정상일 때와 묶음으로 처리할 때의 결과가 달라지면 안 됐기 때문입니다.

먼저 락을 잡는 부분과 실제 차감을 처리하는 부분을 나눴습니다. 단건 차감 함수가 락 획득부터 사용 내역 저장까지 모두 맡고 있으면, 묶음 처리에서도 단건 함수를 여러 번 부르게 됩니다. 겉으로는 묶은 것처럼 보여도 실제로는 요청 수만큼 다시 락을 잡는 구조가 됩니다.

그래서 락은 조직 단위로 한 번만 잡고, 그 안에서 요청별 차감과 사용 내역 저장을 처리하도록 경계를 바꿨습니다. 요청 하나가 들어왔을 때의 차감 결과, 중복 처리 기준, 저장되는 기록의 의미는 바꾸지 않았습니다.

적용 범위도 좁게 잡았습니다. 장애가 보인 차감 경로부터 시작하고, 환경이나 조직 범위를 제한해 확인할 수 있게 했습니다. 크레딧 차감은 한 번에 넓게 바꾸기보다, 기록이 맞는지 확인하면서 조금씩 넓히는 쪽이 안전했습니다.

묶음 ID는 추적용으로만 남겼습니다

요청을 묶으면 자연스럽게 묶음 ID가 생깁니다. 이 값은 로그에서 같은 처리 단위를 찾을 때는 유용합니다. 하지만 정산 기준이나 중복 차감 방지 기준으로 쓰면 안 됐습니다.

중복 차감을 막는 기준은 여전히 요청별로 남아야 합니다. 같은 요청이 다시 들어왔을 때 막아야 하는 것은 묶음 전체가 아니라 특정 요청의 중복 차감입니다.

차감 기록도 마찬가지입니다. 요청 A, B, C를 한 번의 락 안에서 처리하더라도 기록은 A, B, C로 남아야 합니다. 그래야 나중에 비용이 어디서 생겼는지, 어떤 요청이 실패했는지, 어떤 요청이 중복으로 들어왔는지 다시 확인할 수 있습니다.

이번 작업에서 계속 지킨 기준은 단순했습니다. 요청은 짧게 모으고, 차감 근거는 그대로 둔다. 성능을 위해 줄인 것은 락 앞의 대기 횟수였고, 줄이지 않은 것은 나중에 설명해야 할 기록이었습니다.

이 기준을 그림으로 보면 경계가 더 분명합니다. 묶음은 락 진입 전까지만 의미가 있고, 실제 차감 근거는 요청별로 남습니다.

범위는 일부러 작게 잡았습니다

이번 변경은 같은 백엔드 서버 안으로 들어온 요청만 묶습니다. 로드밸런서가 같은 조직의 요청을 여러 서버로 나누면, 각 서버에서 별도의 묶음이 만들어질 수 있습니다. 전체 시스템에서 조직당 묶음이 하나만 생기는 구조는 아닙니다.

이 한계를 알고도 범위를 작게 잡았습니다. 관찰된 실패는 한 번의 AI Agent 실행에서 짧게 몰린 요청이 락 앞에 서는 상황이었습니다. 서버 안에서 발생한 요청 몰림만 줄여도 락을 기다리는 요청 수는 줄어듭니다. 공유 대기열을 새로 두지 않기 때문에 응답 의미, 재처리 방식, 부분 실패 기준도 한꺼번에 다시 정하지 않아도 됩니다.

여러 서버를 가로질러 같은 조직의 요청이 계속 몰린다면 다음 선택은 달라집니다. 그때는 Redis Streams나 Kafka 같은 공유 대기열을 검토할 수 있습니다. 하지만 그 선택은 차감 처리 방식 전체를 바꾸는 일입니다. 이번에는 현재 보이는 실패 패턴을 작게 줄이는 쪽이 더 맞았습니다.

다음에 헷갈리지 않기 위해 남긴 값

요청을 모으면 확인할 지점도 하나 늘어납니다. 락 대기는 줄었지만, 묶음 안에서 오래 기다리는 요청이 생길 수 있습니다. 묶음이 너무 커지면 오히려 락을 잡고 있는 시간이 길어질 수도 있습니다.

그래서 성공 여부만 보지 않았습니다. 다음에 같은 실패가 생겼을 때 원인을 다시 감으로 좁히지 않도록, 로그에는 필요한 값만 남겼습니다.

  • 몇 개의 요청이 함께 처리됐는지
  • 가장 오래 기다린 요청은 얼마였는지
  • 락을 얻는 데 얼마나 걸렸는지
  • 락 안에서 저장 처리가 얼마나 걸렸는지
  • 실패가 Redis 장애인지, 락 대기 실패인지, 차감 규칙 오류인지, 대기열 초과인지

요청 전문이나 사용자 이메일처럼 민감할 수 있는 값은 남기지 않았습니다. 대신 묶음 크기, 총 차감량, 락 대기 시간, 실패 사유처럼 원인을 좁히는 데 필요한 값만 남겼습니다.

이 기준이 없으면 개선 후에도 장애가 났을 때 같은 질문으로 돌아갑니다. Redis가 느린 것인지, 요청이 몰린 것인지, DB 저장이 오래 걸린 것인지 다시 감으로 좁혀야 하기 때문입니다.

빨라진 것보다 먼저 확인한 것

이 작업은 단순한 성능 개선이 아니었습니다. 크레딧 차감 경로에서는 처리 시간이 줄어도 기록의 의미가 흐려지면 안 됩니다. 요청을 묶었는데 나중에 어떤 요청에서 비용이 생겼는지 설명할 수 없다면, 대기 시간이 줄어도 좋은 개선이라고 보기 어렵습니다.

그래서 먼저 본 것은 속도가 아니라 기록이었습니다.

  • 단건 요청 결과가 기존과 같아야 합니다.
  • 여러 요청을 한 번의 락 안에서 처리해도 차감 기록은 요청 수만큼 남아야 합니다.
  • 각 기록에는 요청별 사용량과 중복 차감 방지 기준이 유지되어야 합니다.
  • 서로 다른 조직의 요청은 서로 막지 않아야 합니다.
  • 처리 중 예외가 나도 일부 기록만 어색하게 남는 상태를 피해야 합니다.

운영 지표도 같은 기준으로 봤습니다. 락 대기는 줄었지만 가장 오래 기다린 요청 시간이 길어졌다면, 병목을 락에서 대기열로 옮긴 것뿐입니다. 락 안에서 걸린 시간이 길어졌다면 묶음 크기나 DB 저장 과정을 다시 봐야 합니다.

이번 글의 선 밖에 둔 것

이번에 줄인 것은 같은 실행에서 생긴 차감 요청이 각각 락을 기다리는 상황이었습니다. 같은 백엔드 서버 안에서 같은 조직의 요청이 짧게 몰릴 때, 여러 요청이 따로 락 앞에 서지 않도록 했습니다.

반대로 일부러 건드리지 않은 영역도 있었습니다.

  • Redis 자체가 느리거나 접근이 안 되는 문제
  • DB 저장이 느려져 락을 오래 잡는 문제
  • 여러 서버를 가로질러 모든 요청을 하나로 모으는 구조

이 구분을 일부러 남긴 이유는 다음 대응을 빠르게 하기 위해서입니다. 차감 실패가 다시 발생하면 먼저 Redis 장애, 락 대기 실패, 차감 규칙 오류, 대기열 초과를 나눠 봅니다. 락 대기 실패라면 묶음 크기와 가장 오래 기다린 요청 시간을 봅니다. 락 안에서 시간이 길다면 DB 저장 과정을 봅니다.

마무리

이 작업에서 가장 피하고 싶었던 것은 “오류가 덜 보이게 만드는 개선”이었습니다. 대기 시간을 늘리면 당장은 실패가 줄어 보일 수 있습니다. 하지만 같은 조직의 요청이 짧은 시간에 몰리는 구조가 그대로라면, 다음 피크에서도 비슷한 실패가 다시 나타날 수 있습니다.

그래서 고친 범위는 생각보다 작았습니다. 기존 분산락과 차감 경로는 그대로 두고, 같은 실행에서 나온 요청이 락 앞에 따로 서는 횟수만 줄였습니다. 대신 차감 기록과 중복 차감 방지 기준은 요청별로 남겼습니다.

결국 이 글에서 남기고 싶었던 기준은 하나였습니다. 기록은 합치지 않고, 기다리는 줄만 줄인다. 비용과 연결된 경로에서는 빠른 처리만큼이나, 나중에 왜 차감됐는지 설명할 수 있는 구조가 중요했습니다.