FeaturesStore (매장)Store Broadcast Now (/store/broadcast/now)

Store Broadcast Now — /store/broadcast/now (점장 즉시방송 작성)

즉시방송 슬라이스. 시안: TTS 탭 = design_handoff_linkmusic/design/screens/broadcast-now.jsx · 녹음/템플릿 탭 = design_handoff_linkmusic_8/.../store-broadcast-{recording,templates}.jsx(§8E-②·③, 시안 정합 완료). 전제: BE previewStoreBroadcast·sendStoreBroadcast(V24 tts_announcement.source·created_by_store_id) 라이브. player 변경 0.

Overview

점장(STORE_MANAGER)이 apps/space /store/broadcast/now 에서 방송을 만들어 미리듣기한 뒤 자기 매장 스피커로 송출하는 화면. 입력 방식이 탭 3종이다 — (1) TTS 탭(텍스트→합성), (2) 녹음 탭(마이크 녹음→업로드), (3) 자주 쓰는 방송 탭(템플릿 CRUD·로드). (1)·(2) 두 탭은 같은 미리듣기 게이트·[송출하기]·“송출 후 취소 불가” 경고를 공유하고, (3) 템플릿 탭은 저장해둔 안내를 TTS 탭으로 불러와(로드) 재사용한다. 베타 게이트(“본사 세팅→점장 재생 end-to-end”)에 더해 점장이 직접 매장 안내를 내보내는 첫 화면이다.

