Skip to content

[Infra] 유휴 DB 쿼리 1904→74/분 · 배포·클라우드 워커의 행별 격리 누락 수정 [ #340 ] - #350

Merged
Danto7632 merged 3 commits into
developfrom
danto/perf-workers
Sep 11, 2026
Merged

[Infra] 유휴 DB 쿼리 1904→74/분 · 배포·클라우드 워커의 행별 격리 누락 수정 [ #340 ]#350
Danto7632 merged 3 commits into
developfrom
danto/perf-workers

Conversation

@Danto7632

Copy link
Copy Markdown
Member

요약

폴링 워커 4종이 유휴에도 매초 DB 를 쳤다(#340). 유휴 쿼리 1904/분 → 74/분 (−96%). 그리고 실제 버그 1건을 함께 고쳤다.

유휴 쿼리/분
develop 6960436 1904
이 브랜치 74

신규 스키마 두 개(meas_u5_a·meas_u5_b)에서 각각 74/60s. 리드가 별도 스키마(verify_u5_lead)로 독립 측정해 같은 값을 확인했다.

1. 먼저 버그 — 배포·클라우드 워커가 작업을 2분간 방치하고 있었다 (fix)

재현: 배치 [51, 52] 를 claim 한 뒤 executeQueued(51)TaskRejectedException 을 던지게 하면, 기존 코드는 그 예외가 for 루프를 통째로 끊어 52가 영영 위임되지 않는다. executeQueued@Async 라 실행기 포화 시 프록시가 그 자리에서 던진다. 52는 IN_PROGRESS/RUNNING 인 채 리스 만료(2분)까지 남고, 그동안 화면에는 "배포 중"·"권한 확인 중" 이 떠 있다.

AgentRunWorker#55 에서 이미 고쳐진 형태인데 나머지 둘에 그대로 남아 있었다.

수정: 위임을 dispatchOne 으로 분리해 행별로 잡고, 잡으면 releaseClaim 으로 즉시 PENDING 복귀. claim 이 올린 attempt 도 되돌린다 — 포화는 이 작업의 실패가 아니라 배압이라 재시도 예산을 쓰면 안 된다. releaseClaim 의 WHERE 는 claim 과 같은 조건부 UPDATE 모양(id + status + lease_owner)이라 이 claim 을 쥔 워커만 되돌리고, 동시에 도는 리스 회수와 이중 전이를 만들지 않는다. 배포는 next_run_at 이 있어 backoff 까지 얹었다(포화 중 claim↔release 핫루프 방지). 재현 테스트 4건.

2. 폴링 구조 — 비용의 대부분은 SELECT 가 아니라 트랜잭션 의례였다

실측 다이제스트에서 1904 중 SET autocommit 1501 + COMMIT 717 이었다. 폴링 한 번이 SET autocommit=0 → SELECT → COMMITSET autocommit=1 로 왕복 4배가 되고, 워커 4종 × (recover + claim) = 폴링당 8 트랜잭션이 초당 반복됐다.

(a) recover + claim 을 한 트랜잭션으로 (recoverAndClaim / recoverAndClaimPending) — 폴링당 트랜잭션 2→1 이라 의례가 절반. 덤으로 회수된 행을 같은 폴링의 claim 이 곧바로 집는다.

(b) WorkerPollGate (신규 common/worker 패키지) — 큐별 적응형 백오프 1s → ×2 → 30s.

@Scheduled 간격은 1초 그대로 두고 게이트가 "이 틱에 DB 를 칠지" 만 정한다. 스케줄러 스레드가 1초마다 깨는 비용은 인메모리 비교 한 번이고, 대신 두 가지를 공짜로 얻는다 — (1) 깨우기 신호가 오면 다음 틱(≤1초)에 곧바로 폴링하므로 첫 claim 지연이 백오프와 무관하게 항상 1초 이내다. 동적 Trigger 로 간격 자체를 늘렸다면 깨우기가 재스케줄까지 해야 했다. (2) 스케줄러 인프라를 손대지 않아 워커별로 켜고 끄기가 자유롭다.

깨뜨리지 않았다는 근거

claim 원자성 — 후보 SELECT → 행 단위 조건부 UPDATE → 영향 행 수로 판정하는 구조를 그대로 뒀다. 바꾼 것은 트랜잭션 경계뿐이다. 실제 MySQL 에서 두 스레드가 같은 행에 recoverAndClaimPending 을 동시에 던져 정확히 하나만 이긴다는 것을 통합 테스트로 못박았다(WebhookMergedPollClaimIntegrationTest). 이미 claim 된 행이 다른 인스턴스 폴링에 재선택되지 않는 것, 만료 리스가 합친 호출 안에서 여전히 회수되는 것도 함께.

첫 claim 지연@TransactionalEventListener(AFTER_COMMIT) 로 enqueue 커밋 시 그 큐의 백오프를 즉시 푼다. 백오프가 30초여도 다음 틱(≤1초)에 claim 한다. 발행 지점은 TaskStore 6곳(enqueue/retry/승인복구/결과승인·거절/재계획/입력수신), 배포·클라우드 job 은 PENDING 저장 순간, 웹훅은 enqueue 수락 순간.

테스트 3층: 게이트 단위(상한 백오프 중 wake → 즉시 open), 워커 단위(백오프 중엔 DB 무접촉, wake 직후 첫 틱에 claim), 통합(커밋 전엔 안 깨고 커밋 후에 깬다 — 커밋 전에 깨우면 헛폴링 후 도로 물러나 상한만큼 늦어진다).

다중 인스턴스 — 깨우기는 JVM 로컬이라 폴백 폴링을 남기고 상한을 뒀다. 이 값이 정하는 게 "남의 인스턴스가 넣은 일을 최대 얼마나 늦게 보는가" 라는 걸 설정 주석에 적었다. 폴링 중 도착한 깨우기가 그 폴링의 백오프에 묻히지 않게 세대 번호로 막았다.

3. 나머지 (5-3 ~ 5-10, 5-6 제외)

웹훅 executor 위임(+행별 격리·releaseClaim 을 처음부터 함께), StuckDeploymentRecoveryWorker 1→2→5→10분 백오프, DomainVerificationWorker executor 위임 + 경과시간별 간격(1/5/10분), StuckApprovalSweeper 5분, DeploymentExecutionRegistryrenewLeases skip, ChatTrashCleanupScheduler 벌크 DELETE, Docker prune 비동기. 5-4·5-5 공용으로 NextCheckSchedule(TTL 자동 만료로 누수 방지).

4. 워커가 죽은 게 아니라 덜 도는 것

측정 구간만 격리한 다이제스트로 확인했다 — 워커 4종이 각각 60초에 정확히 2회 폴링(30초 상한), SET autocommit 20 / COMMIT 10(이전 1501/717). 폴링당 트랜잭션이 1개인 것도 여기서 보인다.

남은 74 의 절반 이상은 provisioning/**(preview_sessions 8, provisioned_databases 4, domain_bindings 3) — #344 구역이라 건드리지 않았다.

검증

전체 1518 = 통과 1506 / 실패 0 / 스킵 12 (리베이스 후 재확인)

하지 않은 것

  • 5-6 ServerHealthMonitorWorker: provisioning/** 는 다른 담당자 구역(Issue BL: [Provisioning] 성능 U9 — STS·SDK 클라이언트 매 호출 생성, ECR lifecycle 미설정(과금 누적) #344)
  • 워커 공통 베이스 클래스: 4종은 claim 타입(Long/String)·복구 반환값·위임 방식이 다 달라 상속으로 묶으면 훅만 늘어난다. 게이트 합성 + 리포지토리 메서드로 풀었다 — AGENTS.md 의 "신규 로직은 새 패키지로 격리" 에도 이쪽이 맞다
  • cloud_connection_verification_jobsnext_run_at 추가: 마이그레이션이 필요한데 Flyway 번호 충돌 위험 대비 이득이 작다. 이 큐만 release 후 다음 폴링에 재claim 되는데 배치 2건짜리 유계 루프이고 자리 나면 스스로 풀린다 — 코드 주석에 남겼다
  • purgeExpiredConversationschat_messages 를 안 지우는 기존 누수: 벌크 전환과 무관한 별건이라(같은 클래스의 deleteConversationsForProject 는 지운다) 손대지 않았다. 데이터 삭제 범위를 조용히 넓히는 건 이 단위가 결정할 일이 아니다 — 별도 이슈감으로 보고만 한다
  • .notion/ 문서: 병렬 충돌 때문에 리드가 통합 시 일괄
  • Flyway 마이그레이션 없음

리뷰 포인트

ConversationRepository.findAllByDeletedTrueAndDeletedAtLessThanEqual 가 운영에서 미사용이 됐다. 테스트가 never() 로 "로드 후 삭제하지 않는다" 를 지키는 데 쓰고 있어 일부러 남겼다.

Closes #340

https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr

DeploymentRunWorker 와 CloudConnectionVerificationWorker 의 dispatch 루프에는 행별
try/catch 가 없었다. executeQueued 는 @async 라 실행기가 포화되면 프록시가 그 자리에서
TaskRejectedException 을 던지는데, 그 예외가 루프를 통째로 끊었다.

끊긴 루프가 남기는 것은 "한 건 실패"가 아니다. 같은 배치에서 이미 claim 된 다음 행이
IN_PROGRESS/RUNNING 인 채 남고, 그것을 돌릴 스레드는 어디에도 없으며, 리스 만료(2분)
전까지는 아무도 회수하지 않는다. 그 2분 동안 사용자 화면에는 "배포 중"·"권한을 확인하고
있습니다"가 떠 있고 실제로 진행되는 것은 없다.

AgentRunWorker 는 #55(ADR-Y3)에서 같은 버그를 이미 고쳤다. 그 패턴을 그대로 옮긴다.

- 위임을 dispatchOne 으로 분리하고 RuntimeException 을 그 안에서 잡는다
- 잡으면 releaseClaim 으로 즉시 PENDING 으로 되돌린다. claim 이 올린 attempt 도 되돌린다 —
  실행기 포화는 이 작업의 실패가 아니라 시스템 부하로 인한 배압이므로 재시도 예산을
  써서는 안 된다
- releaseClaim 의 WHERE 는 claim 과 같은 조건부 UPDATE 모양(id + status + lease_owner)이라
  이 claim 을 실제로 쥔 워커만 되돌릴 수 있고, 동시에 도는 리스 만료 회수와 이중 전이를
  만들지 않는다
- 배포는 next_run_at 이 있으므로 되돌릴 때 backoff 를 얹어 포화 중 claim<->release 가 매
  폴링 반복되는 것을 막는다(qeploy.deployment.worker.dispatch-reject-backoff-ms)

재현 테스트를 남긴다: 배치 [51, 52] 에서 51 의 위임이 거부돼도 52 는 그대로 위임되고,
51 은 claim 을 붙든 채 방치되지 않는다.

Claude-Session: https://claude.ai/code/session_01R9ahjBP2PFa5pCDbbWftvq
유휴(사용자 0명·작업 0건) 상태에서 워커 4종이 매초 DB 를 쳐서 분당 1904 쿼리가 나갔다.
실측 다이제스트를 보면 그 비용의 대부분은 SELECT 자체가 아니라 트랜잭션 의례다 — 폴링
한 번이 SET autocommit=0 → SELECT → COMMIT → SET autocommit=1 로 왕복 네 번이 된다.
워커 4종 × (recover + claim) = 폴링당 8 트랜잭션이 초당 반복됐다.

두 가지를 한다.

1. recover 와 claim 을 한 트랜잭션으로 합친다(recoverAndClaim / recoverAndClaimPending).
   폴링당 트랜잭션이 2 → 1 이 되므로 의례가 절반이 된다. 덤으로 회수 지연이 한 폴링
   짧아진다 — 회수 UPDATE 가 같은 트랜잭션에서 먼저 반영되므로 방금 되돌아온 행을 같은
   폴링의 claim 이 곧바로 집는다.

2. WorkerPollGate — 큐별 적응형 백오프. 일이 있으면 1초, 없으면 두 배씩 늘려 30초까지
   물러난다. @scheduled 간격 자체는 1초로 두고 게이트가 "이 틱에 DB 를 칠지"만 정한다.
   스케줄러 인프라를 손대지 않고, 깨우기가 재스케줄까지 할 필요가 없어진다.

깨지지 않게 지킨 것:

- claim 의 원자성. 후보 SELECT + 행 단위 조건부 UPDATE + 영향 행 수로 판정하는 구조를
  그대로 둔다. 합친 것은 트랜잭션 경계뿐이다. 실제 MySQL 에서 두 스레드가 같은 행을
  동시에 노려도 정확히 하나만 집는 것을 재현 테스트로 못박는다.

- 첫 claim 지연. enqueue 가 커밋되면 @TransactionalEventListener(AFTER_COMMIT) 가
  그 큐의 백오프를 즉시 푼다. 백오프가 상한까지 늘어나 있어도 사용자는 다음 틱(≤1초)에
  집힌다. 커밋 "뒤"여야 하는 이유도 테스트로 남긴다 — 커밋 전에 깨우면 워커가 아직
  보이지 않는 행을 찾다가 도로 물러나 그 일이 상한만큼 늦어진다.
  발행 지점: TaskStore 의 enqueue/retry/승인복구/결과승인/재계획/입력수신 6곳,
  배포·클라우드 job 은 PENDING 으로 저장되는 순간, 웹훅은 enqueue 수락 순간.

- 다중 인스턴스. 깨우기 신호는 JVM 로컬이라 인스턴스를 넘지 못한다. 그래서 폴백 폴링을
  남기고 백오프에 상한을 둔다 — 남의 인스턴스가 넣은 일도 최대 30초 안에는 본다.
  이 값이 실제로 정하는 것이 그 최대 지연이라는 것을 설정 주석에 적었다.

- 좀비 리스 회수. 실행기가 포화라 claim 을 생략할 때도 회수는 돌린다(claimLimit=0).
  포화를 이유로 폴링을 통째로 건너뛰면 좀비 리스가 그만큼 오래 남는다. 그리고 그 폴링은
  "일이 없다"로 치지 않는다 — 자리가 나는 것을 알려줄 신호가 없으므로, 여기서 물러나면
  큐에 쌓인 태스크가 상한만큼 늦게 출발한다.

폴링 중에 도착한 깨우기가 그 폴링의 백오프에 묻히지 않도록 세대 번호로 막는다.

Claude-Session: https://claude.ai/code/session_01R9ahjBP2PFa5pCDbbWftvq
5-3 WebhookDeliveryWorker — 배달 처리를 전용 executor(webhookExecutor)로 넘긴다. 핸들러가
GitHub API 를 호출하므로 한 배달이 느리면 그 동안 폴링이 통째로 밀리고 스케줄러 풀을
공유하는 다른 잡까지 굶었다(한 폴링 최대 10건 직렬). 비동기가 되면 TaskRejectedException
이라는 새 실패 경로가 생기므로, 5-2 에서 고친 것과 같은 행별 격리 + releaseClaim 을
처음부터 함께 넣는다 — 되돌리지 않으면 GitHub 이 다시 보내주지 않는 배달을 잃는다.

5-4 StuckDeploymentRecoveryWorker — 판정을 못 얻은 이력의 재조회를 1→2→5→10분으로 미룬다.
멈춘 배포 한 건이 포기 시각(120분)까지 매분 GitHub 을 쳐서 최대 120회, runId 가 없으면
목록 조회(per_page=30)까지 돌았다. 조회 실패(레이트 리밋 등)도 "판정을 못 얻은 것"으로
같은 백오프를 태운다 — 레이트 리밋에 걸린 채 매분 다시 치는 것이 가장 나쁘다.

5-5 DomainVerificationWorker — 프로브를 전용 executor 로 넘기고, 생성 후 경과 시간별로
간격을 둔다(10분까지 1분 → 1시간까지 5분 → 그 뒤 10분). 커스텀 도메인은 TTL 이 1440분이라
한 건당 최대 1,440회 × (Cloudflare + GitHub + HTTPS 프로브)가 나갔다. 기다리는 대상이 DNS
전파와 호스팅 반영이라 시간이 지날수록 "다음 1분 안에 바뀔" 확률이 떨어진다.

5-7 StuckApprovalSweeper — 60초 → 5분. 자기 자바독이 "정상이면 항상 0건"이라고 적은
회귀망이다. 회귀가 실제로 생겼을 때 5분 안에 잡히면 충분하다.

5-8 DeploymentRunWorker.renewLeases — DeploymentExecutionRegistry 를 두고, 이 인스턴스가
실제로 돌리는 이력이 없으면 쿼리를 아예 내지 않는다. 갱신 범위도 그 스냅샷으로 좁힌다 —
실행기가 거부한 행의 리스를 하트비트가 계속 살려두면 recoverExpiredLeases 가 그것을 영영
회수하지 못한다(AgentRunWorker 가 #55 ADR-Y4 에서 고친 것과 같은 이유). 등록은 제출 직전에,
해제는 executeQueued 의 finally 에서 한다.

5-9 ChatTrashCleanupScheduler — 엔티티 전체 로드 + deleteById N회를 벌크 DELETE 한 문장으로
바꾼다. 삭제 조건이 곧 SELECT 조건이었으므로 지우려고 읽을 이유가 없었다.

5-10 DockerGarbageSweeper — prune 을 maintenanceExecutor 로 넘긴다. prune 3회가 도커 데몬을
잠시 붙잡는 동안 스케줄러 스레드를 점유하지 않는다.

공통으로 NextCheckSchedule 을 새로 둔다(5-4 · 5-5). 대상별 "다음에 볼 시각"을 인메모리로
들고 있고, 오래 손대지 않은 항목은 스스로 만료시켜 누수를 막는다. DB 가 아니라 메모리인
이유는 이 간격이 정확성이 아니라 예의의 문제이기 때문이다 — 재시작하면 장부가 비고 모든
대상을 한 번씩 다시 보는데, 그것이 오히려 옳은 동작이다.

Claude-Session: https://claude.ai/code/session_01R9ahjBP2PFa5pCDbbWftvq
@Danto7632
Danto7632 merged commit 4ddf4a0 into develop Sep 11, 2026
1 check passed
@Danto7632
Danto7632 deleted the danto/perf-workers branch September 11, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant