LiveKit agent readiness 타임아웃 — 워커 디스패치 즉시 포기 원인 분석 분석PR #1027

마지막 업데이트 2026-09-12

쉬운 설명 한 장 보기 · 사전지식 없이 읽는 요약

2026-08-26 (같은 날 정정) 사건 2026-08-25 (재발: 08-24) 브랜치 PPI-1237 · PR #1027 apps/livekit-agent · apps/web · LiveKit 서버

요약

LIVEKIT_CLIENT_SESSION | LiveKit agent readiness wait failed 로 AI 세션이 시작되지 못한 사건. 전체 워커 풀에는 빈 슬롯이 11개 있었지만, 그 job 을 처리한 LiveKit 노드에 등록된 워커 11개는 전부 꽉 차 있었다. 그중 3개만 아직 WS_AVAILABLE 로 보고된 상태(막 채워져 status 미갱신)라 후보가 됐고, 셋 다 실제로는 꽉 차서 거절하자 후보가 고갈돼 job 이 폐기됐다. 클라이언트는 통보를 못 받고 10초 뒤 스스로 타임아웃했다.

세 가설의 판정 — ① 세션수 급증? 아니다. 전체 동시 점유가 한계(48)에 닿은 적이 없다(최대 44). ② 끝난 방이 즉시 반환되지 않아서? 기여 요인. 아동 퇴장 후 20.1초간 슬롯이 유지되며 전체의 14.9%를 차지한다 — 그만큼 각 노드의 여유 워커 수가 줄어든다. ③ 직접 원인은 노드 단위 포화다. 풀 전체가 아니라 그 노드가 말랐고, 거기에 후보 고갈 시 재시도 없음(서버)과 실패 통보 없음(클라이언트)이 겹쳐 실패가 확정됐다.
정정 이력 (2026-08-26, 같은 날 3회 개정) — 초판은 원인을 "서버가 후보 1~3개 시도하고 포기"(재시도 상한 오해)로 적었다. 소스 확인 결과 디스패치 루프에는 상한이 없고, 3개에서 멈춘 것은 후보 고갈이었다. 2판은 이를 "status 2.5초 지연으로 여유 워커가 후보에서 빠짐"으로 설명했으나, status 전환 로그를 전수 재구성하니 서버가 아는 AVAILABLE 이 11개여서 지연만으로는 설명되지 않았다. 노드 단위로 갈라 본 결과가 현재 판이다(4절). 아울러 사후 점유 20초의 원인을 empty_timeout 으로 적었던 것도 departure_timeout 으로 바로잡았다(6절).
수정 2건이 PR #1027 로 올라가 있다(브랜치 PPI-1237, 커밋 314fb05b) — ① agent.pyattach_session_close_job_shutdown() 신설로 사후 점유 20초 제거, ② use-ai-session.ts + livekit-readiness-retry.ts 에 readiness 재시도 1회 + 로깅 추가. dev 서버 배포 후 검증 예정이며, 확인 항목은 9절 말미에 있다. 노드 간 워커 불균형(11/10/3)은 손대지 않았으므로 근본 원인은 남아 있다.

1. 증상과 범위

2026-08-25 기술이슈 일일 리포트에서 LiveKit agent readiness wait 가 긴급으로 2건 잡혔으나, 원본 세션 로그를 열어 보면 실제로는 3건이다. 최시원 세션은 대표 시그니처가 consumer resume 실패로 분류돼 같은 실패가 가려졌다.

아동 / 회기클라이언트 타임아웃서버 디스패치 포기아동 체감
이나엘 14회기20:10:27.22120:10:17.123 (후보 1개)-
석우주 6회기20:17:14.61220:17:06.586 (후보 3개)20:17:28 "화면/친구가 멈췄어요" 신고
최시원 6회기20:17:17.74220:17:06.596 (후보 3개)-

같은 20:17:06에 944fb00c·4142658b 두 방도 동일하게 폐기됐다. 그 세션들은 다른 이유로 리포트에 안 잡혔을 뿐 같은 사건이다.

재발 사건이다. 2026-08-24 20:14:38.785 / 20:14:39.708 에도 두 방이 후보 워커 2개만 시도하고 같은 메시지로 폐기됐다(리포트 포맷 before/after 문서가 "20:14:49 김시은45·김민하10 동시 발생 — 개별 아동 이슈가 아니라 에이전트측 사건"으로 이미 지목했던 그 건). 세션별 카드 나열 포맷에서는 동시성이 보이지 않아 매번 별개 이슈로 읽힌다.

2. 시간순 인과