범위: TTS 탭 + 녹음 탭 + 자주 쓰는 방송(템플릿) 탭 + 긴급방송 옵션(SPEC #082) + 예약방송 옵션(SPEC #083 — 즉시/예약 모드 토글, 본사 #078 스케줄 인프라 재사용). 긴급은 플래그 적재 + 시안 UI + 응답 노출까지(player 인터럽트 재생은 F1, 본사 audit 누적은 F2 후속). 예약은 점장 등록 + 본사 디스패처 자동 픽업 + 본사 점유 정확-시각 충돌 차단(#109) + 5분 슬롯 그리드(ReservationSlotGrid — 빈 시간대 1탭 선택, 점유/지난 슬롯 비활성) + 본사 점유 사전 조회 (GET /api/v1/store/slot-occupancy — 2026-07 감사 #9)까지(반복 차단은 슬롯 모델 후속 · 취소·목록 view 는 #091·#092 라이브). 송출되면 기존 점장 player(/store)가 pending 폴링으로 자동 재생하므로 player 는 변경하지 않는다.

시안 출처: TTS 탭 = workspace parent dir design_handoff_linkmusic/design/screens/broadcast-now.jsx §tab==='tts'(안내 내용 textarea + 카운터·목소리 select·미리듣기 게이트 footer·“송출 후 취소 불가” 카피). 녹음 탭·자주 쓰는 방송 탭은 design_8 핸드오프로 시안 정합 완료design_handoff_linkmusic_8/design/screens/ store-broadcast-recording.jsx(§8E-②)·store-broadcast-templates.jsx(§8E-③). 인터랙션은 §8E-① 확정대로(시작/정지 = 탭 토글, 행 탭 = 로드, 삭제 = 인라인 2-step). 시각만 시안 정합 — 동작·API· 검증·에러매핑·접근성 로직은 보존. [[feedback_design_only_from_handoff]].

2-step 미리듣기 게이트 (Critical state)

[텍스트 1~200자 + 목소리 5종]
        │  ① [미리듣기]

usePreviewStoreBroadcast  →  POST /api/v1/store/broadcasts/preview
  body BroadcastPreviewRequest{ text, voice }
        │  201 BroadcastPreviewResponse{ announcementId, audioUrl, durationSeconds? }

화면 내 <audio src=audioUrl> 재생(들어보기) + [송출하기] 활성화
        │  ② [송출하기]  (previewedAnnouncementId 보유 시에만)

useSendStoreBroadcast  →  POST /api/v1/store/broadcasts/{announcementId}/send
        │  201 BroadcastSendResponse{ dispatchId }

토스트 "송출됐습니다. 곧 매장 스피커에서 재생됩니다." + 입력 리셋 (즉시 송출)
        ▼  (별도 흐름 — player 무변경)
점장 player(/store) pending 폴링이 잡아 음악 위에 동시재생(더킹 + 오버레이) → ack
  • 미리듣기 전 [송출하기] 비활성previewedAnnouncementId === null 이면 disabled. 잘못된 방송이 매장에 그대로 나가지 않게 미리듣기를 강제한다(시안 PreviewGate).
  • 재미리듣기 무효화 — 미리듣기 후 텍스트/목소리를 바꾸면 이전 미리듣기를 폐기한다 (announcementId·audioUrl null·송출 버튼 재비활성·<audio> 정지). 바뀐 내용이 송출되는 혼선을 차단한다. [다시 미리듣기]로 새 draft 를 만든 뒤에야 송출할 수 있다.
  • 송출 후 취소 불가 — footer 상단에 경고 배너(시안 명시). 송출하면 바로 매장에 재생되며 되돌릴 수 없다.
  • 미리듣기 만료(404)면 게이트를 초기화한다(2026-07 감사 #13). 송출이 404 TTS_ANNOUNCEMENT_NOT_FOUND(또는 status 404)로 실패하면 previewedAnnouncementId·previewAudioUrl 을 비우고 미리듣기 <audio> 를 멈춘다 → [송출하기] 재비활성. 종전엔 게이트를 그대로 둬서 같은 만료 id 로 404 가 무한 반복됐다(메시지는 “다시 미리듣기 후 송출해주세요” 인데 정작 미리듣기를 강제하지 않았다). 409 DISPATCH_SLOT_OCCUPIED 처리와 같은 방식이다.

결과 피드백 — 즉시=토스트 / 예약=배너+[확인] (2026-07 감사 #13)

모드피드백이유
즉시 송출토스트(useToast, tone=success) 후 onSent() 로 모달 즉시 닫기종전엔 setSentMessage 직후 모달을 닫아 배너가 한 프레임도 뜨지 않았다(성공 신호 0). 토스트는 셸(/store layout)의 ToastHostProvider 소유라 모달이 닫혀도 남는다. PL 적용(SPEC #163)과 같은 관례.
예약 등록인라인 배너(store-broadcast-sent) + 확인소리 신호가 없어 “조용한 성공”·중복 등록 위험(SPEC #160 H13). 사용자가 명시적으로 닫는다.

BroadcastNowClientuseToast() 를 쓰므로 이 컴포넌트를 렌더하는 테스트는 ToastHostProvider 로 감싸야 한다(실제 셸과 동일).

입력·검증

필드제약비고
안내 내용(textarea)@minLength 1 @maxLength 200(BE 계약 일치)카운터 n / 200자. 빈/초과는 클라이언트에서 미리 막아 불필요한 합성 호출 절약(frontend.md §8)
목소리(select)BroadcastPreviewRequestVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)라벨은 본사 안내방송 화면(tts-voice-meta)의 TTS_VOICE_LABELS 미러(broadcast-voice-meta.ts)

톤은 미노출(BE NORMAL 고정). storeId·hqId 는 토큰 주체에서 도출하므로 요청 파라미터 없음.

녹음 탭 (MediaRecorder · 미리듣기 게이트 공유)

녹음 탭은 TTS 의 “합성→미리듣기” 자리를 “녹음→업로드” 로 대체하되, 업로드 성공 이후의 게이트는 TTS 와 동일하다(같은 previewedAnnouncementId·[송출하기]·“송출 후 취소 불가” 경고·send·리셋).

[녹음 시작] → getUserMedia({audio:true}) → MediaRecorder(webm/opus 협상, isTypeSupported)
        │  경과 타이머 + 30초 도달 시 자동 정지

[정지] → 녹음 Blob → <audio controls> 미리듣기 + [다시 녹음] / [이 녹음 사용하기]
        │  [이 녹음 사용하기]

useRecordStoreBroadcast → POST /api/v1/store/broadcasts/recording
  multipart file=Blob + ?durationSeconds=초(query)
        │  201 BroadcastRecordingResponse{ announcementId, audioUrl, durationSeconds? }

onPreviewed(announcementId, audioUrl) → 공유 게이트 세팅 → [송출하기] 활성(TTS 와 동일)
  • 30초 자동 정지 — 경과 카운터가 30초에 도달하면 MediaRecorder.stop() 을 호출한다. 정지 시점에 녹음 길이(초)를 고정해 업로드 durationSeconds 로 보낸다(1~30 클램프).
  • MIME 협상MediaRecorder.isTypeSupportedaudio/webm;codecs=opusaudio/webmaudio/mp4audio/mpeg 순으로 첫 지원 타입을 채택(BE 허용 3종에 매핑).
  • 다시 녹음 — 미리듣기·게이트를 폐기하고 idle 로 돌아가 새 녹음을 받는다.
  • 마이크 권한 거부 UXgetUserMedia reject 를 코드별로 안내한다:
    • NotAllowedError/SecurityError → “마이크가 차단됐어요 — 브라우저 설정에서 허용한 뒤 다시 시도”
    • NotFoundError → “마이크를 찾을 수 없어요” · NotReadableError → “다른 앱이 마이크를 쓰는지 확인”
      • [다시 시도] 버튼.
  • 정리 — 언마운트/탭 전환 시 MediaRecorder.stop()·stream track stop·objectURL revoke·타이머 clear. 녹음 상태·권한·업로드 결과는 aria-live 로 announce.
  • 탭 전환 무효화 — 탭을 바꾸면 공유 미리듣기 게이트를 무효화한다(바뀐 입력 송출 차단).

계약/제약(BE): MIME audio/webm·audio/mp4·audio/mpeg, ≤10MB, duration 1~30초. 위반 시 400 RECORDING_UNSUPPORTED_FORMAT(형식)·RECORDING_INVALID_FIELD(빈 파일·10MB 초과·범위 밖). 빈/ 초과/범위 밖은 클라이언트에서 미리 막아 불필요한 업로드를 절약한다(frontend.md §8).

디자인 시안 정합 완료: 녹음 탭은 design_8 핸드오프 (design_handoff_linkmusic_8/design/screens/store-broadcast-recording.jsx, §8E-②)로 정식 시각을 흡수했다 — 상태별 카드(대기 72px / 녹음 중 96px danger·진행바 / 녹음 완료 60px·미리듣기 audio / 올리는 중 스피너 / 마이크 권한 거부 3종)·80px 큰 버튼·40~50대 가독성·aria-live 동적 announce. 인터랙션은 §8E-① 확정대로 시작/정지 = 탭 토글(“꾹 누르지 않아도 됩니다”). 시각만 교체 — 녹음 흐름·업로드 API·에러매핑은 보존.

자주 쓰는 방송 탭 (템플릿 CRUD · 로드)

자주 쓰는 안내(주차·시음·영업 종료 등)를 이름·텍스트·목소리로 저장해두고 다음부터 한 번에 불러 쓰는 탭. 합성/송출 없이 TTS 탭 입력을 채우는 로드까지만 담당한다 — 미리듣기·송출은 TTS 탭 게이트가 맡는다.

[목록] useListBroadcastTemplates → GET /api/v1/store/broadcast-templates
  200 BroadcastTemplateListResponse{ items: BroadcastTemplateResponse[], total }   (updated_at DESC, 매장당 최대 20)

        ├─ [+ 새 템플릿] → 인라인 폼(name 1~60·text 1~200·voice 5종)
        │     useCreateBroadcastTemplate → POST  201 / 상한 도달 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED
        ├─ 행 [편집] → 기존값 prefilled 인라인 폼
        │     useUpdateBroadcastTemplate → PUT /{id}  200 / 소유 아님 404 BROADCAST_TEMPLATE_NOT_FOUND
        ├─ 행 [삭제] → 2-step 확인 → useDeleteBroadcastTemplate → DELETE /{id}  204(소프트삭제)
        └─ 행 탭(로드) → onLoad(text, voice) → TTS 탭으로 전환 + text/voice 채움 (※ 자동 미리듣기 없음)
  • 상한 20개total >= 20 이면 [+ 새 템플릿] 비활성 + 안내 배너. 저장 시 서버 409 도 안내로 매핑.
  • 로드 게이트 유지 — 행을 탭하면 TTS 탭으로 전환하고 입력만 채운다. 자동 미리듣기·송출은 하지 않는다 — 점장이 직접 [미리듣기]로 게이트를 통과해야 송출할 수 있다(잘못된 방송 송출 차단). 로드 시 이전 미리듣기 게이트는 무효화한다.
  • 저장/편집/삭제 성공 시 목록 query 를 invalidate 해 최신 상태로 재조회한다. 검증은 BE 제약과 동일 (name 160·text 1200, frontend.md §8) — 빈/초과는 클라이언트에서 미리 막는다.

디자인 시안 정합 완료: 템플릿 탭은 design_8 핸드오프 (design_handoff_linkmusic_8/design/screens/store-broadcast-templates.jsx, §8E-③)로 정식 시각을 흡수했다 — 행 레이아웃(Star 박스·name·text 요약·voice pill·상대시각·편집/삭제 아이콘 버튼)·인라인 저장/편집 폼(카운터 병기)·인라인 2-step 삭제(행 자리 danger 확인 카드)·빈 상태(점선 카드 + 큰 [새 템플릿 만들기] CTA)·상한 warn 배너. 인터랙션은 §8E-① 확정대로 행 본문 탭 = 로드(자동 미리듣기 X)·[+ 새 템플릿] = 인라인 폼·삭제 = 인라인 2-step. 시각만 교체 — CRUD API·검증·로드 동작은 보존. [[feedback_design_only_from_handoff]].

긴급방송 옵션 (SPEC #082)

TTS / 녹음 탭 양쪽에 긴급(isEmergency) 체크박스를 추가했다. 시안 broadcast-now.jsx §EmergencyToggle 인라인 미러(새 컴포넌트 추출 X) — 활성 시 빨간 톤(border: var(--danger) + background: color-mix(in oklch, var(--danger) 10%, transparent)) + 경고 텍스트(“긴급 옵션은 미아·화재·정전·분실물·응급 안전 안내에만 사용하세요. 마케팅·이벤트 안내에 사용하면 감사 로그에 누적되어 본사에 보고됩니다.”). 송출 버튼 라벨·톤도 분기한다.

일반 (off)긴급 (on)
송출 버튼 라벨송출하기 / 송출 중…긴급 송출하기 / 긴급 송출 중…
송출 버튼 톤variant="primary"variant="danger"
preview payload{ text, voice }{ text, voice, isEmergency: true }
send payload{}{ isEmergency: true }
recording upload?durationSeconds=N (query)?durationSeconds=N&isEmergency=true
  • mutation 페이로드(D11): true 만 명시 전송한다(false 는 생략 — BE default false). preview/send 는 JSON body, 녹음 업로드는 query param(BE @RequestParam)이다.
  • 게이트 무효화: 토글 변경 시 이전 미리듣기 게이트를 무효화한다(audioUrl·announcementId null·송출 재비활성). 송출 의도가 바뀌면 BE response·DB row 의 isEmergency 도 재합성에서 다시 확정되어야 정확하다.
  • 송출 후 리셋: 송출 성공 시 emergency=false 로 자동 리셋(다음 방송은 일반으로 시작).
  • 자주 쓰는 방송(템플릿) 탭(D12): 토글 없음. 템플릿 load 시 emergency=false 로 고정(긴급은 즉시 결정 사항이라 템플릿화하지 않는다 — 부주의한 재사용 차단). 템플릿 자체에는 emergency 필드 없음.
  • DB·BE 반영: tts_announcement.is_emergency BOOLEAN NOT NULL DEFAULT FALSE(V30). preview 단계에 draft row 에 set 되고 send 단계 body 가 있으면 마지막 확정(텍스트·voice 등 합성 파라미터는 preview 단계에서 확정 — send 의 옵션 DTO BroadcastSendRequestisEmergency·scheduledAt·deviceId 3종을 받는다). 점장 pending 조회 응답(PendingAnnouncementItem.isEmergency)에도 노출되어 후속 F1(player 인터럽트)·F2(audit 누적)에서 분기 근거가 된다.

예약방송 옵션 (SPEC #083)

TTS / 녹음 탭의 공유 송출 게이트에 즉시(IMMEDIATE) / 예약(SCHEDULED) 모드 라디오 토글을 추가했다. 시안 부재라 본사 안내방송 송출 다이얼로그(DispatchAnnouncementDialog SPEC #078) 의 라디오 + date/time 인풋 패턴을 그대로 inline 미러(새 컴포넌트 추출 X · atom-grounded). 본사 #078 의 스케줄 인프라 (announcement_dispatch.scheduled_at V29 + HqDispatchScheduler 1분 cron)를 그대로 재사용 — 점장 row 도 디스패처가 도래 시 SCHEDULED → PENDING 전이시키므로 player 변경 0(기존 PENDING 폴링이 자동 픽업).

즉시 (IMMEDIATE, 기본)예약 (SCHEDULED)
송출 버튼 라벨송출하기 / 송출 중…예약 등록하기 / 예약 등록 중…
긴급 조합 라벨긴급 송출하기 / 긴급 송출 중…긴급 예약 등록하기 / 긴급 예약 등록 중…
send body{} (또는 { isEmergency: true }){ scheduledAt: ISO-8601 UTC } (긴급이면 isEmergency: true 함께)
송출 후 카피”송출하면 바로 매장 스피커에서 재생되며 취소할 수 없습니다.""예약을 등록하면 등록 후 취소할 수 없어요.”
결과 배너”송출됐습니다. 곧 매장 스피커에서 재생됩니다.”"{YYYY-MM-DD HH:mm} 예약 등록 완료" (KST)
  • 입력 = 날짜 선택 + 5분 슬롯 그리드: <input type="date" min={today-KST}> (날짜 선택) + ReservationSlotGrid(시각 선택 — 종전 <input type="time"> 자유 입력 대체). 시안 정합: workspace parent dir design_handoff_linkmusic_15/design/screens/store-broadcast-reservation-grid.jsx. 하루(00:00~23:55)를 5분 단위 288칸(시간 축 + 12칸 매트릭스, 한 행=1시간)으로 보여주고 빈 슬롯 1탭 으로 예약 시각을 5분 경계(KST)에 스냅 선택한다. 진입 시 KST 현재 + 1h 를 5분 경계로 올림해 자동 채움(#078 D1 과 같은 규칙 — 자동 채움 값 자체가 그리드 슬롯과 대응해야 점유 검사·표시가 일치한다). 자동 채움 helper 는 hourCycle: "h23" 을 명시한다 — hour12: false 는 h23 을 보장하지 않아(로케일 기본이 h12 인 en-CA) 자정이 "24:xx" 로 나오고, 2026-06-11T24:00+09:00다음 날 00:00 으로 파싱돼 표시 날짜와 실제 등록 날짜가 어긋난다(KST 23 시대 진입 시 재현 — #078 과 동종 버그). 결과 배너의 KST 표기(formatKstDateTime)도 같은 이유로 hourCycle 을 명시한다. KST→UTC ISO 정규화는 종전과 동일(composeScheduledAtIso·defaultScheduledFields·todayKstDateString 보존) — 그리드는 scheduledTime(HH:mm)을 스냅 세팅하고 scheduledDate 와 결합해 +09:00toISOString() (...Z) 으로 송신. 5분 스냅이 안전한 이유: 디스패처가 매분 cron(#078)이라 5분 경계도 정확히 그 분에 전이(누락 없음).
  • 점유 슬롯 비활성(회색): (a) 본인 매장 예약 = useListStoreScheduledBroadcasts({ page: 0, size: 50 }) (본인 매장 SCHEDULED) 의 scheduledAt 을 선택 날짜(KST)로 매칭해 사전 비활성(“예약됨(내 매장)”). size 는 BE(store-playback scheduled-list) coerceIn(1,50) clamp 와 일치시켜 50 으로 요청(SPEC #160 D3 — 종전 size:100 은 BE 가 조용히 50 으로 줄여 미래 예약 50건 초과 시 점유 슬롯이 누락돼 “가용”으로 보이던 가정 오류. 점유 매칭은 어차피 선택 날짜로만 좁혀 보므로 한 매장·한 날짜 50건 초과 극단에서만 과소표시). (b) 본사 점유 = useGetStoreSlotOccupancy({ date: scheduledDate }) 사전 조회가 주 소스 (2026-07 감사 #9). SCHEDULED 모드이고 날짜가 유효한 YYYY-MM-DD 일 때만 켜고(enabled), 응답 occupiedSlots(UTC ISO)를 kstDateTimeParts + snapToFiveMin 으로 KST HH:mm 집합으로 바꿔 그리드에 넘긴다 — 송출을 시도하기 전에 “본사 점유”로 비활성. 날짜를 바꾸면 queryKey(date)가 바뀌어 자동 refetch 되므로 날짜별 캐시는 react-query 가 관리한다(수기 관리 X). 종전에는 이 endpoint 가 BE·생성 클라이언트에 이미 있는데도 FE 가 쓰지 않아, 본사 점유 시각이 “예약 가능”으로 보였고 점장은 텍스트 작성 → TTS 미리듣기(과금·수 초 대기) → 슬롯 선택 → [예약 등록하기] 까지 간 뒤에야 409 를 받았다(게다가 그 학습이 컴포넌트 로컬 state 라 모달을 닫으면 소실 — 같은 실패 무한 반복). 조회 시점 이후 본사가 새로 예약한 race 는 여전히 send 409 DISPATCH_SLOT_OCCUPIED 로만 알 수 있으므로, 거부된 슬롯을 날짜별 occupiedHqByDate 에 누적해 사전 조회 ∪ 409 보정으로 합치고(보조), 그 날짜의 slot-occupancy 쿼리도 함께 invalidate 한다. 과거 가드(오늘 지난 슬롯)도 비활성. 409 발생 시 scheduledTime=null 로 선택도 함께 해제(SPEC #160 D4) — 그대로 두면 canSend 가 유지돼 같은 시각으로 재시도 시 또 409(dead loop). 선택 해제로 scheduledMissing → canSend=false 가 돼 점장이 다른(가용) 슬롯을 다시 고르게 강제한다. 그리드 stateClass점유(hq/own/past)를 selected 보다 먼저 평가(SPEC #160 D4) — 점유 슬롯이 어쩌다 value 와 겹쳐도 “선택됨” 색·시맨틱으로 가려지지 않게 하고, aria-pressed(점유 시 false)·aria-label(점유 라벨 우선) 과 시각을 일치시킨다. 각 슬롯 button aria-label(시각 + 가용/점유/지난)·aria-pressed(선택됨), 색만 아니라 테두리·라벨로 상태 구분(WCAG 2.1 AA).
  • 송출 게이트가 점유를 직접 본다(리뷰 PR #429): 점유 집합(occupiedHq·occupiedOwn·과거 가드)이 그리드 셀 disabled 에만 쓰이면, 시각이 그리드 클릭 밖의 경로로 정해진 두 케이스가 그대로 통과한다 — (1) 첫 마운트 자동 채움(그리드를 한 번도 안 눌러도 값이 있다), (2) 날짜 변경 후 잔존 선택(그리드는 회색인데 요약은 그 시각을 보여주고 버튼은 활성). 본사 점유면 TTS 미리듣기 (과금·수 초) 뒤 409(감사 #9 가 없애려던 경험), 본인 점유면 BE 가 점장끼리의 중복을 막지 않아 같은 시각에 2건이 실제로 등록된다(감사 #13 과 동종). 그래서 selectionOccupied = SCHEDULED && scheduledTime !== "" && (occupiedHq.has || occupiedOwn.has || minutesOfDay(time) <= pastBeforeMinutes)canSend 에 포함하고, 요약 대신 힌트 (store-broadcast-schedule-hint-occupied — “이미 예약됐거나 지난 시각입니다…”)를 띄운다. 함께 점유 조회 로딩 중(occupancyLoading)에도 등록 버튼을 잠근다 — 그리드만 잠그고 버튼을 열어두면 자동 채움 시각이 점유인지 모르는 채로 등록될 수 있다.
  • 날짜 변경 시 선택 해제(리뷰 PR #429): <input type="date"> 이 바뀌면 scheduledTime="" 로 비운다. 점유·과거 집합은 날짜별이라 이전 선택이 새 날짜에서는 점유·지난 슬롯일 수 있고, 그리드 (회색)와 요약·버튼(활성)이 어긋난다. 선택은 그리드에서만 만들어진다는 규칙으로 통일한다. selectionOccupied 게이트가 생긴 뒤로 이 해제는 이중 방어다(새 날짜 점유 도착 전에는 occupancyLoading 이, 도착 후에는 게이트가 막는다). 그래도 유지하는 이유는 어긋난 표시를 애초에 만들지 않기 위해서고, 비용은 반복 예약 시 날짜를 바꿀 때마다 288칸에서 슬롯을 다시 찾는 것이다.
  • 날짜가 비면 그리드 잠금(리뷰 PR #429): date 가 유효한 YYYY-MM-DD 가 아니면(사용자가 지움) 점유 조회를 켤 수 없어(빈 date 로 400 왕복 방지) 로딩 힌트도 실패 배너도 뜨지 않는다. 그 상태의 그리드는 288칸이 전부 “예약 가능” 으로 보이므로(감사 #13 이 없애려던 화면의 재현) dateInvalid 로 그리드를 잠그고 store-broadcast-schedule-hint-date(“날짜를 먼저 선택해 주세요”)를 띄운다.
  • 점유 조회 로딩·실패 반영(2026-07 감사 #13·#9): useListStoreScheduledBroadcasts(내 예약) 와 useGetStoreSlotOccupancy(본사 점유) 두 조회의 상태를 그리드가 함께 본다(OR 결합).
    • 로딩 중(둘 중 하나라도 isLoading) — 그리드 전체 disabled + “예약 현황을 불러오는 중…” (store-broadcast-occupancy-loading). 현황 없이 슬롯을 고르면 이미 예약된 시각을 “예약 가능”으로 오인한다.
    • 실패(둘 중 하나라도 isError 또는 fetchStatus === "paused") — warn 배너 store-broadcast-occupancy-error(“예약 현황을 불러오지 못했습니다. 내 예약이나 본사가 예약한 시각도 빈 칸으로 보일 수 있으니 [다음 방송] 목록에서 확인 후 등록해주세요.”). 종전엔 isError/isLoading 을 아무 데서도 보지 않아, 5xx·네트워크 실패로 목록을 못 받으면 점유 집합이 빈 채로 288칸이 전부 “예약 가능” 으로 보였다. 실패는 그리드를 잠그지 않는다(disabledoccupancyLoading 만 반영) — 조회 장애가 곧 “예약 전면 불가” 가 되면 안 되므로, 경고만 띄우고 등록은 계속 가능하게 둔다(fail-open 계약. 테스트가 슬롯 enabled 뿐 아니라 [예약 등록하기] enabled 까지 단언해 이 계약을 고정한다).
    • 오프라인 = paused(리뷰 PR #429): react-query 기본 networkMode: "online" 에서 오프라인이면 쿼리가 status:"pending" + fetchStatus:"paused" 로 멈춘다. 이때 isLoading(= pending && fetching) 도 isError 도 false 라, 종전 판정으로는 로딩 힌트도 실패 배너도 없이 288칸이 가용으로 보이고 버튼도 열렸다. pausedoccupancyLoading 이 아니라 occupancyFailed 로 흡수한다 — 로딩으로 다루면 오프라인 동안 그리드·버튼이 통째로 잠겨 위 fail-open 계약이 깨진다.
  • 예약 성공 시 목록 invalidate(2026-07 감사 #13): onSuccess 에서 getListStoreScheduledBroadcastsQueryKey()(파라미터 없이 = 접두 매칭)를 무효화한다. 없으면 staleTime: 30_000 때문에 방금 예약한 슬롯이 30초간 “예약 가능” 으로 보이고, BE 는 점장 본인끼리의 동일 시각 중복을 막지 않으므로 같은 시각에 두 방송이 실제로 등록된다(본사 점유 409 는 source='HQ' 만 검사).
  • mutation 페이로드(D8): non-null/SCHEDULED 만 명시 전송. IMMEDIATE 면 scheduledAt 키 자체를 생략(BE default null=즉시 송출 보존, 기존 트레이스와 동일). 긴급(isEmergency) 과 자유롭게 조합 가능 (D14 — 긴급 예약 = 정당한 use case).
  • 클라이언트 검증(D9 · 리뷰 PR #429 보강) — 보조 텍스트 분기 순서는 날짜 없음 → 미선택 → 과거 → 점유 → 정상 요약:
    • SCHEDULED + 날짜 비어 있음 → 그리드 잠금 + “날짜를 먼저 선택해 주세요”(…-hint-date)
    • SCHEDULED + 슬롯 미선택 → 송출 disabled + “예약 시각을 슬롯에서 선택해 주세요”(…-hint-missing)
    • SCHEDULED + 시각 ≤ 현재 → 송출 disabled + “현재 이후 시각을 선택해 주세요”(…-hint-past. 과거 슬롯 자체도 그리드에서 비활성)
    • SCHEDULED + 선택 시각이 점유(본사·내 매장·지난) → 송출 disabled + “이미 예약됐거나 지난 시각입니다. 시간표에서 빈 칸을 다시 선택해 주세요.”(…-hint-occupied)
    • 점유 현황 조회 로딩 중 → 그리드·송출 모두 disabled(점유 여부 미상 상태에서 등록 차단)
    • 슬롯 선택 시 하단 요약에 선택 시각(KST) 표기(store-broadcast-schedule-selected).
    • 매 렌더마다 Date.now() 비교 → disabled 가 1분 단위로 자연 갱신(타이머 없이도 안전).
  • 녹음 탭: 녹음 흐름은 recordStoreBroadcast(업로드) → 공유 게이트 통과 → sendStoreBroadcast (송출) 2단계라, 공유 게이트 영역의 schedule UI 가 그대로 동작한다(녹음 업로드 단계엔 scheduledAt 파라미터 없음 — 송출 단계 body 에만 실린다).
  • 자주 쓰는 방송(템플릿) 탭(D13): 송출 게이트 자체가 숨겨지므로 schedule UI 도 자동 숨김 (템플릿 탭에선 로드만 함). 점장이 템플릿을 TTS 탭으로 불러온 뒤 즉시/예약을 선택한다 — #082 의 긴급 토글과 동일 idiom(템플릿화하지 않는 즉시 결정 사항).
  • 모드 리셋: 송출/예약 등록 성공 시 scheduleMode=IMMEDIATE 로 리셋(다음 방송은 즉시가 자연 디폴트). 날짜/시각도 다시 현재 + 1h(5분 올림)로 재초기화.
  • 성공 신호 — 예약/즉시 분기(SPEC #160 H13 · 2026-07 감사 #13 갱신): 모달 컨텍스트(점장 player)에서 이 컴포넌트는 성공 시 onSent() 로 모달을 닫는다. 즉시 송출은 바로 닫되 토스트로 성공을 남긴다(모달이 닫혀도 셸의 ToastHostProvider 가 소유하므로 사라지지 않는다 — 종전엔 배너를 세팅한 즉시 닫아 한 프레임도 안 보였다). 예약 등록은 소리 신호가 없어 즉시 닫으면 “조용한 성공”(무신호· 중복 등록 위험)이 되므로, 배너 “{KST 시각} 예약 등록 완료” + [확인] 버튼 (store-broadcast-sent-ack)을 노출하고 사용자가 명시적으로 닫는다(sentNeedsAck). [확인] 클릭 시 배너를 지우고 모달이면 onSent() 로 닫는다. 페이지 진입(onSent 미지정)에서는 배너만 지운다. 새 미리듣기·입력 변경 시 sentNeedsAck 는 함께 초기화된다.
  • 진행 중 모달 잠금(2026-07 감사 #13): onPendingChange(previewing || sending || uploading) 로 진행 상태를 부모(모달)에 올린다. 부모는 그동안 Esc·바깥 탭·닫기(X)를 막는다 (점장 player §모달 닫기). 녹음 탭은 자기 업로드 상태를 onUploadingChange 로 부모에 위임한다. 언마운트 시 false 로 정리. 같은 pending 값이 탭 전환도 잠근다 — 업로드 중 탭을 바꾸면 RecordingTab 이 언마운트되며 진행 신호가 꺼져 모달 닫기 잠금이 풀리기 때문이다(종전 가드는 previewing || sending 뿐이었다). 잠금에는 30초 상한이 있다(요청이 응답 없이 정지하는 dead-end 방지 — player §모달 닫기 참조).

본사 점유 시각 차단 (F1 — #109, 옵션 A 구현됨): 같은 매장에 본사가 예약한 동일 시각 (store_id + status=SCHEDULED + scheduledAt 일치 + announcement.source='HQ')이면 BE 가 송출 단계에서 409 DISPATCH_SLOT_OCCUPIED 를 반환하고, FE 는 “본사 방송이 이미 예약된 시각입니다. 다른 시각을 선택해 주세요.” 로 매핑하고 그 슬롯을 그리드에서 사후 비활성(“본사 점유”) 처리한다. 정확한 시각(Instant) 충돌만 차단한다 — 20-policy §3-3반복(hourly/even/odd) 예약 차단은 본사 예약을 슬롯 모델로 재설계하는 별도 대형 SPEC 후속(현 본사 예약 #078 은 1회성 single Instant 라 반복 모델이 코드에 없음). 점장발 SCHEDULED(자기 예약)는 충돌로 보지 않으며, 즉시 송출(scheduledAt 없음)은 검사 대상이 아니다. ✅ 정책의 “회색 비활성 슬롯” 그리드 UI 는 5분 슬롯 그리드(ReservationSlotGrid) 로 구현됨 — 본인 매장 예약은 listStoreScheduledBroadcasts, 본사 점유는 전용 조회 GET /api/v1/store/slot-occupancy(useGetStoreSlotOccupancy) 로 사전 비활성(2026-07 감사 #9 — 종전 409 사후 비활성에서 전환). 409 는 조회 이후 race 보정으로만 남는다. 반복 예약만 잔여 후속.

후속 (F2·F3): F2 점장 예약 취소 ✅ (#092) · F3 점장 예약 목록 view ✅ (#091 — 본인 매장 예약 송출 조회 라이브). 본 슬라이스는 점장 등록 + 디스패처 자동 픽업까지.

예약 송출 목록 view (SPEC #091 · /store/broadcast/scheduled)

예약방송 #083 을 보낸 뒤 본인 매장에 도래하지 않은 SCHEDULED row 가 몇 개 쌓였는지 점장이 확인할 수 있는 read-only 페이지. 즉시방송 페이지 우측 상단 [예약 목록] 링크에서 진입(아이콘 CalendarClock). 사이드바 없는 점장 모드 셸이라 페이지 안에서 ← 뒤로 로 복귀한다.

시안 출처: design_14 핸드오프(Phase 8 §8G-③ store-scheduled-broadcasts.jsx)로 시안 정합 완료 — max-w-760 카드 리스트, inline [취소]→warn confirm strip 2-step, 긴급 배지 병기. 서브페이지 헤더는 신규 공용 atom StoreSubHeader. 시각만 교체 — 목록 fetch·취소 mutation·에러 매핑·격리 동작은 보존. ※ 이 정합은 예약 목록/취소·즉시 방송 화면 한정이며, 즉시방송 §의 5분 슬롯 그리드· 즉시방송 schedule UI 는 슬롯 모델 후속(아래 참조)으로 별개다.

  • 데이터: useListStoreScheduledBroadcastsGET /api/v1/store/scheduled-broadcasts (STORE_MANAGER-only). 토큰 claim → BE 가 본인 매장으로 스코프(store_id + status = 'SCHEDULED' + 상한 50).
  • 정렬: scheduled_at ASC (가장 가까운 예약이 위) — 본사 디스패처(#078)가 도래 시 PENDING 으로 전이하기 직전 row 만 노출. 즉시 송출/PENDING·종착/PLAYED·CANCELED 자동 제외.
  • 3탭 (SPEC #142 + 캘린더): 목록 상단에 ARIA tablist(role=tablist·tab·aria-selected·aria-controls) 3탭 — 오늘 남은 방송(scope="today" 필터) / 전체 방송 목록(scope="all") / 캘린더(scope="calendar"). 기본 선택 탭은 “오늘 남은 방송”(모달·페이지 공통). 탭 전환 시 page·열린 strip·결과 Banner 를 초기화한다. “캘린더” 탭은 목록(today/all)과 별개 데이터 소스라 목록 fetch·필터·페이지네이션 대신 월 그리드(아래)만 렌더한다. 패턴은 admin/announcements/announcement-tabs.tsx(점장 셸 톤 — surface/hairline 토큰 + 활성 밑줄)를 참고.
  • 표 컬럼: 제목(announcementTitle) · 예약 시각(scheduledAt → KST 24시간제) · 긴급 배지 (isEmergency=true 면 빨간 “긴급” pill — 즉시방송 페이지 긴급 톤과 동일) · 등록 시각(createdAt → KST) · 즉시 방송(SPEC #142, 아래) · 취소(SPEC #092).
  • 즉시 방송 (SPEC #142 — 미리듣기 완전 대체): 각 행에 (구 [미리듣기] 자리) [즉시 방송] 토글 버튼 (Radio 아이콘 + aria-expanded). 클릭 → 그 행 아래 1-step 확인 strip(“지금 송출할까요? 예약은 예정대로 유지됩니다” · [지금 송출]/[닫기]) 펼침(점장 실수 방지·큰 터치타깃 h-12). [지금 송출] → useBroadcastStoreScheduledNow({ dispatchId }) (POST /api/v1/store/scheduled-broadcasts/{dispatchId}/broadcast-now) → 201. 원래 SCHEDULED row 는 무변경(원래 시각에 정상 발화) — 별도 IMMEDIATE dispatch 1건만 추가 송출돼 점장 player 가 폴링으로 받아 재생한다(더킹, SPEC #141). pending 중 [지금 송출] 은 aria-busy + spinner + “송출 중…”(연타 방지). 성공 시 상단 success Banner(“지금 송출했습니다 — 곧 매장에 재생됩니다. 예약은 예정대로 유지됩니다.”) + list query invalidate. 404 TTS_ANNOUNCEMENT_NOT_FOUND(그 사이 도래· 취소돼 더는 SCHEDULED 아님) → danger Banner “이미 도래했거나 취소된 예약입니다. 목록을 새로고침했습니다.”
    • invalidate · 그 외 → “지금 송출에 실패했습니다. 잠시 후 다시 시도해주세요.” audioUrl 이 빈 문자열 (트림 후 길이 0)인 행은 [즉시 방송] 버튼 자체를 미노출(방어). 이전 SPEC #131 의 네이티브 <audio> 미리듣기는 #142 에서 [즉시 방송] 으로 완전 대체(previewId state·펼침 audio·관련 테스트 제거). 성공 시 store_audit_log STORE_DISPATCH_BROADCAST_NOW 1건(target DISPATCH·info 톤, Store 감사).
  • 상태: 로딩 시 행 스켈레톤 3줄 · 에러 Banner danger(401/403/5xx code→메시지 매핑은 mapListError)· 빈 상태 “예약된 송출이 없습니다.” 단문(추가 진입은 헤더 [← 뒤로]가 담당).
  • 격리: STORE_MANAGER 본인 매장 외엔 보이지 않음(BE verifyStoreScope). 다른 매장 예약은 응답에 없다.
  • 취소 (SPEC #092): 각 행 우측 마지막 셀에 inline [취소] 버튼. 클릭 → 헤더 아래 confirm strip (**{제목}** 예약을 취소하시겠습니까? [취소 확정] [닫기]) 노출 — 본사 #077 cancel strip 패턴 그대로 미러. [취소 확정] → useCancelStoreDispatch({ id }) (PATCH /api/v1/store/dispatches/{id}/cancel) → 204 → list query invalidate → 해당 row 제거. mutating 중 strip 의 [취소 확정] 은 aria-busy + spinner + “취소 중…” 라벨. 실패는 같은 자리를 Banner danger 로 교체: 404 DISPATCH_NOT_FOUND → “이미 처리됐거나 취소된 예약입니다.” (BE 가 미존재·이미 종착·타 매장·PENDING 을 한 코드로 은닉 하므로 가장 흔한 race 메시지로 평탄화) · 그 외 → “예약 취소에 실패했습니다.” 본사 #077 차이: 본사는 PENDING 도 취소 가능했지만 점장은 SCHEDULED 만 취소 — 본사가 즉시 보낸 PENDING 을 점장이 무효화 하는 건 정책 위반(SPEC #092 §D5). PLAYED·CANCELED 는 종착 — BE 에서 자동 제외됐으므로 표에 노출되지 않는다. 취소 성공 시 store_audit_log(V36) STORE_DISPATCH_CANCELED 1건 기록(SPEC #114, #092 F2 마감) — target DISPATCH·target_id=dispatch.id·detail 없음. 원자 UPDATE 후 affected=1 이 확정될 때에만 같은 트랜잭션에서 기록(race window 0). 운영자가 점장 모드로 위장해 취소한 경우 actor_role= OPERATOR_IMPERSONATING 으로 원본 운영자도 함께 추적. v1 은 기록만(조회 view 후속). 상세는 Store 감사.
  • 후속(F1·F2): F1 페이지네이션(50건 초과 매장)SPEC #102 도착 · F2 점장 취소 audit 누적SPEC #114 도착 · 미리듣기 audio(#091 F2)SPEC #131 도착즉시 방송(#142)으로 대체SPEC #142 도착.

라우트·파일: apps/space/src/app/store/broadcast/scheduled/page.tsx (server 셸, force-dynamic) + store-scheduled-list-client.tsx ('use client', 목록 fetch·2탭·표·빈 상태·에러 매핑·**inline cancel strip

  • mutation**·행 [즉시 방송] 토글 + 1-step 확인 strip + useBroadcastStoreScheduledNow). 새 컴포넌트 추출 X — 카드/표는 본사 안내방송 목록(#061) atom 미러, cancel strip 은 본사 #077 cancel strip atom 미러, 탭은 announcement-tabs.tsx 패턴 참고.

defaultScope prop — 이 client 는 두 진입점에서 재사용된다. SPEC #142 에서 scope 를 내부 탭 토글 state 로 일반화했다(이전엔 고정 prop). 별도 페이지(/store/broadcast/scheduled)와 점장 player 의 [다음 방송] 모달(embedded defaultScope="today") 둘 다 2탭(오늘 남은 / 전체)을 노출하며 기본 탭은 “오늘 남은 방송”(defaultScope="today"). “오늘 남은 방송” 탭은 지금부터 오늘 영업종료(KST 자정)까지 남은 예약만 클라이언트에서 필터한다(now <= scheduledAt <= 오늘 KST 23:59:59.999). 매장 도메인에 영업시간 필드가 없어 영업종료를 “오늘 KST 자정”으로 정의한다. “오늘 남은 방송” 탭에선 페이지네이션 미노출 + 빈 상태가 “이번 영업시간 내 남은 예약이 없습니다.”로 바뀐다. “전체 방송 목록” 탭은 BE SCHEDULED 전체(상위 50건·페이지네이션)를 그대로 보여준다.

송출 캘린더 (월 그리드)

예약 송출 목록의 “캘린더” 탭(StoreDispatchCalendar → 본사와 공용 presentational DispatchCalendar). 본인 매장의 송출 예약/이력을 월 그리드(7열×주행, 일요일 시작)로 본다. 전용 시안 부재 — atom-grounded 최소 (기존 토큰·atom 재사용, 새 시각 창작 X).

  • 데이터: useGetStoreDispatchCalendar({ from, to })(generated) → GET /api/v1/store/dispatch-calendar. 보는 달의 그리드 범위(앞뒤 패딩 ≤42칸)만 fetch → BE 의 62일 cap 안에서 단일 호출(@/lib/calendar monthGridRange). 이전/다음 월 네비 시 from/to 변경 → 자동 refetch. storeId 토큰 주체 도출(본인 매장 고정).
  • 본사 캘린더와 동일 컴포넌트: @/lib/calendar(KST day 변환·월 그리드)·dispatch-calendar-meta(kind/status 색)·DispatchCalendar(월 그리드 표현)를 그대로 재사용한다. 점장은 본인 매장 고정이라 매장명을 숨긴다(showStoreName=false, 응답 storeName 도 null). 색·셀·+N건 요약·범례·오늘 강조·로딩/빈/에러 처리는 본사 캘린더와 동일.
  • KST 변환: 이벤트 scheduledAt(UTC ISO) → kstDayOf 로 KST 날짜로 변환해 셀 배치(목록 탭의 todayKstCloseEpochMs 와 동일 UTC+9 offset 기법).

멀티 기기 매장의 방송 (SPEC #178)

한 매장에 재생 PC 가 여러 대 있을 때 방송이 어떻게 나가는지. 기기 모델 자체는 Store Devices 참조.

대상 — 방송 유형별

유형재생 대상target_device_id근거
예약 방송(SCHEDULED → PENDING)전 기기 동시NULL매장 전체에 들려야 하는 안내다(D6)
본사 즉시방송전 기기 동시NULL상동(D7)
점장 즉시방송(이 화면)누른 기기 1대만그 기기 id점장이 자기 앞 PC 에서 확인용/즉석 안내를 내보내는 흐름(D8)
점장 예약 송출을 즉시 방송(broadcastStoreScheduledNow)전 기기 동시NULL”지금 이 PC 에서 방송한다”가 아니라 “예약해 둔 방송을 앞당긴다” — 원래 예약의 대상(매장 전 기기)을 유지하는 것이 점장 의도에 맞다

“전 기기 동시”는 전달 보장(아래 pending 기기 인지)과 시각 동기(playAt) 두 축으로 이뤄진다. 양쪽 다 라이브다(BE·FE).

“누른 PC 1대만”은 announcement_dispatch.target_device_id(V63)가 DB 차원에서 강제한다. 점장 즉시 송출 body 에 optional deviceId 를 실으면 서버가 본인 매장의 살아있는 기기일 때만 그 값을 dispatch row 에 박고, 기기 인지 pending 조회 양쪽 가지(target_device_id IS NULL OR = :deviceId) 가 걸려 나머지 PC 는 그 row 를 아예 보지 못한다. 구버전 클라이언트(deviceId 미전송)는 IS NULL 인 것만 보므로 하위호환도 유지된다.

⚠️ play_at 만으로는 막지 못했다. 종전 교정은 종착(PLAYED) 후 재전달 창에만 play_at IS NOT NULL 을 걸었는데, dispatch 는 생성부터 첫 ack(=재생 종료)까지 PENDING 이라 그 사이 나머지 PC 가 각자의 20초 폴링 tick 에 같은 PENDING row 를 그대로 가져갔다. 15초짜리 멘트면 15초 동안 창이 열려 있는 셈이라, 카운터 PC 에서 누른 “잠시 후 마감입니다”가 매장 4대에서 최대 20초씩 어긋나 반복 재생됐다(에코처럼 겹쳐 들리는 원 증상). 대상 기기는 생성 시점에 확정된 사실이므로 상태·파생 컬럼으로 추정하지 않고 row 가 직접 든다.

예약 송출(scheduledAt 있음)에는 deviceId 를 붙이지 않는다 — 예약은 정의상 전 기기 동시 재생이다. 타 매장·회수된 기기 id 는 400 이 아니라 전 기기 폴백이다(급한 안내를 막지 않는다). FE 도 즉시 모드 에서만 body 에 싣는다(!isScheduled && deviceId).

전 기기 재생 보장 — pending 조회의 기기 인지

dispatch 는 매장 단위 1 row 이고 기기는 20초 주기로 폴링한다. 기기 A 가 먼저 폴링해 10~30초짜리 멘트를 다 틀고 ack 하면 dispatch 가 그 자리에서 PLAYED 로 종착해, 20초 뒤 폴링한 기기 B·C 는 status=PENDING 조건에 걸려 그 방송을 영영 못 봤다(폴링 창 안에 재생이 끝나는 흔한 케이스).

GET /store/announcements/pending?deviceId= 는 PENDING 에 더해 최근 5분(DEVICE_FANOUT_GRACE) 안에 PLAYED 로 종착한 송출까지 내려주되, 이 기기가 이미 PLAYED·SKIPPED 로 ack 한 송출은 제외한다 (중복 재생 방지). 두 가지 모두 (target_device_id IS NULL OR = :deviceId) 로 대상 기기를 거른다. deviceId 를 생략하면 응답이 한 바이트도 달라지지 않는다(구버전 하위호환 — 기기 지정 방송은 애초에 그 기기가 deviceId 를 보내야 받을 수 있다).

FAILED ack 는 제외 대상이 아니다 — 그 기기에는 계속 재전달한다. outcome 을 가리지 않으면 일시적 CDN 5xx 한 번으로 그 기기가 긴급 방송을 영영 못 듣고, BE 소유 재시도 정책이 기기 단위에서 무력화된다 (무한 재전달은 매장 단위 실패 임계치 → MISSED 와 도래+grace 만료가 자연 상한이다).

⚠️ 쿼리는 PENDING 가지와 “최근 PLAYED” 가지 두 개의 index scan 으로 분해돼 있고, 애플리케이션이 합친 뒤 created_at ASC, id ASC 로 다시 정렬한다(응답 순서 계약 유지). 한 쿼리의 OR 로 두면 planner 가 두 partial index(V23 …_store_pending · V64 …_store_played)를 동시에 살리지 못해 seq scan 으로 떨어지는데, 매장 100개 × 기기 4대 × 20초 폴링 = 분당 1,200회 도는 경로라 그대로 장애가 된다(이 레포에는 커넥션 고갈 사고 이력이 있다).

player 는 이 파라미터를 항상 싣는다(기기 등록 전에는 생략 → 매장 단위 폴백).

기기별 ack — “한 대라도 재생됐으면 들린 것”

ack 은 원자 조건부 UPDATE(WHERE status='PENDING')라 여러 기기가 같은 방송을 재생하면 첫 기기만 204 를 받고 나머지는 404 였다 — 실제로는 여러 대가 재생했는데 이력엔 1건이고, 어느 기기가 실패했는지도 알 수 없었다. deviceId 를 실으면 결과가 dispatch_device_ack(PK(dispatch_id, device_id))에 기기별로 남고, dispatch 자체는 매장 단위로 유지된다(본사 송출 이력 UI 변경 최소화).

상황dispatch 종착
한 대라도 PLAYEDPLAYED — 그 방송은 매장에 들린 것이다. 다른 기기가 먼저 PLAYED 로 만들어 affected=0 이어도 404 로 만들지 않는다(404 면 FE 가 실패로 오인해 재시도·오보고한다)
이번 라운드의 온라인 기기 전부가 FAILED/SKIPPED 보고(성공 0)기존 실패 경로 — FAILED 누적 → 임계치 MISSED(PLAYBACK_FAILED) · SKIPPED → MISSED(SKIPPED)
아무도 ack 하지 않음기존 도래+grace(10분) 만료 cron 이 MISSED 로 종결(변경 없음)

꺼져 있는 PC 한 대 때문에 방송이 “미도달” 로 잡히지 않는다. 판정 모수는 등록 기기 수가 아니라 온라인 기기 수(재생 상태 heartbeat 기준 — 대시보드 OFFLINE 파생과 같은 PlaybackLivenessPolicy 임계)다.

종착 사유는 실패 우선으로 고정한다. 마지막 보고자의 outcome 을 그대로 쓰면 3대 FAILED + 마지막 1대 SKIPPEDSKIPPED 로 남아 본사 리포트의 원인 진단이 뒤집힌다(실제로는 음원·CDN 을 봐야 하는데 “점장이 폐기함”으로 읽힌다). FAILED 가 하나라도 있으면 PLAYBACK_FAILED 다.

기기 미식별 PC 의 레거시 ack 도 정족수를 존중한다. 등록 상한(4대)을 넘거나 기기가 회수돼 deviceId 없이 ack 하는 PC 가 매장 전체의 결론을 혼자 뒤집으면 안 된다. 그 dispatch 를 다른 기기가 이미 PLAYED 로 보고했으면(dispatch_device_ack.countPlayed > 0 + 소유 확인):

  • PLAYEDaffected=0 이어도 404 가 아니라 204 — 기기 경로와 같은 의미로 맞춘다(FE 가 실패로 오인해 재시도·오보고하던 폴백 경로).
  • FAILED/SKIPPED 는 실패 카운터를 올리거나 MISSED 로 종결하지 않는다 — 이미 매장에 들린 방송이다. 종전엔 상한에 걸려 미등록인 5번째 PC 의 오디오 고장 하나로, 정상 재생한 1~4번 PC 의 방송이 MISSED(PLAYBACK_FAILED)로 종착해 본사 리포트에 미도달로 남고 다른 PC 에도 영영 안 내려갔다.
  • 소유 확인(findByIdAndStoreId)을 함께 걸어 미존재·타 매장 dispatch 는 항상 종전 404 은닉이다.

동시 재생 — playAt

기기마다 폴링 위상이 달라 같은 방송이 최대 20초까지 어긋나 재생됐다(스피커가 분리된 매장에서는 에코처럼 겹쳐 들린다). BE 는 announcement_dispatch.play_at 을 채워 pending 응답 item 에 playAt (절대 재생 시각)을 내려준다. 각 기기는 큐 응답 serverNowIso 로 계산한 서버-클라 시각 오프셋을 보정해 그 시각에 시작한다. 과거 시각이면 즉시 재생한다.

⚠️ 오디오 프리페치는 아직 없다(후속). 오버레이 <audio> 는 방송이 활성화된 뒤에 렌더되므로 로드가 playAt 시점에 시작된다 — 기기 간 잔여 편차는 각 PC 의 네트워크·디코드 시간 차이만큼이다(폴링 위상 차이인 최대 20초에 비하면 무시할 수준이고, 음속 34cm/ms 기준 스피커 10m 간격이 이미 30ms 자연 지연이다).

값을 채우는 경로:

송출 경로play_at
본사 단건 즉시 송출(dispatchHqTtsAnnouncement)발행 + 리드타임(dispatch.immediate-play-lead-seconds, 기본 2초)
본사 단건 예약 송출scheduledAt (row 생성 시 세팅)
본사 반복 예약 전개(OccurrenceMaterializer)해당 회차 시각 (row 생성 시 세팅)
점장 예약 송출(StoreBroadcastService)scheduledAt (row 생성 시 세팅)
점장 즉시 송출null — 대상이 target_device_id 로 1대에 고정돼 있어 시각을 맞출 상대가 없다(D8). FE 는 null 이면 받는 즉시 재생
점장 예약 송출 [즉시 방송](broadcastStoreScheduledNow)새 IMMEDIATE dispatch — target_device_id 는 null(전 기기)

최종 안전망은 SCHEDULED→PENDING 전이 UPDATE 다: play_at = COALESCE(play_at, scheduled_at). 생성 경로가 앞으로 늘어나도 예약 송출은 자동으로 시각을 갖는다 — 경로별 세팅을 빠뜨려 한 채널만 동기화가 안 되는 일(실제로 반복 예약·점장 예약에서 발생했다)을 구조적으로 막는다.

player 소비(store-player-client.tsx):

  • 도래 전(playAt 이 미래)이면 재생 후보에서 제외한다 — 긴급도 같은 게이트를 탄다(긴급이야말로 전 기기가 동시에 나와야 한다).
  • 도래 시각에 맞춰 타이머로 한 번 깨운다. 폴링(20초)만 믿으면 최대 한 주기 늦게 재생돼 동시성이 무너진다. 타이머 지연은 폴링 1주기로 상한을 둔다(브라우저 타이머는 긴 지연에서 부정확하고 백그라운드 탭에서 스로틀되므로, 멀리 있는 방송은 폴링이 다시 계산한다).
  • 판정 기준은 서버 시각(큐 응답 serverNowIso 오프셋 + performance.now()) — 매장 PC 벽시계는 몇 분씩 틀어져 있는 게 흔하다. 앵커는 비-placeholder 큐 응답마다 다시 잡는다(1회 고정이면 절전 복귀 후 게이트가 영구히 안 열린다 — Store Player §서버 시각 앵커).
  • 서버 시각이 한 번도 동기되지 않았으면 재생 게이트는 fail-open(항상 재생), 폐기(drain) 판정은 fail-closed(버리지 않음)다. 방향이 반대인 두 판정을 같은 불확실성 아래 각각 안전한 쪽으로 연다.
  • 시작 시점 backlog drain 은 도래하지 않은 방송을 폐기하지 않는다. 그건 “밀린 것” 이 아니라 “곧 나올 것” 이다 — 게이트가 없으면 개점 직후 켠 PC 만 그 방송이 빠진다.
  • playAt 이 없거나 파싱 불가면 즉시 재생(소실보다 어긋남이 낫다).

즉시 통지 — SSE 가속 + 폴링 안전망 (SPEC #178 도입 · #180 FE 구독)

GET /api/v1/store/announcements/stream(text/event-stream)은 본사 즉시방송을 폴링 대기(최대 20초) 없이 통지한다. 페이로드는 신호만(event: announcement) 담고, 기기는 통지를 받으면 기존 pending endpoint 를 조회해 실제 내용을 가져간다(두 경로의 계약을 하나로 유지).

  • 연결: POST /api/v1/store/announcements/stream-ticket 로 단기 티켓을 받아 new EventSource(응답의 streamUrl)브라우저 → 백엔드 직접(BFF 스트리밍 프록시 아님).
  • 이벤트: 구독 직후 connected 1회(연결 확정) → announcement(신호) → 25초 주기 : ping comment.
  • 통지 지점: 본사 즉시 송출 · 예약 도래(SCHEDULED→PENDING) · 본사 원격 즉시중단(revoke).
  • 폴링은 안전망으로 유지 — 연결 확정 중에만 60초로 완화하고, 끊기면 즉시 20초 복귀한다. 그래서 SSE 가 죽어도 방송 소실이 없다(기능 손실 0).
  • 커넥션 수명 30분 · 티켓 TTL 60초(재연결마다 새로 발급) · 재연결은 backoff + jitter.
  • ⚠️ 단일 인스턴스 전제 — 구독자 registry 가 인메모리다. 스케일아웃하면 SSE 만 동시성을 잃고 폴링으로 동작한다(Redis pub/sub 은 별도 SPEC).
  • FE 훅·계측 상세는 Store Player §실시간 통지.

에러 분기 (code → 메시지)

단계status/code메시지
미리듣기(TTS)502 TTS_SYNTHESIS_FAILED합성에 실패했습니다. 잠시 후 다시 시도해주세요.
미리듣기(TTS)503 TTS_TOKEN_NOT_CONFIGURED방송 기능이 아직 설정되지 않았습니다. 본사에 문의해주세요.
업로드(녹음)400 RECORDING_UNSUPPORTED_FORMAT지원하지 않는 녹음 형식이에요. 브라우저를 최신으로 업데이트한 뒤 다시 녹음해주세요.
업로드(녹음)400 RECORDING_INVALID_FIELD녹음이 올바르지 않아요. (빈 파일·10MB 초과·길이 1~30초 범위 밖) 다시 녹음해주세요.
송출404 TTS_ANNOUNCEMENT_NOT_FOUND미리듣기가 만료됐습니다. 다시 미리듣기 후 송출해주세요. + 미리듣기 게이트 초기화(감사 #13 — [송출하기] 재비활성).
송출(예약)400 BROADCAST_SCHEDULED_AT_PAST과거 시각으로 예약할 수 없습니다.
송출(예약)400 BROADCAST_SCHEDULED_AT_TOO_FAR1년을 초과한 예약은 허용되지 않습니다.
송출(예약)409 DISPATCH_SLOT_OCCUPIED본사 방송이 이미 예약된 시각입니다. 다른 시각을 선택해 주세요. (#109 — 본사 점유 정확-시각 충돌)
템플릿 저장409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED템플릿은 최대 20개까지 저장할 수 있어요. 쓰지 않는 템플릿을 지운 뒤 다시 시도해주세요.
템플릿 수정/삭제404 BROADCAST_TEMPLATE_NOT_FOUND템플릿을 찾을 수 없어요. 이미 삭제됐을 수 있어요.
공통403(권한)·5xx·네트워크안전 기본 메시지(권한 없음·일시 장애·연결 실패)

합성은 수 초 걸릴 수 있어 [미리듣기]/[송출하기] 버튼을 “합성 중…”/“송출 중…” 로딩으로 표시한다.

접근성

  • 미리듣기 상태·예약 결과를 aria-live="polite" 영역으로 전달한다. 즉시 송출 결과는 토스트 (Radix Toast 자체 live region)로 전달한다.
  • 미리듣기 <audio controls> 로 들어볼 수 있게 한다. 무효화·송출 성공·만료(404) 시 pause() 로 정리.
  • 페이지 진입의 닫기 버튼은 aria-label="닫기"(홈 /store 복귀). 모달 컨텍스트에서는 Dialog 우상단 닫기(X) 버튼(44×44 · aria-label="닫기")이 같은 역할을 하며, 진행 중에는 잠긴다(감사 #13). 잠금 표현은 disabled 가 아니라 aria-disabled + title(이유) + onClick preventDefault 다 — disabled 버튼은 포커스를 받지 못해 스크린리더가 이유를 읽어줄 방법이 없다(onClick preventDefault 라 마우스뿐 아니라 Enter/Space 키보드 경로도 함께 막힌다). 30초 상한이 지나면 잠금이 풀리고 경고 배너가 뜬다 — 문구는 진행 중이던 요청의 종류에 따라 송출이면 중복 송출 경고, 합성(미리듣기)·녹음 업로드면 “요청이 지연되고 있습니다” 로 갈린다(아직 아무것도 송출되지 않은 상태에 중복 송출을 경고하지 않는다 — 자세히는 Store Player).

보안 불변식 (격리)

점장 즉시방송이 만든 안내방송은 tts_announcement.source='STORE_BROADCAST'(V24)로 본사 안내방송 (HQ)과 격리된다. 본사-facing 쿼리는 모두 source='HQ' 로 필터하므로 점장이 만든 row 는 본사 화면·계약에 노출되지 않는다. 점장 송출은 본인 매장에만 dispatch 된다. 상세는 TtsAnnouncementSource.

라우트·구성

  • apps/space/src/app/store/broadcast/now/page.tsx — server 셸(force-dynamic). /store layout (server)이 STORE_MANAGER role 가드를 담당하므로 본 page 는 셸 역할만 한다.
  • broadcast-now-client.tsx'use client' 본체(탭 전환·공유 미리듣기 게이트·<audio>·TTS 검증·에러 매핑).
  • recording-tab.tsx'use client' 녹음 탭(MediaRecorder·30초 자동 정지·미리듣기·업로드→공유 게이트 콜백·마이크 권한/업로드 에러 매핑·정리). 업로드는 useRecordStoreBroadcast(multipart) — 음원 업로드 (useUploadMusic)와 동일하게 BFF catch-all 프록시로 통과(신규 BFF route 불필요).
  • templates-tab.tsx'use client' 자주 쓰는 방송 탭(목록·인라인 저장/편집 폼·2-step 삭제·로드 콜백·상한 20·에러 매핑). CRUD 는 useListBroadcastTemplates·useCreateBroadcastTemplate· useUpdateBroadcastTemplate·useDeleteBroadcastTemplate — 모두 BFF catch-all 프록시로 통과 (신규 BFF route 불필요). 로드는 부모 onLoad(text, voice) 콜백 → TTS 탭 전환·입력 채움.
  • broadcast-voice-meta.ts — voice value→한글 라벨/옵션(본사 tts-voice-meta 미러).
  • reservation-slot-grid.tsx'use client' 5분 슬롯 그리드(예약 시각 선택, SPEC #083). 288칸 (시간 축 + 12칸/시) · 점유(본인/본사)·지난 슬롯 비활성 · value/onSelect 제어 · 점유 집합· pastBeforeMinutes props. 부모(broadcast-now-client)가 useListStoreScheduledBroadcasts(본인 매장 점유)·useGetStoreSlotOccupancy(본사 점유 사전 조회)·409 사후 보정(occupiedHqByDate)·KST 날짜/분 파생(kstDateTimeParts·minutesOfDay·snapToFiveMin) 을 소유. BFF catch-all 프록시 경유라 신규 route·middleware 등록 불필요.

후속

녹음 탭·템플릿 탭 정식 시안 정합(handoff)·Safari 트랜스코딩(webm 미지원 폴백)· 긴급방송 F1(player 인터럽트) ✅ (#090 — 폴링 응답에 긴급 row 가 들어오면 진행 중인 일반 방송을 preempt 하고 음악을 즉시 더킹 + 오버레이 동시재생)·F2(본사 audit 누적 — 마케팅 오용 보고)· 예약방송 F1 본사 점유 시각 차단 ✅ (#109 — 정확-시각 충돌 409 DISPATCH_SLOT_OCCUPIED5분 슬롯 그리드(ReservationSlotGrid) ✅ (빈 시간대 1탭 선택·점유/지난 비활성·KST 5분 스냅 — 반복 차단(20-policy §3-3)은 본사 예약 슬롯 모델 도입 후속 대형 SPEC본사 점유 전용 조회 연동 ✅ (2026-07 감사 #9 — GET /api/v1/store/slot-occupancy 사전 조회로 그리드 비활성, 409 는 race 보정으로만)· F2 점장 예약 취소 ✅ (#092 — SCHEDULED row [취소] inline strip + cancelStoreDispatch 라이브)·예약 목록 페이지네이션(#091 F1) ✅ (#102)· 예약 목록 미리듣기 audio(#091 F2) ✅ (#131 — 행 펼침 네이티브 <audio controls>·단일 재생)· 점장 취소 audit 누적(#092 F2) ✅ (#114 — store_audit_log STORE_DISPATCH_CANCELED)·송출 rate limit·송출 이력/audit. 계약·DTO 는 API 카탈로그 · DTOs.