콘텐츠로 이동

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) 이 판단을 다시 확인한다.
  • 여전히 검증하지 못한 경계: 실제 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의 상태(제안됨)는 팀 승인 전까지 그대로 둔다.