// 2026-08-25 20:17 — 석우주·최시원 (서버 시각, ms)
20:17:05.963  AW_64kum  ← job 배정   (1→2, 꽉참)
20:17:06.063  AW_KdQyc  ← job 배정   (0→1)
20:17:06.133  AW_CzqNH  ← job 배정   (1→2, 꽉참)
20:17:06.491  AW_KdQyc  ← job 배정   (1→2, 꽉참)   ← 623ms 만에 여유 워커 소진
20:17:06.579  석우주 AJ_UTHb5RmiJKKy → AW_64kum  worker not available
20:17:06.583                          → AW_KdQyc  worker not available
20:17:06.586                          → AW_CzqNH  worker not available
20:17:06.586  no worker available to handle job — no workers with sufficient capacity
              ↑ 실제 점유는 41/48 (빈 슬롯 7개). 그런데 서버 눈에 AVAILABLE 인 워커는
                이 3개뿐이었다 — 나머지는 2.5초 전 보고값 FULL 이 그대로 남아 후보에서 제외
20:17:06.588  최시원 AJ_ZukyvHG3Ckpb → 같은 워커 3개 → 동일하게 폐기
20:17:07.738  AW_yaQqc  ← job 배정   다른 방은 1.2초 뒤 정상 수락 — 워커는 있었다

// 클라이언트 (게스트, 세션 로그)
20:17:04.277  LiveKit session descriptor created
20:17:04.524  room connection state = connected
20:17:04.601  livekit_agent_ready_begin        ready_probe 전송, 재전송 500/1500/3500ms
              …에이전트가 방에 들어오지 않으므로 ready_response 없음…
20:17:14.612  LiveKit agent readiness wait failed (LiveKitAgentReadinessTimeoutError)
20:17:14.615  AI_SESSION | Failed to start session
20:17:17.912  UNHANDLED_REJECTION
20:17:28.557  아동: "화면/친구가 멈췄어요"

이나엘 건은 더 단순하다 — AW_tbv3g 가 20:10:16.672/.696 두 번 연속 배정으로 꽉 찬 직후 20:10:17.123 에 그 워커 하나만 후보로 잡히고, 거절당하자 곧바로 폐기됐다. 그 시점 실제 점유는 42/48(빈 슬롯 6개)이었고, 2초 뒤에는 다른 워커 3개가 정상적으로 job을 받았다.

3. 용량 모델 — 상한은 48이고, 닿은 적이 없다

워커 admission 은 CPU 기반 기본 load 함수가 아니라 PPI 자체 함수로 결정된다.

// apps/livekit-agent/agent.py:299-325
PPI_AGENT_MAX_JOBS_PER_WORKER = _parse_positive_int_env(..., 2)
PPI_AGENT_LOAD_THRESHOLD = 0.99

def _ppi_agent_load(server):
    active_jobs = len(server.active_jobs)
    return min(active_jobs / PPI_AGENT_MAX_JOBS_PER_WORKER, 1.0) * PPI_AGENT_LOAD_THRESHOLD

server = AgentServer(load_fnc=_ppi_agent_load,
                     load_threshold=PPI_AGENT_LOAD_THRESHOLD,
                     num_idle_processes=PPI_AGENT_MAX_JOBS_PER_WORKER)

워커당 2잡에서 load = 0.99 = threshold → FULL. 실측 ECS 태스크(=워커) 24개이므로 하드 실링은 24 × 2 = 48 동시 세션이다.

시점점유 / 48빈 슬롯워커 분포
20:10:17 (이나엘 실패)4262×18, 1×6
20:17:06 (석우주·최시원 실패)37112×14, 1×9, 0×1
19:30~20:30 최대444-
이 수치는 확정값이다. LiveKit 서버 로그의 assigned job to worker / job ended 로 재구성한 워커별 점유가, CloudWatch 에이전트 로그(ECS 태스크별 received job request / process exiting)로 독립 재구성한 값과 완전히 일치한다 — 20:17:06 = 37/37, 20:10:17 = 42/42. 두 로그는 jobID(AJ_*)를 공유하므로 워커↔태스크가 24/24 모두 1:1로 조인된다.

수요 자체도 스파이크가 아니다. 분당 신규 job 은 최대 24 / 중앙값 11이고, 하루 failed to assign job to worker 57건은 19:00~21:20 수업 피크에 고르게 퍼져 있다(20:17이 10건으로 최다). 문제는 분 단위 총량이 아니라 초 단위 동시성이다 — 1초 창 안에 3건 이상 배정된 경우가 19회 있었고, 실패는 그 창에서 발생했다.

1초 창 동시 배정 건수 분포 (19:30~20:30, n=571)
1건 : 450    2건 : 102    3건 : 15    4건 : 4

4. 서버는 왜 후보를 3개만 봤나 — 노드 단위 포화

prod LiveKit 은 노드 3대로 운영되고, agent worker 는 각자 한 노드에 등록된다. 실패한 job 을 처리한 노드에는 여유 슬롯이 0개였다 — 전체 풀에는 11개가 남아 있었는데도.

노드등록 워커실제 여유 슬롯서버가 아는 AVAILABLE
ip-172-31-46-118 ← 석우주·최시원 job 처리11개0개3개 (전부 stale)
ip-172-31-10-23610개5개5개
ip-172-31-8-233개3개3개
합계24개8개11개
여유 워커 8개는 전부 다른 노드에 있었다. 서버가 시도한 3개(AW_64kum·AW_KdQyc·AW_CzqNH)는 모두 46-118 등록 워커이고, 그 노드의 11개는 그 순간 전부 슬롯이 찼다.
"AVAILABLE 이 11개인데 왜 부족한가"는 주체를 하나로 뭉쳐서 생기는 착시다. 11개는 클러스터 전체 합계이고, 판단을 내린 것은 46-118 노드 하나다. 그 노드의 AgentHandler 가 순회하는 namespaceWorkers 에는 자기 워커 11개만 들어 있고, 그중 WS_AVAILABLE 은 stale 한 3개뿐이었다. 셋 다 거절하자 availableSum == 0그 노드에게는 정확한 판단이었다. 숫자도 맞아떨어진다: 전체 11 = 진짜 여유 8(다른 노드) + stale 3(그 노드).

그 3개가 후보로 뽑힌 이유 — stale status

워커 24개의 슬롯 점유(서버 로그)와 status 보고(에이전트 로그)를 워커별로 교차검증했다. 21/24 일치, 불일치 3개가 정확히 서버가 시도한 그 3개다.

// 20:17:06.579 시점 교차검증 (일부)
 AW_yyWWGxpFtSqx  슬롯 2/2 → 예상 FULL   status로그 FULL
 AW_tbv3g4aLW5Ps  슬롯 2/2 → 예상 FULL   status로그 FULL
 AW_64kumGuumT5B  슬롯 2/2 → 예상 FULL   status로그 AVAIL   ← 20:17:05.963 에 막 참
 AW_KdQycxRaFqV3  슬롯 2/2 → 예상 FULL   status로그 AVAIL   ← 20:17:06.491 에 막 참
 AW_CzqNH6oz4ifh  슬롯 2/2 → 예상 FULL   status로그 AVAIL   ← 20:17:06.133 에 막 참
 AW_ov8jJiiuVrLX  슬롯 1/2 → 예상 AVAIL  status로그 AVAIL  ← 여유 있으나 다른 노드
status 전환은 로그로 확정된다. worker.py:1471 _update_worker_status() 가 상태가 바뀔 때 로그를 남기고, prod 에 실제로 찍힌다 — "worker is at full capacity, marking as unavailable" {"load": 0.99, "threshold": 0.99} (2잡), "worker is below capacity, marking as available" {"load": 0.495} (1잡). agent.pyactive_jobs / 2 × 0.99 값이 그대로 나온다.

서버 동작 — 재시도 상한은 없다

// pkg/service/agentservice.go — JobRequest
attempted := make(map[*agent.Worker]struct{})
for {                                              // 상한 없음
    selected, err := h.selectWorkerWeightedByLoad(key, attempted)
    if err != nil { "no worker available to handle job"; return }
    attempted[selected] = struct{}{}
    ...
}
// selectWorkerWeightedByLoad — 후보는 WS_AVAILABLE 인 워커만, 여유(1-load) 비례 가중 랜덤
if availableSum == 0 { return errors.New("no workers with sufficient capacity") }
prod 서버 버전은 v1.13.2 로 확정됐다. 기동 로그에 {"msg":"starting LiveKit server","version":"1.13.2","nodeID":"ND_D68geB58ANRL","nodeIP":"3.34.194.144"}(2026-07-28)가 남아 있고, 그 태그 소스의 로그 라인이 prod 로그 caller 와 정확히 일치한다 — 402/412/428 (master 는 415/425/441). 그리고 v1.13.2 의 JobRequest 는 위 master 코드와 동일하다. 재시도 상한은 존재하지 않는다.

워커는 접속한 노드에만 등록된다

// v1.13.2 — pkg/service/agentservice.go
:148  namespaceWorkers  map[workerKey][]*agent.Worker      // AgentHandler(=노드) 로컬 맵
:233  func (h *AgentHandler) HandleConnection(...)         // 워커가 WebSocket 으로 붙을 때
:300      h.namespaceWorkers[key] = append(workers, w)     // ← 그 노드에만 등록

// selectWorkerWeightedByLoad 는 h.namespaceWorkers 만 순회 = 자기 노드 워커만 본다
다른 노드에 여유가 있어도 볼 수 없다. 이것이 "전체 풀에 11 슬롯이 남았는데 실패"의 코드 근거다.

마른 노드가 job 을 가져온 이유 — affinity 도 status 기반

