본문 바로가기
Backend/Architecture

[AI Agent] 호출 횟수만으로 부족했던 조회 API의 비용 제한

미지시료 2026. 11. 1.

Agent 조회 API의 사용량을 응답 행 수 기준으로 맞추고, 데이터 노드 호출 횟수에 가중치를 더했다. 요청 전에는 추정 비용을 예약하고 응답 후에는 정산하도록 나눴다. 이 방식으로 구분할 수 있는 부하와 여전히 놓치는 부하를 정리했다.





조회 한 번을 모두 같은 비용으로 볼 수 없었다

MCP 서버에 연결할 Agent API를 만들면서 목록, 현재값, 시계열과 이력 조회를 함께 열었다. 외부에서 보면 모두 API 요청 한 번이지만 내부에서 하는 일은 달랐다.

앞서 사용자에게 허용된 데이터 범위와 API Key의 호출 기능을 나눴다. 이번에는 권한 검사를 통과한 요청이 얼마나 많이 실행될 수 있는지를 다뤘다. 읽을 권한이 있다는 사실만으로 조회량까지 무제한으로 허용할 수는 없었다.

마스터 DB에서 짧은 목록을 읽는 요청도 있고, 여러 데이터 노드 호출의 결과를 모아야 하는 요청도 있었다. 같은 엔드포인트라도 요청한 기간과 대상 수에 따라 처리 범위가 달라졌다.

이런 요청을 모두 한 번으로만 세면 작은 조회와 큰 조회를 구분하기 어렵다. 그렇다고 API별로 고정 점수만 붙이면 같은 API 안에서 달라지는 결과 크기를 반영하지 못한다. 그래서 사용량 정산 기준을 응답 행 수로 통일하고, 데이터 노드를 거치는 경로에는 호출 횟수에 따른 가중치를 추가했다.

이 작업에서는 정산 단위와 페이지 응답 형식을 함께 맞췄다. 목록마다 다른 필드에서 개수를 추측하는 대신, 공통 응답의 rows.length를 기준으로 비용을 계산할 수 있게 했다. 전체 결과 수를 나타내는 total과 이번 페이지에 담긴 행 수는 구분했다. 전체 10,000건 중 100건을 반환했다면 기본 정산 값은 10,000이 아니라 100이다.

여기서 비용은 클라우드 청구액이나 LLM 토큰 사용료가 아니다. 백엔드 조회 사용량을 제한하기 위해 정한 내부 점수다. 실제 CPU 시간과 DB 스캔량을 정확하게 측정한 값으로 설명할 수는 없다.

응답 행 수에 데이터 노드 호출 가중치를 더했다

마스터 DB만 읽는 API는 반환한 행 수를 정산 값으로 사용했다. 데이터 노드를 호출하는 API는 다음 식으로 계산했다.

정산 비용 = ceil(응답 행 수 × (1 + 0.2 × 데이터 노드 호출 횟수))

구현에서는 비율을 정수로 두고 마지막에 올림했다.

export const AGENT_NODE_CALL_PERCENT = 20;

export function nodeAgentCost(rows: number, nodeCalls: number): number {
  const percent = 100 + AGENT_NODE_CALL_PERCENT * Math.max(0, nodeCalls);
  return Math.ceil((rows * percent) / 100);
}
응답 행 수 데이터 노드 호출 횟수 정산 비용
100 0 100
100 1 120
100 3 160
1 1 2
0 3 0

가중치는 호출마다 곱해지는 복리 방식이 아니다. 세 번 호출했다면 1.2³이 아니라 1.6을 곱한다. 호출한 서버의 고유 개수와도 다르다. 같은 서버를 날짜별로 세 번 조회했다면 호출 횟수는 세 번이다.

처음에는 이력 조회의 날짜 수와 시계열의 개별 조회 단위 수를 비용 계산에 전달했다. 이후 여러 조회를 배치로 묶으면서 비용 계산에 넘기는 횟수도 함께 바꿨다.

조회 유형 배치 전 기준 현재 정산 기준
시계열 개별 시계열 수 데이터를 묶어 요청하는 운영 단위 수
이력 조회할 날짜 수 기간을 묶은 데이터 노드 호출 1회
현재값 데이터 노드 호출 1회 같은 기준 유지

여기서 운영 단위는 데이터와 접근 권한을 묶어 관리하는 프로젝트 같은 구분이다. 시계열에서는 같은 운영 단위에 속한 항목들을 하나의 범위 요청으로 보낸다. 세 항목을 한 번에 조회해 100행을 반환했다면 비용은 160이 아니라 120이다. 서로 다른 운영 단위 세 곳을 각각 호출했다면 160이 된다. 같은 서버에 저장되어 있어도 요청을 세 번 보냈다면 한 번으로 세지 않는다.

