ADR-0055: KIS 호출 한도를 PostgreSQL 공유 예산으로 통제한다 (기본 비활성)¶
- 상태: 제안됨
- 날짜: 2026-09-27
- 관련: ADR-0060(분 가격 수집만 전용 KIS 키로 분리 — 이 ADR 을 대체하지 않는다)
맥락¶
분봉 워커·업종지수·iNAV·EOD 배치 등 KIS 호출자는 같은 앱키 한도(공식 18/s)를 나눠 쓴다. 지금은 프로세스마다
PoliteClient 간격만 둔다. 그래서 합계가 한도를 넘을 수 있고(로컬 모의 벤더 측정 1초 창 최대 18~19),
호출자 사이에 우선순위도 없다. ETF 커버리지 확장을 검토하면서 이 합산 통제가 선행 조건으로 드러났다(ALPHA-1087).
결정¶
- 모든 KIS 발신 시도는 PostgreSQL
call_budget·call_budget_class행에서 발신 슬롯을 받는다 (call_budget_acquire한 번 = 짧은 트랜잭션 하나). - 슬롯은 균등 간격 예약이고 버스트는 0이다. 벤더 창이 고정인지 슬라이딩인지 몰라 보수적으로 잡는다.
- 등급 우선순위와 하한 몫(기아 방지)을 둔다.
- 시간 계약. DB 처리 시각은 호출자 시계로
[t_req, t_resp]어딘가로만 안다. 그래서t_resp + wait이후,t_req + wait + send_window이전에만 발신한다. 왕복이rtt_max를 넘으면 그 허용은 버리고 반환하지 않는다. - 발신 중단(fail-closed). 저장소에 닿지 못하거나, 응답이 불확실하거나, 대기 상한을 넘기면 발신하지 않는다.
- 저장소 호출 1회(잠금 대기·이름 해석·연결·TLS·질의)는
min(남은 대기, statement_timeout + 0.5s)안에 끝난다. 이름은 호출자가 기한 안에서 풀어hostaddr로 넘긴다(libpq 는 연결 시작 시 DNS 를 동기로 푼다). - 응답을 못 받았어도 서버가 예약을 확정했을 수 있다. 그 예약은 반환하지 않고 손실로 센다.
- 로컬 PostgreSQL 앞에 장애 주입 프록시를 두고 측정했다. 응답 유실과 연결 정지는 1.0s에서 끝났고, 불확실 구간의 발신은 0이었다.
- 기본 비활성.
call_budget_enabled=false이고 분봉 동시 요청은 1이다. 켜는 것은 별도 운영 결정이다. 꺼져 있으면 terraform 은 동시 요청 변수를 싣지 않는다(이전 이미지가 모르는 키로 기동을 거부한다). - Redis는 이번 문제에서 채택하지 않는다(보류 범위는 아래 대안).
대안¶
- Redis 토큰 버킷: 같은 정책을 로컬에서 구현해 비교했다. 편익은 저장소가 아니라 공유 우선순위 정책에서 나왔고, PostgreSQL로 처리량 기준(합계 ≥14/s)을 충족했다. 새 상주 구성요소와 장애 경계를 더할 이유가 부족해 보류한다. 학습용 비교 구현은 별도 작업이다.
- 프로세스별 간격 조정: 합계는 맞출 수 있어도 호출자 사이의 우선순위와 유휴 몫 재분배가 없다. 새 호출자가 생길 때마다 모든 간격을 다시 나눠야 한다.
- 단일 호출 조정 프로세스: 모든 호출을 한 프로세스로 모으면 그 프로세스가 단일 장애점이 된다. 배포 구조(ECS 서비스·배치 태스크 분산)도 바꿔야 한다.
결과¶
- 켜면 합산 속도와 우선순위가 한곳(DB 행)에서 정해진다. 발신마다 DB 질의 1회가 든다 (로컬 약 25질의/s, 예산 행과 등급 행을 잠근다).
- 저장소 장애는 수집 중단(
StopFetch계열, 오류 문자열CALL_BUDGET_*)으로 드러난다.http_failure()로 분류하는 경로에서만 실패 코드CALL_BUDGET_BLOCKED(TRANSIENT)가 붙는다. 예산 밖 로컬 폴백은 두지 않는다. - 활성화 전 확인이 의무다(ALPHA-1087 PR 본문):
- KIS 한도 창 유형 확인
- 워커→RDS 왕복 p99가
rtt_max보다 작은지와 폐기율 - KIS 발신→도착 편차
- RDS 연결·질의·잠금 부하
- window 완료 지연
- 토큰 발급 횟수
- 수동 백필 절차
- 대기 상한의 시작점과 끝점:
- 저장소 호출 1회:
acquire·ensure_connected진입부터 반환까지min(남은 대기, statement_timeout + 0.5s). pace(): 진입부터 반환·예외까지max_wait_sec안이다.- 저장소 장애 판정(
store_outage_max_sec)은 첫 실패를 관측한 시각부터 센다. 그래서 첫 실패 호출 1회와, 기한이 지난 뒤 진행 중이던 호출 1회·재시도 간격(0.2s)만큼 늦게 끝날 수 있다. 기본값에서 최대 12.2s이고, 이 경우에도max_wait_sec를 넘지 않는다. - 제외:
pace()가 돌아온 뒤 HTTP 발신(연결·TLS)까지의 지연. 이 구간은 벤더 도착 편차로 남는다. - TLS·DNS 경계 검증(2026-09-28):
- 환경: 운영 이미지 그대로(Python 3.12.14·psycopg 3.3.6 binary·번들 libpq 18.0.6·OpenSSL 3.5.8)와
hostssl만 허용한 PostgreSQL 16.14(TLSv1.3)를 썼다. 사이에 toxiproxy 를 두고, 조작 가능한 DNS 서버를 붙였다. - handshake 지연·SYN 무응답·응답 유실·부분 응답 뒤 정체·송신 정체·리셋·부분 응답 뒤 단절·1KB/s 대역: 전부 1.006s 안에 발신 없이 끝났다. 스레드·fd·서버 연결은 늘지 않았다.
- DNS: 수정 전에는 지연 3s 가 3.0s, 무응답이 20.0s 걸렸다. 수정 후에는 둘 다 1.004s다. 기한을 넘긴 조회는 저장소당 1개만 끝까지 돈다. 그래서 DNS 가 복구돼도 그 조회가 끝날 때까지는 재연결하지 않는다 (발신 없음). 무응답 기준 최대 20s다.
- 동시 4호출자가 한 저장소를 공유할 때 응답을 잃으면 3.4s(장애 판정 2s 설정)에 전원 발신 없이 끝났다. 그 뒤 연결은 1개로 복구됐다.
- 소스로 확인했지만 재현하지 못한 것:
- libpq TLS 읽기는
SSL_ERROR_WANT_WRITE에서 바쁜 반복을 한다(pgtls_read, 기한 검사 밖). - 이 반복에 들어가려면 두 조건이 모두 필요하다. 서버가 클라이언트의 쓰기를 요구하는 메시지를 보내야 하고 (TLS1.2 재협상 — PostgreSQL 은 9.5 부터 하지 않는다), 커널 송신 버퍼가 가득 차 있어야 한다. 이 경로는 질의 하나(수백 바이트)를 보내고 응답을 기다리므로 송신 버퍼가 차지 않는다.
- 그래서 구조 변경은 하지 않았다. 실제 RDS 의 협상 TLS 버전은 권한(
pg_stat_ssl비공개) 때문에 직접 읽지 못했다. - 이것은 "방어로 제거됨"이 아니다. 현재 경로에서 도달하지 않는다고 판단했으나 직접 재현하지 못한 한계다. 이미지의 psycopg·libpq·OpenSSL이 바뀌면(이미지는 빌드 시점 최신 의존성을 받는다 — ALPHA-1126) 이 판단을 다시 확인한다.
- libpq TLS 읽기는
- 여전히 검증하지 못한 경계: 실제 RDS·VPC 경로의 장애와 왕복 분포, 세션을 반쯤 열어 둔 채 사라지는 NAT 유형.
- 롤백(
false)은 전환 중 추가 호출만 막는다(pause 후 소진 대기). 되돌아간 기존 방식은 합계가 한도를 넘을 수 있다.
배포 상태(2026-09-29)¶
- PR #948(
d3d8c40f)로 dev에 기본 비활성 배포했다. 운영 적용(활성화)이 아니다. - 스키마
V202609291230적용. 예산·등급 행은 초기화하지 않았다(0행). - 배포 이미지: psycopg 3.3.6 binary·libpq 18.0.6·OpenSSL 3.5.8. TLS·DNS 경계 검증과 같은 조합이다.
- 활성 모드의 한도 준수·처리량 근거는 여전히 로컬 모의 KIS 검증이다.
- 후속:
- ALPHA-1124: KIS 호출 계측과 비활성 기준선.
- ALPHA-1125: 실환경 측정(M0~M4)과 활성화 결정.
- ALPHA-1126: 이미지 의존성을 lock으로 고정.
- 이 ADR의 상태(제안됨)는 팀 승인 전까지 그대로 둔다.