// v1.13.2 — 어느 노드가 이 job 을 처리할지 정하는 계산
func (h *AgentHandler) JobRequestAffinity(ctx, job) float32 {
    var affinity float32
    for _, w := range h.workers {
        if w.Status() == livekit.WorkerStatus_WS_AVAILABLE {
            affinity += max(0, h.targetLoad-w.Load())
        }
    }
    return affinity
}
stale AVAILABLE 이 두 번 해를 끼쳤다.46-118 노드는 실제로 슬롯이 다 찼는데 stale AVAILABLE 3개 덕분에 affinity 가 0 이 아니어서 job 을 가져왔다. ② 그 노드 안에서 그 3개가 유일한 후보가 됐고, 실제로는 꽉 차서 전부 거절했다.
3개에서 멈춘 것은 상한이 아니라 후보 고갈이다. 그 노드에서 WS_AVAILABLE 인 워커가 stale 한 3개뿐이었고, 셋 다 실제로는 꽉 차서 거절했다. attempted 로 제외되자 availableSum == 0 이 되어 job 이 폐기됐다.

근거 등급

주장등급근거
워커별 슬롯 점유 수팩트서버·에이전트 두 로그 독립 재구성 일치(37/37, 42/42)
워커 status 전환(FULL/AVAILABLE)팩트prod 에이전트 로그 405건, load/threshold 값 포함
거절한 3개가 stale AVAILABLE 이었다팩트슬롯 2/2 vs status AVAIL 불일치가 정확히 그 3개(21/24 일치)
실패 노드의 여유 슬롯 0개추론워커→노드 매핑을 assigned job to worker 가 찍힌 노드의 다수결로 추정했다. 실제 WebSocket 등록 노드를 직접 확인한 것은 아니다
다른 노드 워커는 후보가 될 수 없다팩트namespaceWorkers 가 노드 로컬 맵이고(:148, :300) 후보 선정이 그것만 순회한다 — v1.13.2 소스
재시도 상한이 없다 / prod 버전 v1.13.2팩트기동 로그의 version 필드 + 해당 태그 소스의 로그 라인이 prod caller(402/412/428)와 일치
마른 노드가 stale status 때문에 선택됐다추론JobRequestAffinityWS_AVAILABLE 워커의 여유 합으로 계산되는 것은 코드 확정. 다만 노드 선택 결과 자체는 로그에 남지 않아 직접 관측하지 못했다
이 절은 조사 과정에서 결론이 두 번 바뀌었다. 초판은 "서버가 후보 1~3개만 시도하고 포기"(재시도 상한 오해), 2판은 "status 2.5초 지연으로 여유 워커가 후보에서 빠짐"이었다. status 전환 로그를 전수 재구성한 결과 서버가 아는 AVAILABLE 은 전체 11개였고 지연만으로는 설명되지 않아, 노드 단위로 갈라 보니 실패 노드의 여유가 0이었다. 이후 prod 버전(v1.13.2)을 확정하고 해당 태그 소스를 확인해 재시도 상한 부재워커의 노드 로컬 등록을 팩트로 승격했다. 남은 추론은 두 가지 — 워커→노드 매핑이 로그 다수결 추정이라는 점과, 노드 선택 결과가 로그에 없어 affinity 경로를 직접 관측하지 못했다는 점이다.

5. 사후 점유 20.1초 — 기여하지만 원인은 아니다

아동이 방을 떠난 뒤에도 job 이 워커 슬롯을 정확히 20.1초 더 잡고 있다. 편차가 거의 없다(n=571, 중앙값 20.1s / p10 19.7 / p90 20.5) — LiveKit room departure_timeout 만료로 방이 닫힐 때까지 agent participant 가 살아 있기 때문이다. prod 로그로 확정했다(아래 6절).

// 같은 방(b47e6620) 3개 로그 소스 대조
20:16:02.295  [서버]    participant closing  user-…b47e6620  CLIENT_REQUEST_LEAVE
20:16:02      [에이전트] closing agent session due to participant disconnect
                        (RoomInputOptions.close_on_disconnect — 세션은 즉시 닫힘)
              ↓ 20초 공백 — 세션은 끝났는데 job process 는 살아 있고 슬롯을 점유
20:16:22.584  [서버]    participant closing  agent-AJ_QLqYtvKjYBkk  ROOM_CLOSED
20:16:22      [에이전트] process exiting  job_id=AJ_QLqYtvKjYBkk
20:16:22.589  [서버]    room closed
지표실측
아동 재실 시간 (배정 → 아동 퇴장)중앙값 103.0s / 평균 115.0s
사후 점유 (아동 퇴장 → job ended)중앙값 20.1s / p10 19.7 / p90 20.5
전체 슬롯·시간 중 사후 점유 비율14.9%
동시 점유 피크 — 실제 / 즉시 반환 가정44 → 37
상위 5% 수준 — 실제 / 즉시 반환 가정40 → 34

코드상 인과 — 왜 세션이 닫혀도 슬롯이 안 돌아오나

job 프로세스가 끝나는 트리거는 ipc/job_proc_lazy_main.py정확히 셋뿐이다.

# livekit-agents 1.6.4 — ipc/job_proc_lazy_main.py
:240  if isinstance(msg, ShutdownRequest):        # ① 부모 워커가 종료 요청 (draining 등)
          ...self._shutdown_fut.set_result(...)

:279  @self._room.on("disconnected")              # ② 방에서 끊길 때
:280  def _on_room_disconnected(*args):
:282      self._shutdown_fut.set_result(_ShutdownInfo(reason="room disconnected"))

:289  def _on_ctx_shutdown(reason):               # ③ ctx.shutdown() 호출 시
:293      self._shutdown_fut.set_result(_ShutdownInfo(user_initiated=True, ...))

아동이 나갈 때 실제로 실행되는 코드는 이 셋 중 어느 것도 아니다 — 세션만 닫는다.

# voice/room_io/room_io.py — _on_participant_disconnected
:404  if (self._options.close_on_disconnect
          and participant.disconnect_reason in DEFAULT_CLOSE_ON_DISCONNECT_REASONS ...):
:411      logger.info("closing agent session due to participant disconnect ...")
:420      self._agent_session._close_soon(reason=CloseReason.PARTICIPANT_DISCONNECTED)
                                            ↑ 세션만 닫는다. job 은 건드리지 않는다

# voice/agent_session.py:937 _close_soon → _aclose_impl
#   → ctx.shutdown() 도, ShutdownRequest 도 호출하지 않는다
그래서 남는 경로가 ② 하나뿐이다. 우리 agent.pyppi_agent() 에는 ctx.shutdown() 호출이 없고(③ 미사용), 평상시 draining 도 아니다(① 미사용). 결국 방이 닫혀 room disconnected 가 발생해야만 job 이 끝나고 워커 슬롯이 반납된다. 그 방이 닫히는 시점이 LiveKit 의 departure_timeout 만료(ROOM_CLOSED)이고, 실측상 마지막 아동 퇴장 +20.1초다.
20초의 정체는 departure_timeout 으로 확정됐다. 초판은 이를 empty_timeout 으로 적었으나 두 값은 다르다 — empty_timeout(기본 300초)은 아무도 들어온 적 없는 방, departure_timeout(기본 20초)은 다 나간 뒤의 방이다. prod 로그의 reason 필드가 이를 직접 말해 준다(6절 참조).
순단으로는 발동하지 않는다. 세션이 닫히는 사유는 voice/room_io/types.py:17 의 세 가지뿐이다 — CLIENT_INITIATED / ROOM_DELETED / USER_REJECTED. 실측에서 본 PEER_CONNECTION_DISCONNECTED(네트워크 순단)는 목록에 없어 세션이 유지되고, 세션이 닫히지 않으면 close 이벤트도 없으므로 아래 수정도 발동하지 않는다. 게다가 SESSION_CLOSE_JOB_SHUTDOWN_REASONSparticipant_disconnected 하나로 한정했다.

이 20초는 실제로 쓰이는 유예인가 — 실측

LiveKit 의 departure_timeout 은 "마지막 참가자가 나간 뒤 재접속할 시간을 준다"는 목적의 유예다. 그 목적이 우리 서비스에서 달성되고 있는지 19:30~20:30 전수로 확인했다.

지표결과
room closed − 마지막 아동 퇴장n=575, 중앙값 20.1s (min 19.6 / max 20.7) — 예외 없음
25초를 넘긴 방 (= 유예 중 재입장 정황)0건
한 방에 job 이 2회 이상 배정된 방0 / 571
아동 퇴장이 2회 찍힌 방1개 — 재입장이 아니라 PEER_CONNECTION_DISCONNECTED 20초 뒤 CLIENT_REQUEST_LEAVE 로 그대로 이탈
575번 발생했고 단 한 번도 쓰이지 않았다. 게다가 아동이 나가는 그 순간 에이전트는 close_on_disconnect 로 세션을 닫으므로, 20초 안에 돌아와도 방 껍데기만 있고 대화 상태는 이미 소멸한 뒤다. 새 job 도 배정되지 않는다(위 0/571).
단, 표본은 한 시간이다. 그리고 구조적으로 재입장이 불가능한 것은 아니다 — 방 이름이 voiceSessionId 기반이라(apps/web/app/api/livekit/call/route.ts:71 createRoomName) 같은 세션 ID 로 다시 요청하면 같은 방으로 들어간다. 이 표본에서 그 경로가 도는 것을 관측하지 못했을 뿐이다.
왜 원인이 아닌가 — 실패 순간 빈 슬롯이 6~11개 있었으므로 사후 점유를 다 없앴어도 "슬롯이 없어서" 실패한 것이 아니다. 왜 그래도 중요한가 — 사후 점유가 사라지면 여유 워커 가 늘어 서버가 고르는 후보 1~3개가 동시에 꽉 찰 확률이 크게 떨어진다. 즉 실패 확률을 낮추는 가장 값싼 레버다.

6. 20초의 정체 — departure_timeout 확정

어디에도 "20초를 기다려라"는 코드는 없다. 20초를 만드는 주체는 LiveKit 서버이고, 에이전트 job 은 그 결과(방이 닫히는 것)를 기다릴 뿐이다.

prod 로그 — reason 필드가 직접 말해 준다