이 횟수는 패킷이나 SQL 실행을 계측한 값이 아니라 조회 경로에서 계산한 호출 단위다. 배치 요청 하나가 내부에서 여러 테이블을 읽더라도 그 SQL 개수까지 반영하지는 않는다. 조회 구조를 바꿀 때 비용 계산도 같이 확인해야 하는 이유다.

20%라는 값은 정한 정책이지 실측 자원 사용량을 환산한 계수는 아니다. 이 식의 장점은 서로 다른 조회를 같은 단위로 비교할 수 있다는 것이고, 실제 부하를 정확히 설명한다는 뜻은 아니다.





실행 전에 예약하고 끝난 뒤 정산했다

응답 행 수는 조회가 끝나야 알 수 있다. 결과를 받은 뒤에만 한도를 차감하면 요청을 받아들일 시점에는 아직 진행 중인 작업의 사용량이 보이지 않는다.

그래서 요청을 처리하기 전에 추정 비용을 먼저 예약했다. 이때는 Gateway가 요청에서 읽을 수 있는 limit이나 대상 ID 개수를 사용했다.

조회 유형 사전 예약 기준
일반 목록과 시계열 페이지 요청의 limit, 없거나 잘못된 값이면 최대 페이지 크기
ID 배열로 요청하는 현재값 ID 개수와 페이지 크기 중 작은 값
작은 메타데이터 목록 고정값 1로 예약한 뒤 응답으로 정산

현재 페이지 크기 상한은 300이다. 추정값은 최소 1로 보정하고, 제한기의 버킷 용량보다 크게 예약하지는 않는다. 이 처리는 입력 검증을 대신하지 않는다. 잘못된 요청은 내부 API에서 별도로 거부한다.

요청 도착
  → 인증 주체 확인
  → 추정 비용 계산
  → 남은 한도 확인과 예약
  → 내부 API 실행
  → 응답 행 수와 호출 가중치로 비용 계산
  → 예약액과 정산액의 차이 반영

추정 비용을 E, 응답 후 계산한 비용을 A라고 하면 차이는 E - A다. 예약이 더 컸으면 돌려주고, 정산 비용이 더 컸으면 추가 차감했다.

예를 들어 300을 예약했지만 실제로 40행을 반환했다면 마스터 조회는 40으로 정산하고 260을 돌려준다. 100을 예약한 데이터 노드 조회가 100행을 반환하고 세 번 호출했다면 160으로 정산해 60을 더 차감한다.

예약의 효과는 요청이 겹칠 때 드러난다. 남은 예산이 300이고 추정 비용이 200인 요청 두 개가 들어오면, 첫 요청이 200을 예약한 뒤 두 번째 요청은 남은 100을 기준으로 판단한다. 응답 후에만 차감한다면 둘 다 아직 300이 남아 있다고 보고 통과할 수 있다. 이 예시는 시간에 따른 보충이 없다고 놓고 예약의 역할만 비교한 것이다.

이때 사전 예약은 확정 상한이 아니다. 데이터 노드 가중치가 사후에 더해지거나 고정 예약값보다 결과가 크면 이미 허용한 요청의 비용이 남은 한도를 넘을 수 있다. 정산은 이미 실행한 조회를 취소하지 않는다. 따라서 이 구조를 실행 부하의 엄격한 상한으로 보지는 않았다.

내부 API가 정산 값을 알려주도록 했다

Gateway는 내부에서 몇 번의 조회를 했는지 알기 어렵다. 그래서 결과를 만든 내부 API가 x-agent-cost 응답 헤더에 비용을 넣고, Gateway가 이를 읽어 정산하도록 했다.

내부 API
  → 결과 행 수와 호출 횟수를 알고 있음
  → x-agent-cost 응답 헤더 설정

Gateway
  → 내부 응답 헤더에서 비용 읽기
  → 예약한 한도 정산
  → 남은 예산을 응답 헤더로 전달

사용자가 요청에 적은 비용을 믿는 구조는 아니다. 비용을 알려주는 쪽은 내부 응답을 생성한 서비스다. 반대로 내부 Controller가 이 헤더를 빠뜨리면 실제로 큰 결과를 반환해도 올바른 비용을 알 수 없다. 조회 API를 추가할 때 응답 DTO뿐 아니라 비용 헤더도 함께 확인해야 했다.

