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·audioUrlnull·송출 버튼 재비활성·<audio>정지). 바뀐 내용이 송출되는 혼선을 차단한다. [다시 미리듣기]로 새 draft 를 만든 뒤에야 송출할 수 있다. - 송출 후 취소 불가 — footer 상단에 경고 배너(시안 명시). 송출하면 바로 매장에 재생되며 되돌릴 수 없다.
- 미리듣기 만료(404)면 게이트를 초기화한다(2026-07 감사 #13). 송출이 404
TTS_ANNOUNCEMENT_NOT_FOUND(또는 status 404)로 실패하면previewedAnnouncementId·previewAudioUrl을 비우고 미리듣기<audio>를 멈춘다 → [송출하기] 재비활성. 종전엔 게이트를 그대로 둬서 같은 만료 id 로 404 가 무한 반복됐다(메시지는 “다시 미리듣기 후 송출해주세요” 인데 정작 미리듣기를 강제하지 않았다). 409DISPATCH_SLOT_OCCUPIED처리와 같은 방식이다.
결과 피드백 — 즉시=토스트 / 예약=배너+[확인] (2026-07 감사 #13)
| 모드 | 피드백 | 이유 |
|---|---|---|
| 즉시 송출 | 토스트(useToast, tone=success) 후 onSent() 로 모달 즉시 닫기 | 종전엔 setSentMessage 직후 모달을 닫아 배너가 한 프레임도 뜨지 않았다(성공 신호 0). 토스트는 셸(/store layout)의 ToastHostProvider 소유라 모달이 닫혀도 남는다. PL 적용(SPEC #163)과 같은 관례. |
| 예약 등록 | 인라인 배너(store-broadcast-sent) + 확인 | 소리 신호가 없어 “조용한 성공”·중복 등록 위험(SPEC #160 H13). 사용자가 명시적으로 닫는다. |
BroadcastNowClient 가 useToast() 를 쓰므로 이 컴포넌트를 렌더하는 테스트는
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.isTypeSupported로audio/webm;codecs=opus→audio/webm→audio/mp4→audio/mpeg순으로 첫 지원 타입을 채택(BE 허용 3종에 매핑). - 다시 녹음 — 미리듣기·게이트를 폐기하고 idle 로 돌아가 새 녹음을 받는다.
- 마이크 권한 거부 UX —
getUserMediareject 를 코드별로 안내한다: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 1
60·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·announcementIdnull·송출 재비활성). 송출 의도가 바뀌면 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 의 옵션 DTOBroadcastSendRequest는isEmergency·scheduledAt·deviceId3종을 받는다). 점장 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 dirdesign_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:00→toISOString()(...Z) 으로 송신. 5분 스냅이 안전한 이유: 디스패처가 매분 cron(#078)이라 5분 경계도 정확히 그 분에 전이(누락 없음). - 점유 슬롯 비활성(회색): (a) 본인 매장 예약 =
useListStoreScheduledBroadcasts({ page: 0, size: 50 })(본인 매장 SCHEDULED) 의scheduledAt을 선택 날짜(KST)로 매칭해 사전 비활성(“예약됨(내 매장)”). size 는 BE(store-playbackscheduled-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으로 KSTHH:mm집합으로 바꿔 그리드에 넘긴다 — 송출을 시도하기 전에 “본사 점유”로 비활성. 날짜를 바꾸면 queryKey(date)가 바뀌어 자동 refetch 되므로 날짜별 캐시는 react-query 가 관리한다(수기 관리 X). 종전에는 이 endpoint 가 BE·생성 클라이언트에 이미 있는데도 FE 가 쓰지 않아, 본사 점유 시각이 “예약 가능”으로 보였고 점장은 텍스트 작성 → TTS 미리듣기(과금·수 초 대기) → 슬롯 선택 → [예약 등록하기] 까지 간 뒤에야 409 를 받았다(게다가 그 학습이 컴포넌트 로컬 state 라 모달을 닫으면 소실 — 같은 실패 무한 반복). 조회 시점 이후 본사가 새로 예약한 race 는 여전히 send 409DISPATCH_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(점유 라벨 우선) 과 시각을 일치시킨다. 각 슬롯 buttonaria-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칸이 전부 “예약 가능” 으로 보였다. 실패는 그리드를 잠그지 않는다(disabled는occupancyLoading만 반영) — 조회 장애가 곧 “예약 전면 불가” 가 되면 안 되므로, 경고만 띄우고 등록은 계속 가능하게 둔다(fail-open 계약. 테스트가 슬롯 enabled 뿐 아니라 [예약 등록하기] enabled 까지 단언해 이 계약을 고정한다). - 오프라인 =
paused(리뷰 PR #429): react-query 기본networkMode: "online"에서 오프라인이면 쿼리가status:"pending"+fetchStatus:"paused"로 멈춘다. 이때isLoading(= pending && fetching) 도isError도 false 라, 종전 판정으로는 로딩 힌트도 실패 배너도 없이 288칸이 가용으로 보이고 버튼도 열렸다.paused는occupancyLoading이 아니라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분 단위로 자연 갱신(타이머 없이도 안전).
- SCHEDULED + 날짜 비어 있음 → 그리드 잠금 + “날짜를 먼저 선택해 주세요”(
- 녹음 탭: 녹음 흐름은
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 가 송출 단계에서 409DISPATCH_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, 긴급 배지 병기. 서브페이지 헤더는 신규 공용 atomStoreSubHeader. 시각만 교체 — 목록 fetch·취소 mutation·에러 매핑·격리 동작은 보존. ※ 이 정합은 예약 목록/취소·즉시 방송 화면 한정이며, 즉시방송 §의 5분 슬롯 그리드· 즉시방송 schedule UI 는 슬롯 모델 후속(아래 참조)으로 별개다.
- 데이터:
useListStoreScheduledBroadcasts→GET /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. 404TTS_ANNOUNCEMENT_NOT_FOUND(그 사이 도래· 취소돼 더는 SCHEDULED 아님) → danger Banner “이미 도래했거나 취소된 예약입니다. 목록을 새로고침했습니다.”- invalidate · 그 외 → “지금 송출에 실패했습니다. 잠시 후 다시 시도해주세요.”
audioUrl이 빈 문자열 (트림 후 길이 0)인 행은 [즉시 방송] 버튼 자체를 미노출(방어). 이전 SPEC #131 의 네이티브<audio>미리듣기는 #142 에서 [즉시 방송] 으로 완전 대체(previewId state·펼침 audio·관련 테스트 제거). 성공 시store_audit_logSTORE_DISPATCH_BROADCAST_NOW1건(targetDISPATCH·info 톤, Store 감사).
- invalidate · 그 외 → “지금 송출에 실패했습니다. 잠시 후 다시 시도해주세요.”
- 상태: 로딩 시 행 스켈레톤 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 로 교체: 404DISPATCH_NOT_FOUND→ “이미 처리됐거나 취소된 예약입니다.” (BE 가 미존재·이미 종착·타 매장·PENDING 을 한 코드로 은닉 하므로 가장 흔한 race 메시지로 평탄화) · 그 외 → “예약 취소에 실패했습니다.” 본사 #077 차이: 본사는 PENDING 도 취소 가능했지만 점장은 SCHEDULED 만 취소 — 본사가 즉시 보낸 PENDING 을 점장이 무효화 하는 건 정책 위반(SPEC #092 §D5). PLAYED·CANCELED 는 종착 — BE 에서 자동 제외됐으므로 표에 노출되지 않는다. 취소 성공 시store_audit_log(V36)STORE_DISPATCH_CANCELED1건 기록(SPEC #114, #092 F2 마감) — targetDISPATCH·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/calendarmonthGridRange). 이전/다음 월 네비 시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 에 optionaldeviceId를 실으면 서버가 본인 매장의 살아있는 기기일 때만 그 값을 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 종착 |
|---|---|
한 대라도 PLAYED | PLAYED — 그 방송은 매장에 들린 것이다. 다른 기기가 먼저 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대 SKIPPED 가 SKIPPED 로 남아 본사 리포트의 원인 진단이 뒤집힌다(실제로는 음원·CDN 을 봐야 하는데 “점장이
폐기함”으로 읽힌다). FAILED 가 하나라도 있으면 PLAYBACK_FAILED 다.
기기 미식별 PC 의 레거시 ack 도 정족수를 존중한다. 등록 상한(4대)을 넘거나 기기가 회수돼 deviceId
없이 ack 하는 PC 가 매장 전체의 결론을 혼자 뒤집으면 안 된다. 그 dispatch 를 다른 기기가 이미 PLAYED 로
보고했으면(dispatch_device_ack.countPlayed > 0 + 소유 확인):
PLAYED는affected=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 스트리밍 프록시 아님). - 이벤트: 구독 직후
connected1회(연결 확정) →announcement(신호) → 25초 주기: pingcomment. - 통지 지점: 본사 즉시 송출 · 예약 도래(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_FAR | 1년을 초과한 예약은 허용되지 않습니다. |
| 송출(예약) | 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(이유) + onClickpreventDefault다 —disabled버튼은 포커스를 받지 못해 스크린리더가 이유를 읽어줄 방법이 없다(onClickpreventDefault라 마우스뿐 아니라 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)./storelayout (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제어 · 점유 집합·pastBeforeMinutesprops. 부모(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_OCCUPIED)·
5분 슬롯 그리드( ✅ (빈 시간대 1탭 선택·점유/지난 비활성·KST 5분 스냅 —
반복 차단(ReservationSlotGrid)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.