// Loki {service_name="ppi-livekit", env="prod"} |= "closing idle room"  (20:14~20:18, 72건)
20:14:01.590  caller=rtc/room.go:804  msg="closing idle room"  reason="departure timeout"
20:14:08.677  caller=rtc/room.go:804  msg="closing idle room"  reason="departure timeout"
...
20:17:36.587  reason="departure timeout"  room=ppi-7f7291c4-…-taeyang2_room-0eecea6f   ← 석우주의 실패한 방
20:17:38.587  reason="departure timeout"  room=ppi-98a60cf9-…-newpp06_woosung-746cbe6c  ← 최시원의 실패한 방

reason="empty timeout" 은 0건

서버 코드 — 두 타이머가 갈리는 지점

// github.com/livekit/livekit — pkg/rtc/room.go, CloseIfEmpty()
if r.FirstJoinedAt() > 0 && r.LastLeftAt() > 0 {
    elapsed = time.Now().Unix() - r.LastLeftAt()
    timeout = r.protoRoom.DepartureTimeout       // 누군가 들어왔다가 다 나간 방 ← 우리 케이스
    reason  = "departure timeout"
} else {
    elapsed = time.Now().Unix() - r.protoRoom.CreationTime
    timeout = r.protoRoom.EmptyTimeout            // 아무도 들어온 적 없는 방
    reason  = "empty timeout"
}
if elapsed >= int64(timeout) {
    r.Close(types.ParticipantCloseReasonRoomClosed)
    r.logger.Infow("closing idle room", "reason", reason)
}

// pkg/config/config.go — 기본값
:550    EmptyTimeout:     5 * 60,   // 300초
:551    DepartureTimeout:     20,   // ← 실측 20.1초와 일치
실측 편차까지 코드와 맞는다. time.Now().Unix() - r.LastLeftAt()초 단위 정수 연산이라 구조적으로 최대 1초 오차가 생긴다. 실측 분포가 min 19.6 / max 20.7 (n=575)로 정확히 그 폭에 들어온다.
부수 소득 — prod 서버 버전 단서. 로그의 caller=rtc/room.go:804 는 master 의 :8051줄 차이다. 4절에서 추론으로 남겨둔 agentservice.go 쪽(로그 402/412/428 vs master 415/425/441)도 비슷한 정도의 근소한 차이일 가능성이 높아졌다. 다만 그쪽은 여전히 확정이 아니다.
토큰으로 방별 제어가 가능하다. room.go:298~300protoRoom.DepartureTimeout == 0 일 때만 서버 config 값으로 채운다 — 즉 토큰의 RoomConfiguration 이 서버 설정보다 우선이다. 우리는 이미 apps/web/lib/voice-agent/livekit-token.ts:107 에서 RoomConfiguration 을 만들어 싣고 있고(지금은 agents 만), @livekit/protocoldepartureTimeout 필드가 존재한다. 서버를 건드리지 않고 방별로 낮출 수 있다는 뜻이다.
남은 미확정 — 값 20 이 LiveKit 기본값 그대로인지, livekit.yaml 에서 명시 설정한 것인지는 구분할 수 없다(기본값도 20). 다만 동작은 확정됐으므로 실질적 영향은 없다.

7. 조사 레시피와 함정

데이터 소스

대상경로
LiveKit 서버 (방·디스패치)Loki {service_name="ppi-livekit", env="prod"}
LiveKit agent (워커·job)CloudWatch /ppi/livekit/prod/agent, logStream agent/ppi-livekit-agent/<taskId>
클라이언트 (게스트·모니터)S3 prod/lesson-logs/{userId}/lesson-{n}/archives/lesson-log-YYYYMMDD.jsonl.gz
Grafana 화면으로 볼 때 — viewer 계정은 Explore 권한이 없어 /explore 링크가 Home 으로 리다이렉트된다. 커스텀 앱 /a/ppilogbrowser-app/log-browser 를 쓰면 CloudWatch 뿐 아니라 Loki 서비스(ppi-livekit)도 같은 UI 에서 조회되고, URL 파라미터로 바로 재현된다. ?env=prod&service=ppi-livekit&instance=All&from=2026-08-25T20:14:00&to=2026-08-25T20:18:00&search=closing%20idle%20room (from/to 는 ISO 형식이면 적용되고 epoch ms 는 Invalid date 로 깨진다)

함정