비용 헤더는 0 이상의 정수를 숫자 문자열로 전달하도록 했다. 헤더가 없거나 음수, 소수처럼 이 형식에 맞지 않으면 1로 정산한다. 예외가 발생한 경로도 기본값 1을 유지하고 finally에서 정산해 예약액이 그대로 남지 않도록 했다.

따라서 비용 헤더의 0과 헤더 누락은 다르다. 정상적으로 빈 결과를 반환해 0을 보냈다면 0으로 정산하지만, 헤더가 없으면 기본 비용 1이 된다. 정상 응답에서 헤더를 빠뜨린 경우를 오류 응답의 기본 처리와 같은 것으로 넘기면 비용 집계가 조용히 낮아질 수 있다.

실패 비용을 1로 두는 것도 완전한 부하 계산은 아니다. 비싼 쿼리를 실행한 뒤 실패했다면 서버 자원은 이미 사용했지만 정산에는 충분히 반영되지 않는다. 이 값은 실패 요청 처리 정책이며, 실패가 저렴하다는 측정 결과는 아니다.





순간 사용량과 한 시간 총량을 따로 제한했다

짧은 시간에 몰리는 요청과 오랜 시간 이어지는 사용량을 같은 조건 하나로 다루지는 않았다. 토큰 버킷과 한 시간 총량을 함께 검사했다. 여기서 토큰은 인증 토큰이 아니라 비용 단위를 뜻한다.

제한 동작 목적
토큰 버킷 정해진 용량 안에서 사용하고 시간에 따라 보충 짧은 시간의 집중 사용 제한
한 시간 총량 해당 주체의 첫 요청부터 한 시간 동안 누적 지속적인 대량 사용 제한

처음에는 모든 Key에 버킷 용량 1,000,000, 초당 보충량 3,000, 한 시간 총량 2,000,000을 적용했다. 외부 프로그램에서도 조회 API를 사용하게 하면서 Key별로 한도 등급을 지정할 수 있도록 확장했다.

등급 버킷 용량 초당 보충량 한 시간 총량
basic 1,000,000 3,000 2,000,000
extended 5,000,000 15,000 10,000,000
bulk 20,000,000 60,000 40,000,000

DB의 rate_tier에는 등급 이름만 저장하고 실제 수치는 서버 코드에서 관리했다. 기존 Key는 기본값 basic을 사용한다. 등급이 없거나 알 수 없는 이름이면 역시 basic으로 처리해 오타 때문에 제한이 풀리지 않게 했다. 이 값들은 부하 시험으로 적정성을 입증한 권장값이 아니라 사용 양상을 보면서 조정할 초기값이다.

비용 공식은 등급과 무관하게 같다. 같은 조회는 같은 점수로 정산하고, 등급은 사용할 수 있는 예산만 바꾼다. 높은 등급을 지정해도 조회 권한이나 페이지 크기 상한이 늘어나는 것은 아니다.

등급 변경을 반영할 때도 기존 사용량을 초기화하지 않는다. 다음 예약에서 전달된 등급을 적용하고, 남은 버킷은 새 용량을 넘지 않게 맞추며 시간당 누적 사용량은 유지한다. 따라서 등급을 낮춘 직후 이미 새 한도를 넘었다면 추가 요청이 거부될 수 있다. 인증 정보가 캐시되는 경로에서는 DB 변경이 즉시 반영된다고 가정하지 않고 캐시 갱신 시점까지 함께 봐야 한다.

배포 순서도 필요했다. 새 코드가 rate_tier를 읽기 전에 운영 DB에 컬럼을 추가하고, 기존 행에 basic이 적용되는지 확인하도록 마이그레이션을 마련했다.

두 조건을 모두 통과해야 예약한다. 버킷이 남아 있어도 한 시간 총량을 소진했다면 거부한다. 거부한 요청에는 비용을 추가 차감하지 않고 429와 Retry-After를 반환한다. 남은 버킷과 시간당 예산도 별도 응답 헤더로 전달했다.

버킷이 가득 찰 때까지의 시간과 다음 요청이 가능해질 때까지의 시간은 다르다. 후자는 요청의 추정 비용과 시간당 총량을 함께 봐야 한다. 클라이언트가 버킷 초기화 시간 하나만 보고 재시도하지 않도록 두 정보를 구분했다.

Retry-After도 그 시간이 지나면 반드시 통과한다는 보장은 아니다. 거부 시점의 잔액과 요청 비용으로 계산한 대기 시간이므로, 기다리는 동안 같은 자격 증명의 다른 요청이 예산을 사용하면 재시도도 거부될 수 있다.

한 시간 총량도 매시 정각에 초기화되는 것은 아니다. 해당 주체의 첫 요청에서 창을 열고, 창이 끝난 뒤 예약, 정산 또는 잔액 조회로 상태에 접근할 때 새 창을 시작한다. 최근 60분을 계속 합산하는 이동 창과는 다른 방식이다. 창 경계 양쪽으로 사용량이 몰릴 수 있으므로 이 총량 제한 하나만으로 짧은 시간의 집중 사용까지 막는다고 설명하지 않았다.

정산 중에 시간 창이 바뀌는 경우도 나눴다

요청을 예약할 때와 응답을 받을 때의 시간 창이 같다는 보장은 없다. 이전 창에서 예약한 요청이 새 창이 시작된 뒤 끝날 수 있다.

그래서 예약 정보에 창의 시작 시각을 남겼다. 정산할 때 현재 창과 다르면 이전 예약의 차액을 새 창의 시간당 사용량에 반영하지 않았다. 새 창에서 이미 사용한 비용을 과거 요청의 환불로 지우지 않기 위해서다.

버킷은 연속 보충 방식이므로 시간 경과를 반영한 뒤 정산했다. 환불해도 최대 용량을 넘지 않게 하고 추가 차감해도 0 밑으로 내려가지 않도록 했다. 버킷의 초과 비용을 음수 잔액으로 보존하지는 않으므로, 버킷만 보면 초과분 전체를 이후 요청에 빚처럼 이월하는 모델은 아니다. 반면 같은 시간 창의 누적 사용량에는 추가 비용을 반영한다. 버킷 잔액이 0에서 멈추더라도 시간당 사용량은 한도를 넘을 수 있고, 이후 예약은 이 사용량을 기준으로 거부될 수 있다.

테스트에는 예약보다 적거나 큰 정산, 버킷 상한과 하한, 시간당 한도 소진, 창이 바뀐 뒤 늦게 도착한 정산을 넣었다. 429 반환 여부뿐 아니라 정산 후 잔액과 시간당 사용량도 확인하도록 했다.

인증 수단별 한도와 사용자별 한도는 달랐다

한도를 세는 식별자는 API Key ID 또는 OAuth의 연결 ID인 sid로 만들었다. 문자열 앞에 인증 수단도 붙였다.

key:42
oauth:connection-a

OAuth access token이 갱신돼도 같은 sid라면 같은 예산을 사용한다. Key와 OAuth의 식별자가 우연히 같아도 서로 섞이지 않는다.

현재 한도 등급은 API Key에 저장한다. OAuth 인증 결과에는 별도 등급을 지정하지 않아 기본 한도를 적용한다. 같은 사용자의 Key가 bulk라고 해서 OAuth 연결까지 그 등급을 물려받는 것은 아니다.

대신 같은 사용자가 Key 여러 개나 OAuth 연결 여러 개를 가지고 있으면 예산도 나뉜다. 현재 제한은 사용자 전체의 총량 제한이 아니라 자격 증명 또는 연결 단위 제한이다. 사용자별 총량이 필요하다면 인증 수단별 예산 위에 별도의 사용자 한도를 추가해야 한다.





행 수 기반 비용으로 잡히지 않는 부하도 남았다

정산 기준을 통일했다고 조회 부하를 모두 제어한 것은 아니다. 특히 빈 결과는 비용이 0이다. 여러 데이터 노드를 읽고 아무 행도 반환하지 않았다면 사전 예약을 전부 돌려준다. 응답이 비어 있다고 실행 비용도 없는 것은 아니다.

같은 한 행이라도 문자열과 컬럼 수에 따라 응답 크기가 달라진다. 결과가 적어도 정렬이나 필터를 위해 많은 행을 스캔할 수 있다. 페이지 크기 300도 응답 행 수의 상한이지 DB가 읽은 행 수의 상한은 아니다.

특히 분 단위 시계열의 한 행에는 여러 항목의 값이 함께 들어간다. 한 항목을 담은 100행과 같은 운영 단위의 열 항목을 담은 100행은 호출 횟수도 같다면 같은 점수로 정산된다. 대상 수 제한을 따로 둔 이유도 여기에 있다. 행 수 기반 정산에 모든 부하 제어를 맡기지는 않았다.

