# 분석 v2 실행 자동화 — 측정 기록 v2 전망·가격변동 실행 자동화의 기준 측정과 계약 재현 기록이다(ALPHA-1142, 동시 실행은 ALPHA-1157). 기준 측정, 로컬 검증, dev 검증, 실제 스케줄 관측을 구분해 적는다. ## 구성과 재현 - `aws/`: dev 조회 SQL(`q1.sql`~`q4.sql`)과 조회 스크립트(`dbq.sh`). - `local/`: 탐침 `probe.py`, 격리 DB `docker-compose.yml`. 탐침은 결과를 `local/results.jsonl`에 쓴다. 실행 구조 대안 비교는 `review_probe.py`(아래 '실행 구조 대안 검토'), 100~900건 동시 부하와 PgBouncer 비교는 `load900.py`·`pool_check.py`(아래 '로컬 동시 부하와 PgBouncer 비교'). - `dev/`: 배치 실행·조회 스크립트 `batch_run.py`. 결과를 `dev/results/`에 쓴다. `report <실행 이름>`은 끝난 실행을, `latest`는 가장 최근 실행(스케줄 발화 포함)을 집계한다. - 측정 원본(실행·조회 결과 JSON, 탐침 결과 JSONL)은 저장소에 두지 않는다. 위 결과 경로는 `.gitignore`에 있다. 이 문서 수치의 근거인 2026-10-02 원본 28개는 커밋 `66123f75`의 같은 폴더에 남아 있고 `git show 66123f75:tests/loadtest/analysis-v2/<경로>`로 꺼낸다. 아래에서 인용하는 원본 파일 이름은 그 커밋 기준이다. - 측정 시점 코드: `4311a19b`. 배포 이미지 `analysis-v2-cb9fa258`(작업 정의 9번)과 v2 소스 차이 0줄. 대상 목록 37종, 해시 `dc2373347520956f073a75214de38db1a8af9ebcd22a452efe73480f30f9225c`. ```sh cd tests/loadtest/analysis-v2/local EDGE_REPO=<저장소 루트> docker compose -p v2probe up -d postgres EDGE_REPO=<저장소 루트> docker compose -p v2probe --profile migration run --rm flyway migrate uv run --python 3.12 --with 'psycopg[binary]>=3.1' --with claude-agent-sdk==0.2.160 --with 'PyYAML>=6' --with 'jsonschema>=4' --with boto3 python probe.py sweep --kind outlook --c 2 --llm 6 uv run --python 3.12 --with 'psycopg[binary]>=3.1' --with claude-agent-sdk==0.2.160 --with 'PyYAML>=6' --with 'jsonschema>=4' --with boto3 python probe.py scenarios uv run --python 3.12 --with 'psycopg[binary]>=3.1' --with claude-agent-sdk==0.2.160 --with 'PyYAML>=6' --with 'jsonschema>=4' --with boto3 python probe.py slots docker compose -p v2probe down ``` 비밀번호 `local_only`는 로컬 컨테이너 전용이다. 실제 자격증명은 이 폴더에 없다. ## 기준 측정 (2026-10-02) **연결 상태** (코드 관찰과 AWS dev 조회) - v2 호출자는 없다. 스케줄러와 Airflow DAG 어디에도 v2 호출이 없고, app-api는 호출 권한만 있고 코드가 없다. v2 실행 이력은 수동 3건이 전부다. - v2 결과 소비자도 없다. tenant-sync-api와 super-admin은 v1 표(`explanation_*`, `tenant_delivery`)만 읽는다. v2 결과는 v2 조회 API로만 나간다. - v1 가격변동 경로는 가동 중이다. price-consumer → outbox → relay → SQS `price-explanation-realtime` → analysis-consumer(자동 확장 0~4) → `explanation_result` → `tenant_delivery`. 10월 1일에도 소비자가 처리했다. - v1에는 전망 생산자가 없다. v1 장마감 analyze 단계는 SFN에서 제거된 상태다. 전망은 v2 신규 기능이며 대체할 v1이 없다. - 트리거의 `entity_id`가 ETF 6자리 코드다. v2가 `minute_price_trigger`를 같은 값으로 조회한다. **실측** (dev, 표본이 작으므로 분포 추정에 쓰지 않는다) - 클라우드 실행 3건(전망 1, 가격변동 2, 모두 091160, 성공): SFN 전체 257초, 170초, 121초. 구간은 ECS 기동 21~28초, 컨테이너 시작 2초, 원천 읽기와 시작 처리 1초 이하(전망 1건), LLM과 도구 56~204초, 저장과 관측 내보내기 1초 이하, ECS 종료 25~27초. 도구 실행 합계는 0.1~0.8초다. - DB 이력의 실데이터 분석(첫 도구 시작부터 발행까지, 로컬 실행 포함): 전망 완료 11건 135~226초, 중앙값 161초, 실패 2건. 가격변동 완료 13건 56~112초, 중앙값 79초, 실패 0건. 대상 ETF 10종. - 전망 실패 2건은 모델이 낸 근거 ID 검증 실패 1건과 SDK 최종 응답 실패 1건이다. - 클라우드 1건 실행 중 `DatabaseConnections`는 20에서 23으로 3 늘었다(2건 관측). `FreeableMemory` 변화는 6MB 이내다. - RDS 7일: 연결 20~32, `FreeableMemory` 578~651MB, `max_connections` 181. 인스턴스는 db.t4g.small이다. - 역할 연결 한도(pg_roles 조회): writer 5, reader 3, api_reader 5. - 가격변동 트리거 큐 발행량(회수 포함, 8거래일): 하루 5~47건, 5분 최대 25건(9월 22일 09:00). 가장 오래 대기한 메시지는 하루 최대 13분~2.1시간. - 뉴스 배치는 00:10(00:20~00:22 종료)과 08:10에 돈다. 08:10 배치는 08:00까지 저장하는 전망에 들어갈 수 없다. 이 관찰은 뉴스 배치 스케줄에 한정되며 다른 입력의 최신성을 말하지 않는다. **로컬 실험** (PG 16.14 컨테이너 2GB, 실제 마이그레이션 77개, 실제 `worker.run`과 저장 코드. LLM, 원천 조회, S3는 대역) - 배포된 한도(writer 5, reader 3)에서 동시 1건은 성공한다. 동시 2건은 13회 중 11회가 1건만 성공, 2회가 0건 성공이며 2건 모두 성공한 경우는 없다. 동시 4건과 8건에서는 0~1건만 성공한다. 오류는 `too many connections for role`이다. - 한도를 로컬에서만 풀면 동시 37건이 모두 성공한다(writer 세션 111개, 컨테이너 메모리 약 120MiB → 341MiB). 전망 37건과 가격변동 37건을 함께 돌리면 `max_connections` 181에 걸려 74건 중 18건이 실패한다. - 재시도 계약: 실패한 시도는 원인과 무관하게 분석 ID를 소진한다. 워커가 시작하자마자 관측 manifest를 `IfNoneMatch`로 선점하기 때문이다. 완료 ID를 워커로 다시 실행해도 같은 이유로 실패한다. 저장 결과 재사용은 API 계층에만 있다. - 강제 종료된 실행은 `running` 행을 남기고 락은 풀린다. 새 ID 재시도는 성공한다. - 연결 한도나 ETF 락으로 진 실행은 DB 행을 남기지 않는다. 실패 사유는 SFN 실패와 S3 manifest에만 남는다. - 같은 ETF의 전망과 가격변동 병행은 ETF 락이 아니라 writer 한도 때문에 한쪽이 실패한다. 한도를 풀면 둘 다 성공한다. **기각하거나 정정한 가설** - '커넥션 풀 대기로 느리다': 기각. v2에는 풀이 없고 `psycopg.connect` 직접 연결이다. 연결 생성은 로컬 최대 76ms다. - '연결을 오래 쥐어 메모리가 고갈된다': 현재 요구 범위에서는 근거 없음. 건당 writer 3개를 LLM 대기 내내 쥐는 것은 사실이나, 지금 동시 실행을 막는 것은 메모리가 아니라 역할 한도 5다. - '건당 약 20분': 실측과 맞지 않는다. 건당 2~4분이다. 10월 1일 로컬에서 10종 전망을 2개씩 돌린 묶음이 약 19분 걸렸는데, 이 수치의 출처일 수 있다(추정). - '1,100초 제한과 충돌': 충돌하지 않는다. 실제 상한은 모델 대화 제한 300초(`run_model` 기본값)다. 관측 최대 226초는 첫 도구 시작부터 발행까지라 구간이 달라서 여유를 초 단위로 단정하지 않는다. 긴 꼬리 실패 위험은 남는다. - 'dev 문서의 최대 2개': 로컬 대시보드 경로(건당 writer 2개) 기준이다. 클라우드 워커 경로는 건당 3개라 2개 동시 실행이 성립하지 않는다. **미확인** - DeepSeek 동시 호출 한도(실측은 동시 2까지). - 실제 RDS에서 writer 세션이 늘 때의 메모리 변화(로컬 수치는 참고용). - 37종 전체의 건당 시간 분포(표본은 10종). - 가격변동 `analysis_at`의 의도된 기준(트리거 생성 시각으로 보이나 작성자 확인 전). - Step Functions 내장 기능의 실제 동작 네 가지(아래 계획에 표시). ## 전망 배치 (ALPHA-1142, 동시 실행은 ALPHA-1157) EventBridge Scheduler → 배치 워크플로(`edge-dev-analysis-v2-outlook-batch`) → Inline Map → 기존 단건 워크플로(`edge-dev-analysis-v2`) 중첩 호출. 정의는 `infra/terraform/modules/analysis-v2/outlook_batch.asl.json`, 자원은 같은 폴더의 `outlook_batch.tf`다. 분석 로직, 요청 계약, 기준시각과 결측 규칙은 바꾸지 않았다. ### 실행 계약 - **업무 기준시각**: 스케줄의 예정 시각이 `analysis_at`이 된다. 실제 시작이 늦어지거나 스케줄러가 재전달해도 같은 값이다. 수동 재실행은 같은 문자열을 입력으로 준다. 소수 초 없이 `Z`로 끝나는 UTC 표기(`2026-10-01T18:00:00Z`)만 받는다. 분석 ID가 이 문자열에서 나오므로 같은 순간의 다른 표기는 다른 작업이 된다. - **분석 ID**: `MD5(outlook:ETF:analysis_at:시도 번호)`. 시도마다 새 ID이고, 같은 작업의 같은 시도는 어느 배치에서 계산해도 같은 ID다. 논리적 작업은 종류, ETF, 기준시각이다. - **완료분 재사용**: 시도 0부터 해당 ID의 저장 상태를 조회 API(`GET /v2/analyses/outlook/{id}`)로 읽는다. 완료면 재사용, 실패면 다음 시도, 없으면 실행한다. '없음'은 앱이 낸 404(`Analysis not found`)만이다. API Gateway가 낸 404나 읽을 수 없는 응답은 `unknown`으로 두고 분석을 시작하지 않는다. 날짜별 최신 발행본은 보지 않으므로 같은 날 다른 기준시각의 작업을 건너뛰지 않는다. - **재시도**: 항목당 시도 2회(재시도 1회). 소진되면 그 항목은 실패다. `max_attempts`를 올려 재실행하면 실패 항목의 다음 시도만 돈다. - **저장 뒤 실패**: 단건 워크플로가 실패로 끝나도 같은 ID의 저장 상태를 다시 읽어 판정한다. 종료 코드보다 DB가 정본이다. - **동시 수**: 분석은 슬롯 수(`analysis_slots`, dev 12)만큼 동시에 돈다. 상한은 단건 워크플로의 자리 확보가 지고, 워커의 분석 슬롯이 한 번 더 확인한다. 아래 '동시 실행과 분석 슬롯' 참고. - **시작 전 확인**: 단건 워크플로의 실행 중 개수가 슬롯 수 이상이면 30초 간격으로 기다린다. 자리를 기다리기만 할 실행을 미리 시작하지 않기 위한 것이고 상한은 아니다. 자리를 기다리는 단건 실행도 실행 중으로 센다. 같은 작업을 다른 배치가 이미 돌리고 있으면 끝나기를 기다렸다가 그 결과를 쓴다. - **마감**: 기준시각과 같은 UTC 날짜의 23:00Z(= 08:00 KST)를 절대 시각으로 계산한다. 마감이 지나면 새 분석을 시작하지 않는다. 마감 뒤에 발행된 결과는 완료가 아니라 `late`로 센다. 정의의 `TimeoutSeconds`(8시간)는 마감 장치가 아니라 안전망이다. - **결과와 알람**: 전 항목이 마감 전 완료일 때만 성공한다. 그 밖에는 `OutlookBatch.Incomplete` 또는 `OutlookBatch.DeadlineExceeded`로 실패하고 cause에 건수와 미완료 항목이 실린다. 실패와 시간 초과 알람, 스케줄 전달 실패 DLQ 알람이 기존 알람 토픽으로 간다. ### 동시 실행과 분석 슬롯 (ALPHA-1157) - **두 겹의 상한**: 같은 `analysis_slots` 값을 두 곳에서 지킨다. 1. 단건 워크플로의 자리 확보(ALPHA-1161, 2026-10-02 23:23 KST 배포): Fargate를 띄우기 전에 실행 제어 Lambda가 `analysis_execution_slots` 표에 자리를 기록한다. 자리가 없거나 같은 종류·ETF가 이미 돌고 있으면 워크플로가 30초 간격으로 기다린다. 기다리는 동안 태스크도 DB 연결도 쓰지 않는다. 동작과 복구는 `src/apps/cloud/analysis-engine-v2/docs/price-automation.md`에 있다. 2. 워커의 분석 슬롯(ALPHA-1157): 아래 '슬롯'. 1번이 생긴 뒤로는 평소 기다릴 일이 없는 두 번째 확인이다(37종 실측 38건 모두 대기 0초). ALPHA-1167 뒤로는 워커가 실행 ARN을 받지 못한 경우에만 쓴다. - **슬롯**: 클라우드 워커는 ETF 락을 잡은 뒤, 원천을 읽기 전에 슬롯 하나를 잡는다(`cloud/worker.py`의 `acquire_slot`). 슬롯 수의 기본값은 1이고 dev는 12다(2026-10-06 ALPHA-1240, 이전 3). 연결 한도 마이그레이션과 새 워커 이미지가 dev에 배포된 것을 확인한 뒤 별도 변경으로 올렸다(머지마다 마이그레이션, 이미지, terraform이 순서 없이 따로 적용된다). 없으면 5초 간격으로 최대 10분 기다린다. 슬롯은 워커가 분석 내내 쥐는 락 연결의 세션 advisory lock이라, 태스크가 죽으면 연결과 함께 풀린다. - **적용 범위**: 단건 워크플로가 자리를 확보하고 슬롯 수(`ANALYSIS_SLOTS`)를 워커에 넘긴다. 배치, 조회 API의 분석 요청, 수동 시작, 가격변동 접수가 모두 이 워크플로를 지나므로 같은 상한을 받는다. **로컬 대시보드 실행은 슬롯을 잡지 않는다.** 클라우드 워커를 거치지 않기 때문이고, 이 경로는 통제하지 못한다. - **락과 감사 기록(ALPHA-1167)**: 단건 워크플로가 워커에 실행 ARN(`ANALYSIS_EXECUTION_ARN`)을 넘기면, 클라우드 워커는 ETF 락·워커 슬롯·분석 ID 락을 잡지 않고 DB 단계마다 연결을 열고 닫는다. 같은 종류·ETF 하나씩과 상한은 자리 표가, 같은 분석 ID 한 번은 실행 이름과 관측 manifest 선점이 맡는다. 새 결과는 발행 트랜잭션이 자기 자리 행을 `FOR SHARE`로 잠근 채 커밋하고, 자리가 이미 회수됐으면 발행하지 않는다. 툴 기록의 독립 커밋과 발행 트랜잭션 경계는 그대로다. 실행 ARN이 없으면(워크플로 정의가 워커 이미지보다 오래된 동안) 이전처럼 락 3개와 연결 3개를 쓴다. 로컬 대시보드도 이전과 같다. - **연결 수명**: 실행 ARN이 있는 클라우드 분석 1건의 연결이다. 모델을 기다리는 동안 writer 연결은 0개다. 단건 워크플로에서 자리를 기다리는 실행은 태스크를 띄우지 않고, 30초마다 실행 제어 Lambda가 writer 연결을 잠깐 연다(Lambda 예약 동시 실행 3이라 많아야 3개). | 연결 | 열림 | 닫힘 | 쥐는 것 | |---|---|---|---| | 원천 연결(reader) | 원천 읽기 | 원천 읽기 직후(초 단위) | | | 단계 연결(writer) | 단계마다: 시작 행, 이전 설명과 툴 정의 등록(한 번), 툴 기록마다, 초안 근거 검증, 발행, 근거 표 읽기, 전달 등록, 관측 내보내기 | 그 단계 끝 | 발행 때만 자기 자리 행(`FOR SHARE`) | | 실행 제어 Lambda(writer) | 호출마다 | 호출 끝 | 자리 확보와 반납 | 실행 ARN이 없는 경로는 락 연결(ETF 락·슬롯), 작업 연결(분석 ID 락·결과 저장), 감사 연결을 분석 내내 쥔다. - **연결 재시도**: 연결이 거절되거나 서버가 세션을 끊으면(연결 실패, SQLSTATE 08·57P01~57P03·53300) 그 단계 전체를 0.5초부터 두 배씩(실제 대기는 그 절반~전부 사이 무작위), 합 20초 안에서 다시 한다. 단계는 멱등이다: 시작 행·정의 등록은 충돌 시 비교, 발행은 완료 상태면 저장된 결과를 돌려주고, 툴 기록은 같은 값이면 저장된 결과를, 다르면 유일 키 위반을 낸다. 문장 시간 초과(57014), 제약·데이터 오류, 입력 오류는 다시 하지 않는다. 모델은 다시 부르지 않는다. - **역할 연결 한도**: writer 5 → 20, reader 3 → 6(`V202610022100`). 아래 표는 ALPHA-1157 당시(B 이전, 워커가 분석당 writer 연결 3개를 쥐던 때)의 산식이다. 지금 예산은 표 아래 'B 이후 연결 예산'을 본다. | 구성 | writer | reader | |---|---|---| | 실행 중 3건 | 9 (건당 3) | 3 (원천 읽기는 슬롯을 잡은 뒤에만) | | 워커에서 슬롯을 기다리는 태스크 | 6 (건당 1, 락 연결만) | 0 | | 로컬 대시보드 실행 2건(슬롯 밖) | 4 (건당 2) | 2 | | 여유 | 1 | 1 | | 합계 | 20 | 6 | 2026-10-02 dev 조회: 전체 세션 29, 최근 7일 최대 35, `max_connections` 181. `edge` 21, `airflow_meta` 4(한도 10), `app_sync_ro` 1, v2 역할 0(`aws/dbq-roles-20261002.json`). 동시 3건 실측(아래 '37종 전체 실측 — 동시 3건', B 이전)에서 writer는 최대 9, reader는 최대 3, 전체 세션은 최대 39였다. 단건 워크플로가 자리를 먼저 확보하므로 '워커에서 슬롯을 기다리는 태스크' 몫 6은 쓰이지 않았다. 실행 제어 Lambda와 분석을 마친 태스크의 내보내기 연결이 이 몫에서 나간다. **B 이후 연결 예산**(ALPHA-1167 이후, 한도 값은 그대로) - 실행 ARN을 받은 워커는 DB 단계마다 연결을 열고 닫으므로 분석당 장기 연결이 없다. - 10-04 dev 실측에서 성공 분석 1건은 writer 연결을 약 100회 열었고, DB 단계 시간 합은 약 5초였다. 분석 시간 약 6분 동안 평균 점유는 분석당 약 0.02 연결이다. - 그래서 writer 20은 다음 몫으로 나눠 본다: 실행 제어 Lambda(예약 동시 실행 3, 호출 동안 연결을 연 채 SFN·ECS를 묻는다), 로컬 대시보드 4, 동시에 시작하는 워커의 시작 단계(원천 읽기는 reader, 거시·재무·정의 등록은 writer). - 한도는 넘치는 연결을 거절할 뿐이다. 풀처럼 재사용하거나 기다리게 하지 않는다. 넘친 연결은 B가 지터를 둔 재시도(합 20초)로 다시 연다. - 실측 범위: 동시 3과 동시 37(설정) 각 1회에서 거절·재시도 0(아래 '동시 37 dev 실측'). - **툴 정의 동시 등록**: 동시에 시작한 분석이 같은 툴 정의를 처음 등록하면 한쪽이 `(function_name, version)` 유일 키에 걸려 실패했다(로컬 재현). 등록의 충돌 처리를 모든 유일 키로 넓혔다. 다른 정의를 거절하는 비교는 그대로다. ### 알려진 한계 - 자리 대기는 공정하지 않다. 기다리는 단건 워크플로는 30초마다 다시 확인할 뿐 줄을 서지 않는다. 배치는 실행 중 개수가 슬롯 수 이상이면 새로 시작하지 않으므로, 기다리는 다른 실행에 자리가 돌아간다. - 단건 워크플로의 자리 대기에는 시간 제한이 없다(워크플로 전체 제한 8시간). 배치가 단건을 기다리는 제한은 1,500초라서, 배치가 시작한 단건이 다른 실행에 밀려 오래 기다리면 배치 쪽 대기가 먼저 끝난다. 그때 단건이 중단되면 배치는 그 시도를 실패로 세고 다음 시도로 넘어간다. 중단되는지는 확인하지 못했다. 배치는 실행 중 개수를 보고 시작하므로 확인과 시작 사이에 다른 실행이 끼어들고 그 뒤로 자리가 20분 가까이 안 날 때만 생긴다. 관측한 적은 없다. - 실행 제어 Lambda는 예약 동시 실행 3이다(ALPHA-1166 전에는 1). 호출이 스로틀되거나 Lambda 서비스 오류가 나면 워크플로가 전용 규칙으로 8회까지 지터를 두고 재시도한다(간격 2초부터 두 배, 최대 20초). 함수 자체의 오류는 따로 3회 재시도한다(5초, 10초, 20초). 동시 실행 1이던 37종 실측에서 38건 중 2건이 스로틀됐고 모두 재시도로 넘어갔다. 수동 4건 동시 시작에서는 한 건이 3회를 다 썼다. 자리 확보에서 재시도를 다 쓰면 그 단건 실행은 분석 없이 실패하고 배치는 시도 하나를 잃는다. 분석이 끝난 뒤 자리 반납에서 다 쓰면 단건 실행은 실패로 끝나지만, 배치는 저장 상태를 다시 읽어 완료로 판정한다. 반납하지 못한 자리는 다음 자리 확보가 회수하는데, 근거는 실행 이력의 `STOPPED`, ECS의 `STOPPED`, RunTask를 부르지 않은 실행뿐이다. 확인하지 못한 자리는 점유로 남아 상한과 같은 종류·ETF 제약에 들어가고, 수동 복구를 기다린다(`src/apps/cloud/analysis-engine-v2/docs/price-automation.md`). ALPHA-1166 전에는 이런 자리의 태스크를 ECS가 `MISSING`으로 답하면 다른 ETF의 자리 확보까지 실패했다(아래 '실행 구조 대안 검토'의 C1). - (실행 ARN이 없는 경로) 워커의 슬롯 대기(두 번째 확인)는 10분 안에 슬롯이 안 나면 `SlotUnavailable`로 실패한다. 원천과 모델은 시작하지 않는다. 이 대기 중인 태스크는 writer 연결 1개를 쥔다. - 로컬 대시보드 실행은 두 상한 모두의 밖이다. 역할 한도에 2건 몫(writer 4, reader 2)을 잡아 두었을 뿐이다. - 보유종목 비중 합이 1을 넘는 ETF는 워커가 분석을 시작하기 전에 거부했다(ALPHA-1162, 분석 입력 검증). 2026-08-01 이후 26개 거래일 중 25일에 37종 가운데 1종 이상이 해당했다. 원인은 적재가 원천의 현금 행을 싣지 않아 현금이 음수이거나 반올림이 겹치면 주식만의 합이 1을 넘는 것이다. 지금은 적재되지 않은 원천 행이 있으면 받는다. 그런 행이 없는데 1을 넘으면 계속 거부한다. - (실행 ARN이 없는 경로) 분석을 마친 태스크는 연결 3개를 놓고 관측 내보내기용 1개를 잠깐 연다. 이 연결은 위 표의 '기다리는 태스크' 몫에서 나간다. - 자리를 잃은 옛 워커는 새 결과를 발행하지 못하지만, 모델 호출과 툴 기록은 끝날 때까지 계속할 수 있다(비용). 툴 기록은 그 분석 ID에만 묶여 다른 분석의 근거가 되지 않는다. - 연결 재시도의 합이 20초를 넘는 장애는 그 시도를 실패시킨다. 그때 DB가 실패 기록도 받지 않으면 행이 `running`으로 남을 수 있다(원래 오류는 그대로 올린다). - (실행 ARN이 없는 경로) 태스크의 호스트가 연결을 닫지 못하고 사라지면, DB가 끊긴 것을 알아챌 때까지 그 슬롯이 잡혀 있다. 프로세스 종료나 태스크 중지는 연결이 닫혀 바로 풀린다. - 슬롯 수의 검증 상한은 #1124(2026-10-04) 이후 1~40이다(Terraform 변수와 실행 제어 Lambda, 배치 Inline Map 동시 반복 한계). 그 전에는 1~3이었다. dev 실제 값은 3이다. 동시 37은 dev에서 한 번만 쟀다(아래 '동시 37 dev 실측'). - 진행 중인 분석은 마감이 지나도 중단하지 않는다. 끝난 뒤 `late`로 드러난다. - 강제 종료된 시도의 `running` 행은 그대로 남는다(조회 API는 실패로 판정한다). - 매일 돈다(주말·공휴일 포함, 2026-10-03 에 평일 전용에서 바꿨다). 휴장일에는 기준시각까지 보이는 마지막 거래일의 가격·수급을 읽는다(가격은 `trade_date < 기준일`·`available_at <= 기준시각`). - 기준시각은 스케줄 예정 시각과 같다. 기준시각을 시작 시각보다 앞당기려면 입력을 계산하는 단계가 따로 필요하다. - 스케줄은 당일 00:00~08:00 KST 사이여야 한다. 전망의 날짜는 기준시각의 한국 날짜이고, 마감은 기준시각과 같은 UTC 날짜의 23:00Z로 계산하기 때문이다. - 수동 실행 문서의 전망 기준시각 관례(해당 날짜 08:30 KST)와 다르다. 08:00 마감 전에 도는 배치에서는 08:30이 미래 시각이 되고, v2 API의 분석 요청은 미래 기준시각을 거절한다(워커를 직접 부르는 경로에는 이 검사가 없다). ### 검증 상태 | 단계 | 상태 | 근거 | |---|---|---| | 정의 문법 | 통과 | AWS `ValidateStateMachineDefinition` | | 계약 테스트(대역) | 22건 통과, 변이 16건 전부 검출 | `infra/terraform/modules/analysis-v2/tests/test_outlook_batch.py` | | 분석 슬롯(로컬, 실제 PostgreSQL) | 5개 시나리오 통과 (2026-10-02) | `local/probe.py slots`, `local/results-slots-20261002.jsonl` | | Terraform | `validate` 통과 | 37종 목록이 `sources.toml` 파싱 결과와 순서까지 일치(해시 동일) | | dev 소량 통합 | 통과 (2026-10-02) | 아래 'dev 검증' | | dev 37종 전체 실측(순차) | 실행 완료, **34종만 저장** (2026-10-02) | 아래 '37종 전체 실측' | | dev 동시 2건, 3건, 경합 | 통과 (2026-10-02) | 아래 'dev 동시 실행 검증' | | dev 37종 전체 실측(동시 3건, A·B 적용 전) | **37종 저장, 81분 32초** (2026-10-03) | 아래 '37종 전체 실측 — 동시 3건' | | 실제 스케줄 발화 관측 | 발화·마감 내 유효 입력 저장 확인, **37종 전체는 미달** (2026-10-04 06:00) | 34/37 저장, 입력 오류 3종으로 배치 `FAILED`. 아래 '실제 스케줄 관측' | | dev 동시 37(설정) 1회 | 유효 34종 저장 13분 5초 (2026-10-04, 일요일) | 아래 '동시 37 dev 실측' | 분석 슬롯 시나리오는 실제 `worker.run`과 저장 코드를 실제 PostgreSQL(마이그레이션 81개, writer 20, reader 6) 위에서 프로세스 여러 개로 돌린다. 모델, 원천 조회, S3는 대역이다. | 시나리오 | 결과 | |---|---| | 6건 동시 시작, 슬롯 3 | 6건 모두 성공. 모델 대기가 겹친 최대 건수 3, writer 세션 최대 12, 연결 거절 0 | | 6건 동시 시작, 슬롯 제어 없음(대조) | 6건이 한꺼번에 분석(겹침 6, writer 세션 18) | | 전망 3 + 가격변동 3, 슬롯 3 | 6건 모두 성공, 겹침 3 | | 슬롯을 쥔 태스크 강제 종료 | 기다리던 건이 종료 뒤 슬롯을 받아 완료 | | 대기 한도 초과(슬롯 1, 대기 2초) | `SlotUnavailable`, 모델 호출 0, DB 행 없음 | 계약 테스트는 상태 전이, 내장 함수, Retry와 Catch를 AWS의 실제 해석기(TestState API)로 평가한다. 대역은 조회 API, 실행 중 목록, 단건 워크플로 호출 셋이다. IAM 권한, 실제 API 응답 형식, 부모 중단 시 자식 정리는 dev 검증에서 확인한다. 자격증명이 필요해 CI에서는 돌지 않는다. ```sh AWS_PROFILE=edge uv run --with boto3 python -m unittest discover -s infra/terraform/modules/analysis-v2/tests -v AWS_PROFILE=edge uv run --with boto3 python infra/terraform/modules/analysis-v2/tests/mutate_outlook_batch.py ``` ### dev 검증 (2026-10-02, 배포 커밋 `390b1532`, 수동 시작) 실제 AWS dev에서 배치를 수동으로 시작해 확인했다. 스케줄 발화가 아니다. 실행별 원본은 `dev/batch_run.py`로 `dev/results/`에 다시 뽑는다(추적하지 않는 경로다). | 확인 | 실행 이름 | 결과 | |---|---|---| | 정상 실행 2종 | `small-20261002T0558Z` | 성공, 14분 38초. 단건 실행 3회. 091160의 첫 시도가 모델 대화 제한 300초로 실패(`TimeoutError`)했고 새 ID의 둘째 시도가 성공 | | 같은 입력 재실행 | `reuse-20261002T0558Z` | 성공, 1.8초, 단건 실행 0회. 실패한 시도 0을 건너 시도 1의 완료분을 재사용 | | 마감이 지난 뒤 시작 | `late-20261002T0558Z` | `OutlookBatch.DeadlineExceeded`, 0.5초, 단건 실행 0회. 실패 알람이 ALARM으로 전이 | | 같은 배치 2개 동시 시작 | `dupA-`, `dupB-20261002T0558Z` | 둘 다 성공. 단건 실행은 1개뿐이고 두 배치가 그 결과를 함께 썼다 | | 실행 중 부모 중단 | `abort-20261002T0558Z` | 자식 실행이 1초 뒤 ABORTED, ECS 태스크는 약 68초 뒤 종료(종료 코드 137). DB 행은 `running`으로 남는다 | | 중단 뒤 재실행 | `recover-20261002T0558Z` | 성공. 중단된 시도 0을 실패로 읽고 시도 1을 새 ID로 실행 | 대역 테스트가 덮지 못하던 것 중 여기서 확인된 것: - 조회 API를 IAM 인증으로 호출할 수 있고, 없는 분석의 404 cause에 `Analysis not found.`가 실린다. - 실행 중 목록 조회, 단건 워크플로 중첩 시작과 완료 대기, 발행 시각 비교가 실제 응답으로 동작한다. - 직전 항목이 끝난 직후의 총량 확인에서 30초 추가 대기는 생기지 않았다(전이 5회 관측). - 시도 하나에 붙는 배치 쪽 부대 시간은 약 5초다(조회 2~3회와 총량 확인). 확인하지 못한 것: 스케줄 발화로 시작한 실행, 실제 시각으로 마감을 넘기는 경우. ### 37종 전체 실측 — 순차 (2026-10-02 15:26~19:18 KST, 수동 시작) 실행 `full-20261002T0626Z`, 기준시각 `2026-10-02T06:26:00Z`. 원본은 `dev/results/full-20261002T0626Z.json`(항목별 시도와 시간)과 `full-20261002T0626Z.db-rows.json`(DB 행 45개)이다. | 항목 | 값 | |---|---| | 배치 결과 | `OutlookBatch.Incomplete` — 37종 중 **34종 완료, 3종 실패**(0190G0, 0005G0, 486240이 2회 모두 실패) | | 전체 소요 | 13,918초(3시간 52분) | | 단건 실행 | 45회: 성공 34, 실패 11. 재시도한 항목 8종 | | 성공 건 소요 | 203~359초, 중앙값 268초, 평균 272초, 합 9,256초 | | 실패 건 소요 | 평균 330초, 합 3,625초(약 60분) | | 실패 사유 | 모델 대화 제한 300초 초과(`TimeoutError`) 8건, 모델이 낸 근거 ID 검증 실패(`ToolInputError`) 3건 | | 발행 시각 | 첫 건 15:30:07, 마지막 건 19:17:33 KST | | 배치 부대 시간 | 1,037초. 그중 다른 실행을 기다린 대기가 29회(약 14.5분) | | 실행 이력 | 2,458 이벤트(한도 25,000) | | DB 연결 수(1분 최대) | 시작 전 10분 25, 실행 중 35. 장중이라 다른 서비스와 아래 수동 실행이 섞인 값이다 | | 실패 알람 | 19:19에 ALARM 전이 | - 완료 34종 중 13종은 직전 전망을 이어받은 갱신이고 21종은 첫 작성이다. - 실패는 모두 분석 실행 안에서 났다. 배치 계약(새 ID 재시도, 저장 상태 판정, 순차 실행)은 의도대로 동작했다. 실패율은 시도 기준 24%(11/45)다. - 실행 중에 다른 사람이 v2 분석을 수동으로 6건 시작했다. 4건이 도는 동안 배치는 총량 확인에서 기다렸다(15:38~15:45, 18:21~18:28). 나머지 2건(15:58, 377990)은 배치의 분석이 도는 중에 시작돼 `ResultDatabaseError`로 약 1분 만에 실패했다. 역할 연결 한도가 끼어든 쪽을 거절한 것이고 배치 쪽 분석은 영향받지 않았다. - 낮 시간 실측이다. 02:00 KST의 모델 응답 시간은 재지 않았다. - 워커 이미지는 실행 중에 작업 정의 18번(`analysis-v2-9e762b87`)에서 21번(`analysis-v2-937369fa`)으로 바뀌었다. 모델은 `deepseek-flash`, 모델 대화 제한은 300초였다. ### dev 동시 실행 검증 (2026-10-02 23:03~23:48 KST, 슬롯 3, 수동 시작) 실제 모델로 돌렸다. 앞의 세 실행은 단건 워크플로의 자리 확보가 배포되기 전(워커 슬롯만 있던 상태), 넷째와 다섯째는 배포된 뒤다. | 확인 | 실행 | 결과 | |---|---|---| | 동시 2건 | `c2-20261002T0635Z` (2종) | 성공, 10분 46초. 두 건이 같은 초에 시작해 겹쳐 돌았다. 한 건이 근거 검증 실패로 재시도해 성공. 연결 거절 0 | | 배치 2개 동시 시작(3종 + 2종) | `raceC-`, `raceD-20261002T0636Z` | 둘 다 성공, 5종 저장. 단건 실행은 최대 3개. 나머지 2종은 배치의 시작 전 확인에서 30초씩 20회 기다린 뒤 자리가 나자 시작. writer 세션 9, 전체 세션 38 | | 배치를 거치지 않은 수동 시작 4건 동시 | 단건 워크플로 직접 시작(기준시각 `06:37Z`) | 4건 모두 성공. 3건만 분석했고 넷째는 워크플로에서 30초씩 11회(6분 38초) 기다렸다. 앞 건이 끝난 14초 뒤에 시작. 기다리는 동안 태스크 0, writer 세션 9 그대로 | | 비중 합 1 초과 3종(현재 자료) | `weights-20261002T1610Z` | `OutlookBatch.Incomplete`. 069500, 0093A0, 388420이 두 시도 모두 55~76초에 `ValueError`로 거부. 모델 호출 0 (ALPHA-1162) | - 조회 뒤 시작 경합: 배치 2개 동시 시작에서는 시작 전 확인이 먼저 걸러 3건을 넘지 않았다. 확인을 거치지 않는 수동 4건은 단건 워크플로의 자리 확보가 막았다. 워커 슬롯까지 내려가 기다린 실행은 dev에서 없었다. 워커 슬롯의 대기와 상한은 로컬 시나리오(위 표)로만 확인했다. - 슬롯 1 상태에서 배치 2개를 동시에 시작한 실행(`raceA-`, `raceB-20261002T1200Z`)은 한쪽(069500)이 비중 합 검증에서 0.6초 만에 거부돼 대기를 보지 못했다. 이 실패가 ALPHA-1162의 출발점이다. ### 37종 전체 실측 — 동시 3건 (2026-10-02 23:50 ~ 10-03 01:12 KST, 수동 시작) 실행 `full3-20261002T0640Z`, 기준시각 `2026-10-02T06:40:00Z`. `batch_run.py report full3-20261002T0640Z`로 다시 뽑는다. | 항목 | 값 | |---|---| | 배치 결과 | 성공 — **37종 중 37종 완료**, 마감 뒤 발행 0 | | 전체 소요 | 4,891.7초(81분 32초) | | 단건 실행 | 38회: 성공 37, 실패 1. 재시도한 항목 1종(0190G0, 첫 시도 398초에 `ToolInputError`, 둘째 시도 335초에 성공) | | 성공 건 소요 | 240~455초, 중앙값 357초, 평균 365초, 합 13,494초 | | 동시 실행 | 최대 3, 평균 2.84(단건 소요 합 13,892초 ÷ 전체 소요) | | 대기 | 배치의 시작 전 확인 0회, 단건 워크플로의 자리 대기 0회, 워커 슬롯 대기 0초(38건) | | 실행 제어 호출 | 스로틀 3회(단건 2건), 재시도로 넘어감. 단건 시작부터 태스크 제출까지 중앙값 1.9초, 최대 17.2초 | | 발행 시각 | 첫 건 23:54:35, 마지막 건 01:11:59 KST | | DB 연결 수(1분 최대) | 시작 전 10분 31, 실행 중 36 | | 역할별 세션(표본 3회) | 시작 전 writer 0·reader 0·전체 29 → 원천 읽기 시점 writer 7·reader 3·전체 39 → 분석 중 writer 9·reader 0·전체 37. 한도는 writer 20, reader 6, `max_connections` 181 | | DB 자원 | `FreeableMemory` 최소 568 → 538MiB, CPU 최대 7.6 → 9.4% | | 실행 이력 | 1,687 이벤트(한도 25,000) | | 다른 실행의 간섭 | 없음. 실행 구간과 겹친 다른 단건 실행 0건 | **순차 실측과의 비교.** 조건이 달라서 전체 소요의 차이를 병렬화만의 효과로 볼 수 없다. | 조건 | 순차 (`full-20261002T0626Z`) | 동시 3건 (`full3-20261002T0640Z`) | |---|---|---| | 전체 소요, 저장 | 13,918초, 34/37 | 4,892초, 37/37 | | 시도, 실패 | 45회, 실패 11(시간 초과 8, 근거 검증 3) | 38회, 실패 1(근거 검증 1) | | 단건 소요 합(성공 + 실패) | 12,881초 | 13,892초 | | 성공 건 중앙값 | 268초 | 357초 | | 기준시각, 대상 | `06:26Z`, 37종 | `06:40Z`, 같은 37종 같은 순서. 둘 다 10-01 보유종목 스냅샷 | | 배포 코드(dev) | `390b1532` | `1a78fb96` | | 워커 이미지 | 작업 정의 18 → 21번 | 33번(`analysis-v2-66123f75`) → 34번(`f7b15346`, 문서만 바뀐 커밋의 재배포) | | 모델, 대화 제한 | `deepseek-flash`, 300초 | `deepseek-flash`, 600초(#1089) | | 실행 제어 | 실행 중 1건이면 대기, writer 한도 5 | 자리 3, writer 한도 20 | | 간섭 | 다른 수동 실행 6건, 대기 29회(약 14.5분) | 없음 | | 시간대 | 장중 낮 | 밤 | - 같은 34종의 성공 건을 짝지으면 32종이 이번에 더 오래 걸렸다(평균 1.39배). 그 사이 분석 로직 커밋이 여러 건(#1079, #1080, #1089 등) 들어갔고 동시에 3건을 돌렸다. 어느 쪽 때문인지는 이 측정으로 가를 수 없다. 참고로 슬롯 1 상태(순차)에서 새 이미지로 돈 396500 한 건은 397초였다(순차 실측 209초, 이번 350초). 표본이 한 건이다. - 실패가 11회에서 1회로 줄었다. 시간 초과 8건이 0건이 된 것은 대화 제한이 600초로 늘어난 것과 시기가 겹친다. 병렬화의 효과로 볼 근거는 없다. - 단건 소요 합은 두 실측이 비슷하다(12,881초, 13,892초). 전체 소요가 줄어든 것은 같은 양의 일을 평균 2.84건씩 겹쳐 돌린 결과다. - 이번 37종 저장은 10-01 스냅샷 기준이다. 같은 날 15:52 KST에 적재된 10-02 스냅샷으로는 3종이 거부된다(위 'dev 동시 실행 검증'). ### 시작 시각 **매일 06:00 KST**다(주말·휴일 포함 — ALPHA-1142. 06:00 은 ALPHA-1157, 이전 값은 02:00). 기준시각도 같은 시각이고 마감은 08:00 KST다. 동시 수는 12다(2026-10-06 ALPHA-1240, 이전 3). 아래 표는 동시 3 기준이며, 12에서의 첫 정기 실행은 아직 재지 않았다. | 경우 | 끝나는 시각 | 08:00까지 여유 | |---|---|---| | 동시 3건 실측 그대로(81분 32초, 실패 시도 1회) | 07:22 | 38분 | | 순차 실측만큼 실패가 날 때(실패 시도 11회. 늘어나는 10회를 건당 약 400초, 2.84건씩 겹쳐 약 23분 추가) | 07:45 | 15분 | | 스케줄 전달이 재시도 창 끝(30분)까지 늦을 때 | 07:52 | 8분 | | 위 둘이 겹칠 때 | 08:15 | 없음. 08:00 뒤에는 새 분석을 시작하지 않고 남은 항목이 `deadline_exceeded`로 남는다 | - 동시 2건이면 단건 소요 합 13,892초를 둘로 나눠도 약 116분이라 여유가 4분뿐이다. 그래서 최종 동시 수는 검증한 최대값 3이다. - 실측 한 번에서 계산한 값이며 마감 보장이 아니다. 06:00 KST의 모델 응답 시간은 재지 않았다(실측은 23:50~01:12 KST). - 스케줄은 `flexible_time_window`가 꺼져 있어 평소 지연은 초 단위다. 30분은 대상 호출이 실패해 재전달될 때의 창(`maximum_event_age` 1,800초)이다. - 재시도는 항목당 1회 그대로다. 미완료는 배치 실패와 알람으로 드러난다. - 비중 합 검증(ALPHA-1162)이 풀리기 전에는 해당 종목이 있는 날 37종 전체 저장이 되지 않는다. 시작 시각과 무관하다. ### 실제 스케줄 관측 **첫 발화 관측(2026-10-04 일요일)**: 배치 실행 `636ac16c-d03f-4968-8ea2-fc85e2d03ac6`. - 실행 조건: B 적용 이미지(`ebe41e47`, 태스크 정의 37), 동시 3, 모든 시도에 실행 ARN이 전달됐다. - 원본: `dev/results/636ac16c-….json`(`batch_run.py report`)과 `….metrics.json`(`run_metrics.py`). gitignore 대상이라 저장소에는 없고 아래 명령으로 다시 만든다. - 발화: `input.analysis_at`은 `2026-10-03T21:00:00Z`(예정 시각)였다. 06:00:08 KST에 시작해 07:16:54 KST에 끝났다(76분 46초). - 결과: `completed` 34, `failed` 3(069500, 388420, 0093A0. 두 시도 모두 `ValueError` — ALPHA-1162 비중 합 검증 거부, 모델 호출 0), `late`·`deadline_exceeded`·`unknown` 0. 배치 상태는 `FAILED`(`OutlookBatch.Incomplete`)다. - 실패 시도 7건 가운데 6건이 위 입력 오류이고, 1건은 `ToolInputError`로 재시도에서 저장됐다. - 마감: 실패 배치의 보고서에는 항목별 `published_at`이 없다. 대신 배치가 07:16:54에 끝났으므로 저장 34건은 모두 08:00 전이다. - 동시성과 대기: `max_concurrent_children` 3. 게이트·자리 대기 0, 워커 슬롯 대기 0, 실행 제어 실패 0. 첫 시도 시작까지 대기는 중앙 2,039초(자리 순서)였다. - DB: RDS 연결 최대 27, CPU 최대 8.0%(실행 전 6.5%), 여유 메모리 최소 580 MB. 워커 DB 재시도 0. - 알람: `edge-dev-analysis-v2-outlook-batch-execution-failed`가 07:17에 `ALARM`, 07:32에 `OK`로 바뀌었다. 같은 날 11:31의 `ALARM`은 수동 실험 배치(`stage2-c37-20261004`)의 실패(같은 입력 오류 3종)다. - 판정: 정기 스케줄 발화와 08:00 마감 안의 유효 입력 34종 저장은 확인했다. 37종 전체 저장은 ALPHA-1162 입력 오류 때문에 미달이다. ```sh cd tests/loadtest/analysis-v2/dev AWS_PROFILE=edge uv run --no-project --with boto3 python batch_run.py latest # 또는 report <실행 이름> AWS_PROFILE=edge uv run --no-project --with boto3 python run_metrics.py <실행 이름> ``` 다음 발화부터 볼 것은 위와 같다: 예정 시각과 시작 지연, `summary`(완료·실패·late·deadline_exceeded·unknown), 실패 사유, 동시 수와 대기, RDS 지표, 알람. 시간 목표(08:00 전 종료)와 37종 전체 저장은 따로 판정한다. ## 가격변동 연결 (ALPHA-1161, 담당 정준영) 가격변동 큐의 소비자는 2026-10-02에 dev에서 v2로 바뀌었다. 동작, 전환 절차와 검증 기록은 `src/apps/cloud/analysis-engine-v2/docs/price-automation.md`에 있고 여기에는 전망과 함께 쓰는 자원만 적는다. 위 '기준 측정'의 연결 상태는 그 전 시점의 기록이다. - 전망과 가격변동이 같은 단건 워크플로, 같은 자리 수(`analysis_slots` 12, 10-06 이전 3), 같은 writer 역할 한도(20)를 쓴다. 가격변동 분석이 도는 동안 전망 배치는 시작 전 확인이나 자리 확보에서 기다린다. - 가격변동은 장중에 발생하므로 06:00~08:00 KST의 전망 배치와 평소 겹치지 않는다. 겹쳤을 때의 전망 소요는 재지 않았다. - 트리거 큐 발행량(5분 최대 25건)은 회수를 포함한 수치다. 분석 요청 수로 그대로 쓰지 않는다. ## 실행 구조 대안 검토 (2026-10-03, 로컬) 질문은 하나다. 모델을 기다리는 동안 DB 연결을 쥐지 않으면서 중복 실행과 재시도를 안전하게 처리할 가장 단순한 구조는 무엇인가. 코드 대조, 로컬 실험, AWS 읽기 조회만 했다. 서비스 코드, 배포, 스케줄, 역할 한도는 바꾸지 않았다. 기준 코드는 `604e5e9f`(origin/dev, 10-03 13:50 조회까지 변경 없음)다. 이 절에서 '지금 배포'·'지금 회수 판정'은 그 코드를 말한다. 그 뒤 채택해 dev에 적용한 동작은 이 절 끝의 '구현 결과'에 있다. ### 결론 **대안 B는 조건을 갖추면 채택할 수 있다.** B는 단건 워크플로의 자리 표(`analysis_execution_slots`)를 실행 소유권으로 그대로 쓰고, 워커는 DB 연결을 연산마다 열고 닫는 안이다. 시간 만료 lease와 heartbeat는 새로 만들지 않는다. 조건은 넷이다. 1. 클라우드 실행은 모두 자리 표를 거친다. IaC에서 워커 태스크를 시작할 권한은 단건 워크플로 역할에만 있다(`infra/terraform/modules/analysis-v2/main.tf`의 `ecs:RunTask`). 수동 RunTask와 로컬 대시보드는 밖이다. 2. 태스크를 확인하지 못한 채 자리를 회수하는 틈을 막는다. 지금 회수 판정(`cloud/control.py`의 `tasks_stopped`)은 실행 이력에 태스크 제출 기록이 없으면 ECS를 묻지 않고 회수한다(실험 C2). RunTask 호출 도중 실행이 중단되면 태스크가 떠 있어도 기록이 없을 수 있다. 지금은 워커의 ETF 락이 이때 둘째 실행을 거절하지만 B는 그 락을 없앤다. 그래서 B에는 발행 저장 트랜잭션에서 자기 자리 행이 아직 있는지 확인하는 보완이 필요하다(아래 '구현 전에 해결할 것' 2). 3. 한 시도 안의 재시도는 연결 계열 오류에만, 트랜잭션 단위로, 시간 상한을 두고 한다. 툴 기록 저장은 유일 키 위반 때 되읽어 같은지 비교한다(실험 S, R). 4. 연결 생성 비용을 줄인다. 비밀값과 boto3 클라이언트를 프로세스 안에 캐시하고 툴 정의 등록과 관측 내보내기를 한 연결로 묶는다. 이 환경의 새 연결 1회는 수 밀리초가 아니다(아래 '연결 비용과 풀'). 이 밖에 B와 무관하게 **지금 배포에 있는 결함 하나**를 찾았다(실험 C1). 반납 없이 끝난 실행의 자리가 남아 있는 동안 ECS가 그 태스크를 `MISSING`으로 답하게 되면, 그 뒤 모든 종류·ETF의 자리 확보가 실패한다. 회수 판정이 `MISSING`을 '종료 확인 불가'로 보고 예외를 내기 때문이다. 멈춘 지 13시간 지난 태스크가 `MISSING`인 것은 확인했고, 몇 시간부터 그렇게 되는지는 재지 않았다. 10-03 dev 조회로는 지금 남은 자리가 없다고 판단한다(아래 C1). 확인 범위는 그 절에 적었다. 지금 바로 B가 필요한 것은 아니다. 현재 구조로 dev에서 37종을 81분 32초에 저장했다(동시 3건). B를 구현하더라도 먼저 동시 3건을 유지한 채 연결 수명만 바꿔 검증하고, 동시 수 상향은 별도 단계로 잰다. 두 변경의 효과를 섞지 않기 위해서다. ### 연결 3개의 목적 | 연결 | 세션 락 | 그 밖의 쓰임 | 클라우드에서 같은 보장을 주는 것 | |---|---|---|---| | 락 연결 | ETF 락 `cloud:{종류}:{ETF}`, 워커 슬롯 `cloud:slot:{i}` | 거시·재무 입력 읽기, 가격변동 전달 등록 | 자리 표의 `request_key` 유일 키와 자리 수 | | 작업 연결 | 분석 ID 락 | 시작 행, 이전 설명 읽기, 초안 근거 검증, 발행 저장, 실패 기록 | 실행 이름 = 분석 ID, 관측 manifest 선점(`IfNoneMatch`) | | 감사 연결 | 없음 | 툴 기록 저장 | 락이 없어 연결을 짧게 써도 계약이 그대로다 | - 세션 락 셋은 연결 둘에 있다. 감사 연결이 따로인 것은 락이 아니라 저장 분리 때문이다. `ToolStore`는 발행 트랜잭션과 섞이지 않는 전용 연결을 요구한다(`_require_idle`). 툴 호출은 분석 안에서 한 번에 하나다(`agent/runner.py`의 `gate`). - 발행 때 잡는 ETF별 락(`pg_advisory_xact_lock`)은 트랜잭션 락이라 B에서도 그대로다. ### 자리 표가 보장하는 것과 아닌 것 | 성질 | 보장 | 근거 | |---|---|---| | 같은 종류·ETF 동시 실행 방지 | 한다(자리 표를 거치는 실행) | `request_key` 유일 키, 실험 C5 | | 전체 동시 수 상한 | 한다 | 트랜잭션 락(`analysis-v2-capacity`) 안에서 센다, 실험 C5 | | 끝난 실행의 태스크가 살아 있으면 회수 보류 | 이력에 태스크 ARN이 있을 때만 한다 | 실험 C3 | | 태스크 제출 기록이 없는 끝난 실행 | ECS를 묻지 않고 회수한다 | 실험 C2 | | 접수 순서나 `analysis_at` 순서대로 실행 | 하지 않는다. 기다리는 실행은 30초마다 다시 묻고 먼저 묻는 쪽이 자리를 받는다 | `single_analysis.asl.json`의 `Wait` | | 늦게 끝난 결과가 더 새 결과를 덮지 않음 | 자리 표가 아니라 전달 가드와 최신 조회 정렬이 한다(가격변동) | 실험 O1 | 순서가 뒤집힌 가격변동은 이렇게 된다(실험 O1). 같은 ETF의 t1(10:00) 사건이 t2(10:10) 뒤에 실행되면 t1은 완료되고 자기 행도 게시되지만, t2가 이미 전달돼 있어 금융사로 전달되지 않는다(`enqueue_movement`의 `analysis_at` 비교). 최신 조회는 더 새 것을 고르고, 뒤 사건 t3의 이전 설명 사슬에도 t1은 들어가지 않는다. 덮어쓰기는 없지만 t1의 정보는 빠진다. 늦은 사건을 합칠지, 버릴지, 따로 전달할지는 정해지지 않은 제품 정책으로 남긴다. ### 대안 비교 | 대안 | 소유권과 중복 | 실패 복구 | 바꿀 범위 | 판단 | |---|---|---|---|---| | A 현재 유지 | 세션 락 3개와 자리 표가 겹친다 | 분석 중 연결이 끊기면 그 시도는 실패하고, 발행 저장 도중이면 실패 기록도 못 남긴다(실험 F3) | 실행 제어 수정만 | 동시 3건까지는 충분 | | B 자리 표 + 짧은 연결 | 자리 표, 실행 이름, manifest. 회수 불확실성은 발행 시 자리 확인으로 막는다 | 연결 계열 오류를 그 시도 안에서 재시도한다 | 워커, `execute_request`, `ToolStore`의 연결 수명, 재시도, 캐시, 발행 시 자리 확인 | **조건부 추천** | | C DB lease + 소유권 번호 | 시간 만료, heartbeat, 모든 쓰기에서 번호 확인 | heartbeat가 끊기면 중단할지 넘길지 정해야 한다 | 새 표, 갱신 루프, 쓰기 경로 전부 | 채택하지 않음. 자리 표가 태스크 종료 확인으로 같은 일을 하고, 남은 틈은 B의 2번으로 막힌다 | | D 큐 + 고정 워커 | SQS 가시성 시간, FIFO 그룹 | 처리가 가시성 시간을 넘으면 같은 메시지가 다른 워커에 다시 간다. 가시성 연장이나 저장 시 소유 확인이 필요하지만 DB lease가 꼭 필요한 것은 아니다 | 배치, API 상태 조회, 접수, 배포를 다시 짠다 | 채택하지 않음. 이유는 전환 비용이다. 이득은 건당 ECS 기동·종료 약 50초다 | ### 연결 비용과 풀 - dev 읽기 조회(CloudWatch). 실행 제어 Lambda는 호출마다 boto3 클라이언트 생성, Secrets Manager 조회, TLS 연결, 쿼리, SFN·ECS 조회를 한다. 10-02 23:45~10-03 01:20 KST(37종 동시 3건 실측과 비중 합 확인 실행) 93회의 소요는 p50 1.31초, p90 2.22초, 최소 1.03초, 최대 4.72초였다(메모리 256 MB). API Lambda는 요청마다 클라이언트 생성, 비밀값 조회, TLS 연결, 쿼리를 한다. 09-26~10-03 964회의 소요는 p50 204 ms, p90 460 ms였다(512 MB). 둘 다 구간을 나눠 재지 못했고 워커(1 vCPU)의 연결 1회 비용도 재지 않았다. 새 연결 1회가 수백 밀리초일 수 있다는 상한으로만 쓴다. - 실행 제어 Lambda는 동시 실행 1에서 호출 1회가 1초 이상이라 초당 1건도 처리하지 못한다. 스로틀은 동시 실행 수만이 아니라 호출 시간 문제이기도 하다. - B의 연결 수(실험 K, 툴 호출 수에 따라 변한다). 전망 1건(툴 40회)은 79회로, 툴 기록 42, 툴 정의 등록 17, 관측 내보내기 10, 그 밖 10이다. 가격변동 1건(툴 12회)은 47회다. 정의 등록과 내보내기를 한 연결로 묶으면 전망은 약 54회다(산정). | 비용 | B 그대로 | 비밀값·클라이언트 캐시 | 프로세스별 작은 풀(유휴 몇 초 뒤 닫기) | 공유 프록시(RDS Proxy 등) | |---|---|---|---|---| | 모델 대기 중 연결 점유 | 없음 | 없음 | 유휴 시간만큼 | 클라이언트 쪽 점유는 그대로, DB 쪽은 줄어든다 | | 연결마다 TLS·인증·서버 프로세스 생성 | 매번 | 매번 | 툴 호출이 몰릴 때 줄어든다 | DB 쪽은 줄고 클라이언트-프록시 TLS는 남는다 | | Secrets Manager 반복 조회 | 매번 | 없어진다 | 없어진다 | 인증 방식에 따라 다르다 | | 워커별 유휴 연결의 합 | 없음 | 없음 | 최대 워커 수만큼 | 프록시가 묶는다 | | 동시에 시작할 때의 순간 부하 | 역할 한도에 걸리면 연결 재시도가 줄 세운다(실험 2) | 같음 | 같음 | 프록시가 줄 세운다 | - 판단: 비밀값·클라이언트 캐시와 정의 등록·내보내기 묶기는 B에 넣는다. 작은 풀과 프록시는 워커의 연결 1회 비용을 잰 뒤 정한다. 프록시는 상시 비용이 들고, 클라이언트 쪽 TLS가 남는다. - 가장 작은 후속 측정: B 첫 구현에 연결마다 '비밀값 조회 시간'과 '연결 시간'을 나눠 남기는 로그를 넣고, dev 동시 3건 실행 한 번에서 분포를 본다. 구현 전에 숫자가 필요하면 기존 `edge-dev-db-query` 태스크로 연결 50회를 재는 방법이 있다(AWS 태스크 실행이라 사용자 승인 뒤). ### 실험 `local/review_probe.py`. probe.py와 같은 컨테이너(PG 16, 2 GiB, `max_connections` 181), 마이그레이션 87개, 실제 `worker.run`, `execute_request`, 저장 코드, 실행 제어 함수 `control`을 쓴다. 모델, 원천 조회, S3, SFN·ECS 응답은 대역이다. B는 서비스 코드를 바꾸지 않고, 결과 DB 연결을 연산마다 열고 닫는 대역(`ShortConnection`)으로 바꿔 끼워 흉내 냈다. 저장 재시도(F3의 `--save-retry`)와 툴 기록 되읽기(R)도 탐침 안의 시험 구현이고 서비스 코드에는 없다. 이 탐침에는 자리 표가 없고 모든 실행이 워커 슬롯 99(사실상 제어 없음)로 돈다. 그래서 1·2의 A 결과는 배포 상태(자리 3에서 3건씩 실행, dev 37종 실측에서 연결 거절 0)의 실패율이 아니라 '한꺼번에 띄우면 무엇이 먼저 막히는가'다. 모델 대기는 30초(실제 240~455초)이고 툴 호출 40회(dev 실데이터 전망의 중앙값 41회)를 그 사이에 나눠 부른다. 툴 호출이 실제보다 약 12배 촘촘하다. **연결 점유와 역할 한도** | # | 조건 | A 현재 | B 짧은 연결 | |---|---|---|---| | 1 | 전망 37건 동시, 역할 한도 없음 | 37/37. writer 최대 111, 연결-초 3,392 | 37/37. writer 최대 29, 연결-초 43, 연결 2,929회 | | 2 | 전망 37건 동시, 한도 writer 20·reader 6 | 6/37. reader 거절 29회, writer 거절 2회 | 재시도 없음 12/37(reader 거절). 연결 재시도 10초 37/37, writer 최대 12 | | 3 | 가격변동 10건 동시, 20·6 | 6/10 | 연결 재시도 10초 10/10 | B에 남는 부하는 시작 순간의 원천 읽기(reader)다. 실제 클라우드에서는 태스크 기동 시각이 흩어진다(재지 않았다). **DB 장애** (전망 3건, 한도 20·6) | # | 조건 | A | B 재시도 없음 | B 재시도 있음 | |---|---|---|---|---| | F1 | 모델 대기 중 writer 세션 전부 종료 | 0/3, 행 `running` | 3/3 | 3/3 | | F2 | 모델 응답을 받은 뒤 저장 전에 writer 세션 종료 + 로그인 5초 차단 | 0/3, 행 `running`(실패 기록도 못 남김) | 0/3, 행 `running`(실패 기록 연결도 거절) | 3/3(연결 재시도 15초), 모델 재호출 0 | | F3 | 발행 트랜잭션 도중(첫 항목 INSERT 직전) 그 세션 종료 | 실패. 실패 기록도 못 남겨 행 `running`, 오류가 `ValueError`로 보인다 | 실패, 행 `failed`. 남은 항목 0행 | 완료(트랜잭션 재시도 1회), 항목 21행 = 기준, 모델 호출 1 | | F4 | 커밋은 됐는데 호출자는 연결 오류를 받음 | — | 워커는 실패로 끝나지만 DB는 `completed`, 항목 21행 | 완료(재호출이 저장된 결과를 돌려줌), 모델 호출 1 | - F1에서 B가 산 것은 그 순간 열린 연결이 없어서다. 연산 도중에 끊기면 F3처럼 그 트랜잭션은 되돌려지고, 트랜잭션 단위 재시도가 있어야 산다. - 주입 단계는 분석마다 확인했다. F1은 모델 시작 뒤·모델 끝 전, F2는 세 분석 모두 모델 응답 뒤·발행 저장 시작 전이다(F2의 툴 기록 126건은 모두 장애 전에 저장됐다). 처음 F2는 전체 시작 기준 시각으로 주입해 실제로는 마지막 툴 기록 저장에 걸렸다(툴 기록 123건). 로컬 리뷰가 이를 짚어 모델 응답 표시를 기다려 주입하도록 고쳤고, 결과 성공 수는 같았다. - F3의 A: 끊긴 연결로 실패 기록을 하려다 `PublicationStore._idle` 검사에 걸려 원래 오류 대신 `ValueError`가 남는다. - F4는 전망이면 배치가 DB 상태로 판정하므로 해가 없다. 가격변동이면 워커가 저장 뒤 전달 등록 전에 실패로 끝나 금융사 전달이 빠진다. 그 사건은 자동으로 다시 실행되지 않는다(아래 '재시도'). **중복** (B) | # | 조건 | 결과 | |---|---|---| | D1 | 같은 분석 ID 2건 동시 | 1건만 돈다(manifest 선점) | | D2 | 같은 ETF·다른 ID 2건(1초 차) | 둘 다 완료된다. 워커 안에서는 막지 않는다. A는 ETF 락이 한쪽을 거절한다 | D2는 같은 순간에 시작하면 대역이 문장 하나 동안 잡았다 놓는 ETF 락끼리 부딪혀 한쪽이 졌다. 설계가 아니라 대역의 부산물이라 1초 간격으로 다시 쟀다. **실행 제어 함수** (AWS 응답은 대역. 응답 형식은 10-03 dev 읽기 조회로 맞췄다. 실제 AWS 동작을 입증한 것이 아니라 제어 로직의 확인이다) | # | 조건 | 결과 | |---|---|---| | C1 | 끝난 실행(`ABORTED`)의 자리가 남아 있고, 그 태스크를 ECS가 `MISSING`으로 답함 | **다른 ETF의 자리 확보도 `RuntimeError: Cannot confirm task termination`.** 자리는 그대로 남는다. ALPHA-1166 뒤로는 그 자리만 점유(`task-missing`)로 남고 다른 ETF는 자리를 받는다 | | C2 | 끝난 실행의 이력에 태스크 제출 기록이 없음 | ECS를 묻지 않고 회수, 새 실행이 자리를 받는다. 이 실험의 이력엔 RunTask 호출도 없다. ALPHA-1166 뒤로는 호출 기록이 있는데 태스크 ARN이 없으면 회수하지 않는다(`task-unrecorded`) | | C3 | 끝난 실행의 태스크가 아직 `RUNNING` | 회수하지 않고 `StopTask`, `STOPPED` 확인 뒤 회수 | | C4 | 실행 이력 조회 실패 | 다른 ETF의 자리 확보도 예외로 실패. ALPHA-1166 뒤로는 그 자리만 점유로 남는다 | | C5 | 동시 호출 37개 × 20회(자리 3, 1), 같은 종류·ETF 10개, 끝난 자리 회수와 새 확보 10개 동시 × 20회 | 매번 상한·'종류·ETF 하나'를 지켰고 끝난 자리는 한 번만 회수됐다. 예약 동시 실행 1은 정합성에 필요하지 않다 | C1의 AWS 쪽 사실과 현재 상태는 이렇게 확인했다. - 10-03 01:16 KST에 멈춘 분석 태스크를 13시간 뒤 `DescribeTasks`로 물으면 `tasks: []`, `failures: [{reason: MISSING}]`이다. ECS가 멈춘 태스크를 얼마 동안 보여 주는지 정확한 경계는 재지 않았다. - 회수 판정은 `failures`가 있으면 예외를 낸다. 반납 단계를 거치지 않고 끝난 실행(`ABORTED`, `TIMED_OUT`), 또는 반납 Lambda 재시도를 다 쓴 실행의 자리가 남고, 그 태스크가 `MISSING`이 될 때까지 아무도 자리를 청하지 않으면 C1이 된다. 배치를 밤에 수동 중단하는 경우가 그렇다. - 자리 표 배포(10-02 23:23 KST) 뒤 단건 실행 중 `ABORTED`·`TIMED_OUT`은 0건이다. `FAILED` 7건은 모두 반납 Lambda가 성공했고, 그 시점 태스크는 `STOPPED`였다(실행 이력의 태스크 실패 기록). 그래서 지금 남은 자리는 없다고 판단한다. DB를 직접 조회하지는 않았다. **순서와 저장 재호출** | # | 조건 | 결과 | |---|---|---| | O1 | 같은 ETF 가격변동 t1(10:00)·t2(10:10)·t3(10:20)를 t2→t1→t3 순으로 실행(A, 동시성 없음) | 이전 설명: t1 없음, t2 없음, t3 = t2. 금융사 전달: t1 0, t2 1, t3 1. 최신 조회 t3. t3의 사슬은 t3→t2 | | R | 완료된 발행 저장 재호출 / 같은 툴 기록 재저장 / 되읽기 시험 구현으로 같은 내용·다른 내용 재저장 / 재시도 오류 분류(`transient`) | 저장된 결과 그대로, 행 변화 0 / 유일 키 위반(ALPHA-1167 뒤 서비스 `save_run`은 같은 내용이면 저장된 결과를 돌려준다. 탐침은 두 동작을 구분해 기록한다) / 같으면 확인, 다르면 '다른 기록' 오류로 거절하고 저장값은 그대로 / SQLSTATE 없음·08006·57P01·53300은 재시도, 57014·23505는 아님 | | K | B에서 분석 1건이 writer 연결을 여는 곳 | 위 '연결 비용과 풀' | ```sh cd tests/loadtest/analysis-v2/local # 컨테이너와 마이그레이션은 위 '구성과 재현'과 같다 R() { uv run --no-project --python 3.12 --with 'psycopg[binary]>=3.1' --with claude-agent-sdk==0.2.160 --with 'PyYAML>=6' --with 'jsonschema>=4' --with boto3 python review_probe.py "$@"; } R occupancy --n 37 --llm 30 --tools 40 # 1 R occupancy --n 37 --llm 30 --tools 40 --writer-limit 20 --reader-limit 6 # 2 (B 재시도 없음) R occupancy --modes b --n 37 --llm 30 --tools 40 --writer-limit 20 --reader-limit 6 --retry 10 R occupancy --kind movement --n 10 --llm 10 --tools 12 --writer-limit 20 --reader-limit 6 --retry 10 # 3 R faults # F1·F2 R save-faults # F3·F4 R dup # D1·D2 R control # C1~C5 R order # O1 R retry-safety # R R sites # K ``` 결과는 `local/results.jsonl`에 쌓인다(추적하지 않는 경로). 10-03 18시 전후 A·B가 들어간 dev 코드(`ebe41e47` 이후)로 단언이 있는 명령 6개(`control`, `retry-safety`, `order`, `dup`, `faults`, `save-faults`)를 다시 돌려 모두 통과했다. `control`과 `retry-safety`는 검토 기준 코드의 동작과 A·B 뒤 동작을 구분해 기록한다(`stale-slot-behavior`, `plain_rerun_same_content`). 기준 코드로는 다시 돌리지 않았다. 1~3, D, C3, C5의 동시 확보는 10-03 13:12~13:30 KST에 두 번(작업 사본과 이 파일) 돌렸고 성공 수는 재시도 없는 B의 2번(9/37, 12/37)만 달랐다. C1·C2·C4, C5의 회수 경합, O1, R의 되읽기, K는 13:56~13:58에, 단계 확인을 넣은 F1·F2는 14:07~14:10에, 결과 단언을 넣은 F3·F4는 14:13에 돌렸다(F3·F4는 13:57에도 같은 결과). 마지막 코드로 F1·F2(14:16~14:18)와 D·C·O1·R·F3·F4·K(~14:20)를 다시 돌려 단언이 모두 통과했다. 그 뒤 재시도 오류 분류와 t1 게시 단언을 더하고 R·F3·F4·O1을 다시 돌려 통과했다(~14:26). ### 재시도 재시도 단위를 셋으로 나눈다. 연결 생성 재시도는 아무 연산 앞에서나 안전하다. 문장 하나 재시도는 트랜잭션 안에서는 쓰지 않는다(오류가 난 트랜잭션은 이미 되돌려진다). 트랜잭션 전체 재시도는 그 트랜잭션이 멱등일 때만 쓴다. | 연산 | 다시 불러도 되는가 | 근거 | |---|---|---| | 시작 행 `begin` | 된다 | `ON CONFLICT DO NOTHING` 뒤 같은 입력인지 비교 | | 툴 정의 등록 | 된다 | 같은 방식(#1088) | | 툴 기록 `save_run` | 지금은 안 된다 | 유일 키 위반(실험 R). 되읽어 비교하도록 바꾸면 된다 | | 발행 저장 `save_*` | 된다 | 완료 상태면 쓰지 않고 저장된 결과를 돌려준다(실험 R, F3, F4) | | 실패 기록 `fail` | 된다 | `status='running'`인 행만 바꾼다 | | 가격변동 전달 등록 | 된다 | 같은 분석이 이미 전달됐으면 0건(`enqueue_movement`) | - 재시도할 오류: 연결 실패와 서버 쪽 세션 종료(SQLSTATE 08 계열, 57P01~57P03), 역할 연결 한도 초과(53300). 그 밖의 오류(`ValueError`, `ToolInputError`, 유일 키·검사 제약 위반, 데이터 오류)는 재시도하지 않는다. 문장 시간 초과(57014)는 재시도로 해결된다는 근거가 없어 넣지 않는다. - 시간 상한: 연산마다 수십 초 이하로 두고, 분석 태스크 제한(1,100초)과 전망 마감(08:00)을 넘지 않는다. 같은 시도 안의 재시도라 모델은 다시 부르지 않는다. | 실패 | 지금 | 권고 | |---|---|---| | 연결·트랜잭션의 일시 오류 | 그 시도가 실패하고 모델 비용을 다시 쓴다 | B에서 위 범위의 재시도 | | 모델 실패, 근거 검증 실패 | 새 분석 ID로 새 시도(배치) | 유지. 실패한 시도의 툴 기록은 새 시도의 근거로 쓸 수 없어(`PublicationStore._evidence`) 모델 응답을 시도 사이에 재사용하지 않는다 | | 실행 제어 Lambda 스로틀 | 자리 확보 재시도 3회를 다 쓰면 배치가 시도 하나를 잃는다 | 아래 '실행 제어 Lambda 스로틀' | | 입력 오류(ALPHA-1162) | 두 시도 모두 모델 호출 전에 거부된다(약 1분) | 지금은 둔다. 워커가 원인 문구를 지워 배치가 구분하지 못한다 | | 저장 뒤 관측 업로드·응답 유실 | 전망은 DB 상태로 판정(배치, API) | 유지. 가격변동은 F4처럼 전달이 빠질 수 있어 B의 재시도가 이를 줄인다 | | 가격변동 분석 실패 | **재시도 구현이 없다.** 분석 ID가 사건에서 나오고 실행 이름은 90일 동안 다시 쓸 수 없다 | 시도 번호를 넣은 새 ID로 다시 시도하는 것은 가능하다. 그러려면 요청 저장소의 `source_event_id` 유일 키(사건당 요청 1개), ID 규칙, 회수 조회(`is_retracted`가 요청 행으로 사건 좌표를 찾는다)를 함께 바꿔야 한다. 허용 지연을 정한 뒤 결정한다 | | 08:00 마감 | 마감 뒤 새 시도를 시작하지 않는다 | 유지 | 같은 분석 ID는 한 번만 돈다(실행 이름, manifest). 실패 뒤 새 시도는 모델을 다시 부른다. 같은 작업의 두 시도가 모두 완료되는 경우는 남는다. 배치가 단건을 1,500초 넘게 기다리다 다음 시도로 넘어갔는데 옛 단건이 끝까지 돈 경우다. 그러면 발행이 둘이 되고 최신 조회는 나중 것을 쓴다. 정확히 한 번은 보장하지 않는다. ### 확인하지 못한 것 - 워커(1 vCPU)의 연결 1회 비용과 그 구간별 내역. Lambda 두 개의 소요는 상한 참고값이다. - RunTask 도중 실행이 중단될 때 태스크가 실제로 남는지, 얼마나 자주인지(C2의 틈이 실제로 열리는지). - ECS가 멈춘 태스크를 보여 주는 시간의 정확한 경계(C1이 시작되는 시점). - 동시 수를 3보다 올릴 때의 SFN `DescribeExecution`·`GetExecutionHistory` 호출량과 API 한도, DeepSeek 동시 호출(이번 검토의 전제), RDS `db.t4g.small`의 CPU 크레딧. - 실제 RDS 재시작과 장애 조치. 로컬에서는 세션 종료와 로그인 차단으로 흉내 냈다. ### 구현 전에 해결할 것과 미룰 것 B 구현 전에 해결할 것: 1. 자리 회수의 `MISSING` 결함(C1). 지금 배포에도 해당한다. 끝난 실행의 태스크가 `MISSING`이면 멈춘 지 오래된 것으로 보고 회수하고, 판정할 수 없는 자리 하나 때문에 다른 ETF의 자리 확보까지 실패하지 않게 그 자리만 점유로 둔다. 실행 제어는 팀원 담당(ALPHA-1161)이라 범위 조율이 먼저다. (검토 당시 권고다. 최종 구현은 `MISSING`을 회수 근거로 쓰지 않는다 — 아래 '구현 결과' C1.) 2. 회수 불확실성 보완(C2). 워커에 단건 실행 ARN을 넘기고, 발행 저장과 가격변동 전달 등록 트랜잭션에서 자기 자리 행을 `FOR SHARE`로 확인한다(최종 구현은 발행 저장에서만 확인한다 — 아래 '구현 결과'). 행이 없으면(회수됨) 발행하지 않는다. 행이 있으면 회수의 `DELETE`가 그 트랜잭션 뒤로 밀려, 다음 실행은 이 발행을 이전 설명으로 본다. 새 표, heartbeat, 시간 만료는 없다. 대안은 워커의 ETF 락만 연결 하나로 계속 쥐는 것(분석당 writer 1개 상시)이다. 3. 위 '재시도'의 범위(연결 계열 오류만, 트랜잭션 단위, 시간 상한)와 `save_run` 되읽기. 4. 비밀값·클라이언트 캐시, 정의 등록과 관측 내보내기 묶기, 연결 시간 로그. 미룰 것: 동시 수 상향, 작은 풀·프록시(연결 시간 측정 뒤), 가격변동 재시도(허용 지연 결정 뒤), 늦은 사건의 처리 정책, 입력 오류 분류. **실행 제어 Lambda 스로틀**(팀원 담당, 범위와 검증 조건만): 예약 동시 실행을 1보다 올리고(정합성은 C5) writer 역할 한도 산식에 그 수를 넣는다. 자리 확보·반납 `Retry`에 `Lambda.TooManyRequestsException` 전용 규칙을 두고 지터와 최대 대기를 준다. 호출 1회가 1초 이상인 원인(비밀값·클라이언트를 매 호출 생성, 256 MB)을 줄인다. 검증은 계약 테스트(`infra/terraform/modules/analysis-v2/tests/test_single_analysis.py`)에 재시도 규칙을 넣고, dev에서 단건 6건 이상을 동시에 시작해 자리 확보가 재시도를 다 쓰지 않는지 본다. ### 다음 구현 단계와 완료 조건 동시 수는 3으로 둔 채 연결 수명만 바꾼다(위 1~4 포함). - 이 탐침의 B 시나리오를 실제 구현으로 다시 돌려 같은 결과가 나온다(1·2·F1~F4·D1·R). D2는 발행 시 자리 확인이 막는지 따로 확인한다. - 기존 단위·슬롯·실행 제어 테스트가 통과하고 대시보드 경로는 분석 ID 락을 그대로 쓴다. - dev에서 동시 3건으로 37종을 한 번 돌려 writer 최대와 연결-초가 줄고 저장 수가 그대로이며, 연결 시간 분포가 남는다. - 동시 수 상향은 그 뒤 별도 변경으로 재고 두 효과를 섞지 않는다. ### 구현 결과 (2026-10-03) 위 1·2는 ALPHA-1166(PR #1111, 실행 제어 회수), 3·4는 ALPHA-1167(PR #1112, 워커 연결 수명)로 구현해 dev에 적용했다. #1111은 `8702bd1f`(워커 태스크 정의 36), #1112는 `ebe41e47`이다. #1112는 10-03 17:04 KST Terraform 적용으로 단건 워크플로가 `ANALYSIS_EXECUTION_ARN`을 넘기고, 17:05 KST에 워커 태스크 정의 37과 실행 제어 Lambda가 같은 이미지로 바뀌었다. - 적용된 것: 실행 ARN을 받은 클라우드 워커는 DB 단계마다 연결을 열고 닫고, 발행 저장 트랜잭션에서 자기 자리 행을 `FOR SHARE`로 확인한 뒤에만 새 결과를 커밋한다. 분석 동시 실행 수는 3 그대로다. - dev에서 확인한 것(2026-10-04, 위 '실제 스케줄 관측'과 아래 '동시 37 dev 실측'): - 정기 실행(동시 3)과 수동 동시 37(설정) 1회 모두 실행 ARN이 전달됐고, 워커는 분석당 짧은 writer 연결 약 100회로 돌았다. - 두 실행 모두 워커 DB 재시도는 관측상 0이었다. 08:00 마감 안의 유효 입력 34종 저장은 정기 실행에서만 확인했다. 수동 동시 37은 11:17~11:31 KST에 돌았고, 마감은 실험용 입력으로 지정했다. - B 이전과 같은 조건으로 연결 점유를 비교한 dev 측정은 없다. RDS 전체 연결 최대 36(10-02, B 전)과 27(10-04, B 후)은 날짜와 다른 서비스 부하가 달라 B 효과로 단정하지 않는다. - 아래 표는 로컬 대역 측정이라 dev 성과가 아니다. 검토 권고와 달라진 점은 셋이다. - C1: 회수 근거는 실행 이력의 태스크 `STOPPED`, ECS의 `STOPPED` 응답, RunTask를 부르지 않은 실행뿐이다. `MISSING`, 빈 응답, 시간 경과, 조회 실패는 근거가 아니다. 판정하지 못한 자리는 점유로 남아 상한과 같은 종류·ETF 제약에 들어가고, 다른 자리 판정은 계속한다. 수동 복구는 ECS `STOPPED`를 확인한 자리만 지운다(`src/apps/cloud/analysis-engine-v2/docs/price-automation.md`). #1111 초안은 제출 10분 뒤 `MISSING`을 근거로 썼지만, `MISSING`은 태스크가 멈췄다는 기록이 아니어서 머지 전에 뺐다(AWS 문서의 '최대 5분 대기'는 재시도 권고이지 완전성 보장이 아니다). - C2: RunTask를 불렀지만 태스크 ARN이 기록되지 않은 실행의 자리는 회수하지 않는다(`task-unrecorded`). #1111 초안의 '10분 뒤 ECS 목록에 같은 종류·ETF의 멈추지 않은 태스크가 없으면 회수'도 같은 이유로 뺐다. RunTask를 부르기 전에 끝난 실행의 자리는 곧바로 회수한다. B의 발행 시 자리 확인은 회수 판정이 틀렸을 때 옛 워커가 결과를 내지 못하게 하는 두 번째 방어다. - 소유권 확인은 발행 저장에서만 한다. 전달 등록은 이미 커밋된 발행을 원장에 옮길 뿐이고, 여기서 막으면 발행은 있는데 전달이 빠지는 경우가 생긴다. 늦은 결과가 더 새 전달을 덮지 않는 것은 기존 `enqueue_movement` 비교가 맡는다. 실제 구현을 이 탐침의 모드 c로 다시 돌렸다(`EDGE_REPO`가 ALPHA-1167 코드를 가리켜야 한다. dev `ebe41e47` 이후 체크아웃이면 된다. 측정한 커밋은 PR #1112 브랜치의 `e5c0f1e1`, 10-03 15:38~15:41 KST. squash 머지라 dev 기록에는 없고 `git fetch origin pull/1112/head`로 받는다. 워커·분석·저장 코드는 dev `ebe41e47`과 같다). | # | 조건 | B 대역(위 표) | 실제 구현 | |---|---|---|---| | 1 | 전망 37건 동시, 역할 한도 없음 | 37/37. writer 최대 29, 연결-초 43, 연결 2,929회 | 37/37. writer 최대 21, 연결-초 20.6, 연결 1,813회(1건당 49) | | 2 | 같은 조건, 한도 20·6 | 연결 재시도 10초 37/37 | 37/37. writer 최대 16, reader 거절 뒤 재시도 67회 | | F1 | 모델 대기 중 writer 세션 종료 | 3/3 | 3/3. 종료할 세션이 0개 | | F2 | 모델 응답 뒤 로그인 5초 차단 | 3/3(재시도 15초) | 3/3. 거절 9회를 재시도로 넘김 | | F3·F4 | 발행 중 세션 종료 / 커밋 응답 유실 | 완료 | 둘 다 완료, 항목 행 = 기준, 모델 호출 1 | | D1 | 같은 분석 ID 2건 | 1건만 | 1건만(manifest 선점) | | D2 | 같은 ETF·다른 ID 2건 | 둘 다 완료 | 둘 다 완료. 탐침은 실행마다 다른 `request_key`로 자리 행을 넣어 같은 ETF 하나씩을 재지 않는다. 그것은 자리 확보(C5, #1111 통합 테스트)가, 자리를 잃은 실행의 발행 거부는 #1112 통합 테스트가 확인한다 | - 2에서 지터 없는 재시도는 36/37이었다(PR #1112 브랜치의 `67424e17`, 위와 같은 방법으로 받는다). 함께 거절된 워커가 함께 돌아와 20초를 다 썼다. 대기를 절반~전부 사이 무작위로 바꾼 뒤 세 번 모두 37/37이다. - 1건당 연결은 대역의 79회에서 49회로 줄었다. 툴 정의 등록(17회)과 관측 내보내기(10회)를 각각 연결 하나로 묶었다. 툴 기록은 기록마다 연결 하나다. - 이 탐침은 결과 DB 연결 함수를 바꿔 끼우므로 워커의 `db_connections`에 연결 시간이 잡히지 않는다. 연결 1회 비용은 dev 실행에서 잰다. ```sh EDGE_REPO= R occupancy --modes c EDGE_REPO= R occupancy --modes c --writer-limit 20 --reader-limit 6 EDGE_REPO= R faults --only-c EDGE_REPO= R save-faults --only-c EDGE_REPO= R dup --real ``` ## 확장 검토 — 37종 동시와 900종 (2026-10-03, 코드 대조·dev 읽기 조회·로컬 대역) 질문은 셋이다. 37종을 동시에 돌릴 수 있는가, 약 900종으로 늘려도 08:00 마감과 자원을 관리할 수 있는가, 그러려면 ETF마다 워커를 띄우는 구조를 바꿔야 하는가. 900종은 확장 가정이다. LLM 처리량은 감당 가능하다고 가정했고 재지 않았다. 서비스 코드, 동시 수, 역할 한도, 스케줄, 인프라는 바꾸지 않았다. 기준 코드는 dev `2823ffb1`(A·B 포함)이다. ### 분석 1건이 DB에 하는 일 | 단계 | 연결 | 횟수(1건) | ETF 사이 공유 | |---|---|---|---| | 원천 읽기(`sources/database.py`, 한 REPEATABLE READ 트랜잭션) | reader 1개 | ETF 1, 보유 스냅샷 상태 1, 스냅샷마다 보유종목 1(최대 40), 뉴스 1(180일·1,001건 상한), 뉴스 연결 1, 수급 1, 일봉 1, 장중 트리거 1 | 보유·뉴스·트리거는 ETF 고유. 수급·일봉은 구성종목별 행이라 겹치는 종목만큼 중복 | | 거시·재무(`load_research_observations`) | **writer** 1개(시점 함수 실행 권한이 writer에 있다) | 거시 5, 재무는 구성종목마다 1 | 거시는 기준시각만 같으면 같다. 재무는 (기준시각, 종목)만 인자다 | | 도구 실행 | 없음 | 시작 때 읽은 메모리 자료로 계산한다(`DatabaseTools`) | — | | 툴 감사 기록 | writer, 기록마다 1회(B) | dev 분석당 중앙값 99 | 공유 불가(시도별 근거) | | 시작 행·정의 등록·근거 검증·발행·근거 표·전달 등록·관측 내보내기 | writer, 단계마다 1회 | 약 10 | 공유 불가(소유권·감사·결과) | | 배치 항목 | API Lambda 3회 이상(상태 읽기 2 — 시작 전·실행 뒤, 발행 읽기 1, 모두 DB 읽기), `ListExecutions` 1회 이상, 단건 실행 시작 1 | — | — | | 단건 실행의 실행 제어(`cloud/control.py`) | writer, 호출마다 | 확보·반납 2회 이상(대기 중엔 30초마다). 호출마다 **다른 자리 수만큼 `DescribeExecution`**(119~127줄) | — | ### dev 실측 (10-02 `full3-20261002T0640Z` — A·B 적용 전 코드, 동시 3, 23:50~01:12 KST) - 37종 저장, 81분 32초. 단건 실행 1건 중앙값 357초(240~455초, ECS 기동·종료 포함), 워커 실행 중앙값 302초, 시도 38회 중 실패 1(`ToolInputError`). - 시작 → 첫 툴 기록: 1.0~7.9초, 중앙값 1.4초. 최대는 069500(구성종목 201개)이다. 원천 읽기, writer의 거시·재무, 정의 등록, 첫 모델 응답을 모두 포함한 상한이다. 도구 실행 합은 최대 4.1초다. - RDS(`db.t4g.small`): CPU 최대 9.4%(실행 전 7.6%), 여유 메모리 최소 538 MB, 연결 최대 36, CPU 크레딧 576 그대로. - 입력 중복(`dev/input_overlap.py`): 재무 조회 대상인 현재 보유종목은 합 877, 고유 367(2.39배), 2개 이상 ETF에 든 종목 124(000660은 22개 ETF)다. 일봉·수급 조회 대상인 최근 스냅샷 누적 종목은 합 989, 고유 376이다. 거시는 37건 모두 같은 내용이다. 시작 거시·재무 호출은 ETF마다 하면 1,062회, 한 번 공유하면 372회다. - dev 재무 자료는 거의 비어 있다. 재무 공백 862건이 모두 `no_release_visible`이고 행이 있는 종목은 1개였다. 그래서 위 시작 시간은 자료가 채워진 뒤보다 작을 수 있다. 069500은 빈 응답 201회로도 약 7초였으니 행 수보다 호출 수가 비용을 정할 가능성이 크다(추정). - 뉴스는 37종 중 29종이 1,000건 상한에 걸렸다. 대상 종목 집합과 상한이 ETF마다 적용되므로 결과는 ETF별이다. ### AWS 한도와 단가 (10-03 읽기 조회, ap-northeast-2) - Fargate 온디맨드 vCPU 140. 최근 7일 일 최대 사용은 약 19(다른 서비스 포함)라 1 vCPU 분석 태스크 약 120개가 들어간다. 기동은 순간 100·지속 초당 20. - SFN 토큰 버킷(버킷/초당 보충): `DescribeExecution` 300/50, `GetExecutionHistory` 400/20, `ListExecutions` 100/2, `StartExecution` 800/150, 상태 전이 800/800. - Lambda 계정 동시 실행 400, 실행 제어 Lambda 예약 3. - Fargate 서울 단가(공개 가격표, 2026-09-11 발행): vCPU 시간당 $0.04656, GB 시간당 $0.00511. 분석 태스크(1 vCPU·2 GB)는 시간당 $0.0568, 357초면 1건 약 $0.0056이다. ### 로컬 실험 — 시작 조회와 공유 입력 `review_probe.py occupancy --modes c`(실제 B 워커·저장 코드, PostgreSQL 16, writer 20·reader 6, 대역 LLM 30초·툴 40회). 동시 상한 3은 우회했다(워커마다 자리 행을 직접 넣는다). 실행 제어 Lambda·SFN·ECS는 들어가지 않는다. - **기준**: 원천 읽기 대역 0.3초(reader), 거시·재무는 빈 로컬 DB에서 실제 호출(약 0초). - **시작 조회 실측 상한**(`--research-profile`): ETF별로 writer를 (시작→첫 툴 − 1.0초)만큼 잡는다. 37개 합 35.9초, 최대 6.9초. - **공유 입력 준비**(`--prepare-seconds 13`): 거시 5 + 고유 보유종목 367회 × 호출당 0.034초(069500에서 얻은 상한, 약 12.6초)를 writer 하나로 흉내 내고, 워커는 그 결과 파일을 받는다. 준비 결과와 워커가 직접 읽은 결과가 같은지(내용·기준시각) 단언한다. 로컬 DB에는 원천 자료가 없어 형태와 기준시각만 증명한다. 실데이터에서는 위 거시 37건 동일을 근거로 든다. | 조건 | 회수 | 저장 | 전체 초(준비 포함) | writer 거절 | writer 최대 | reader 거절 | writer 연결 | |---|---|---|---|---|---|---|---| | 37건, 기준 | 4 | 모두 37/37 | 40~59 | 0~14 | 17~20 | 18~52 | 1,813 | | 37건, 시작 조회 실측 상한 | 4 | 모두 37/37 | 46~56 | 0~11 | 12~20 | 26~68 | 1,813 | | 37건, 공유 입력 준비 | 4 | 모두 37/37 | 59~79 | 0~2 | 14~20 | 22~36 | 1,813 | | 74건, 기준 | 1 | 74/74 | 87 | 377 | 20 | 82 | 3,626 | | 74건, 시작 조회 실측 상한 | 1 | 74/74 | 66 | 101 | 20 | 160 | 3,626 | - 모든 조건에서 저장 실패는 0이다. 시작 순간의 거절은 B의 짧은 연결과 20초 재시도가 흡수했다. - 공유 입력은 writer 거절을 줄였다(0~14 → 0~2). 하지만 준비 시간이 더해지고, ETF별 원천 읽기의 reader 거절은 그대로다. - writer 연결 수는 조건과 무관하게 같다. 대부분 툴 감사 기록(분석당 40회)이다. **반복되는 DB 작업의 본체는 원천 조회가 아니라 감사 기록이다.** - 74건에서는 writer가 한도 20에 붙었다. 이 탐침은 분석당 툴 기록 빈도가 dev의 약 4배(30초에 40회 대 약 300초에 99회)라 dev보다 빽빽하다. 로컬 수치를 dev 성과로 옮기지 않는다. ### 동시 수와 마감 계산 필요한 동시 수 C ≈ N × t × (1 + f) / W로 어림한다. W는 06:00~08:00 7,200초에서 마지막 1건(455초)을 뺀 6,745초, t는 단건 357초(중앙값)~455초(최대), f는 시도 실패율 0.03(`full3`)~0.24(10-02 이전 dev 실측)이다. - 37종: 동시 37이면 한 차례 약 6~8분, 재시도 한 차례까지 약 15분으로 본다(추정). 그때(10-03) 동시 3은 81분이었다(10-06부터 dev 12). - 900종: C ≈ 49(t 357초, f 0.03) ~ 76(t 455초, f 0.24). - **실행 제어의 SFN 조회는 동시 수의 제곱으로 는다.** 확보·반납 호출마다 다른 자리를 모두 `DescribeExecution`으로 묻기 때문이다. 한꺼번에 시작하면 C(C−1)/2회다. C 37이면 666회로, 버킷 300을 넘는 366회가 초당 50으로 약 7초 밀린다. C 60이면 1,770회(약 29초), 76이면 2,850회(약 51초)다. 평시에는 완료 1건마다 약 2C회라 초당 2C²/t(C 60 약 20회, 76 약 32회)로 보충 속도 50 안이다. 조회가 스로틀되면 그 자리는 이번 판정에서 점유로 남는다(A의 '판정 못 하면 점유'). 정합성은 유지되고 회수만 늦어진다. 실행 제어 Lambda는 예약 3이라 시작 순간의 확보 호출은 스로틀되고 SFN이 재시도한다(8회, 간격 최대 20초). - 배치 `ListExecutions`: 항목마다 1회 이상이고 시작 순간 C회라, C가 100 이하면 버킷 안이다. 평시는 900/7,200 ≈ 초당 0.13이다. - Fargate: C는 약 120까지 들어간다. 900종 하루 태스크 비용은 약 $5(357초 기준)다. - RDS 연결 생성: 실행 중 분석 1건이 초당 약 0.36회(툴 기록 99 + 단계 약 10, 약 300초)를 만든다. C 37이면 초당 약 13회, 60이면 약 22회, 76이면 약 27회가 2시간 이어진다. `t4g.small`에서 이 비용(CPU·메모리)은 재지 않았다. ### 판단 - **확인된 병목**(코드·한도로 확인): ① (검토 당시) 동시 상한이 1~3으로 묶여 있었다(`infra/terraform/modules/analysis-v2/variables.tf` 검증, `control.py`의 `slots` 검사, 배치 Map `MaxConcurrency`). ② 실행 제어의 자리 판정 비용이 동시 수의 제곱으로 는다(위 계산). ③ 역할 연결 한도 writer 20·reader 6은 동시 3 기준 산식이었다. 로컬에서는 동시 37 시작 순간 거절이 생겼지만, dev 동시 37 1회 실측(아래)에서는 거절과 재시도가 0이었다. ①은 #1124로 검증 상한을 1~40으로 바꿨다(dev 실제 값은 3). - **아직 가설**: LLM 처리량·요금 한도, dev RDS의 연결 생성 비용(`connect_ms`·CPU), 재무 자료가 채워진 뒤의 시작 조회 시간, 900종 구성. 지금 `load_source`는 XKRX·XKOS 밖 구성종목이 하나라도 있으면 분석을 거절한다. 해외 자산을 담은 ETF가 대상에 들면 처리량보다 지원 범위가 먼저 문제다. - **입력 공유(배치 전 준비, 캐시) — 보류.** - 줄어드는 것: 시작 거시·재무 호출 1,062 → 372회(37종). - 그 시간의 비중: 시작 구간 전체가 중앙값 1.4초로 단건 357초의 1% 미만이다. 로컬에서 줄어든 writer 거절도 B가 이미 흡수하던 것이다. - 남는 것: writer 연결 수와 ETF별 원천 읽기는 그대로 남는다. - 늘어나는 것: 준비 단계(+13초 추정), 버전 식별(기준시각·준비 시점 스냅샷·입력 구성 코드 이미지), 늦게 적재된 과거 시점 자료의 가시성 차이라는 정확성 표면이 는다. - 필요할 때 채우는 캐시는 06:00에 C개가 함께 비어 있어 시작 순간 부하를 줄이지 못한다. - 다시 볼 조건: 재무 자료가 채워져 시작 구간이 10초를 넘거나, dev에서 시작 거절이 재시도를 다 쓸 때. - 공유하더라도 툴 감사 기록·소유권·발행은 DB에 남고, 근거 ID는 시도별로 새로 만든다. - **공유 풀(RDS Proxy·PgBouncer) — 보류.** 워커 안 풀은 전체 연결 합을 제한하지 못한다. 역할 `CONNECTION LIMIT`은 넘치는 연결을 거절할 뿐이라 풀의 연결 재사용과 대기를 대신하지 않는다. 넘친 연결은 B가 재시도로 다시 연다(로컬 74건 거절 377회, 실패 0). dev 동시 37(설정) 1회 실측에서 연결 생성 시간은 분석별 p95의 중앙값이 약 40 ms였고, 워커 DB 재시도는 관측상 0이었다. 연결 생성이 DB 단계 시간의 약 절반이지만, 분석 시간(약 6분)에 비하면 작다. 연결 생성 빈도가 더 커질 때(동시 40 초과, 툴 기록 증가) 다시 잰다. 그때도 새 구성요소보다 한 모델 턴의 툴 기록을 한 연결로 묶는 쪽이 먼저다. (10-04 로컬에서 동시 수백 건으로 직접 연결과 풀을 비교했다 — 아래 '로컬 동시 부하와 PgBouncer 비교'. 툴 기록 묶기는 재지 않았다.) - **실행 단위 변경(적은 워커에 여러 분석) — 보류.** 분석 1건의 태스크 비용은 약 $0.0056이다. 900종에 필요한 vCPU(약 49~76)는 할당량 여유(약 120) 안이고, 기동 속도도 충분하다. 반면 지금 코드는 프로세스 하나에 분석 하나를 전제한다(전역 `STATS`, 환경변수 요청, 분석마다 SDK 프로세스). 그래서 여러 분석을 한 프로세스에서 돌리려면 격리·취소·실패 복구를 다시 짜야 한다. 다시 볼 조건: C가 120을 넘거나 ECS 기동(건당 21~28초)이 마감을 정할 때. ### 동시 37 dev 실측 (2026-10-04) #1124로 상한 검증을 1~40으로 바꾸고 dev `analysis_slots`를 실험 동안만 37로 뒀다가, #1125로 3으로 되돌렸다(10-04 11:32 KST 적용·재조회). - 역할 한도(writer 20·reader 6·api_reader 5)와 실행 제어·API Lambda 예약 동시 실행(각 3)은 바꾸지 않았다. - 실제 LLM과 정상 저장 경로를 썼다. 마감은 공식 입력(`--deadline`)으로 지정했고, 기준시각은 10-04 06:00 정기 실행 1·2분 뒤(같은 스냅샷, 완료 재사용 없음)다. - 실행 이미지는 `sha256:4bcb956d…`(태스크 정의 41, `f00e9208`)다. 분석 코드는 B(`ebe41e47`)와 같고 문서만 다르다. 모든 시도에 실행 ARN이 전달됐다. - 원본: `dev/results/<실행 이름>.json`(`batch_run.py report`), `<실행 이름>.metrics.json`(`run_metrics.py`), `stage2-c37-20261004.samples.jsonl`(10초 표본: 실행 중 단건 SFN 수, 태스크 상태별 수). gitignore 대상이라 저장소에는 없다. 실행 이름과 아래 '재현' 명령으로 SFN 실행 이력과 S3 관측 산출물에서 다시 만든다(표본 파일은 다시 만들 수 없다). | 항목 | 기준: 10-04 06:00 정기(동시 3) | 1단계(12종) | 2단계(37종, 동시 37) | |---|---|---|---| | 실행 이름 | `636ac16c-…` | `stage1-c37-20261004` | `stage2-c37-20261004` | | 입력 대상 / 유효 입력 / 저장 | 37 / 34 / 34 | 12 / 12 / 12 | 37 / 34 / 34 | | 입력 오류(ALPHA-1162, 모델 호출 0) | 3종 × 2시도 | 0 | 3종 × 2시도(069500·388420·0093A0) | | 실행 실패(재시도로 회복) | `ToolInputError` 1 | `TimeoutError` 1(261060, 666초) | `ToolInputError` 1 | | 전체 시간 | 76분 46초 | 16분 23초 | **13분 5초** | | 단건 시간(성공) 최소·중앙·최대 | 255·360·514초 | 304·337·393초 | 227·354·535초 | | 배치 시작 → 항목 첫 시도 시작 중앙·최대 | 2,039·4,232초(자리 순서) | — | 17·78초(API 스로틀 재시도로 추정) | | 자리 확보(단건 시작 → 태스크 제출) 중앙·최대 | 0.9·4.4초 | 2.2·3.8초 | 1.1·5.4초 | | ECS 기동(생성 → 시작) 중앙·최대 | 23.7·31.3초 | 23.9·33.5초 | 23.5·32.3초 | | 슬롯 대기(단건 30초 대기) | 0 | 0 | 0 | | 최대 동시 단건 실행 / RUNNING 태스크 | 3 / 3 | 12 / 12 | 37 / 35(입력 오류 태스크 포함, 유효 분석 최대 34) | | 워커 DB 재시도(writer·reader, 관측상) / 재시도 소진 / 통계 누락 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | | 성공 분석 1건 writer 연결 최소·중앙·최대 | 64·102·191회 | 78·103·125회 | 61·100·156회 | | 연결 생성 시간: 분석별 p50의 중앙 / 분석별 p95의 중앙 / 전체 최대 | 23 / 33 / 229 ms | 26 / 37 / 184 ms | 21 / 40 / 262 ms | | 실행 제어 Lambda 스로틀 / 그로 인한 최종 실패 | 0 / 0 | 4 / 0 | 11 / 0 | | 실행 제어 Lambda 평균·최대 | 281 ms·3.2초 | 508 ms·2.7초 | 456 ms·2.1초 | | API Lambda 스로틀 / 상태 미확인(`unknown`) | 0 / 0 | 18 / 0 | 66 / 0 | | RDS CPU 최대(실행 전) / 연결 최대 / 여유 메모리 최소 | 8.0%(6.5%) / 27 / 580 MB | 14.2%(7.0%) / 26 / 581 MB | 26.2%(7.8%) / 29 / 582 MB | | 모델 비용(성공 분석만, 실패 시도 제외) | $155.50 | $52.11 | $149.77 | - 전체 시간 감소(76분 46초 → 13분 5초)는 단건 시간(중앙 360초 → 354초)이 아니라 첫 시도 시작까지의 대기(중앙 2,039초 → 17초)가 줄어서다. 두 실행 모두 B 적용 후이므로 B 단독 효과가 아니라 동시 수 설정의 차이다. B는 동시 37에서도 DB 거절 없이 돌 수 있게 한 전제다. - 동시 37은 설정값이다. 입력 오류 3종이 바로 끝나서 유효 분석은 최대 34건이 동시에 돌았다. 37종 모두가 동시에 분석된 것은 아니다. - RDS CPU 26.2%는 시작 직후 1분뿐이다. 이후 17.6 → 13.3 → 약 10%로 내려왔다. `db.t4g.small` CPU 크레딧은 576에서 0.08 줄었다가 회복했다. - 동시 37에서 먼저 포화한 것은 DB가 아니라 예약 동시 실행 3인 Lambda 두 개다. - API Lambda는 66회 스로틀됐다. 배치의 상태 읽기 재시도(4회, 5~40초)가 흡수했다. 일부 항목의 시작이 최대 78초 늦은 것은 이 재시도 때문으로 본다(항목별 원인은 확인하지 않았다). - 실행 제어 Lambda는 11회 스로틀됐고 A의 스로틀 전용 재시도가 흡수했다. - 실행 제어는 호출 동안 writer 연결을 연 채 SFN·ECS를 묻는다. 그래서 예약 동시 실행을 올리면 writer 몫도 같이 는다. - 계산으로 어림한 '실행 제어 SFN 조회 지연'(위 '동시 수와 마감 계산')은 실측이 아니다. 실측된 것은 자리 확보 최대 5.4초와 스로틀 횟수다. - 이 결과는 한 번의 실행이다. 37종·동시 37에서 DB 거절이 없었다는 것이지, 더 큰 동시 수나 다른 시간대(장중 다른 서비스 부하)에서 안전하다는 뜻은 아니다. - 실패한 시도 2건(1단계 `TimeoutError`, 2단계 `ToolInputError`)의 모델 비용은 위 합계에 없다. 1단계 `TimeoutError` 시도는 비용 기록도 남지 않았다. ### 로컬 동시 부하와 PgBouncer 비교 — 100~900건 (2026-10-04) 질문은 두 가지다. B(단계별 연결, 연결 오류 재시도 20초·지터, 발행 시 자리 확인)로 분석 약 900건이 동시에 진행되면 DB 접근이 어디서 막히는가. 막히는 곳이 연결 문제라면 PgBouncer transaction pooling이 같은 조건에서 그것을 푸는가. 격리 로컬 PostgreSQL에서만 쟀고 LLM, SFN·Lambda·ECS, 실제 원천 데이터는 넣지 않았다. **900종 전체 시스템을 검증한 결과가 아니다.** **구성** (`local/load900.py`, 분석 코드 dev `80cd82f9`) - DB: `local/docker-compose.yml`의 PostgreSQL 16.14(2 CPU·2 GiB, `max_connections` 181, 슈퍼유저 예약 3, `scram-sha-256`, 데이터는 tmpfs), 마이그레이션 87개. 역할 한도는 dev와 같은 writer 20·reader 6이다. 직접 연결의 세션 옵션은 실제와 같다(writer `statement_timeout` 15초·`idle_in_transaction_session_timeout` 30초, reader 읽기 전용·REPEATABLE READ·15초). - 그대로 도는 코드: 실행 ARN을 받은 `worker.run` → `execute_request` → 저장, 그리고 `retry_transient`(20초, 지터). - 실제 워커와 다른 점: - 분석 1건 = 스레드 1개. 분석마다 프로세스를 띄우면 900건에서 부하 발생기 메모리가 먼저 바닥나기 때문이다. 프로세스 15개에 60건씩 나눴다. 풀이나 세마포어 없이 모든 분석이 같은 시각(또는 지정한 분산 시각)에 깨어난다. - 연결: Secrets Manager·TLS 없이 비밀번호 인증으로 붙는다. 그래서 dev보다 싸다(로컬 직접 연결 생성 p50 3~9 ms, dev는 분석별 p50의 중앙 약 21 ms). - 원천 읽기: reader 한 트랜잭션에서 `pg_sleep(0.3)` 뒤 합성 입력을 돌려준다. dev 원천 읽기 시간은 따로 잰 적이 없어 기존 탐침 값을 썼다. 거시·재무는 실제 함수지만 빈 DB라 약 0초다. - 모델: 대역이다. '실제 빈도'는 dev 동시 37 실행(`stage2-c37-20261004`)의 성공 34건이 남긴 툴 기록 시각을 그대로 재생한다(`dev/tool_profile.py`, S3 관측 산출물만 읽음). 분석당 54·91·148회(최소·중앙·최대), 작업 172·299·483초이고, 간격의 75%가 1초 미만인 묶음이다. 같은 프로필을 받은 분석끼리 겹치지 않게 분석마다 시간축을 0.9~1.1배로 늘였다. 본문 편집 호출이 프로필의 둘째 기록 자리를 쓴다. '고르게'는 정해진 구간에 툴 N회를 같은 간격으로 부른다. - ETF: 분석마다 다른 합성 코드다. 자리 행은 실행 제어 없이 미리 넣었다(슬롯 우회). 중복 실행 제어는 재지 않았다. - 같은 일을 하는지 대조(37건, 툴 40회·30초): 프로세스 방식(`review_probe.py occupancy --modes c`)과 이 스레드 방식 모두 37/37이었다. writer 연결 1,813회, 툴 기록 1,554행, 분석당 항목 1·요인 5·요인 지표 13행으로 같다. 비교 실험의 실제 빈도 900건에서 분석당 writer 연결은 101회로 dev 실측 중앙 100회와 비슷하다. - 부하 발생기 경로: 처음에는 macOS 호스트에서 Docker Desktop 포트 중계(`127.0.0.1:55461`)를 거쳤다. 300·900건에서 writer 연결이 `server closed the connection unexpectedly`로 30~40초 동안 실패했다(300건 1,729회, 900건 3,037회). 서버 로그와 시각을 맞춰 보면 역할 한도 거절과 `too many clients`는 클라이언트 기록과 1:1로 맞는데, 이 오류만은 서버 쪽 기록이 0건이고 그 구간 PostgreSQL CPU는 8%까지 내려가 있었다. PostgreSQL에 닿지 못한 연결로 보고 DB 병목에서 뺐다. 이후 실험은 자식 프로세스를 DB와 같은 Docker 네트워크의 컨테이너(compose `generator`)로 옮겨 쟀다. 호스트 경로 결과(`load900-real-n100`·`n300`·`n900`)는 `results.jsonl`에 남기되 판단에 쓰지 않는다. 그 900건 실행 중 진단 스크립트가 연결을 1회 시도했다. **측정을 바로잡은 것** (로컬 Codex 리뷰 2회와 결과 대조에서 나왔다) - 모델 대기 최대: 처음 집계는 모델 대기 중 실패한 분석을 뺐다. 끝난 시각까지 세도록 고쳐 원본에서 다시 계산했다(900건 동시 시작 318 → 320, 시작 60초 분산 357 → 727). - 프로필 재생: 기준 실험(14:21~15:00)은 본문 편집을 프로필 기록에 더해 불러 분석당 툴 기록 1건과 근거 검증 연결 1회가 실측보다 많았다. 비교 실험(15:38~)부터 편집이 프로필 기록 하나를 대신한다. - 풀 대기 초과로 끊긴 연결은 psycopg가 `close()`를 부르지 않아 닫힌 시각이 없었다. 첫 두 풀 실행(동시 시작 900건)은 그 연결 478·486개를 실행 끝까지 열린 것으로 셌다(분석별 DB 시간 p50 528초). 같은 분석이 실패를 받은 시각을 닫힌 시각으로 보도록 고치고, 그 두 실행은 자식 원본으로 다시 집계했다(`load900.py resummarize`, 영향 없는 직접 실행으로 같은 값이 나오는지 먼저 확인). - 무효 실행 1건: PgBouncer가 앞 실행의 서버 연결(`server_idle_timeout` 600초, reader 6개 전부)을 쥔 채 직접 실행을 돌려, reader 역할 한도가 처음부터 차 있었다(0/900). `local/results-invalid-cmp-20261004.jsonl`에 따로 두고 판단에서 뺐다. 이후 모든 실행은 v2 역할 연결 0개에서 시작한다. 직접 실행은 PgBouncer를 내리고 남은 연결이 있으면 거부하며, 풀 실행은 시작 때 풀을 비운다(`KILL`·`RESUME`). **기준: 직접 연결(현재 B), 동시 수 사다리** (기준 실험, 조건마다 1회. 툴 기록이 분석당 1건 많던 코드다 — 위 '측정을 바로잡은 것'. 900건은 아래 비교 실험에서 고친 코드로 직접 2회를 다시 쟀다) | 조건 | DB 저장 | 실패·`running`·행 없음 | 모델 대기 최대 | writer 시도·거절 | reader 시도·거절 | 재시도·소진 분석 | writer 연결 생성 p50·p99(ms) | PG CPU p90·최대(%, 2코어=200) | |---|---|---|---|---|---|---|---|---| | 37, 툴 40회 고르게(30초), 동시 시작 | 37 | 0·0·0 | 37 | 1,813·0 | 105·68 | 68·0 | 3.7·16.2 | 34·40 | | 100, 실제 빈도, 동시 시작 | 100 | 0·0·0 | 100 | 10,250·0 | 422·322 | 322·0 | 3.1·9.1 | 17·68 | | 300, 실제 빈도, 동시 시작 | 262 | 0·0·38 | 262 | 26,747·0 | 1,599·1,337 | 1,299·38 | 3.1·9.5 | 38·179 | | 900, 실제 빈도, 동시 시작 | 318 | 2·0·580 | 320 | 36,537·3,303 | 5,135·4,814 | 7,534·582 | 3.1·187.5 | 43·208 | | 900, 실제 빈도, 시작 60초 분산 | 357 | 477·25·41 | 727 | 67,940·20,294 | 1,080·180 | 19,888·543 | 3.7·439.8 | 202·212 | | 900, 최종 저장 동시(툴 30회 고르게, 시작 60초 분산, 모두 150초에 모델 종료) | 691 | 209·0·0 | 900 | 40,618·5,763 | 900·0 | 5,518·245 | 2.8·80.3 | 122·212 | - 100건까지는 시작 순간 reader 거절(한도 6)을 재시도가 모두 흡수했다. 분석별 재시도 대기는 최대 13.2초로 예산 20초에 가까웠다. 300건부터 시작 reader 한도에서 재시도를 다 써 실패했다. - 거절은 대부분 역할 한도(`too many connections for role`)다. 서버 한도(`sorry, too many clients already`)는 거절된 연결이 인증을 마칠 때까지 서버 프로세스를 차지해 `max_connections`에 닿은 경우다. **PgBouncer 호환성** (`local/pool_check.py`, PgBouncer 1.26.0. 기대값을 판정하고 어긋나면 실패로 끝난다) | 항목 | 직접(현재 워커) | 풀 + 현재 워커 그대로 | 풀 + 역할 기본값(실험 변경) | |---|---|---|---| | 시작 옵션 값과 실제 적용 | 15초·30초 / 읽기 전용·15초, 15초 넘기면 57014 | **연결 거절**: `unsupported startup parameter in options: statement_timeout` | 같은 값, 15초 넘기면 57014 | | reader REPEATABLE READ·읽기 전용 | 다른 연결의 커밋을 같은 트랜잭션에서 보지 않음, 쓰기 25006 | 연결 불가 | 같음 | | 발행 시 자리 확인(`FOR SHARE`) | 회수 DELETE가 커밋까지 대기(55P03), 자리 없으면 `OwnershipLost` | 연결 불가 | 같음 | | prepared statement(psycopg는 한 연결에서 같은 문장이 5번 넘으면 사용, 발행 저장이 해당) | 동작 | 연결 불가 | 서버 연결 2개를 오가며 동작. 풀의 지원(`max_prepared_statements`)을 끄면 26000으로 깨짐 | - 실험 변경: 풀 경로에서만 시작 옵션을 빼고(`load900.py --no-startup-options`), 같은 값을 역할 기본값에서 받는다. writer 값은 마이그레이션 `V202609282100`에 이미 있다. reader 값(`default_transaction_read_only=on`, `statement_timeout=15s`)은 `pool_check.py`가 로컬 역할에 넣는다. `load900.py`는 역할 기본값이 시작 옵션과 같지 않으면 풀 실행을 거부한다. 서버 쪽 세션 값은 두 경로가 같으므로 비교 결과에 주는 영향은 없다고 판단한다. - 세션 상태: B 경로는 트랜잭션 범위의 잠금(`pg_advisory_xact_lock`)과 `SET TRANSACTION`만 쓴다. 세션 advisory lock은 실행 ARN이 없는 경로(대시보드·이전 A 경로)에만 있어 그 경로는 transaction pooling에 붙일 수 없다. - 배포에 필요한 것(이번에 하지 않음): dev reader 역할의 기본값. 이 역할은 마이그레이션 밖에서 만들어져 저장소로는 지금 값을 확인할 수 없다. 그리고 풀 경로 연결에서 시작 옵션을 빼는 코드 변경이다. **같은 조건 비교** (10-04 15:38~17:23 KST. 조건마다 직접 → 풀 → 풀 → 직접, 300건은 직접 → 풀 한 쌍) - 같게 둔 것: PostgreSQL 컨테이너·자원·데이터·역할 한도, 분석 수·툴 기록 빈도·시작 분산, 워커 재시도 예산 20초, 클라이언트 인증 `scram-sha-256`, 시작 상태(v2 역할 연결 0개). - 서버 연결 예산: 풀도 writer 20·reader 6(`max_user_connections`, 실행 시작 때 역할 한도와 대조)이다. 클라이언트 연결 수 상한(`max_client_conn` 4,000)은 DB 예산이 아니다. - 대기 상한: 풀은 한 시도의 대기 상한 `query_wait_timeout`이 20초다. 워커 재시도가 이를 이어 붙이면 이론상 한 연산이 20초를 넘을 수 있다. 측정한 풀 실행 7회에서 재시도는 0회였고, 실패는 모두 첫 시도의 20초 대기 초과 1회였다. 분석별 DB 시간은 최대 22.5초였다. 재시도가 0회인 이유는 아래 'DB 접속 대기와 재시도 정책 검토'에 있다. 재시도 예산이 첫 시도부터 흐른 시간이라, 20초 대기 뒤에는 남은 예산이 없다. - 풀에만 더한 자원: PgBouncer 프로세스 하나(DB의 2 CPU 밖, 단일 스레드). 그 CPU를 아래 표에 함께 적는다. - 분석별 DB 시간: 연결 시도(거절 포함), 연결 점유(풀 대기열 포함), 재시도 대기를 모두 더했다. | 조건 | 방식 (순서, 시작) | DB 저장 | 실패·`running`·행 없음 | 거절 writer·reader | 풀 대기 초과 | 재시도·소진 분석 | writer 연결 생성 p50·p99(ms) | 분석별 DB 시간 p50·최대(초) | PG CPU p90·최대(%) | PG 새 세션 | 서버 연결 최대 w·r | 풀 대기 클라이언트 최대 w·r / 가장 긴 대기(초) w·r | PgBouncer CPU p90·최대(%, 1코어=100) | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | 900, 동시 시작 | 직접 1 (15:47, 재집계·값 동일) | 282 | 16·0·602 | 4,867·4,887 | 0 | 9,125·618 | 4.5·386 | 17.0·40.1 | 68·211 | 29,956 | 20·6 | — | — | | 900, 동시 시작 | 풀 2 (15:56, 재집계) | 422 | 0·0·478 | 0·0 | 478 | 0·478 | 2.9·39 | 20.7·22.1 | 9·44 | 285 | 20·6 | 85·873 / 0.0·20.0 | 8·54 | | 900, 동시 시작 | 풀 3 (16:05, 재집계) | 414 | 0·0·486 | 0·0 | 486 | 0·486 | 2.9·111 | 20.4·22.5 | 9·68 | 284 | 20·6 | 192·875 / 0.1·20.3 | 8·57 | | 900, 동시 시작 | 직접 4 (16:14) | 281 | 25·0·594 | 5,547·4,816 | 0 | 9,734·619 | 4.5·481 | 17.2·48.3 | 60·220 | 30,556 | 19·6 | — | — | | 900, 시작 60초 분산 | 직접 1 (16:23) | 316 | 482·28·74 | 21,088·385 | 0 | 20,827·584 | 5.1·571 | 35.0·79.5 | 203·214 | 43,466 | 19·6 | — | — | | 900, 시작 60초 분산 | 풀 2 (16:33) | 900 | 0·0·0 | 0·0 | 0 | 0·0 | 2.8·6 | 0.8·1.1 | 15·59 | 297 | 8·6 | 0·0 / 0.0·0.0 | 17·33 | | 900, 시작 60초 분산 | 풀 3 (16:43) | 900 | 0·0·0 | 0·0 | 0 | 0·0 | 2.9·12 | 0.9·1.3 | 18·65 | 309 | 20·6 | 5·0 / 0.0·0.0 | 17·54 | | 900, 시작 60초 분산 | 직접 4 (16:53) | 287 | 517·29·67 | 22,107·474 | 0 | 21,903·613 | 9.2·480 | 37.4·79.5 | 203·227 | 40,474 | 19·6 | — | — | | 900, 최종 저장 동시 | 직접 1 (16:56) | 401 | 499·0·0 | 8,433·0 | 0 | 7,849·584 | 4.1·477 | 23.1·31.8 | 205·227 | 35,505 | 20·5 | — | — | | 900, 최종 저장 동시 | 풀 2 (16:59) | 900 | 0·0·0 | 0·0 | 0 | 0·0 | 2.8·283 | 19.2·20.4 | 68·214 | 111 | 20·6 | 880·0 / 9.1·0.0 | 43·94 | | 900, 최종 저장 동시 | 풀 3 (17:02) | 900 | 0·0·0 | 0·0 | 0 | 0·0 | 2.9·304 | 19.2·20.3 | 65·198 | 111 | 20·6 | 880·1 / 9.1·0.0 | 38·95 | | 900, 최종 저장 동시 | 직접 4 (17:05) | 497 | 403·0·0 | 7,547·4 | 0 | 7,115·436 | 4.3·224 | 18.9·28.2 | 203·212 | 35,651 | 20·6 | — | — | | 300, 동시 시작 | 직접 1 (17:13) | 251 | 0·0·49 | 989·1,351 | 0 | 2,291·49 | 4.5·144 | 13.5·25.5 | 52·207 | 25,873 | 19·6 | — | — | | 300, 동시 시작 | 풀 2 (17:22) | 300 | 0·0·0 | 0·0 | 0 | 0·0 | 2.9·20 | 8.6·16.3 | 7·46 | 279 | 13·6 | 1·293 / 0.0·14.9 | 6·42 | - 모든 실행에서 중복·부분 저장, 요청 ID 밖의 행, 툴 기록 수 불일치는 0이다. 완료 행은 모두 같은 모양이다. 관측 스레드 오류와 닫힘 기록 없는 연결도 0이다. - 부하 발생기는 한계가 아니었다. 프로세스당 CPU 최대 0.3~2.7코어, 메모리 최대 약 230 MB였다. `asyncio.sleep` 깨어남 지연은 p99 21~82 ms였고, 동시 시작 지연은 최대 146 ms(자식 시계 차와 파이프 지연 0.27초 이하 포함)였다. - 최종 저장 동시 조건의 직접 실행은 DB에는 발행됐지만 워커가 관측 내보내기에서 재시도를 다 써 실패로 끝난 분석이 있다(DB 저장 401·497, 워커 성공 383·496). **구간별로 무엇이 바뀌었나** 1. **시작 조회가 몰릴 때(900건 동시 시작)**: 저장이 281·282건에서 414·422건으로 늘었다. - 바뀐 것: 역할 한도 거절 약 1만 회와 그 재시도가 0이 됐다. PostgreSQL CPU 최대가 211·220%에서 44·68%로, 새 세션이 약 3만에서 285로 줄었다. - 그대로인 것: reader 서버 연결 6개 × 원천 읽기 0.3초라는 읽기 처리량(초당 약 20건)이다. 20초 대기 안에 약 420건만 읽었고, 나머지는 풀 대기열에서 20초를 넘겨 실패했다. 실패 원인이 '거절 뒤 재시도 소진'에서 '풀 대기 초과'로 바뀌었을 뿐 용량은 그대로다. - 300건은 그 처리량 안이라 풀에서 300/300이었다(reader 대기 최대 293, 가장 긴 대기 14.9초). 직접은 251/300이었다. 2. **모델 대기 사이 감사 기록(900건, 시작 60초 분산)**: 287·316건에서 900·900건이 됐다. - 직접은 모델 대기 중 분석이 약 600~700건일 때 짧은 연결 생성과 거절 재시도가 CPU 2코어를 포화시켰다(p90 203%, 새 세션 4만여). 연결 생성 p99는 약 0.5초였다. - 풀은 같은 부하를 서버 연결 writer 8~20개로 받았다. PostgreSQL CPU p90 15·18%, 분석별 DB 시간 p50 0.8·0.9초, 대기 클라이언트는 사실상 0이었다. 3. **최종 저장이 몰릴 때(900건이 같은 순간 발행)**: 401·497건에서 900·900건이 됐다. - 그대로인 것: 풀에서도 PostgreSQL CPU 최대가 198·214%였다. 새 세션이 111개뿐이라 연결 생성이 아니라 발행·내보내기 쿼리가 CPU를 쓴 것으로 본다(쿼리별로 나눠 재지는 않았다). writer 클라이언트 880개가 줄을 서 가장 긴 대기가 9.1초(한도 20초)였고, 분석별 DB 시간도 직접과 비슷했다(p50 19초). - 새로 생긴 비용: PgBouncer(단일 스레드)의 CPU가 최대 94·95%로 한 코어에 닿았다. 풀 연결 생성 p99도 0.3초로 늘었다. 이 조건보다 더 몰리면 풀 대기 20초나 PgBouncer 자체가 다음 한계가 된다. **판단** - 현재 B는 이 로컬 조건에서 동시 진행 100건까지 거절을 모두 흡수했다. 그 위에서 먼저 막힌 것은 역할 연결 한도와 연결 생성·인증 비용이고, 재시도 소진은 그 결과다. 표본 상위 대기에 행 락(`Lock`)은 없었다. 디스크는 데이터가 tmpfs라 재지 못했다. - 같은 서버 연결 예산의 transaction pooling은 거절·재시도와 연결 생성 비용을 없앴다. 감사 기록 구간과 최종 저장 구간의 실패가 사라졌다. - 풀로도 풀리지 않은 것: 시작 원천 읽기 용량(reader 6), 최종 저장 순간의 DB 작업량, 단일 PgBouncer의 CPU다. 풀은 DB 처리 용량을 늘리지 않는다. - 직접 실행의 저장 수는 같은 조건에서도 흔들렸다(최종 저장 동시: 기준 실험 691, 비교 401·497). 거절과 재시도가 서로를 키우는 상태라 작은 시각 차이가 결과를 바꾼다. 풀 실행 두 번은 서로 가까웠다. **이 결과로 판단할 수 없는 것**: 실제 LLM 처리량과 비용, SFN·Lambda·ECS 한도와 실제 기동 분산, 실제 원천 데이터와 원천 읽기 시간, TLS·Secrets Manager가 더해진 dev 연결 비용(로컬보다 비싸다)과 클라이언트-풀 사이 TLS 비용, RDS `db.t4g.small`의 CPU 크레딧과 EBS 디스크, RDS Proxy, 장중 다른 서비스 부하, 중복 실행 제어. 반복은 조건마다 직접 2회·풀 2회(300건은 1회씩)다. **다음 변경** - 그때(10-04) 운영(dev 동시 3, 10-06부터 12)과 900종 어림 동시 수(약 49~76, 위 '동시 수와 마감 계산')에는 연결 쪽 변경이 필요하다는 근거가 없다. (10-05 정정: 49~76은 마감 계산값이고 아래 100건 성공도 실제 원천·혼합 부하를 거치지 않아, 둘 다 안전 경계가 아니다.) 직접 연결 100건 동시 시작이 거절을 모두 흡수했다. 다만 재시도 대기가 최대 13초였고, dev 연결은 로컬보다 비싸다. - 동시 진행을 수백 건으로 올린다면 최소 변경 하나는 클라우드 워커 B 경로 앞의 transaction pooling이다. 서버 연결 예산은 writer 20·reader 6 그대로 두고, reader 역할 기본값과 풀 경로 연결의 시작 옵션 제거를 함께 한다. 시작 원천 읽기 용량은 별도로 정한다(기동 분산이나 reader 예산). 풀 대기·재시도·유입 제어를 어떻게 나눌지는 다음 절에서 검토했다. ### DB 접속 대기와 재시도 정책 검토 (2026-10-05, 조사·코드 대조) 이 절은 설계 제안이다. 위 측정 결과는 바꾸지 않는다. 새 부하 실험은 하지 않았다. 기존 원본 `cmp-start-pool-2`의 오류 문구만 다시 셌다. **아래 제안값은 모두 검증 전이다.** **정정 (2026-10-05 후속, 사용자 검토)** — 아래 본문보다 이 목록이 우선한다. - 위 풀 실험은 풀 대기 20초와 재시도 예산 20초가 겹쳐 대기 초과 뒤 재시도가 한 번도 없었다. '짧은 대기 + 재시도'(B)를 잰 결과가 아니다. 아래 표의 A·B 비교 칸은 **예상**이다. - 동시 49~76은 08:00 마감에서 거꾸로 계산한 값이다. 로컬 직접 연결 100건 성공도 실제 원천 조회·혼합 부하를 거치지 않았다. 둘 다 안전 상한이 아니다. 검토 대상은 분석 약 900건 동시 진행이다. 분석 동시 수, 시작 속도, DB 동시 작업 수는 따로 다룬다. - 연결 단계 실패를 모두 일시 장애로 보지 않는다. 아래 '연결 실패 분류'를 따른다. - 서비스별 연결 몫 분리(D)는 **연결 확보**를 보호한다. 다른 서비스의 CPU나 응답 시간을 보장하지 않는다. - '긴 대기가 낫다', '시작 분산이 필요하다'는 아직 검증하지 않은 예상이다. - `retry_transient`의 예산은 진행 중인 호출을 끊는 엄격한 마감이 아니다(그대로 유지). **연결 실패 분류** (제안. psycopg는 연결 단계 실패에 SQLSTATE를 주지 않아 서버 문구로 나눈다 — `storage/database.py` 주석) | 분류 | 예 | 처리 | |---|---|---| | 인증·설정 오류 | `password authentication failed`, 없는 DB·역할, PgBouncer `unsupported startup parameter` | 재시도하지 않는다. 비밀값 교체 대응으로 비밀값을 다시 읽고 1회만 다시 붙는다(지금 `connect_results`의 `stale`). 그래도 같으면 원인 문구와 함께 실패 | | 일시 서버 장애 | 서버 시작·종료 중(57P03, 57P01·57P02), 역할·서버 연결 한도(53300, `too many clients`), 연결 거부·시간 초과·끊김 | 지터 지수 백오프로 단계 마감 안에서 재시도 | | 풀 대기 초과 | PgBouncer 08P01 `query_wait_timeout` | 정책 A·B 비교 대상(아래 실험) | | 원인 불명 | 문구로 분류되지 않는 연결 실패, PgBouncer `server login has been failing`(뒤 서버의 인증 실패와 장애가 같은 문구에 담긴다) | 시도 횟수를 작게 제한(예: 2회)하고 단계 마감 안에서만. 소진 시 원문 문구를 남기고 실패. 무한·장기 재시도 금지 | | SQL 시간 초과 | 57014 | 재시도하지 않는다(지금과 같음) | 질문은 이것이다. LLM 대기가 긴 분석 약 900건이 동시에 진행될 때, 제한된 DB 연결로 대기와 재시도를 어떻게 나누는가. 같은 RDS를 쓰는 API·수집의 지연과 연결 몫도 함께 본다. 시작 속도, DB 접근 대기, 분석 동시 수는 따로 정할 수 있다고 본다. **현재 코드에서 확인한 것** (dev `bc8d32dc`. `storage/database.py`는 이 PR의 기준 `80cd82f9`와 같다) - **재시도 예산은 마감이 아니다.** `retry_transient`의 20초는 docstring의 '재시도 사이 대기의 합'이 아니라 첫 시도 시작부터 흐른 시간이다. 실패 직후 `흐른 시간 + 다음 대기 > 20초`이면 재시도하지 않는다. 진행 중인 시도는 끊지 않는다. 그래서 시도 하나가 20초를 넘을 수 있다. 연결 `connect_timeout` 10초(로컬 psycopg 3.2.10 소스는 주소마다 따로 적용), 문장마다 `statement_timeout` 15초가 걸리고, 한 단계에 문장이 여럿이다. - **풀 대기 초과 뒤에는 재시도가 일어나지 않는다.** 풀 대기 상한(`query_wait_timeout` 20초)과 재시도 예산(20초)이 같아, 첫 대기가 끝나면 예산이 이미 지나 있다. 원본으로 확인했다. `cmp-start-pool-2`의 실패 478건은 모두 `ProtocolViolation: query_wait_timeout` 1회였다. `transient()`는 이를 재시도 대상으로 분류했지만(소진 478) 재시도 대기는 0회였다. 계산상 풀 대기를 19초로 줄이면 재시도가 1회 일어나고, 그 단계는 약 39초를 쓴다. - **PgBouncer 오류는 SQLSTATE로 구분되지 않는다.** `transient()`는 SQLSTATE 08 계열을 모두 재시도한다. PgBouncer 1.26은 풀이 직접 내는 오류에 코드를 따로 주지 않으면 08P01을 쓴다(`src/proto.c` `send_pooler_error`). 다음 오류가 모두 08P01이라 문구로만 나뉜다. - 풀 대기 초과(`query_wait_timeout`) - 시작 옵션 거절(`unsupported startup parameter`) - 클라이언트 수 초과(`no more connections allowed (max_client_conn)`) - 서버 로그인 실패 캐시(`server login has been failing`) - 종료(`server shutting down`), `query_timeout` 연결 단계 실패는 지금 코드에서 SQLSTATE와 무관하게 재시도 대상이다(`connect_results`). 그래서 풀 경로에서 시작 옵션을 빼지 않으면, 설정 오류인 옵션 거절도 예산 동안 재시도된다. - **풀 대기 초과는 그 트랜잭션이 서버에서 시작되기 전이다.** PgBouncer는 서버를 배정받지 못한 대기 목록(`waiting_client_list`)의 클라이언트에만 이 시간 제한을 건다(`src/janitor.c`). 그래서 같은 트랜잭션을 다시 보내도 이중 실행은 없다. 다만 같은 단계의 앞선 트랜잭션은 이미 커밋됐을 수 있다. 그래서 '재시도 단위는 단계 전체이고 단계가 멱등이어야 한다'는 조건은 그대로다. - **커밋 결과 불명확**(커밋 중 연결 끊김)은 지금도 단계 전체 재시도와 되읽기로 처리된다. 각 단계의 처리: - 시작 행: 같은 입력인지 비교한다. - `save_run`: 되읽어 비교한다. - 발행 저장: 이미 완료 상태면 저장된 결과를 돌려준다. - `fail`: `status='running'`인 행만 바꾼다. - 전달 등록: 이미 했으면 0건이다. 예산이 되읽기 전에 끝나면 DB에는 발행이 있는데 워커는 실패로 끝난다. 위 최종 저장 동시 조건의 직접 실행이 그 예다(DB 저장 401, 워커 성공 383). 전망은 배치가 DB 상태로 판정해 결과가 남는다. - **재시도 경계.** `retry_transient`는 DB 단계 하나만 감싸고, 모델 응답은 메모리에 있다. 그래서 단계 재시도는 끝난 LLM 호출을 다시 부르지 않는다. 그러나 재시도를 다 쓰면 분석이 실패하고, 배치가 새 분석 ID로 모델을 처음부터 다시 부른다. 모델 대기 중 툴 기록 저장이 실패해도 같다(`AuditedExecution.call`이 저장 예외를 그대로 올린다). - **연결 몫.** - 저장소 마이그레이션에서 역할 한도를 확인한 것은 v2 역할 셋뿐이다(writer 20·reader 6·api_reader 5). - 실행 제어 Lambda는 워커와 같은 writer 역할을 쓴다(`control.py`). 그래서 워커가 writer 한도를 채우면 자리 확보·반납도 거절된다. - 같은 RDS를 쓰는 super-admin-api·tenant-sync-api는 마스터 사용자로 붙는다(`infra/terraform/envs/dev/main.tf`). 저장소에서 그 한도 설정은 찾지 못했다. - 수집 쪽 DB 호출은 `call_budget.py`의 자체 상한(서버 실행 500 ms 기본 등)을 쓴다. - 역할 한도는 연결 수만 막는다. 거절된 연결도 서버 프로세스 생성과 인증에 CPU를 쓴다(위 '같은 조건 비교'). 그래서 다른 서비스의 CPU 몫은 지켜 주지 않는다. - **낡은 산식(정리 대상).** writer 20의 산식 주석(`V202610022100`)은 B 이전 구조 기준이다. 분석당 연결 3개를 계속 쥐고, 슬롯을 기다리는 동안 락 연결을 쥐는 구조다. 지금 writer 사용(단계별 짧은 연결, 실행 제어 Lambda 예약 3)과 맞지 않는다. 적용된 마이그레이션이라 고치지 않는다. 한도를 바꿀 때 새 산식을 쓴다. **외부 원문에서 확인한 일반 원칙과 적용 조건** | 원칙 | 근거 | 적용 조건 | |---|---|---| | 풀은 대기열을 기본으로 두고, 대기 상한 기본값은 수십~백수십 초다 | PgBouncer `query_wait_timeout` 기본 120초. 문서상 목적은 응답 없는 서버가 연결을 잡아 두는 것을 막고, 서버가 내려가거나 연결을 거부할 때 대처하는 것이다. RDS Proxy `ConnectionBorrowTimeout` 기본 120초, psycopg_pool `timeout` 기본 30초 | 기본값이지 권장 설계는 아니다. 대기가 쓸모 있으려면 그 시간 안에 대기열이 빠져야 한다(일시 집중) | | 꾸준한 과부하에서는 대기열을 짧게 두고 일찍 거절한다 | SRE 'Addressing Cascading Failures': 꾸준한 부하면 대기열을 스레드 수의 50% 이하로, 몰리는 부하면 몰림 크기·빈도와 처리 시간으로 정한다. psycopg_pool `max_waiting` | 서버 요청 대기열에 관한 원칙이라 풀 대기열에는 유추로 적용한다. 대기 상한이 평균 처리 시간보다 몇 자릿수 크면 이중 지연(bimodal)으로 자원을 묶는다 | | 재시도는 한 층에서, 횟수를 제한하고, 지터를 준 지수 백오프로 | SRE 'Handling Overload': 요청당 3회, 클라이언트 재시도 비율 10%. AWS SDK 표준 재시도(2026 갱신 동작): 최대 3회, full jitter, 대기 상한 20초, 재시도 토큰 버킷 500(일시 오류 14, 스로틀 5). AWS 'Exponential Backoff And Jitter' | 실패가 짧고 독립적일 때 효과가 있다. 용량이 모자랄 때는 부하를 더한다. AWS SDK도 일시 오류 재시도에 더 큰 토큰을 매겨 빨리 멈춘다. 토큰 버킷은 프로세스 안에서만 센다 | | 마감은 위에서 정해 아래로 줄여 넘긴다 | SRE: 하위 호출에 같은 절대 마감을 넘기고, 단계마다 남은 시간을 확인한다 | 단계별 상한(연결·풀 대기·SQL)은 모두 남은 마감보다 작아야 한다 | | 오류는 재시도 가능·불가·과부하로 나눈다 | SRE('overloaded; don't retry'), AWS SDK(일시·스로틀·재시도 불가) | 드라이버의 SQLSTATE가 이 구분을 못 하면(PgBouncer 08P01) 문구나 시점으로 나눈다 | | 서버 쪽 시간 제한은 역할·세션 단위로 둔다 | PostgreSQL 문서: `statement_timeout`을 postgresql.conf 전체에 두지 말 것. `transaction_timeout`은 PostgreSQL 17부터다(dev는 16.13) | PgBouncer는 시작 옵션의 `statement_timeout`을 거절하므로 역할 기본값으로 옮긴다 | **대안 비교** (900건 동시 진행, 같은 서버 연결 예산) 이번 문제는 꾸준한 과부하가 아니라 동기화된 몰림이다. 900건이 동시에 진행돼도 모델 대기 중의 DB 수요는 작았다. 풀 + 시작 60초 분산에서 분석별 DB 시간 p50이 0.8~0.9초였고, 대기 클라이언트는 사실상 0이었다. 실패는 시작 원천 읽기와 최종 저장이 한 순간에 몰릴 때 났다. 몰림이 빠지는 시간은 '몰린 건수 × 1건 처리 시간 ÷ 서버 연결 수'로 어림한다. 시작 읽기는 900 × 0.3초 ÷ 6 = 45초다(0.3초는 합성 값). | 기준 | A 긴 풀 대기 + 단계 마감 | B 짧은 풀 대기 + 지터 재시도 | C 유입 속도 제한 + 풀 | D 서비스별 연결 몫·대기 분리 | |---|---|---|---|---| | 일시 집중 | 대기 상한이 몰림 해소 시간보다 길면 모두 처리한다. 대기 순서가 유지된다 | 같은 총시간이면 처리 용량은 A와 같다. 대기 초과 때 PgBouncer가 연결을 닫아, 재시도는 대기열 맨 뒤에 다시 선다 | 몰림 자체를 줄인다(60초 분산 900/900 실측) | 해당 없음(경계만 나눈다) | | 꾸준한 과부하 | 대기열이 계속 늘고 모두 늦게 실패한다(이중 지연) | 재연결과 인증이 부하를 더한다 | 유입을 처리량 아래로 묶는 유일한 수단 | 다른 서비스로 번지지 않게 한다 | | 정상 시 지연 | 0에 가깝다 | 0에 가깝다 | 분산한 만큼 늦게 시작한다(08:00 마감 안이면 무관) | 0 | | 과부하 시 완료율 | 대기 상한 안에서 최대 | 같은 총시간 기준 A 이하(재연결 비용만큼) | 최대 | — | | 접속·재시도 부하 | 재연결 0 | 대기 초과마다 재연결·인증. PgBouncer는 단일 스레드이고 최종 저장 몰림에서 이미 CPU 94~95%였다 | 낮다 | — | | 다른 서비스 영향(예상) | 풀 서버 연결 수가 v2의 DB 동시 작업 수를 묶는다 | A와 같고 풀 CPU가 더 든다 | 낮을 것으로 예상 | 연결 확보만 보호(CPU·응답 시간 보장 아님) | | 대기 중 워커 비용 | 워커는 그동안 할 일이 없다. 1 vCPU 태스크 60초는 약 $0.001(분석 1건 약 $0.0056에서 환산), 모델 재실행은 약 $4.4(10-04 동시 37 성공 34건 평균, 실패 시도 비용 제외)다 | A와 같다 | 배치 단계에서 막으면 태스크도 늦게 뜬다 | — | | 취소·마감·회복 | 단계 마감이 없으면 대기 상한(예: 120초)이 태스크 마감을 넘을 수 있어 단계 마감이 필요하다 | 시도마다 끊겨 마감 계산이 쉽다 | 마감은 시작 계획에서 계산한다 | — | | 중복·부분 저장 | 풀 대기 초과는 미실행이라 안전하다 | A와 같다. 재시도 단위는 단계 전체다(멱등 필요, 현재 충족) | 영향 없음 | 영향 없음 | | 필요한 변경 | 풀 사용자별 `query_wait_timeout`, 워커 단계 마감(절대 시각) | 재시도 예산을 대기 상한보다 크게, 08P01 문구 분류 | 배치 시작 속도(Map 동시 수·시작 간격). 40 초과는 Distributed Map 별도 과제 | 실행 제어용 writer 몫 분리(역할 또는 풀 사용자), 풀 사용자별 서버 연결 상한 | 위 표의 A·B·C 칸은 원리에서 끌어낸 **예상**이고, C의 '60초 분산 900/900'만 실측이다. A와 B는 같은 용량을 어떻게 기다리느냐의 차이라 용량을 늘리지 못한다고 예상한다. 처리 용량을 넘는 몰림은 C가 줄일 수 있고, D는 연결 확보를 보호한다. 풀 경로에서 대기 초과 뒤 재시도(B)가 대기 상한을 늘리는 것(A)보다 낫다는 근거는 찾지 못했지만, B를 잰 적도 없다. 예상은 같은 총시간에 B가 대기 순서를 잃고 재연결 비용만 는다는 것이다. B가 필요한 곳은 대기열이 아닌 실패다. 서버 재시작·장애 조치, 연결 끊김, 풀의 로그인 실패 캐시가 여기에 든다. PgBouncer는 풀별 대기열 길이 상한이 없고, 클라이언트 연결 상한(`max_client_conn` 등)으로만 간접적으로 묶인다. **사용자 제안('짧은 풀 대기 + 지터 재시도')의 장단점** - 장점: - 시도 하나의 대기가 짧아 마감 계산이 쉽다. - 응답 없는 서버나 끊긴 연결에서 빨리 빠져나온다. - 지터가 함께 거절된 워커를 흩는다(실측: 지터 없는 재시도 36/37, 지터 적용 37/37 — 위 '구현 결과'). - 풀이 없는 직접 연결에서는 역할 한도가 대기열이 아니라서 이것이 유일한 대기 수단이다(지금 B). - 약점: - 풀 경로에서는 대기 초과 때 연결이 닫혀, 재시도는 새 연결과 인증 뒤 대기열 맨 뒤에 선다. - 같은 총시간이면 처리 용량은 A와 같고, 재연결 비용(풀 CPU)과 순서 손실만 더한다. - 지금 코드에서는 재시도 예산이 첫 대기와 같아 재시도가 일어나지 않는다. - 08P01을 문구로 나누지 않으면 설정 오류도 재시도한다. - 꾸준한 과부하에서는 재시도가 부하를 더한다. AWS SDK의 재시도 토큰 버킷이 이를 막는 장치지만 프로세스 안에서만 센다. 분석 하나가 프로세스 하나인 지금 구조에서는 900개 워커를 함께 묶지 못한다. **EDGE 권고** (제안, 검증 전) 1. **10-05 당시(dev 3, 10-06부터 12):** dev 동시 37 1회에서 DB 거절·재시도가 관측상 0이었다. 49~76은 마감 계산값이고 안전 경계가 아니다(위 정정). `retry_transient` docstring은 실제 동작과 다르다(첫 시도부터 흐른 시간, 진행 중인 시도는 끊지 않음). 코드를 고칠 때 함께 바로잡는다. 2. **수백~900건 동시 진행:** 결합안 **C + A(풀) + D**로 가고, B는 연결 장애 전용으로 좁힌다. - **C 시작 속도:** 원천 읽기 수요가 reader 처리량을 넘지 않게 시작 속도를 묶는다. 산식은 `시작 속도 × 원천 읽기 시간 ≤ reader 서버 연결 × 목표 사용률`이다. 합성 0.3초·6개면 초당 20건 이하다. 실측(60초 분산 = 초당 15건)은 900/900이었다. dev 원천 읽기 시간은 잰 적이 없어 값을 정하지 않는다. - **A 배치 대기:** 워커용 풀 사용자의 `query_wait_timeout`을 예상 몰림 해소 시간보다 길게 둔다(1.26은 사용자별 설정을 지원한다). 해소 시간은 C를 적용한 뒤 실측으로 정한다. 대기 초과는 기본적으로 재시도하지 않고 경보 신호로 본다. 그 시간 안에 대기열이 빠지지 않았다면, 재시도해도 같은 줄의 맨 뒤에 서기 때문이다. 아래 실험 1에서 B가 낫다고 나오면 바꾼다. - **단계 마감:** 워커 DB 단계마다 절대 마감을 하나 둔다. 풀 대기, 연결, SQL, 재시도 대기를 한 시계로 잰다. 마감은 태스크 남은 시간(1,100초)에서 실패 기록·관측 내보내기 여유를 빼고 거꾸로 계산한다. 그 안에서 풀 대기 상한이 단계 마감보다 작아야 한다. 발행 저장은 모델 비용을 이미 쓴 뒤라 다른 단계보다 길게 둘 근거가 있다(대기 60초 약 $0.001, 모델 재실행 약 $4.4). - **B 연결 장애 전용:** 다음 오류만 지터 지수 백오프로 재시도한다. - 서버 세션 종료(57P01~57P03) - 연결 끊김(SQLSTATE 없음·08006) - 위 '연결 실패 분류'의 일시 서버 장애. 인증·설정 오류는 제외하고, 원인 불명은 횟수를 작게 제한한다 재시도 예산은 시도 하나의 대기 상한과 따로 둔다. 지금처럼 같으면 재시도가 일어나지 않는다. 풀 대기 초과, 시작 옵션 거절, `max_client_conn` 초과는 같은 08P01이라도 문구로 나눠 재시도하지 않는다. 문장 시간 초과(57014)는 지금처럼 재시도하지 않는다. - **D 몫 분리:** 실행 제어 Lambda를 워커와 다른 writer 몫으로 뗀다(별도 역할, 또는 별도 풀 사용자와 서버 연결 상한). 이것은 실행 제어의 연결 확보를 보호한다. 풀 서버 연결 상한은 v2의 DB 동시 작업 수를 묶지만, 다른 서비스의 CPU나 응답 시간을 보장하지는 않는다(혼합 부하 실험 전). API는 지금처럼 직접 연결과 짧은 시간 제한(api_reader 5, 서버 5초, 예약 3)으로 두고, 재시도는 호출자(배치의 상태 읽기 재시도)가 맡는다. 3. **커밋 결과 불명확:** 지금 구조(단계 전체 재시도와 되읽기)를 유지한다. 마감이 되읽기보다 먼저 끝나 워커가 실패로 끝나는 경우는, 배치가 DB 상태로 판정하는 지금 규칙이 덮는다. 4. **약화하지 않는 것:** 발행 트랜잭션의 자리 `FOR SHARE` 확인, 같은 종류·ETF 실행 제어, 세션 락 경로. 세션 advisory lock을 쓰는 대시보드·A 경로는 transaction pooling에 붙이지 않는다. **배치와 API·수집의 정책 구분** (제안) | | 배치 워커(분석) | 실행 제어 Lambda | v2 API Lambda | 수집(data-pipeline)·관리 API | |---|---|---|---|---| | 목표 | 08:00까지의 완료율 | 자리 판정 정확성·빠른 응답 | 짧은 응답 | 각 서비스의 마감·응답 | | DB 접근 대기 | 길게(몰림 해소 시간 이상), 단계 마감 안 | 짧게 | 짧게(서버 5초) | 기존 상한 유지 | | 재시도 | 연결 장애만, 지터, 단계 마감 안 | 기존 SFN 스로틀 재시도 | 호출자 | 기존 유지 | | 연결 몫 | 풀 사용자의 서버 연결 상한 | 워커와 분리 | api_reader 5 | 각자 연결(저장소에서 한도 설정 미확인) | | 과부하 신호 | 풀 대기 시간·대기 초과 수 | 거절 수 | 스로틀 수 | 해당 서비스 지표 | **권고를 확인할 최소 비교 실험** (로컬 `load900.py`, 실행하지 않음) 공통 조건: - 같은 PostgreSQL 컨테이너와 자원을 쓴다. - 풀 서버 연결 예산은 writer 20·reader 6이다. - v2 역할 연결 0개에서 시작한다. - 분석 900건, 실제 빈도 프로필을 쓴다. - 조건마다 2회씩, 1·2·2·1 순서로 교차한다. - 비교 하나에 변수 하나만 바꾼다. - **한 DB 단계가 쓸 수 있는 총시간(단계 마감)은 양쪽에 같게 준다.** 1. **A 대 B (시작 몰림, 분산 없음):** 단계 마감 T를 같게 둔다. A는 풀 대기 T에 대기 초과 재시도 없음, B는 T보다 짧은 풀 대기 t에 대기 초과를 지터 재시도한다. T는 몰림 해소 시간(합성 45초)보다 짧은 값과 긴 값 두 가지를 쓴다. - 볼 지표: 저장 수, 분석별 DB 대기 p50·p99·최대, 풀 새 클라이언트 연결 수, PgBouncer·PostgreSQL CPU. - 예상: 완료율은 같고 B가 재연결과 풀 CPU를 더 쓴다. 차이가 없으면 단순한 A를 고른다. - B의 재시도는 탐침 안의 시험 구현으로 두고 운영 코드는 고치지 않는다(`review_probe.py --save-retry`와 같은 방식). 2. **A 대 C (같은 시작 창 W초):** 900건 동시 시작에 풀 대기 W초를 주는 쪽과, 시작을 W초에 분산하고 풀 대기 20초를 주는 쪽을 비교한다. W=60이면 뒤쪽은 위 '풀 2·3' 결과를 쓸 수 있다. 1의 지표에 워커 대기 시간 합(태스크 비용 대리 지표)을 더한다. 3. **D (다른 서비스 지연):** 최종 저장 동시 조건에서 API 대역을 함께 돌린다. API 대역은 api_reader 성격의 짧은 읽기로, 100 ms 간격, 시간 제한 5초, 직접 연결이다. 직접(지금 B)과 풀(서버 연결 상한)을 비교한다. - 볼 지표: API 대역의 p50·p99와 오류 수, 실행 제어 대역(writer 짧은 트랜잭션)의 거절 수. 실험 전에 필요한 것: - dev 원천 읽기 시간 분포. `load_sources` 구간은 지금 관측 산출물에 없어 기록을 추가하거나 dev에서 읽기 조회로 구한다. - dev reader 역할 기본값. 이 실험으로도 남는 것: TLS·Secrets Manager 비용, RDS CPU 크레딧, 실제 LLM 종료 분포, PgBouncer 배치 위치(사이드카 또는 별도 서비스)와 가용성. 출처: - [PgBouncer 설정](https://www.pgbouncer.org/config.html), PgBouncer 1.26.0 태그 소스 `doc/config.md`·`src/proto.c`·`src/janitor.c`·`src/objects.c`·`src/client.c` - [PostgreSQL 클라이언트 설정](https://www.postgresql.org/docs/17/runtime-config-client.html), [psycopg_pool](https://www.psycopg.org/psycopg3/docs/api/pool.html) - [RDS Proxy 연결 설정](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-connections.html), [AWS SDK 재시도 동작](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html), [Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) - Google SRE Book [Handling Overload](https://sre.google/sre-book/handling-overload/)·[Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/) ### 다음 판단 - **평시 동시 수는 사용자 결정으로 남긴다**(10-06 사용자 결정으로 dev를 3에서 12로 올림 — ALPHA-1240. 장중·정기 실행 확인 전). - 37종 기준으로 동시 3은 76분이라 08:00 마감까지 약 44분이 남는다. 동시 37은 13분이다. - 동시 12로 37종을 돌린 측정은 없다. 3바퀴 남짓에 재시도 한 번이면 20~30분으로 추정한다. - 동시 수를 올리면 Lambda 스로틀과 시작 순간 RDS CPU가 함께 는다. 측정 범위에서는 모두 회복됐다. - **동시 수를 올린다면 함께 볼 것:** - API Lambda 예약 동시 실행을 api_reader 한도(5) 안에서 조정할지 - 실행 제어 예약 동시 실행을 writer 몫과 함께 정할지 - ALPHA-1162 입력 오류 3종. 동시 수와 무관하게 37종 전체 저장을 막는다. - **40 초과:** Distributed Map과 실행 제어 자리 판정의 동시 수 비례 비용을 별도 과제로 둔다. - **수백 건 동시:** 로컬 비교에서 직접 연결은 역할 한도와 연결 생성 비용에서 먼저 막혔고, 같은 서버 연결 예산의 transaction pooling이 그 실패를 없앴다. 시작 원천 읽기 용량과 최종 저장 순간의 DB 작업량은 남는다(위 '로컬 동시 부하와 PgBouncer 비교'). 정책 제안은 위 'DB 접속 대기와 재시도 정책 검토'에 있다: 시작 속도 제한 + 풀 대기열 + 서비스별 몫 분리, 재시도는 연결 장애 전용. 최소 비교 실험 3개는 아직 하지 않았다. ### 재현 ```sh cd tests/loadtest/analysis-v2/dev AWS_PROFILE=edge uv run --no-project --with boto3 python batch_run.py report stage2-c37-20261004 # 끝난 실행 집계 AWS_PROFILE=edge uv run --no-project --with boto3 python run_metrics.py stage2-c37-20261004 # 이미지·ARN·DB 연결·실행 제어·비용·시작 구간 # 동시 37 실험 요청(재현 시 analysis_slots 를 실험 값으로 둔 뒤): batch_run.py start --analysis-at 2026-10-03T21:02:00Z --deadline <지금 이후> --name <이름> AWS_PROFILE=edge uv run --no-project --with boto3 python batch_run.py report full3-20261002T0640Z AWS_PROFILE=edge uv run --no-project --with boto3 python input_overlap.py full3-20261002T0640Z # results/full3-…overlap.json cd ../local # R() 는 위 '실행 구조 대안 검토'의 정의 EDGE_REPO= R occupancy --modes c --n 37 --llm 30 --tools 40 --writer-limit 20 --reader-limit 6 EDGE_REPO= R occupancy --modes c --n 37 --llm 30 --tools 40 --writer-limit 20 --reader-limit 6 --research-profile <프로파일 JSON> EDGE_REPO= R occupancy --modes c --n 37 --llm 30 --tools 40 --writer-limit 20 --reader-limit 6 --prepare-seconds 13 # 로컬 동시 부하·PgBouncer 비교(위 '로컬 동시 부하와 PgBouncer 비교'). 툴 기록 시각 프로필은 dev 쪽에서 먼저 만든다 cd ../dev && AWS_PROFILE=edge uv run --no-project --with boto3 python tool_profile.py stage2-c37-20261004 && cd ../local export EDGE_REPO=<저장소 루트> docker compose -p v2load900 up -d postgres && docker compose -p v2load900 --profile migration run --rm flyway migrate docker compose -p v2load900 --profile generator build generator # 자식 이미지 v2load900-gen L() { uv run --no-project --python 3.12 --with 'psycopg[binary]>=3.1' --with claude-agent-sdk==0.2.160 --with 'PyYAML>=6' --with 'jsonschema>=4' --with boto3 python "$@"; } P=../dev/results/stage2-c37-20261004.tool_profile.json CON='host=127.0.0.1 port=56432 dbname=pgbouncer user=v2_local password=local_only' # 직접(현재 B): PgBouncer 는 내려 둔다(남은 v2 역할 연결이 있으면 load900.py 가 거부한다) L load900.py run --n 37 --llm 30 --tools 40 --net v2load900_default --db-host postgres --port 5432 # 대조. 프로세스 방식은 R occupancy --modes c --n 37 --llm 30 --tools 40 --writer-limit 20 --reader-limit 6 L load900.py run --n 900 --profile $P --net v2load900_default --db-host postgres --port 5432 # --n 100, 300 도 같은 형태 L load900.py run --n 900 --profile $P --start-spread 60 --net v2load900_default --db-host postgres --port 5432 L load900.py run --n 900 --tools 30 --align-end 150 --start-spread 60 --net v2load900_default --db-host postgres --port 5432 # 풀: 호환성 확인이 reader 역할 기본값을 넣고 기대값을 판정한다. 풀 실행은 시작 옵션을 빼고 시작 때 풀을 비운다 docker compose -p v2load900 --profile pool up -d pgbouncer L pool_check.py L load900.py run --n 900 --profile $P --start-spread 60 --net v2load900_default --db-host pgbouncer --port 6432 --no-startup-options --pool-console "$CON" docker stop v2load900-pgbouncer-1 # 다음 직접 실행 전 # 비교 순서는 조건마다 직접 → 풀 → 풀 → 직접(--label cmp-<조건>-<방식>-<순번>). 집계 규칙을 고친 실행은 자식 원본으로 다시 집계한다 L load900.py resummarize --label cmp-start-pool-2 --reason '<이유>' docker compose -p v2load900 --profile pool down ``` `load900.py`는 요약을 `local/results.jsonl`에, 분석별 기록·초 단위 연결 시도·PostgreSQL 활동 표본·컨테이너 CPU·풀 표본을 `local/results-900/<이름>/`에 남긴다(추적하지 않는 경로). 판단에서 뺀 무효 실행은 `local/results-invalid-cmp-20261004.jsonl`에 있다. 시작 조회 프로파일은 `results/full3-…overlap.json`의 `per_etf[*].start_to_first_tool_s`에서 1.0을 빼고 0 아래는 0으로 둔 37개 목록이다. 측정 원본은 `local/results.jsonl`과 `dev/results/`에 있다(추적하지 않는 경로).