① Loki env 라벨을 빼면 dev 가 섞인다. service_name="ppi-livekit" 만으로 조회하면 dev 노드(ip-172-31-1-57) 로그가 함께 나온다. 판별자는 agentName: "ppi-agent-local" + apiKey: "lkdev_…" = dev, agentName: "ppi-agent" = prod. prod 는 노드 2대 이상으로 운영된다.
② 로그를 파일/스트림 단위로 처리하면 점유가 부풀려진다. Loki 응답을 시간순 정렬하지 않고 청크별로 접으면 job ended 가 대응하는 assigned 보다 먼저 처리돼 유실되고, 그 job 이 영원히 활성으로 남는다. 실측 오차 37 → 83 (2.2배). 반드시 전체를 timestamp 로 정렬한 뒤 상태를 재구성한다.
not dispatching agent job since no worker is available 는 노이즈다. JT_PARTICIPANT / JT_PUBLISHER 디스패치에서 정상 방에도 매번 찍힌다(이 프로젝트는 JT_ROOM 만 사용). 실제 신호는 failed to assign job to worker (워커가 거절) 와 no worker available to handle job (서버가 포기) 두 개다.
④ 일일 리포트의 대표 시그니처만 세면 건수를 놓친다. 최시원 세션은 동일 실패를 겪었지만 대표 시그니처가 consumer resume 실패로 잡혀 "readiness wait 2건" 으로 집계됐다. 실제 3건.
⑤ "빈 슬롯이 있었는데 실패했다"를 용량 문제로 읽지 말 것. 서버의 후보 자격은 슬롯 수가 아니라 워커가 보고한 WS_AVAILABLE status 다. 실제 점유(재구성값)와 서버 시야(status)는 최대 2.5초 어긋난다. 두 값을 같은 것으로 놓으면 원인을 태스크 증설로 오진한다.

확진 절차

  1. 세션 로그에서 LiveKit session descriptor createdroomName 을 얻는다 (UUID 접미사가 AI 스텝마다 다르다 — 세션 roomId 로 검색하면 수십 개가 섞인다)
  2. 그 접미사 8자로 Loki prod 를 조회해 assigned job to worker 가 있는지 본다 — 없고 no worker available to handle job 이 있으면 이 사건이다
  3. 같은 초의 전체 assigned job to worker 타임라인을 뽑아 거절한 워커들이 직전 1초 내에 채워졌는지 확인한다 → 버스트 경합 확정
  4. 같은 시각 풀 전체 점유를 재구성해 빈 슬롯이 남아 있었는지 본다 → 용량 부족과 구별

8. 클라이언트 쪽 동작과 재시도

에이전트가 들어오지 않으면 클라이언트는 10초를 기다린 뒤 세션 시작을 포기했다. 실패했다는 통보조차 받지 못한다 — 서버는 job 을 폐기하고 아무것도 알려주지 않는다.

재시도 추가 로컬 적용

원인이 노드 포화든 stale status 든 재시도는 원인과 무관하게 동작한다. 실측상 실패 1.2초 뒤에는 다른 방이 정상 수락됐다.

// apps/web/lib/voice-agent/livekit-readiness-retry.ts (신설, 순수 함수)
READINESS_RETRY_MAX_ATTEMPTS = 1
READINESS_RETRY_DELAY_MS     = 800

shouldRetryAgentReadiness(error, attemptsMade)
  ① error.name === "LiveKitAgentReadinessTimeoutError"
  ② attemptsMade < MAX      // getUserMedia·네트워크 등 다른 실패는 재시도 안 함

// apps/web/entities/guest-session/model/use-ai-session.ts:4658 handleStartSession
startSession() 실패
  → readiness_retry_scheduled  {attempt, elapsedMs, reason}
  → 800ms 대기
  → generation 변경 확인 // 스텝이 넘어갔으면 readiness_retry_abandoned 후 중단
  → startSession() 재호출
       createVoiceSessionId() 가 새 UUID → 새 방 → 노드·워커 재선택
  → 성공 readiness_retry_succeeded / 최종 실패 readiness_retry_exhausted
새 방이어야 의미가 있다. 같은 voiceSessionId 로 재접속하면 살아있는 방에 혼자 다시 들어가는 것이고, launchRoomAgents 는 방 생성 시 1회만 호출되므로(room.go:310) 새 job 이 디스패치되지 않는다. createVoiceSessionId 가 호출마다 새 UUID 를 반환하는 덕에 이 조건이 자연히 충족된다.
소요 시간 — 정상 ~0.7초 / 재시도 복구 약 12초 / 최악(재시도도 실패) 약 21초. LIVEKIT_AGENT_READY_TIMEOUT_MS 10초는 이번 변경에서 건드리지 않았다. 실측 정상 readiness 는 최대 1.62초(n=29, 중앙 0.70 / p90 1.46)라 6초로 줄일 근거는 있으나, 표본이 3세션이고 단축은 정상 케이스에도 영향을 주므로 별도 판단으로 남긴다.

9. 개선 방향

원인이 노드 단위 포화로 좁혀지면서 우선순위가 바뀌었다. 각 노드의 여유 워커를 늘리는 조치가 유효하고, 클라이언트 재시도가 유일한 확실한 안전망이다.