공통 페이지 함수도 이미 조회하고 정렬한 배열을 slice()로 자르는 형태다. 이 함수를 쓰는 경로에서는 응답을 300행으로 제한해도 그 전에 전체 배열을 메모리에 올렸을 수 있다. 페이지 응답을 만들었다는 이유만으로 DB 조회량과 메모리 사용량까지 줄었다고 보지는 않았다. 필요한 경로에서는 조회 단계부터 범위를 제한해야 한다.

이력 조회는 이후 집계와 페이지 처리를 데이터 노드로 옮겼고, 시계열도 현재 페이지에 필요한 시간 범위를 운영 단위별로 묶어 요청하도록 바꿨다. 따라서 모든 조회가 전체 데이터를 받은 뒤 자르는 구조로 남아 있는 것은 아니다. 다만 데이터 노드가 페이지를 만들어 준다는 사실만으로 DB 스캔량까지 작아지는 것은 아니므로, 정산 점수와 실제 쿼리 작업량은 계속 구분한다.

남는 한계 별도로 필요한 제어
빈 결과나 실패 요청의 낮은 정산액 최소 실행 비용 또는 요청 횟수 제한
적은 결과를 만드는 비싼 조회 기간과 대상 수 제한, 쿼리 시간 제한, 실행 계획 점검
넓은 행과 큰 문자열 응답 크기와 메모리 제한
많은 동시 요청 동시 실행 수 제한과 대기열
추정액보다 큰 정산액 보수적인 예약 또는 초과 사용량 처리 정책
사용자 하나의 여러 연결 사용자 단위의 상위 예산

호출 횟수를 버리고 비용 제한 하나로 대체한다는 의미는 아니다. 요청 횟수는 작은 요청이 과도하게 반복되는 상황을 잡고, 비용 예산은 결과 규모가 다른 요청을 구분한다. 서로 다른 문제를 다루므로 함께 사용할 수 있다.

현재 제한 상태는 Gateway 프로세스의 메모리에만 있다. 단일 프로세스 운영을 전제로 만들었고 재시작하면 초기화된다. Gateway를 여러 프로세스나 서버로 늘리면 예산도 각각 생긴다. 그때는 예약과 정산을 원자적으로 처리할 공유 저장소가 필요하다. 요청 이력을 DB에 남긴다고 메모리 한도가 자동 복원되는 것도 아니다.

확인할 기준을 예약, 정산과 운영으로 나눴다

구간 확인 방법
예약 limit과 ID 개수로 추정하고 잘못된 값에도 정해진 범위를 유지한다.
거부 두 한도 중 하나라도 부족하면 거부하고 사용량을 추가 차감하지 않는다.
정상 정산 응답 행 수와 데이터 노드 호출 횟수에 맞는 값을 내부 헤더로 전달한다.
배치 조회 개별 항목이나 날짜 수 대신 변경된 배치 호출 단위를 비용 계산에 반영한다.
한도 등급 등급별 용량, 보충량과 시간당 한도를 적용하고 누락되거나 잘못된 이름은 기본 등급으로 처리한다.
등급 변경 기존 사용량을 보존하고 새 용량과 한도에 맞춰 다음 요청의 허용 여부를 판단한다.
빈 결과와 헤더 누락 명시적 비용 0은 그대로 정산하고, 누락은 기본값 1로 처리하는 차이를 확인한다.
오류 정산 예외에서도 예약을 정리하고 기본 비용 정책을 적용한다.
시간 경계 이전 창의 정산으로 새 창의 시간당 사용량을 변경하지 않는다.
인증 갱신 같은 OAuth 연결의 토큰 갱신으로 예산이 초기화되지 않는다.
확장과 재시작 메모리 상태 초기화와 프로세스별 예산 분리를 운영 제약으로 관리한다.
비용의 적정성 정산 값과 응답 시간, 실패율, 실제 DB 부하를 비교해 계수와 한도를 조정한다.
페이지 응답 total이 아닌 반환 행 수로 정산하고, 응답 전에 읽은 전체 데이터의 크기도 별도로 확인한다.

마치며

Agent 조회 API의 비용을 응답 행 수로 맞추면서, 같은 요청 한 번 안에서도 처리 범위가 다르다는 점을 반영할 수 있었다. 추정 비용으로 먼저 예약하고 내부 API의 응답으로 정산해 실행 전 판단과 실행 후 계산도 나눴다.

하지만 행 수는 자원 사용량의 대리 지표다. 빈 결과와 실패 요청, 비싼 정렬, 동시 실행과 프로세스 확장까지 해결하지는 않는다. 이번 제한은 조회 사용량을 같은 단위로 다루는 출발점으로 삼고, 요청 횟수와 실행 범위, 동시성 제어는 별도 경계로 남겼다.