status 주기 단축(2.5→0.5초)의 재평가. 2판에서는 "원인 직격"으로, 3판에서는 "실패 노드가 실제 포화라 무의미"로 봤다. 그러나 JobRequestAffinityWS_AVAILABLE 기반이라는 것이 확인되면서 stale status 가 두 경로로 작용한다는 것이 드러났다 — 마른 노드가 job 을 가져오게 만들고(①), 그 노드 안에서 헛후보가 된다(②). ①은 실패를 직접 만든다 — 여유 있는 노드로 갔다면 성공했을 것이다. 따라서 폐기가 아니라 노드 분산 점검 다음 후보로 남긴다. 비용은 여전히 livekit-agents 내부 상수 monkeypatch 이며, job 시작·종료 시 즉시 전송하는 경로는 upstream 에 없다.
#조치위치기대 효과 / 근거리스크
1 워커당 슬롯 2→3 env PPI_LIVEKIT_AGENT_MAX_JOBS_PER_WORKER
코드 수정 불필요
2잡일 때 load 가 2/3 × 0.99 = 0.66 < 0.99FULL 진입 자체가 일어나지 않는다 → 2.5초 사각지대에 노출될 일이 없다. 이번 메커니즘을 가장 직접 끊는다 워커당 STT·LLM·TTS 3개 동시 → CPU·메모리. num_idle_processes 도 3으로 올라 silero VAD prewarm 메모리 증가. 실측 필요
2 사후 점유 20초 제거 — 세션 종료 시 ctx.shutdown() 로컬 적용 agent.py:1709 attach_session_close_job_shutdown() 신설 + :2934 에서 호출
테스트 tests/test_session_close_job_shutdown.py 10건
점유 42→34 · 37→29, 피크 44→37. FULL 워커 수가 직접 줄어든다. 5절 실측대로 이 20초는 575번 중 한 번도 쓰이지 않았다 아동 순단 후 같은 방 재접속 경로 확인 필요. 방 유예는 그대로 두고 워커 슬롯만 먼저 반납하는 형태라 영향은 작을 것으로 본다
3 클라이언트 readiness 실패 시 재시도 로컬 적용 lib/voice-agent/livekit-readiness-retry.ts 신설(테스트 9건) + use-ai-session.ts:4658 배선 1·2 는 확률을 낮출 뿐이고, 실패를 실제로 차단하는 유일한 안전망이다. 실패 1.2초 뒤에는 다른 방이 정상 수락됐으므로 짧은 백오프 1회면 3건 모두 복구됐을 것 재시도 중 아동 대기 시간 증가. 현재 10초 타임아웃과의 합산 설계 필요
+ 노드 간 워커 분산 점검 인프라 (ECS ↔ LiveKit 노드 연결) 실측 분포가 11 / 10 / 3 으로 고르지 않았다. 한 노드가 마르면 전체에 여유가 있어도 실패한다 — 이번 사건의 형태가 정확히 그것이다 워커가 어느 노드에 붙는지 직접 확인하지 못했다(4절 추론). 먼저 실제 등록 분포부터 확인해야 한다
폐기된 안PPI_AGENT_LOAD_THRESHOLD 를 0.99→1.0 으로 올려 꽉 찬 워커의 서버측 가중치를 1−load = 0.01 에서 정확히 0 으로 만드는 안을 검토했으나 효과가 없다. 그 가중치는 이미 WS_AVAILABLE 로 걸러진 워커들 사이의 추첨값일 뿐이고, 이번 문제는 그 앞단(해당 노드에 여유 워커가 없었던 것)에서 발생한다.
departure_timeout 을 낮추지 않고 ctx.shutdown() 을 택했나. 6절대로 토큰의 RoomConfiguration.departureTimeout 으로 방별 제어가 가능하다(livekit-token.ts:107). 그러나 그것은 방 자체를 빨리 닫는 것이라 재접속 유예까지 함께 깎인다. ctx.shutdown()방 정책은 그대로 두고 job 만 먼저 빠져나오게 하므로 부작용 범위가 좁다. 5절 실측상 유예는 575건 중 한 번도 쓰이지 않았지만, 표본이 한 시간이라 유예 자체를 없애는 쪽은 보류했다.

dev 서버 검증 항목 (PR #1027)

확인할 것어디서
job ended − 아동 퇴장 간격이 20초 → 1초 미만Loki {service_name="ppi-livekit"}
requesting job shutdown after session close 로그CloudWatch /ppi/livekit/<env>/agent
readiness_retry_scheduled / _succeeded / _exhausted세션 로그
재시도 시 아동 화면 끊김 여부(새 방으로 LiveKit 재연결)실기기 육안
순단 시 오발동 없음실기기 — 네트워크 차단 후 복구
효과 측정 기준선 — 하루 no worker available to handle job 57건, 그중 세션 실패까지 간 것 3건. ①은 앞 숫자를, ②는 뒤 숫자를 줄인다. 두 지표가 층별로 갈리므로 함께 배포해도 각각 측정된다.
태스크 증설로 대응하지 말 것. 증상이 "용량 부족"으로 읽히지만 실패 순간마다 빈 슬롯이 6~11개 있었다. 늘려야 할 것은 총량이 아니라 워커 한 대가 FULL 로 잠기지 않는 여유다.

관련 문서