Store Player Home — /store (점장 Classic player)
SPEC #064 도입. 시안 design_handoff_linkmusic/design/screens/home-classic.jsx (Classic 보수안). 묶음5 design_14 핸드오프(Phase 8 §8G-①)로 시안 정합 — 상태별 brand 그라데이션 커버·큐 source 3종 시각·dead-end CTA·CS 새 답변 dot 정식 시각 교체. SPEC #141 개편으로 음악/방송 표기를 분리(우측 컨트롤 컬럼·커버=음악 전용, 방송 표기는 커버 아래 방송 카드)했다 — 커버 상태는 music/none 2상태.
SPEC #171 개편: 재생 큐가 시간대별 라이브러리 슬롯 구조로 바뀌었다 — 현재 슬롯(currentSlot.items)을 앱이 로컬 셔플해 재생하고, 시간대 경계(nextBoundaryMinute·serverNowIso)에서 손에 쥔 다음 슬롯(nextSlot)으로 전환한다. 수동 [다음]·[이전]·큐 목록·곡 점프는 제거하고, hang 곡은 메인 트랙 stall 워치독이 자동 스킵한다. 아래 시간대 슬롯 전환 참조.
전제: 재생 큐 BE getStorePlaybackQueue(#056·#171 슬롯 확장)·활성 PL(#055)·기본 PL fallback·source(#058) 라이브.
Overview
점장(STORE_MANAGER)이 apps/space /store 에서 본인 매장 활성 플레이리스트의 재생 큐를
받아 브라우저로 직접 재생하는 화면. 직전까지 /store 는 placeholder(“점장 콘솔 준비 중”)
였고, 본 슬라이스에서 Classic player 홈으로 교체했다. 베타 게이트(“본사 세팅→점장 재생
end-to-end”)의 핵심 = 점장이 매장에서 음악을 트는 첫 화면이다.
범위: player 홈 + 재생 + 방송 동시재생(더킹 오버레이 · SPEC #141) + 음악 크로스페이드(곡↔곡 · 큐 내부 · SPEC #139 Part B). 즉시방송 작성·점장 PL 선택·온보딩·장애복구·송출 고도화(스케줄·본사 원격 중단)는 후속(시안 있음). 보조 액션은 모달(Radix Dialog)로 연다 — player 가 언마운트되지 않아 오디오 재생이 끊기지 않는다(이전 라우트 stub 링크에서 모달로 전환, 아래 보조 액션 참조).
방송 동시재생(SPEC #141): 본사가
/admin/announcements에서 [송출](전체 매장)하면 매장당 1 row(PENDING)가 fan-out 된다. 점장 player 가 이를 폴링해 due 시 즉시(곡 끝 대기 없음) 음악을 더킹(낮춤)한 뒤 별도 오버레이<audio>로 음악 위에 동시 재생하고 ack 한다. 음악은 절대 정지하지 않는다. 본사가 만든 TTS 가 점장 화면에서 실제로 들리는 첫 슬라이스다(기존 dead-end 해소).
시안 출처: workspace parent dir
design_handoff_linkmusic/design/screens/home-classic.jsx(HERO: cover + track meta + progress + transport + volume + [즉시방송] + 보조 3버튼, QUEUE: 풀폭 목록). 시안의 “다음 방송(예약/대기)” 큐는 즉시방송/예약 후속 슬라이스라, 본 슬라이스에서는 재생 큐(items) 목록으로 매핑한다. cover 는QueueItem에 이미지 필드가 없어 brand placeholder/그라데이션으로 둔다(SPEC #064 §D4 — 커버 enrich 후속). 시안 더미 보조정보(CM송 빈도·예약 ETA)는 데이터 부재로 미노출.
수동 곡 조작 제거 (SPEC #171 D8): 하단 재생 큐 목록·곡 수 표기·[이전]·[다음] 버튼·큐 행 클릭(수동 곡 점프)을 전부 제거했다(관련 핸들러
goPrev·goNext·selectIndex·QueueRow·SHOW_HIDDEN_PLAYER_SECTIONS·큐 스크롤 상태도 삭제). 재생은 시간대별 슬롯을 자동 진행하고, 재생이 막히는 hang 곡은 메인 트랙 stall 워치독(아래)이 자동 스킵하므로 수동 [다음] 탈출구가 필요 없다. 남는 컨트롤은 [재생/일시정지]와 볼륨뿐이다(자동 진행 엔진 —index/musicId 재매핑·queueEpoch· 크로스페이드는 그대로 유지). 단 재생할 게 없을 때(본사 준비 중·빈 PL·에러)의 dead-end 안내 + CTA(SPEC #118 §D2)는 유지(그때만 하단 박스가 뜬다·emptyState). 플레이리스트 선택 버튼·모달은 유지. 볼륨 슬라이더 좌측 음량 아이콘은 음악 음소거 토글 버튼(VolumeX/Volume2+ border,<audio muted>로 출력만 차단 — 슬라이더 위치·volume state·더킹과 독립). 하단 큐 제거로 생긴 여백은main세로 중앙정렬(my-auto+overflow-y-auto— 넉넉하면 중앙, 부족하면 전체 스크롤) + 배경 브랜드 teal radial glow(장식용·pointer-events-none·blur, 우측 상·하단 두 bloom)로 채운다.
구성 (server 셸 + client)
app/store/page.tsx— server 셸(force-dynamic). 본문(StorePlayerClient)만 렌더. role 가드는app/store/layout.tsx(STORE_MANAGER, SPEC #050 §F1)가 담당.app/store/store-player-client.tsx— client. 데이터 소비 +<audio>제어 + 큐 목록 + source/빈 상태.- 보조 액션은 모달(Radix Dialog)로 연다 — 라우트 이동(
<Link>)이 아니라 player 위에 뜨는 portal 오버레이다. 페이지 이동 시 player 가 언마운트돼<audio>재생이 끊기던 문제를 해결한다(player 와 모든<audio>가 마운트된 채 유지되어 오디오가 계속 흐른다). 모달은 player JSX 안에 마운트되고 (openModalstate 가 어느 모달이 열렸는지 관리), portal 이라 레이아웃 영향은 0이다. - 모달이 재사용하는 client 들은
embeddedprop 으로 페이지 셸(min-h-screen·StoreSubHeader· 닫기 Link 헤더)을 생략하고 본문만DialogBody(스크롤 바디) 안에 렌더한다. 송출/적용 성공 시onSent/onApplied콜백으로 모달을 닫는다:- 즉시방송 모달 =
broadcast/now/broadcast-now-client.tsx(initialTab·initialModeprop 으로 초기 탭/모드 seed) — 즉시방송(tts 탭)·자주 쓰는 방송(templates 탭)·예약 방송(예약 모드)을 한 컴포넌트로 연다(이전 stubbroadcast/templates·broadcast/schedule의 실 기능 연결). - 플레이리스트 모달 =
playlist/store-playlist-client.tsx(점장 활성 PL 선택, SPEC #129). 적용 성공 시 큐 invalidate → player 가 새 활성 PL 반영. - 다음 방송 모달 =
broadcast/scheduled/store-scheduled-list-client.tsx(defaultScope="today"— 기본 탭 “오늘 남은 방송”, SPEC #142 에서 2탭 토글로 일반화) — 커버 아래 단일 방송 카드 (store-player-broadcast-card) 클릭으로 연다. 각 행 [즉시 방송]으로 예약을 지금 송출(원래 예약 유지). - 고객지원 모달(
store-player-support-modal) =support/store-ticket-list-client.tsx+support/[id]/store-ticket-detail-client.tsx+support/new/store-ticket-new-client.tsx. 세 화면이 모달 안 내부 뷰 state(supportView=list | detail | new)로 전환되므로 목록→상세→작성 어느 단계에서도 라우트 이동이 없다(음악 유지). 각 client 는embedded+ 네비게이션 콜백 (onOpenTicket·onCreate·onBack·onCreated·onCancel)을 받는다. 콜백 미지정(페이지 진입)이면 종전router.push/replace그대로다. 티켓 등록 성공 시 목록 캐시 (getListStoreTicketsQueryKey())를 무효화한다 — 모달 안 왕복은 라우트 이동이 없어 전역staleTime: 30_000이 그대로 걸리고, 방금 만든 티켓이 목록에 안 보인다. - 프로필 모달(
store-player-profile-modal) =profile/store-profile-client.tsx(embedded). 모달 안 [고객지원] 진입(onOpenSupport)도 라우트 이동 대신 고객지원 모달로 전환한다. 계정 메뉴 프로필은 콜백 주입 경로에서onSelect의preventDefault()를 하지 않는다 — Radix 에서 그건 “메뉴를 닫지 말라”는 뜻이라, 모달만 여는 동작과 겹치면 드롭다운이 모달 뒤에 열린 채 남아 focus trap 이 중첩된다(기본 라우트 이동 경로는 종전 그대로).
- 즉시방송 모달 =
- 같은 client 들은 페이지로도 유지된다(딥링크 —
broadcast/now·playlist·broadcast/scheduled·support·support/[id]·support/new·profile). 단 페이지 진입은 player 언마운트라 끊김 → 주 동선은 모달. 레거시 stub 라우트broadcast/templates·broadcast/schedule는 실 기능이 모달로 연결돼/store로 redirect 한다. - 레거시 라우트 딥링크 목적지 통일 (SPEC #161 D9): 종전엔
broadcast/{schedule,templates}가 파라미터 없는 맨/store로만 튕겨 빈 홈 dead-end(딥링크가 노렸던 예약/템플릿 화면이 안 열림)였다. 이제 각 라우트는 모달 오픈 파라미터를 붙여 redirect 한다 —schedule → /store?modal=broadcast-schedule,templates → /store?modal=broadcast-templates.StorePlayerClient가 마운트 시useSearchParams()의modal값을 읽어(딥링크 허용 집합broadcast-now·broadcast-templates·broadcast-schedule) 해당 모달을 자동으로 연다(초기openModalstate). 알 수 없는 값은 무시. 살아있는broadcast/{now,scheduled}라우트와 목적지 일관성을 맞춰 dead-end 를 제거한다.
데이터
| 출처 | endpoint | 용도 |
|---|---|---|
useGetStoreMe | GET /api/v1/store/me | 상단 매장명(name)·본사명(hqName). 5xx/네트워크 시 “내 매장” 폴백. |
useStoreDevice | POST /api/v1/store/devices | SPEC #178 — 이 브라우저를 매장 기기로 등록해 deviceId 확보(마운트당 1회·멱등). 아래 §매장 기기 인지. |
useGetStorePlaybackQueue | GET /api/v1/store/queue | 시간대 슬롯 재생 큐(SPEC #171) {currentSlot, nextSlot?, nextBoundaryMinute?, serverNowIso, active, source, playlistId, playlistName, reason, items/total/truncated(하위호환 미러=currentSlot 의 것)}. currentSlot.items(비셔플)를 앱이 로컬 셔플해 재생하고, 경계에서 nextSlot 으로 전환한다(아래 §시간대 슬롯 전환). refetch 트리거를 명시한다 — refetchInterval 30분 + refetchIntervalInBackground: true(본사 PL 편집 반영 상한) · refetchOnWindowFocus: false · refetchOnReconnect: (q) => q.state.status === "error"(실패한 큐만 재연결 시 자동 복구). 아래 §큐 교체와 인덱스 재매핑. |
useListStorePendingAnnouncements | GET /api/v1/store/announcements/pending | 본인 매장의 미재생(PENDING) 안내방송 송출 { items[] } (created_at ASC). interval 폴링(큐와 독립) — SSE 가 연속 90초 연결된 뒤에만 60초 / 그 외 20초(SPEC #180 D6 + 히스테리시스, 아래 §실시간 통지) + refetchIntervalInBackground: true — 탭이 hidden 이어도 계속 폴링한다(2026-07 감사 #4, 아래 §백그라운드 폴링). 별도로 5초 주기 신선도 바닥이 “의도한 주기 + 10초” 를 넘긴 목록을 강제 재조회한다. |
useListStoreScheduledBroadcasts | GET /api/v1/store/scheduled-broadcasts | ”다음 방송” 카드용 예약(SCHEDULED) 목록. pending 과 같은 주기(완화 시 60초 / 그 외 20초) + refetchIntervalInBackground: true. |
useAnnouncementStream (use-announcement-stream.ts) | POST /api/v1/store/announcements/stream-ticket → GET /api/v1/store/announcements/stream | SPEC #180 — 실시간 통지(SSE) 구독. 티켓을 발급받아 응답의 streamUrl(티켓 포함 절대 URL) 로 브라우저 → 백엔드 직접 연결한다(BFF 스트리밍 프록시 아님). connected 이벤트로 연결 확정 → announcement 신호마다 pending·scheduled invalidate. 아래 §실시간 통지. |
useAckStoreAnnouncement | POST /api/v1/store/announcements/{dispatchId}/ack | 안내방송 재생 결과 보고 ack. 변수 { dispatchId, data: { outcome, deviceId?, signalSource?, receivedAt?, startedAt? } } — PLAYED(정상 종료) · FAILED(오버레이 onError·워치독 강제 종료) · SKIPPED(시작 시 backlog drain). 첫 ack(PENDING)만 204, 중복·이미 종착·미존재는 404 DISPATCH_NOT_FOUND(상태·존재 은닉). pending invalidate, 404 는 “이미 소비됨”으로 흡수. SPEC #180 계측 3필드(signalSource·receivedAt·startedAt)는 전부 선택이며 서버 시각 보정값이다 — dispatch 당 1회만 싣고, 서버시각 앵커 미동기이거나 backlog drain(SKIPPED)이면 싣지 않는다(아래 §실시간 통지). |
getStoreNextCommercial (raw fetcher) | GET /api/v1/store/commercial-song/next | SPEC #094 도입 · #104 라운드로빈 #094 F3 마감 — 본사 CM송 1건(매장 단위 라운드로빈, store.last_commercial_song_id 기반 순환) 또는 204(없음). 음악 곡 N회 ended 시점에 imperative 호출(react-query 캐시 미사용 — 매 호출이 새 상태). 200 StoreCommercialNextResponse{id,audioUrl,durationSeconds}. 응답은 generated getStoreNextCommercialResponseSuccess union(status===200 으로 narrow). |
PendingAnnouncementItem = { dispatchId, announcementId, title, audioUrl(Azure public blob), durationSeconds? }.
QueueItem = { musicId, title, audioUrl(Azure public blob), durationSeconds? }. SPEC #171 이후
items 는 비셔플이며 앱이 로컬 셔플한다(종전엔 서버가 셔플했다). QueueSlot = { libraryId?, libraryName?, startMinute?, endMinute?, items[], total, truncated }(폴백=시간표 없음이면 libraryId/startMinute/endMinute
=null 이지만 items 는 채워짐). 보호 endpoint 이므로 generated react-query 훅을 client 에서 호출하고, 토큰은
BFF /api/backend/... catch-all 경유로만 흐른다(서버 전용). mutator 는 성공(200)만 반환하므로
query.data.status === 200 으로 narrow 해 본문을 꺼낸다.
매장 기기 인지 (SPEC #178)
한 매장에서 PC 여러 대가 같은 점장 계정으로 음악을 튼다. 종전엔 “기기” 개념이 없어 전 PC 가 매장 행
하나(활성 PL·재생 상태)와 dispatch 행 하나(방송 ack)를 공유했고, 재생목록이 어긋나거나 한 대에서 PL 을
바꾸면 다른 대도 따라갔다. player 는 마운트 시 이 브라우저를 매장 기기로 등록해 deviceId 를 확보하고,
그 값을 아래 세 경로에 함께 실어 서버가 기기별로 상태를 가르게 한다.
| 경로 | deviceId 를 보내면 |
|---|---|
GET /store/queue | 기기 지정 PL 이 최우선(source=DEVICE) — 해석 우선순위 |
POST /store/playback/report | 재생 상태·play-log 가 기기별로 기록(매장 행 덮어쓰기·신탁 근거 왜곡 해소) |
POST /store/announcements/{dispatchId}/ack | 결과가 기기별로 기록 — 첫 기기만 204·나머지 404 였던 문제 해소 |
- 등록은 마운트당 1회(
startedRef). player 는 오래 떠 있고 리렌더가 잦아 조건을 조금만 느슨하게 잡아도 등록 요청이 반복된다(서버가 멱등이라 데이터는 안전하지만 무의미한 트래픽). - 큐 조회는 등록 시도가 끝난 뒤(
resolved) 시작한다. 등록 도중에 큐를 먼저 부르면deviceId가 채워질 때 쿼리 키가 바뀌어 큐가 통째로 다시 로드되고 재생이 리셋된다. 키 전환 시에는placeholderData로 직전 데이터를 유지해 깜빡임·리셋을 막는다. - 등록 실패는 화면을 막지 않는다.
deviceId가 없으면 그냥 보내지 않고 서버가 종전 매장 단위 경로로 처리한다(하위호환) — 재생이 기기 등록에 인질로 잡히면 안 된다. - 저장소가 막힌 기기(시크릿 모드·키오스크 등)는
getOrCreateDeviceKey()가null을 주므로 등록 자체를 건너뛴다(storageBlocked). 새 키를 매 로드마다 만들면 매장당 상한(4대)을 유령 기기로 소진하기 때문이다. 그 기기는 매장 단위로 동작하며 음악은 정상이고 기기별 재생목록만 못 쓴다.
기기 상한 배너 (409 DEVICE_LIMIT_EXCEEDED)
등록이 상한(4대)으로 거절되면 main 최상단에 warn Banner(store-player-device-limit,
role="alert")를 띄운다 — 제목 “이 매장에 등록된 기기가 가득 찼습니다” + 본문 “음악은 정상 재생되지만,
이 PC 만의 재생목록은 설정할 수 없습니다. 사용하지 않는 기기를 정리한 뒤 화면을 새로고침해 주세요.” +
액션 [기기 정리하기](store-player-device-limit-manage) → 기기 관리 모달.
서버는 오래된 기기를 자동 회수하지 않는다 — 오프라인과 폐기를 구분할 수 없어 잘못 회수하면 재생 중인 기기가 끊긴다. 그래서 정리는 점장이 명시적으로 한다. 화면·정리 흐름은 Store Devices.
재생 (HTML <audio>)
<audio src={...}>로 직접 재생(slotOrder= 현재 슬롯의 로컬 셔플 순서). src 는 원격 blob URL 이 기본이되, 캐시 적재된 곡은 objectURL 이다(아래 §음원 캐시 계층 — SPEC #179).- 재생/일시정지 — toggle.
playingstate 를el.play()/el.pause()와 동기화. [이전]·[다음]· 큐 목록·수동 곡 점프는 제거됐다(SPEC #171 D8 — 자동 진행 + stall 워치독). - 진행바 —
onTimeUpdate/onLoadedMetadata로 currentTime·duration 추적. 비인터랙티브 (role=progressbar· seek 미지원), 메인(음악) 트랙 기준이며 방송 중에도 상시 노출한다(음악은 연속 재생되므로 진행 그대로). duration 미확정 시durationSeconds폴백. - 볼륨 — 0~1 range,
el.volume동기화. - 곡 끝(
onEnded) → 경계 판정 후 진행 + CM 사이클 카운팅(SPEC #141 — 방송 삽입 없음. 음악은 연속 재생하고 방송은 오버레이로 동시재생).handleMusicEnded는 곡을 끝까지 튼 뒤 시간대 경계를 판정해 경계 도달이면nextSlot으로 전환, 아니면 슬롯 내 진행/소진 재셔플한다(아래 §시간대 슬롯 전환). - 슬롯 소진(경계 미도달, 마지막 곡 다음) → 같은 슬롯 로컬 재셔플 반복 +
queueEpochbump (SPEC #171 D11). 서버 재요청 없이 슬롯을 손에 쥐고 반복하므로(BE 가 요청마다 라이브러리 전곡을 로드·셔플하는 부하를 피한다) 재생이 끊기지 않는다. 재셔플은 0번부터다(재매핑 아님). - 슬롯 재셔플 랩-wrap 도 크로스페이드(완전 gapless): 슬롯(또는 폴백=전체 셔플)의 곡을 한 바퀴
다 돌고 재셔플하는 첫 곡도, 슬롯 경계 전환·in-slot 전환과 같은 엔진으로 크로스페이드해
붙인다(SPEC #171 D11+).
resolveNextTransition이 현재 슬롯을 전환당 1회 재셔플해wrapOrderRef에 담고(재-roll 없음 · StrictMode 안전 — 계산은 이벤트 콜백에서만) 그 첫 곡을 incoming 트랙에 프리로드→크로스페이드하며, swap 시 그 order 를 커밋(index 0)한다. 종전엔 이 랩-wrap 첫 곡에서만 크로스페이드가 안 걸려 랩당 1회 수백 ms 갭이 났다. 단 길이 1 슬롯·재셔플 첫 곡 URL 이 현재와 같을 때는 자기 자신 크로스페이드가 되므로 기존queueEpoch-bump 되감기 경로를 유지한다 (advanceToNext가 캐시된wrapOrderRef를 그대로 커밋 — 재셔플 1회 유지). 인터럽트 abort 시 기존advanceToNext경로로 자연 복귀한다.
음원 캐시 계층 (SPEC #179 FE-1 · 서비스워커 없는 앱 레벨)
왜: 음원 대역폭이 원가의 지배 항목이다(12h 매장 월 ~21GB, 멀티 기기 4대면 3배 적자 — 워크스페이스
docs/specs/179-mp3-cache.md). 매장은 같은 곡을 반복 재생하므로 클라이언트 캐시 적중으로 egress 를
자릿수로 줄인다. BE 선행(교체=새 UUID key·D1)으로 URL 이 곧 내용 주소라 URL 을 캐시 키로 그대로
쓴다(교체 시 URL 변경 = 자동 무효화).
- 구조 (
apps/space/src/lib/audio-cache.ts+use-cached-audio-slot.ts):fetch(cors)→ Cache Storage(lm-audio-v1) →Blob→URL.createObjectURL→<audio src>. 서비스워커는 쓰지 않는다(D5) —<audio>의 no-cors Range 요청을 SW 로 받으면 opaque 응답·206 합성 문제를 떠안는다. objectURL 은 seek·크로스페이드가 전부 로컬에서 동작한다. - src 확정은 렌더 시점 동기 — 이미 적재(warm)된 곡만 objectURL, 미적재 곡은 종전대로 원격 스트리밍으로 즉시 시작하고 백그라운드 적재가 다음 사이클부터 적중시킨다. 비동기 resolve 를 기다리거나 재생 중 src 를 objectURL 로 갈아끼우는 경로가 구조적으로 없다(전환 타이밍 불변·재시작 0).
- 적용 대상 (D7): 음악 메인 2트랙(메인·크로스페이드 incoming) + CM 오버레이. 안내방송(TTS)은
제외 —
no-store계약(같은 key 덮어쓰기 업데이트) + 1회성이라 캐시 이득이 없다. - objectURL 수명 = 트랙 슬롯 소유 (
useCachedAudioSlot): 슬롯이 새 곡을 받으면 이전 objectURL 을 커밋 후revokeObjectURL(스킵·에러·재시작·큐 교체 등 모든 전환이 “url 변화” 한 지점으로 수렴). 크로스페이드 중에는 두 곡이 각자의 objectURL 로 동시에 살아 있다(acquire 는 호출마다 새 objectURL — 공유 revoke 경합 원천 차단). revoke 누락 = 24/7 단말 메모리 증가라 회귀 테스트(spy)로 고정한다. - 프리페치 (D9): 곡 시작 시 다음 곡 1곡(in-slot 다음 · 마지막 곡이면 nextSlot 첫 곡)을 백그라운드 적재. 랩-wrap 재셔플은 예측하지 않는다(전환당 1회 결정 계약 보호 — 같은 슬롯 곡이라 첫 랩 후 어차피 적재됨). 원격으로 재생 중인 현재 곡·CM 도 백그라운드 적재한다.
- fail-open (D6 — 배포 게이트 불변식): 캐시 계층의 어떤 실패도 재생을 막지 않는다. Cache Storage 부재(비보안 컨텍스트)·Azure CORS 미설정·네트워크 실패·quota — 전부 원격 URL 폴백으로 종전과 동일하게 재생된다. FE 를 CORS 설정(I1)보다 먼저 배포해도 무해(캐시만 미동작).
- 손상 캐시 자가 복구: objectURL 재생 실패(onError·play() reject·stall)는 캐시 엔트리를 지우고 슬롯을 원격 src 로 전환한 뒤 기존 실패 경로(마킹·스킵·backoff)에 그대로 합류한다. 1곡 큐처럼 url 이 안 바뀌는 경우도 원격 전환이 재생 제어 effect 를 다시 돌려 backoff 대기 없이 복구된다.
- 상한·퇴출 (D8): 총량 3GB · LRU(마지막 사용 시각 메타는 Cache Storage 메타 엔트리 1개, 직렬화
큐). 퇴출·메타 갱신은 적재 직후 백그라운드에서만 — 재생 경로 지연 0.
navigator.storage.persist()1회 요청(거부돼도 동작 동일). - 계측 (D10): 재생 report
logs[].cacheHit에 곡 시작 시점의 슬롯 확정값(objectURL 재생 여부)을 실어 적중률(M2 목표 80%+)을 실측한다 — PlaybackLogRequest ·play_log.cache_hit(V67).
시간대 슬롯 전환 (SPEC #171)
재생 큐가 시간대별 라이브러리 스케줄로 바뀌었다. 서버는 현재 시간대 슬롯(currentSlot)과 다음
슬롯 lookahead(nextSlot), 경계(nextBoundaryMinute), 서버 시각(serverNowIso)을 함께 내려준다.
앱은 currentSlot.items(비셔플)를 로컬 셔플(queue-shuffle.ts)해 재생하고, 시간대 경계에서
손에 쥔 nextSlot 으로 전환한다.
- 한 슬롯 미리보기 재생(D13):
currentSlot.items를 로컬 셔플해slotOrder로 재생한다. 슬롯 곡 소진 시 같은 슬롯을 재셔플해 반복(D11)하며nextSlot은 손에 쥐고 대기한다. 폴백(시간표 없음:currentSlot.libraryId/startMinute/endMinute=null,nextSlot/nextBoundaryMinute=null)이면 경계 전환이 없어 종전처럼 무한 셔플·반복한다(전체 셔플 회귀). - 경계 전환 = 곡을 끝까지 튼 뒤: 곡 종료(
handleMusicEnded/ 크로스페이드 swap) 시 서버시각 오프셋 보정 현재시각이 경계를 지났으면nextSlot으로 전환하고, 전환 직후 refetch 로 그 다음 슬롯을 확보한다(경계 시점 네트워크 무의존 — 이미 nextSlot 을 갖고 있다). 재생 중인 곡은 절대 자르지 않는다. 경계 미도달이면 현재 슬롯을 계속 진행한다. - 슬롯 경계 크로스페이드(D13): 경계 도달 시
nextSlot첫 곡(로컬 셔플 선정)을 incoming 트랙에 미리 로드해 현재 곡 끝자락에서 크로스페이드로 넘긴다(in-slot 크로스페이드와 같은 엔진 ·finishCrossfadeSwap이crossfadePlanRef로in-slotvsboundaryvswrap(슬롯 재셔플 랩-wrap)을 가른다). 세 전환 모두 incoming 프리로드→오버랩→swap 이라 재생 갭이 0 이다(완전 gapless). - 서버 시각 앵커(D6):
serverNowIso와 그 시점의performance.now()를 함께 잡아두고, 현재 서버시각 = 앵커 서버시각 +performance.now()경과(단조 증가)로 계산한다. 경계 판정에 클라 벽시계 (Date.now())를 쓰지 않으므로 매장 단말의 시계 드리프트·NTP 보정에 흔들리지 않는다. 슬롯 경계 판정과 방송playAt게이트(SPEC #178 D10)가 이 앵커를 공유한다. - ⚠️ 앵커는 비-placeholder 큐 응답마다 다시 잡는다(종전엔 첫 응답 1회 고정).
performance.now()가 보장하는 건 앵커 사이의 단조성이지 앵커의 신선도가 아니다. 무인 매장 POS 는 개점에 부팅해 하루 종일 떠 있고 OS 절전/hibernate 를 겪는데, 플랫폼에 따라performance.now()가 suspend 구간을 세지 않아 복귀 후 파생 서버시각이 몇 시간 뒤처진다. 그러면announcementDue가 모든playAt에 영구 false → 예약·즉시 방송이 그 PC 에서 한 건도 재생되지 않고(화면 표시도 없이) BE grace 10분 뒤 MISSED 로 영구 소실되며,boundaryCrossed()도 영구 false 라 하루 종일 같은 라이브러리만 돈다. 30분 주기 refetch 가 매번serverNowIso를 실어 오므로 드리프트 상한이 refetch 주기로 봉인된다(앵커 사이의 벽시계 점프 방어는 그대로). - 앵커 세대(
serverClockVersion)는 ref 미러가 아니라 state 다 — 재고정 직후 방송 due 판정을 즉시 다시 해야 하고(절전 복귀로 앵커가 크게 틀어졌던 경우 이미 도래한 방송이 다음 폴링까지 묻히면 안 된다), backlog drain 게이트가 “동기됨”을 기다렸다가 진행해야 하기 때문이다.0= 아직 한 번도 동기 안 됨. - day-wrap:
nextBoundaryMinute은 KST 분[0,1440]이고 값 1440 은 “다음날 0:00”(자정) 이다. 경계를nowMin >= 1440로 판정하면 자정 이후nowMin이 0 으로 wrap 돼 영영 전환하지 못한다 — 대신 경계를 절대 epoch(ms) 로 환산한다(KST 자정 epoch + nextBoundaryMinute*60000). 1440 은 자연히 다음날 자정이 된다. (BE 는 nextSlot 에 이미 wrap 을 적용해 자정 다음 슬롯을 담아 보낸다.) - 다음-슬롯 refetch 실패 시: 전환은 손에 쥔
nextSlot으로 이미 로컬에서 이뤄졌으므로, 그 다음 슬롯을 받는 refetch 가 실패해도 현재(전환된) 슬롯을 계속 재생한다(무음 금지 · 재시도는 기존 주기 refetch·재연결 복구가 담당). - slot 재조정(reconcile): 서버 refetch 응답은 슬롯 키(
libraryId:startMinute:endMinute, 폴백은fallback)로 현재 재생 슬롯과 비교한다. 같은 슬롯(30분 주기·재연결·본사 편집·경계 전환 후 확인) 이면 재셔플 + musicId 재매핑으로 재생 중인 곡을 이어주고, 다른 슬롯이면 0번부터 새로 셔플한다. - 경계 슬롯 역전 억제(리뷰 P2): 경계에서
nextSlot(B)로 전환한 직후boundaryAbsMs=null로 낸 refetch 를, 서버가 여전히 직전 슬롯(A) 윈도우로 해석해currentSlot=A를 돌려주면(경계 포함/미포함 처리 차·수백 ms 클록 지터로 요청이 A 분에 도달) reconcile 이differentSlot(B→A)로 오분류해 0번부터 A 로 리셋 → B→A 곡 튐 + 이어 경계 재크로스로 A→B→A 사이클(불필요 A 1곡)이 났다. 방금 떠난 직전 슬롯(previousSlotKey)으로의 역전이 전환 후 짧은 창(SLOT_REVERSION_SUPPRESS_MS= 90s, 슬롯 30분 대비 짧고 지터/처리지연 대비 큼) 이내면 리셋을 억제하고 현재 슬롯(B)을 유지한다 — 경계도 재무장하지 않아(소비 상태 유지) 재-전환을 막고, 다음 주기 refetch 가 자연 정정한다(무음·재요청 폭주 없음). 창 밖이거나 제3의 슬롯이면 정상differentSlot처리(정상적인 다음-슬롯 진행은 막지 않고 직전-슬롯 역전만 억제). BE 슬롯 해석은 반열림[startMinute, endMinute)(minute >= start && minute < end)이고nextBoundaryMinute은> nowMinute라 FEkstMinuteOfDayfloor 와 정합한다 — 이 역전은 계약 불일치가 아니라 경계 순간의 타이밍 지터에서만 저확률로 발생한다.
메인 트랙 stall 워치독 (SPEC #171 E6·D14)
수동 [다음]을 없앤 뒤(D8) hang 곡의 유일한 자동 탈출구다. 오버레이 워치독(아래 §오버레이 워치독)의 “진행 정지(stall)” 판정을 메인 음악 트랙에 이식했다.
- 판정: 재생 중(
playing+!paused+!ended+ 크로스페이드 아님)인데currentTime이 10초(MAIN_STALL_TIMEOUT_MS) 동안 진행하지 않고 연속 2회 확인(5초 주기) 되면 hang 으로 본다. 정상 버퍼링은 브라우저가 보통 5초 안에 회복(그 사이currentTime이 밀린다)하므로 이 임계로는 정상 곡을 자르지 않고(오탐 0) 바이트가 안 와timeupdate조차 멎은 진짜 hang 만 잡는다. - 기존 재시도 상태머신에 통합: 감지 시 새 상태를 만들지 않고 오디오
onError와 같은 경로 (handleMusicError)로 흡수한다 — 곡을failedMusicRef에 실패 마킹하고 다음 곡으로 스킵하며, 전곡 실패면 danger 배너 + 지수 backoff refetch, 정상 로드(loadedmetadata) 시 리셋한다. - 시각 기준은 오버레이 워치독과 동일하게 단조 증가
performance.now()다(벽시계 점프 방어).
재생 재개 보장 — queueEpoch (1곡 큐 영구 무음 수정 · 2026-07 감사 #7)
재생 재개 effect 의 deps 는 [playing, musicSrc, queueEpoch] 다. queueEpoch 가 없던 시절엔
재생 재개의 유일한 트리거가 musicSrc 문자열 변화여서, 큐 소진 후 새 큐의 0번 곡 URL 이
직전과 같으면(1곡 큐에서 100% 결정적, 또는 같은 곡이 다시 0번으로 셔플된 경우) deps 가
불변이라 play() 가 영영 재호출되지 않았다. <audio> 는 ended 상태로 주차되고 버튼은
“일시정지”인 채 소리만 안 나는 영구 무음이 됐다.
advanceToNext의 큐 소진 분기·in-slot advance 분기(리뷰 NIT — 방어적 대칭)·[재시작]·전곡 실패 backoff refetch·경계 전환(transitionToNextSlot)·다른 슬롯 도착(reconcile)이queueEpoch를 +1 한다. in-slot advance 도 올리는 이유: 슬롯 내 다음 곡이 현재와 같은 audioUrl(인접 중복)이고 duration 미확정으로 크로스페이드 미발동(handleMusicEnded → advanceToNext) 경로면musicSrc불변 + epoch 불변이라 재생 재개 effect 가 재실행되지 않아 정지할 수 있다(크로스페이드 swap 경로는suppressMusicEndedRefURL 비교로 다루지만 in-slot advance 는 그 래치를 안 건다).- effect 는 “세대만 바뀌고 URL 은 그대로”인 경우(=같은 곡으로 새 랩 시작)
el.currentTime = 0으로 되감은 뒤play()를 호출한다. 곡이 바뀐 경우엔 되감지 않는다(기존 N≥2 동작 불변). - 판정 기준값(직전 세대·URL)은 재생을 실제로 반영한 실행에서만 기록한다(
if (playing)안). 정지 중에도 기록하면 그 사이 발생한 epoch bump(일시정지 상태에서 [재시작] 등)가 그 자리에서 소진돼, 이후 재생 재개 시 되감기가 유실되고 진행바가 튄다.
큐 교체와 인덱스 재매핑 (2026-07 감사 #12)
고객사 제보 “곡이 끝나면 자꾸 다른 플레이리스트로 넘어가는 느낌” 의 정체는 PL 전환이 아니라
재셔플 + 인덱스 미재매핑이었다. BE 의 PL 선택에는 시간대·스케줄 분기가 전혀 없고(입력은
store.active_playlist_id 와 본사 기본 PL 뿐) 비결정적인 것은 셔플 한 줄뿐이다. 그래서 길이가
같은 새 셔플 배열이 오면 items[index] 가 완전히 다른 곡이 되어, 재생 중이던 곡이 그 자리에서
잘리고 엉뚱한 곡이 0:00 부터 시작했다(종전 동작: 범위 안이면 인덱스 그대로 유지).
-
재매핑 기준은
musicId앵커다. 큐는musicId로 dedup 되어 중복이 없으므로findIndex가 유일해를 준다. 같은 곡을 찾으면musicSrc(=audioUrl)가 그대로라<audio src>도, 재생 제어 effect(depsmusicSrc)도 재실행되지 않아 재생이 끊기지 않는다(위치·진행바 유지). -
분기 3가지
상황 결과 [재시작] · 전곡 실패 backoff · 활성 PL 적용/해제 · 다른 슬롯 도착 으로 인한 교체( queueResetPending/다른 슬롯)0번(재매핑 안 함) — “새 랩을 처음부터”가 계약이다( queueEpochbump 와 짝)앵커를 새 큐에서 찾음(같은 슬롯 재조회) 그 인덱스로 이동 = 같은 곡 계속 재생 못 찾음(PL 변경·곡 삭제로 그 곡이 새 큐에 없음) 0번 — 종전 동작(범위 안이면 인덱스를 그대로 유지)이면 새 큐의 임의 위치에 착지한다(예측 불가) SPEC #171 이후: 슬롯 소진 재셔플·슬롯 전환은
slotOrder(로컬 셔플 순서 · query 와 분리된 state)를 직접 0번부터 커밋하므로queueResetPending을 세우지 않는다(서버 refetch 가 없어 소비될 곳도 없다 — 세우면 다음 주기 refetch 가 remap 대신 리셋으로 오분류된다).queueResetPending은 [재시작]·PL 적용/해제·전곡 실패 backoff(refetch 를 동반하는 명시적 “처음부터”)에만 쓴다. -
조정은 reconcile effect 가 slotOrder·index 를 함께(batched) 커밋한다.
items(=slotOrder)는 query 와 분리된 state 라, 서버 refetch 가 커밋돼도 slotOrder 가 이 effect 로 바뀌기 전까지<audio src>는 재생 중 곡 그대로다(중간새 배열 × 옛 index조합이 오디오에 닿지 않아 곡이 끊기지 않는다 — src 대입은 곧load()). 실패 상태 리셋·크로스페이드 abort 같은 부수효과만 별도 effect 가 맡는다. -
큐 교체 시 진행 중 크로스페이드는 abort 한다. fade interval 의 swap 클로저는 진입 시점의
items·index를 캡처하므로, 큐가 바뀐 뒤 swap 되면 stale 인덱스 기준으로 엉뚱한 곡을 고른다. -
리셋 의도(
queueResetPending)는 출처를 구분한다("auto" | "user").auto= 전곡 실패 backoff·다른 슬롯 도착(시스템),user= [재시작]·활성 PL 적용/해제(점장의 명시적 의도). 만료 규칙이 다르다:만료 트리거 autouser큐 성공 응답( dataUpdatedAt)소비 소비 큐 실패 응답( errorUpdatedAt)소비 유지 TTL 60초( QUEUE_RESET_USER_INTENT_TTL_MS)— 소비 성공 만료가 필요한 이유는 구조공유로
items참조가 안 바뀌는 경우(내용 동일한 1곡 큐 등 — 렌더 중 조정 블록이 돌지 않는다)까지 덮기 위해서다. 실패 만료가 필요한 이유는 소진 refetch 가 실패한 무인 매장(회선 장애)에서 의도가 고착돼 한참 뒤의 무관한 교체(PL 변경·주기 refetch)가 0번 리셋으로 오분류되는 것을 막기 위해서다 — 실패로 내리면 재시도 성공분이 재매핑 경로를 타 재생 중이던 곡이 그대로 이어지는데, 이는 계약의 문자적 이행은 아니어도 곡이 잘리는 오분류보다 낫다. 다만 사용자 의도에는 그 tradeoff 가 성립하지 않는다 — PL 적용 직후 요청이 1회 실패하면 재시도 성공분이 재매핑을 타 새 PL 에 지금 곡이 들어 있을 때 “적용했는데 그대로”(handlePlaylistApplied가 없애려던 바로 그 증상)가 좁게 재현된다. 그래서 사용자 의도는 성공 또는 TTL 로만 만료시키고, 고착 리스크는 TTL 60초로 봉인한다(TTL 은 첫 요청 기준이라 연타해도 연장되지 않는다). -
refetchOnReconnect는 마지막 조회가 실패한 큐만((q) => q.state.status === "error"— 큐를 한 번도 못 받은 상태뿐 아니라, 이전 데이터는 남아 있고 최근 refetch 만 실패한 상태도 포함한다. react-query v5 는 데이터를 유지한 채status를error로 둔다). 종전엔 옵션 무지정 → 전역 기본값이었고, 전역은refetchOnWindowFocus: false만 껐을 뿐refetchOnReconnect는 react-query 기본값 true 였다. 매장 Wi-Fi 가 끊겼다 붙을 때마다 재셔플 큐가 도착해 재생 중인 곡이 갈렸다(제보 증상의 두 트리거 중 하나 — 다른 하나가 큐 소진 경계). 재매핑이 들어와 안전해지긴 했지만 재생 중인 큐를 재연결마다 갈아끼울 이유가 없고, BE 는 요청마다 라이브러리 전곡을 로드해 셔플하므로(감사 #17) flapping 이 그대로 서버 부하가 된다. 다만 회선 장애로 큐를 못 받은 무인 단말은 배너의 [새로고침] 수동 클릭 말고 복구 경로가 없으므로, 그 상태의 큐만 자동으로 다시 받는다. (.claude/rules/frontend.md§17 — 무지정은 “검토 안 함”으로 읽히므로 양쪽 다 명시.) -
주기 refetch 30분(
refetchInterval+refetchIntervalInBackground: true). 재연결 refetch 를 좁히고 나면 정상 큐의 신선도 경로가 소진 경계·[재시작]·매장 로컬 PL 변경뿐인데, 소진은 최대 500곡(≈24시간 이상)이라 하루를 넘길 수 있고 본사가 PL 곡을 추가·삭제한 변경은 셋 중 어디에도 걸리지 않는다 — 본사 편집이 하루 넘게 반영되지 않을 수 있었다. 재매핑이 생겨 중간 교체가 안전해졌으므로 주기 refetch 를 둔다. 30분인 이유: BE 가 요청마다 라이브러리 전곡을 로드해 셔플하므로 짧게 잡을 수 없고(감사 #17), 큐는 안내방송(SSE + 20/60초 폴링)과 달리 즉시성이 필요 없다. 매장당 시간 2회면 부하는 무시할 수준이면서 본사 편집 반영 지연 상한이 30분으로 내려온다. 매장 단말은 다른 탭·창을 띄운 채 오래 도는 게 정상이라 백그라운드에서도 돈다. -
빈 큐 경계 워치독(무음 방어 · BE 리뷰 P2-A). 서버 현재 슬롯에 재생 곡이 없으면 (
EMPTY_PLAYLIST·UNPLAYABLE_SOURCES·폴백-current 0곡)ended이벤트가 없어 슬롯 경계가 song-ended 로 소비되지 않아, 다음 시간대에 곡이 생겨도 30분 주기 poll 전까지 무음이 이어졌다. BE 가 빈 큐 응답에도nextBoundaryMinute(계산된 경계)을 실어 보내므로, FE 는 경계를 nextSlot items 유무와 무관하게 무장하고(boundaryAbsMsRef), 재생 곡이 없을 때 그 절대 시각에 맞춰queueQuery.refetch()1회를 스케줄한다. 발화한 경계값을 기록해 같은 절대 경계로 두 번 발화하지 않는다(핫루프 차단). 가드는slotOrder(로컬 셔플 상태)가 아니라 서버 현재 슬롯 원본으로 판정해, reconcile 전 첫 커밋의 빈 slotOrder 로 오발화하지 않는다. 재생 곡이 있으면handleMusicEnded/크로스페이드가 경계를 처리하므로 워치독은 무장하지 않는다(이중 refetch 방지). -
활성 PL 적용/해제(점장 모달)는 리셋 의도를 세운다(
handlePlaylistApplied— 큐 invalidate +queueResetPending+setIndex(0)+queueEpochbump). invalidate 만 하면 새 PL 큐에 지금 곡이 들어 있을 때(공용 라이브러리라 흔하다) 재매핑이 걸려 곡이 그대로 이어져, 점장 눈에는 “적용했는데 아무 일도 안 일어남”으로 보인다. 명시적 사용자 의도는 [재시작]과 같은 계약(0번부터)을 따른다. 해제(본사 기본으로 되돌리기)도 같은 콜백을 탄다.
재생 실패 처리 — onError · play() reject 원인 분기 (2026-07 감사 #1)
깨진 트랙 1개(404/403/디코드 실패)로 플레이어가 영구 사망하던 경로를 막는다. 종전엔 메인
<audio> 에 onError 핸들러가 아예 없었고 play() reject 는 전부 setPlaying(false) 로 삼켜져
화면에 아무 표시도 남지 않았다.
play() reject 의 원인 분기는 공용 판정기 classifyPlayRejection(error, el) 하나로 한다 — 메인
음악 트랙과 크로스페이드 incoming 트랙이 같은 함정을 공유하기 때문이다(둘 다 “reject = 트랙
결함”으로 단정하면 안 된다).
| 입력 | 판정 | 처리 |
|---|---|---|
메인 <audio> onError | — | 그 곡의 musicId 를 failedMusicRef 에 마킹(안내방송 announcementRetryAtRef 쿨다운의 음악판) → 아직 실패하지 않은 다음 곡으로 스킵. 진행 중 크로스페이드는 먼저 abort. |
play() reject — NotAllowedError | autoplay-blocked | 브라우저 자동재생 정책. 곡 문제가 아니므로 실패 마킹하지 않고, 시작 오버레이를 다시 띄워(started=false) 사용자 제스처를 재요청한다. 오버레이에 사유 문구(store-player-autoplay-blocked)를 띄우고 버튼 라벨은 “계속 재생”이 된다. |
play() reject — AbortError, 그리고 el.error(MediaError)가 없는 그 밖의 원인 | superseded | 무시한다. 그 play() 요청이 pause() 나 새 load() 로 대체됐다는 신호일 뿐 트랙 결함이 아니다 — 곡 전환 직후 [일시정지], 로드 중 src 재변경([다음] 연타), 크로스페이드 abort 시 incoming pause() 가 전부 여기 해당한다. 미디어 오류로 오분류하면 멀쩡한 곡이 스킵되고, 반복되면 “전곡 실패” 오판으로 배너 + backoff 무음이 된다. |
play() reject — NotSupportedError 또는 el.error != null | media-error | 이 트랙을 재생할 수 없다. onError 와 동일 경로(실패 마킹 + 스킵)로 처리한다. |
- stale run 가드: 재생 제어 effect 는 실행마다 세대 토큰(
playRunRef)을 올리고, reject 콜백은 토큰이 그대로일 때만 실패 처리를 한다. reject 는 비동기라 그 사이 곡이 바뀌었을 수 있는데 (실패 처리는 최신 참조handleMusicErrorRef를 부른다), 가드가 없으면 아직 시도조차 안 한 다음 곡이 실패로 마킹된다. - 이중 발화 dedupe: 404/디코드 실패는 엘리먼트
error이벤트와play()reject 가 둘 다 발화하는 게 정상이다.handleMusicError는 이미failedMusicRef에 있는 곡의 재진입을 early-return 해 연속 실패 카운터가 1회 실패당 2씩 오르지 않게 한다(그대로 두면 큐 길이의 절반만 깨져도 “전곡 실패”로 오판한다). - 전곡 실패 = 무한 루프 방지: 연속 실패 카운터가 큐 길이에 도달하면 스킵을 멈추고
danger 배너(
store-player-music-error, [다시 시도] 액션 포함)를 노출한 뒤 backoff 후queueQuery.refetch()+queueEpochbump 로 자가복구를 시도한다. backoff 는 지수다 —MUSIC_FAILURE_RETRY_BASE_MS(15초)에서 시작해 실패가 반복될 때마다 2배(30s·60s…),MUSIC_FAILURE_RETRY_MAX_MS(5분)에서 멈춘다. 고정 간격이면 큐 전체가 깨진 매장(컨테이너 권한 오설정 등) 한 대가 무기한 수십 rps 를 유지한다. - 자가복구: 한 곡이라도
loadedmetadata에 성공하면 연속 실패 카운터·실패 마킹·배너·backoff 타이머·backoff 간격을 모두 리셋한다(일시적 네트워크 장애가 영구 차단으로 굳지 않게). [음악이 이상하면 재시작] 버튼도 같은 리셋을 수행한다. - 크로스페이드 incoming 도 같은 판정기를 쓴다.
superseded면 무시하고(=abortCrossfade()가 incoming 을pause()한 결과 — 취소할 때마다 곡이 한 번 더 넘어가던 문제), 그 외에는 크로스페이드를 포기하고 메인 볼륨을 원복한 뒤 기존 gapless advance 로 fallback 한다.
자동재생 시작 게이트 (started · 시작 오버레이)
브라우저 자동재생 정책상 오디오는 사용자 제스처 후에만 재생 가능하므로, 진입 직후엔
started=false 로 두고 전체 화면 dim 오버레이(store-player-start-overlay) + 중앙 [시작하기]
버튼(store-player-start-button)을 띄운다(daiso demo-template started 미러). started 전에는
음악도 방송 오버레이도 재생하지 않는다(방송 due effect 도 started 게이트를 통과해야 한다).
- 매장 정보 로딩 중엔 spinner + “불러오는 중…”, 준비되면 브랜드 마크 + 매장명 + [시작하기].
- 자동 안내방송 토글(
store-player-start-tts-toggle, 기본 ON): OFF 로 시작하면ttsMuted=true로 시작해 방송 오버레이 소리만 0 이 된다(재생·ack·소비는 그대로 — pending 이 쌓이지 않는다). 시작 후에는 방송 카드의 뮤트 버튼으로 계속 토글할 수 있다. - backlog drain: 시작 시점에 남아 있는 비-긴급 pending 은 “늦게 진입해 밀린 방송”으로 보고
오디오 재생 없이 전부 ack(
outcome=SKIPPED) 하고finishedAnnouncementsRef에 넣어 로컬 재선출까지 차단한다(예: 10시 예약을 13시에 진입해 몰아 듣는 상황 방지). BE 는 SKIPPED 를 받으면 즉시 MISSED(missedReason=SKIPPED)로 종결하므로, 재생된 적 없는 방송이 본사 리포트에 “정상 송출” 로 잡히지 않는다(감사 #11 — 종전엔 PLAYED 로 기록됐다). 이후 도착하는 dispatch 는 새 id 라 정상 재생된다. drain 직후 pending 을 즉시 재폴링해 20초 대기를 건너뛴다. - 긴급(
isEmergency)은 drain 대상에서 제외하고 시작 직후 재생한다(감사 #11 결정). 안전 관련 안내가 소리 없이 폐기되는 쪽이, 최대 10분 지난 긴급이 나가는 쪽보다 나쁘기 때문이다. BE grace 가 10분이라 그보다 오래된 긴급은 애초에 pending 에 없다(EXPIRED 로 종결). 제외된 긴급은 종착 집합에도 들어가지 않으므로backlogDrained직후 긴급 due effect 가 그대로 이어받는다. - drain 시점 = pending 의 첫 성공 응답(전용 effect · 클릭 핸들러 아님): [시작하기] 를 누른
순간
pendingQuery가 아직 로딩 중이면 비울 목록을 알 수 없다. 종전엔 클릭 핸들러에서 즉시 drain 해, 그 경우 빈 배열로 no-op drain 이 “완료” 처리되고 1회성 가드가 닫혀 영구히 drain 불가가 됐다(그 뒤 폴링으로 들어온 backlog 가 전부 순차 재생 — drain 이 막으려던 시나리오). 이제 첫 성공 응답까지 기다렸다가 그 목록을 비운다. - drain 완료 전에는 방송을 재생하지 않는다(
broadcastsReady = started && backlogDrained): 기다리는 동안 도착한 backlog 가 재생돼버리면 drain 이 무의미하다. 방송 due effect 2종(긴급· 일반)이 이 게이트를 통과해야 오버레이를 띄운다. - drain 은 최초 시작 1회만(
drainedBacklogRef): 자동재생 정책(NotAllowedError)으로 오버레이가 다시 뜬 뒤의 재시작에서는 drain 하지 않는다 — 그 사이 도착한 정상 pending 방송까지 재생 없이 소비돼 방송이 소실되기 때문이다. 재시작은 재생 재개만 한다.
backlog drain — 준비 게이트
drain 은 되돌릴 수 없는 폐기(ack SKIPPED = 즉시 MISSED 종착)라, 두 전제가 서기 전에 돌면 방송이
매장 단위로 사라진다. 그래서 drainReady = (기기 등록 확정 && 서버시각 동기) || 5초 상한 만료 를
통과해야 진행한다(DRAIN_READY_MAX_WAIT_MS = 5_000).
| 전제 | 안 서면 |
|---|---|
기기 등록 확정(registrationSettled — 성공·상한 초과·저장소 차단·재시도 예산 소진) | deviceId 없는 SKIPPED ack 이 나가 BE 가 매장 단위 레거시로 처리 → dispatch 가 종착해 이미 등록된 다른 PC 들도 그 방송을 못 받는다. “등록은 수백 ms”라는 가정이 깨지는 상황(콜드스타트)이 재시도 로직을 넣은 이유 그 자체다 |
서버시각 동기(serverClockSynced) | 클라 벽시계가 앞선 PC 가 “곧 나올 방송”을 도래로 오판해 소리 없이 폐기 |
- 영구 대기는 더 나쁘다 — drain 이 안 돌면
backlogDrained가 서지 않아 방송 재생 자체가broadcastsReady에 막힌다(무인 매장 무음). 그래서 5초 상한 뒤에는 폴백으로 진행한다. - 비울 후보(비-긴급)가 하나도 없으면 기다리지 않는다 — ack 이 한 건도 나가지 않아 잃을 게 없는데,
기다리면
backlogDrained만 늦어져 시작 직후 도착한 정상 방송이 그만큼 늦게 나온다. 게이트는 “되돌릴 수 없는 폐기”에만 건다. - 폐기 판정은
announcementDue(fail-open)가 아니라 fail-closed 인announcementDrainable로 한다 — 서버 시각이 미동기인 채(상한 폴백) 여기 닿으면playAt게이트가 그 지점에서 무력화되기 때문이다. 미동기면 버리지 않는다(버리지 않은 방송은 pending 에 남아 due effect 가 이어받아 “늦게라도 재생”).
방송 동시재생 — 더킹 오버레이 (SPEC #141 · 본사 송출 수신)
음악은 항상 연속 재생(메인 <audio> + 크로스페이드 A/B). 모든 방송(긴급/일반 안내방송·CM)은
음악을 정지하지 않고 더킹(낮춤)한 뒤 별도 오버레이 <audio data-testid="store-player-overlay-audio">
로 그 위에 풀볼륨으로 동시 재생한다(daiso use-tts-playback 패턴). 끝나면 음악을 100% 복원한다.
- 트랙 분리: 메인 audio(
store-player-audio)의 src 는 음악 전용(musicSrc= 현재 곡). 방송은 더 이상 메인 src 를 교체하지 않고 오버레이 트랙(overlaySrc= 안내방송 ?? CM audioUrl)으로만 흐른다. - 폴링:
useListStorePendingAnnouncements를refetchInterval(SSE 연결 중 60초 / 미연결 20초 — 아래 §실시간 통지) +refetchIntervalInBackground: true로 폴링한다. 재생 큐와 독립. 본사 [송출] 직후 매장당 PENDING row 가 fan-out 되면 SSE 신호(즉시) 또는 다음 폴링에 잡힌다. 백그라운드 플래그의 이유는 아래 §백그라운드 폴링. SPEC #178: 기기 등록이 됐으면 query 에deviceId를 실어 이 기기가 아직 재생하지 않은 방송 (최근 5분 내 PLAYED 포함)까지 받는다 — 먼저 폴링한 PC 가 20초 안에 재생을 마치고 ack 하면 dispatch 가 PLAYED 로 종착해 늦게 폴링한 PC 는 그 방송을 영영 못 보던 문제를 닫는다. 이 기기가 이미 ack 한 송출은 제외된다(중복 재생 방지). playAt게이트 (SPEC #178): pending item 의 절대 재생 시각을 서버 시각 기준으로 판정해, 도래 전이면 재생 후보에서 뺀다(긴급 포함 — 긴급이야말로 전 기기가 동시에 나와야 한다). 도래 시각에 타이머로 깨워 폴링 tick 을 기다리지 않는다.playAt이 없거나(점장 즉시방송) 과거면 즉시 재생. 시작 시점 backlog drain 도 도래 전 방송은 폐기하지 않는다. 자세한 계약은 방송.- 미동기 시 fail-open/fail-closed 분리 — 서버 시각이 한 번도 동기되지 않았으면 재생 게이트
(
announcementDue)는 열고(어긋나 나오는 것보다 안 나오는 게 나쁘다 · BE 는 10분 뒤 MISSED 로 종결한다), 폐기 판정(announcementDrainable)은 닫는다(오판하면 영구 소실). - 클램프된 타이머는 발화 후 다시 무장된다 —
playAtTick을 deps 에 명시한다. 종전엔 남은 후보의earliestFuturePlayAt이 같은 숫자라 deps 가 불변이어서 재무장되지 않았고, 그 뒤 도래는 폴링이 우연히 잡아 최대 20초 늦게 재생됐다(이 게이트가 없애려던 편차 그대로). - 앵커 재고정 직후 즉시 재판정 —
serverClockVersion이 판정기·후보 memo 의 deps 에 들어가 있어, 절전 복귀처럼 앵커가 크게 틀어졌던 경우에도 이미 도래한 방송이 다음 폴링까지 묻히지 않는다.
- 미동기 시 fail-open/fail-closed 분리 — 서버 시각이 한 번도 동기되지 않았으면 재생 게이트
(
- 점장 즉시방송에는
deviceId를 실어 보낸다 — 즉시 송출 모드에서만(예약은 전 기기 대상이라 싣지 않는다). 안 실으면 서버가 전 기기 대상으로 처리해 카운터에서 누른 즉석 안내가 매장 전체 PC 에서 시차를 두고 반복 재생된다. - SSE 가속 + 폴링 안전망 이중화(SPEC #180): 아래 §실시간 통지 참고. 통지가 닿지 않는 기기도
playAt게이트 덕분에 재생 시각 자체는 어긋나지 않는다(리드타임 안에 신호를 못 받은 기기는 도착 즉시 재생 → 그 기기만 늦는다). - due 시 즉시(곡 끝 대기 없음): pending 이 있으면 due effect 가 곧바로 음악을 더킹하고 오버레이를 재생한다. 안내방송 후보는 가장 오래된(목록 순서, created_at ASC) row. 음악 큐가 비어 있어도(곡 없음) 방송은 그대로 오버레이로 재생된다(더킹은 깔 곡이 없으면 비활성).
- 우선순위: 긴급 안내방송 > 일반 안내방송 > CM. 오버레이 재생 중 긴급 도착 시 현재 오버레이 즉시
교체(preempt). preempt 된 비-긴급 안내방송은 ack 하지 않아 pending 에 남고(긴급 후 재선출),
CM 은 폐기된다(
activeCommercial=null). 이미 다른 긴급이 활성이면 그 종료를 기다려 순차 진입한다(D4 다중 긴급·D6 인터럽트 중 추가 도착 — pendingItems 폴링 결과 자체가 큐 역할). - 종료/ack: 오버레이
onEnded(또는onError) → 안내방송이면useAckStoreAnnouncement({ dispatchId, data: { outcome } })→ pending invalidate → 활성 방송 해제 → 음악 더킹 복원(다음 대기 방송이 있으면 due effect 가 즉시 이어받음). CM 이면 ack 없이 카운터 리셋 + 해제. ack 은 BE 원자 조건부 UPDATE(WHERE status='PENDING')라 첫 ack(PENDING)만 204, 중복·이미 종착·미존재는 404DISPATCH_NOT_FOUND(상태·존재 은닉) — 효과는 멱등이나 응답은 404다. player 는 404 도 “이미 소비됨”으로 흡수하고, 오버레이 에러·404 어느 쪽이든 ack 후 해제해 무한 멈춤을 막는다. 레이스 가드 3중: (a)ackingRef동일-tick 가드(onEnded·onError동시 발화 시 두 번째 호출 차단) → (b) ack settle(onSettled)에서ackingRef리셋 + pending invalidate(차단 마킹은 삭제하지 않음 — 아래) → (c) 활성 announcement/dispatchId 일치 검사(해제 후 도착하는 늦은 이벤트 차단). (b) 에는 활성 방송이 바뀌는 시점의 리셋이 하나 더 있다(useEffect([activeAnnouncement?.dispatchId])).ackingRef는 “한 번의 재생 시도” 범위 가드인데 해제가 ack settle 에만 달려 있으면,apiFetch에 timeout 이 없어 ack POST 가 쿨다운(25초)보다 오래 정지할 때 재전달분이 재선출·재생된 뒤 종료 처리가 early-return 해activeAnnouncement가 영구 non-null 로 굳는다(더킹 고정 + 이후 모든 방송 차단, 오버레이는ended라 워치독도 개입하지 않음). 새 재생 시도 시작 시 리셋하면 그 창이 사라지고, 늦게 settle 되는 이전 ack 의onSettled는 다시null을 쓸 뿐이라 무해하다.
ack outcome — 재생 결과 보고와 서버 주도 재시도 (2026-07 감사 #11)
FE 는 자체 재시도 로직을 두지 않는다. 실패할 때마다 그대로 { outcome: "FAILED" } 를 보고하면
서버가 재전달 여부를 결정한다(BE 소유 정책 — 3회 미만이면 PENDING 유지, 3회째에
MISSED/PLAYBACK_FAILED 종결). 응답은 종착이든 재전달 대기든 동일한 204 라 클라이언트가 상태를
판단할 수단도 없고, 그럴 필요도 없다 — 다음 폴링에 다시 내려오면 재생하면 된다.
| player 경로 | outcome | 서버 결과 |
|---|---|---|
오버레이 onEnded(정상 종료) | PLAYED | PENDING→PLAYED(생략해도 같지만 의도를 코드에 드러내려 명시 전송) |
오버레이 onError · 오버레이 play() reject(autoplay-blocked·media-error) · 워치독 강제 종료 | FAILED | 실패 횟수 +1. 3회 미만이면 PENDING 유지 → 재전달 · 3회째 MISSED(PLAYBACK_FAILED) |
| 시작 시 backlog drain(비-긴급) | SKIPPED | 즉시 MISSED(SKIPPED) — 재시도 대상 아님 |
오버레이 play() reject 중 superseded(긴급 preempt 로 src 교체 등) | ack 없음 | pending 유지 — preempt 계약대로 긴급 종료 후 재선출 |
기기별 ack (SPEC #178) — 기기 등록이 됐으면 ack body 에
deviceId를 함께 싣는다. 종전엔 ack 가 매장 단위 원자 조건부 UPDATE 라 한 매장의 여러 PC 가 같은 방송을 재생하면 첫 기기만 204 를 받고 나머지는 404 였다(다 재생했는데 이력엔 1건). 이제 결과가dispatch_device_ack에 기기별로 남고, 한 대라도PLAYED면 그 방송은 매장에 들린 것으로 종착한다(이미 다른 기기가 PLAYED 로 바꿔놔도 404 를 주지 않는다 — 404 면 FE 가 실패로 오인해 재시도·오보고한다). 실패 경로는 활성 기기 전부가FAILED/SKIPPED를 보고했을 때만 탄다 — 꺼져 있는 PC 한 대 때문에 방송이 “미도달” 로 잡히지 않는다. 미등록 기기는 종전 매장 단위 ack 그대로다.
오버레이
play()reject 는 음악 메인 트랙과 같은 판정기(classifyPlayRejection)로 3분기한다. 원인 구분 없이 흡수하면 (a) 소리가 한 번도 나지 않은 실패가 기본 outcomePLAYED로 보고되어 본사 리포트가 허위가 되고 BE 의 실패 누적·재전달이 지워지며 (b) 긴급 preempt 로 대체된 직전play()의AbortError까지 “정상 재생” 으로 처리되어 preempt 된 비-긴급이 ack 되고 소실된다. 늦게 도착한 rejection 이 그 사이 시작된 다음 방송을 오염시키지 않도록, 대상 dispatchId/CM id 는 재생 시도 시점 값을 클로저에 고정해 넘긴다(음악 트랙의playRunRef세대 가드와 같은 의도).
⚠️ 같은 dispatchId 가 다시 내려올 수 있다. 클라이언트에서 영구 블랙리스트로 처리하지 말 것.
FAILED로 보고해도 임계치 미만이면 그 row 는 pending 에 그대로 남아 다음 폴링에 재전달된다. 실패한 id 를 영구 차단하면 (a) 후보 선출 단계에서 재전달분이 전부 걸러지고 (b) 그 id 는 pending 에서 사라지지 않으니 prune effect 도 돌지 않아 세션 내내 풀리지 않는다 = BE 재시도가 통째로 무력화된다.
- 종착 vs 실패 분리:
finishedAnnouncementsRef(영구 차단)에는 서버에서 종착이 확정된 경로만 넣는다 — 정상 재생(PLAYED) · drain 폐기(SKIPPED) · 본사 revoke(이미 CANCELED). 종착은 서버가 그 row 를 다시 내려주지 않으므로 영구 차단이 안전하다. - 실패는 쿨다운:
FAILED는announcementRetryAtRef(dispatchId → 재시도 가능 시각) 에 25초 (ANNOUNCEMENT_FAILURE_RETRY_COOLDOWN_MS) 쿨다운만 건다. 시각은 단조 증가performance.now()기준이다(set·만료 타이머·후보 필터가 모두 클라이언트 로컬이라 서버 시각과 대조할 일이 없다 — 벽시계가 뒤로 점프하면 쿨다운이 물리 시간보다 길어져 BE 재전달분이 grace 10분 안에 재생되지 못할 수 있다). 이 값은 pending 폴링 주기(20초)보다 조금 길어 BE 가 임계치 3회의 근거로 삼은 “서로 다른 폴링 tick 에 분산” 을 만족시키고, 3회를 다 써도 60초 남짓이라 grace(10분) 안에 여유 있게 종결된다. 쿨다운이 없으면 ack invalidate 로 즉시 refetch → 같은 row 재선출 → 또 실패 의 busy-loop 가 되고 BE 가 허용한 3회가 몇 초 만에 소진된다. - 쿨다운 만료 깨우기: 쿨다운은 시각 비교라 시간이 흐른다고 메모가 저절로 재계산되지 않는다. 가장
이른 만료 시점에
blockVersion을 bump 하는 타이머 effect 가nextPlayable*를 깨워 그 dispatchId 를 후보로 복귀시킨다. 만료 대상이 없으면 타이머를 걸지 않아 bump 루프도 생기지 않는다. - FE 실패 카운터 없음: 몇 번째 실패인지, 언제 포기할지는 전부 BE 판정이다. FE 가 카운트를 흉내 내면 BE 임계치와 이중 관리가 되어 어긋난다.
- 한 dispatch 는 1회만 재생(무한 반복 가드): 종착한 dispatchId 는
finishedAnnouncementsRef에 영구 마킹하고 ack 성공/실패·refetch 지연과 무관하게 같은 id 를 다시 재선출하지 않는다. ackonSettled에서 이 집합을 delete 하지 않는 것이 핵심이다 — invalidate refetch 는 비동기라, delete 직후~refetch 완료 전 stale pending 에 같은 항목이 남아 있으면nextPlayable*가 재선출 → due effect 재생 → onEnded → 또 finish → 또 재선출 의 무한 루프가 돌았다(ack 가 서버에서 실패/지연해도 동일). 마킹 시blockVersion(state) 을 bump 해nextPlayable*메모를 즉시 재계산(차단된 id 를 후보에서 뺀다 — ref 만으론 메모가 stale). 집합 무한 증가는 prune effect 가 막는다: pending 이 갱신될 때마다 현재 pending 에 더 이상 없는 id 를 종착 집합·쿨다운 Map 에서 정리한다(pending 에서 빠지면 어차피 재선출 불가 → 안전). 단 실패 쿨다운의 만료는 prune 에 기대면 안 된다 — 재전달되는 동안 그 id 는 pending 에 계속 남아 prune 이 영영 돌지 않기 때문이다(위 타이머 effect 담당). 새 예약은 새 dispatchId 라 키가 달라 정상 재생된다. - 음악 절대 정지 안 함: 위치보존 resume·긴급 full-stop·activeSrc 방송 교체·곡경계 안내방송 삽입은
전부 제거됐다(SPEC #141 — 의도된 모델 변경).
handleMusicEnded는 음악 진행(advance)+CM 카운트만 한다.
실시간 통지 — SSE 가속 + 폴링 안전망 이중화 (SPEC #180)
본사 즉시방송이 폴링 주기만큼(최대 20초) 늦게 도착하던 지점을 실시간 통지 채널로 앞당긴다. 동시에 매장 API 호출의 지배 항목이던 20초 폴링을, 통지가 살아 있는 동안만 60초로 완화한다.
연결 경로 — 브라우저 → 백엔드 직접 + 단기 티켓 (BFF 스트리밍 프록시가 아니다)
POST /api/v1/store/announcements/stream-ticket(BFF 경유 · Bearer 인증) →{ ticket, streamUrl, expiresInSeconds }.new EventSource(streamUrl)—streamUrl은 티켓까지 포함된 완전한 절대 URL 이라 그대로 넘긴다. FE 가 백엔드 base URL 을 조립하지 않는다(프록시 경로 계약 어긋남 재발 방지).- 구독 직후
connected이벤트 1회 → 여기서만 “연결됨” 을 확정한다. 이후announcement(신호 전용) 가 오면 pending·scheduled 를 invalidate 한다 — 본문 계약은 pending endpoint 하나로 유지된다.
BFF 스트리밍 라우트를 쓰지 않는 이유: Vercel maxDuration 에 스트리밍이 포함돼 5~13분마다 전 기기가
강제 재연결되고, in-flight 스트림이 Provisioned Memory 를 상시 과금시켜 비용 절감이 동기인 변경이
상시 비용을 새로 만든다. 발급만 기존 BFF 경로라 세션 스코프·refresh 불변식은 그대로다.
폴링은 안전망으로 남는다 — 완화는 느리게, 복귀는 즉시
- 연결이 연속 90초 확정된 뒤에만
pending·scheduled를 60초로 완화하고(히스테리시스), 끊기거나 티켓 발급이 실패하면 즉시 20초로 복귀한다. 60초 최악 지연도 BE MISSED grace(10분) 대비 마진이 10배다. - 히스테리시스가 없으면 안 되는 이유: react-query 는
refetchInterval값이 바뀔 때마다 인터벌을 clear 하고 0 부터 재무장한다(query-corequeryObserver:setOptions의 값 비교 →#updateRefetchInterval,onQueryUpdate→#updateTimers). 프록시 유휴 컷처럼 60초 미만 주기로 연결이 플래핑하면60s 무장 → 컷 → 20s 무장 → 재연결 → 60s 재무장 …이 반복돼 폴링이 단 한 번도 발화하지 못한다. 무인 매장은 ack·탭 복귀 트리거도 없어 그 굶주림이 그대로 방송 소실이 된다. 플래핑 구간에서는 값이 20초에 고정되므로 타이머 리셋 자체가 사라진다. - 절대 신선도 바닥(2겹째): 5초마다 pending 쿼리의 마지막 갱신 시각(
dataUpdatedAt/errorUpdatedAt) 을 직접 보고, 그 시점에 의도한 주기 + 10초를 넘겼으면 강제로 invalidate 한다. 타이머 리셋의 종류와 무관하게 성립하는 불변식이다. 이미 요청이 나가 있으면(fetchStatus !== "idle") 건드리지 않고, 에러 응답도 시각을 갱신하므로 백엔드 장애 중 재시도가 쏟아지지 않는다.
재연결(use-announcement-stream.ts)
EventSource내장 자동 재연결은 쓰지 않는다 — 만료된 티켓 URL 을 그대로 재사용하기 때문이다.onerror에서close()→ 티켓 재발급 → 새EventSource, 지수 backoff + jitter(2초~60초, ±25%).- 영속 실패는 상한이 5분이다. 티켓 발급 실패(시크릿·base URL 미구성 503)뿐 아니라 발급은 200 인데
연결이 확정되지 않는 경우(CORS
allowed-origins누락이 대표)도 포함한다 — 후자는connected를 한 번도 못 받은 실패가 5회를 넘으면 승격된다. 60초 상한으로 두면 24/7 단말이 하루 1,440회를 mint 해 이 SPEC 이 줄이려던 BFF 호출을 도로 만든다. 한 번이라도 붙었던 뒤의 단절은 일시적일 확률이 높아 종전 60초 상한을 유지한다. - 티켓 mint 요청 상한 10초(
AbortController+setTimeout).apiFetch에는 timeout 이 없어, mint 가 hang 하면 in-flight 가드가 true 로 굳고 재연결 스케줄도 걸리지 않아 재시도 경로가 통째로 사라진다 (24/7 언마운트되지 않는 player 에서는 브라우저 재시작까지 SSE 사망). abort 는 기존 catch → backoff 재연결로 수렴한다. - 연결 확정 워치독 15초 — 티켓 mint 후
connected가 오지 않으면 프록시 버퍼링·행으로 보고 재연결. - 선제 재연결 10분(±10%) · make-before-break — 살아 있는 커넥션도 주기적으로 갈아끼운다. BE
keepalive(
: ping25초)는 SSE comment 라 브라우저가 어떤 JS 핸들러로도 노출하지 않아 “50초 무신호” 를 이벤트로 관측할 수 없다. 이벤트 기반 무신호 워치독을 두면 방송이 없는 평상시에 정상 커넥션을 계속 끊는 재연결 폭풍이 된다. 대신 주기적 갱신으로 “조용히 죽은” 커넥션을 결정적으로 걷어낸다. 갱신은 새 커넥션이connected를 받은 뒤에 옛 것을 닫는다 — 먼저 닫으면 10분마다connected=false플립이 생겨 폴링 타이머가 리셋되고 그 공백에 발행된 신호가 유실된다. 새 쪽이 실패하면 둘 다 닫고 미연결로 떨어진다(폴링 20초 복귀). - 재연결 resync — BE 통지는 fire-and-forget 이라 재전송이 없다. 단절 창(backoff 2~60초)에 발행된 즉시방송·예약 전이·revoke 는 SSE 로 영영 오지 않으므로, 끊겼다 다시 붙는 순간 pending·scheduled 를 1회 invalidate 한다. 첫 연결과 make-before-break 갱신은 단절이 없으므로 제외된다.
- 탭 복귀·
online시 미연결이면 backoff 를 기다리지 않고 1회 즉시 시도한다(throttle =max(5초, 현재 backoff)— 고정 5초면 불안정 AP 의online/offline연타가 backoff 를 매번 무효화해 영속 실패에서도 시간당 720회 mint 가 나간다). 경과 판정은 단조 증가performance.now()기준이다. - 기기(탭)당 EventSource 1개 — 훅은 player 한 곳에서만 마운트한다.
fail-open 불변식: 티켓 발급 실패(401/429/503)·CORS 차단·EventSource 미지원·회선 단절은 전부
조용히 미연결로 수렴한다(throw 없음·배너 없음). SSE 훅의 어떤 실패도 (1) 재생을 막지 않고
(2) 폴링을 20초보다 늦추지 않으며 (3) 기존 due/ack 파이프라인을 우회하지 않는다.
계측(ack 옵션 3필드) — signalSource(SSE | POLLING) · receivedAt(신호 수신 시각) ·
startedAt(실제 재생 시작 시각, 오버레이 playing). 시각은 서버 시각 보정값(SPEC #171 D6 의 큐
응답 앵커 재사용)의 ISO-8601 이며, 앵커가 한 번도 동기되지 않았으면 시각을 아예 싣지 않는다(벽시계
폴백값과 보정값이 섞이면 두 값의 차가 무의미해진다 — 채널 라벨은 시계와 무관하므로 그대로 보고).
announcement 이벤트에는 dispatchId 가 없으므로(신호 전용 계약) 귀속은 시각 대조로 한다. SSE 로
치는 조건은 셋을 모두 만족할 때다:
- 신호 이후 5초 창 안이고,
- 그 pending 응답이 신호보다 늦게 완료됐고(
dataUpdatedAt대조), - 그 응답에서 처음 관측된 항목이다(관측에 성공하면 신호를 소비해, 한 신호가 이후 응답까지 물들이지 않게 한다).
(2)·(3) 이 없으면 신호 직후 우연히 겹친 폴링 tick 이 실어 온 항목(발행 후 최대 60초 지난 것)까지 SSE 로
기록돼, 하루 송출이 수 건인 매장에서는 오염 표본 1건이 p95 를 지배한다. SSE 귀속분의 receivedAt 은
effect 실행 시점이 아니라 신호 시점의 서버 시각(단조 시계 경과를 역산)이다 — 왕복 지연만큼의 체계
편향을 없앤다. 계측은 dispatch 당 1회만 싣는다(재전달 2·3회째 ack 에서 같은 표본이 중복 집계되지
않게). backlog drain 의 SKIPPED ack 는 의도적으로 계측을 싣지 않는다 — 시작 시점에 밀려 있던
방송의 “수신 시각” 은 발행→수신 지연이 아니라서 p95 를 통째로 망친다.
전부 선택 필드이고 BE 가 형식 오류를 400 이 아니라 미보고로 흡수하므로, 계측이 재생 결과 보고를 실패시킬 수 없다.
백그라운드 폴링 — 탭 hidden 시 방송 소실 방지 (2026-07 감사 #4)
방송 두 쿼리(pending·scheduled)는 refetchInterval 과 함께 refetchIntervalInBackground: true
를 지정한다. react-query 의 기본값은 false 이고, 그 경우 query-core 가
refetchIntervalInBackground || focusManager.isFocused() 로 분기해 document.visibilityState === "hidden"
인 동안 폴링 tick 을 통째로 건너뛴다. 전역 설정이 refetchOnWindowFocus: false 라 복귀해도 즉시
따라잡지 못하고 다음 tick(최대 20초)을 더 기다렸다.
BE 는 grace 10분을 넘긴 PENDING 을 MISSED(종착 상태) 로 전이시키므로, 무인 매장 단말의 탭이 10분만 hidden 이어도 그 사이 도착한 방송이 영구 소실됐다. 안내방송은 화면 가시성과 무관한 송출 백본이므로 백그라운드에서도 폴링을 유지한다.
- 보강으로
visibilitychange복귀 시 1회 즉시 invalidate 한다(pending + scheduled). 절전 브라우저·OS 가 백그라운드 타이머를 분 단위로 스로틀할 수 있어, 복귀 순간의 목록이 뒤처져 있을 수 있기 때문이다. - 같은 화면의 CS “새 답변” dot(
useGetStoreSupportUnreadSignal)은 반대로refetchIntervalInBackground: false를 명시한다 — 화면을 봐야 의미 있는 표시라 백그라운드에서 돌릴 이유가 없다(의도를 코드에 남긴 것).
오버레이 워치독 — stall(진행 정지)한 방송만 강제 종료 (2026-07 감사 #8)
오버레이 <audio> 의 종료 신호는 onEnded/onError 뿐이다. blob 응답이 hang 하면(연결은 됐는데
바이트가 오지 않는 경우) 둘 다 발화하지 않아 activeAnnouncement(또는 activeCommercial)가 영구
non-null 로 남았다. 결과: (1) 음악이 더킹 볼륨에 영구 고정, (2) 이후 모든 방송이 due effect 의
if (activeAnnouncement) return 가드에 막혀 불통. 서버가 10분 뒤 MISSED 로 전이시켜도 FE 상태는
풀리지 않아 본사 revoke 전까지 복구 불가였다.
판정 기준은 총 길이가 아니라 재생 진행(progress) 이다. 오버레이의 playing·timeupdate 가
“마지막 진행 관측 시각”(overlayProgressAtRef)을 갱신하고, 워치독은 5초 주기로 그 시각 이후 경과만
확인해 25초(STALL_TIMEOUT) 무진행이면 강제 종료한다. durationSeconds 는 보지 않는다.
시각 기준은 단조 증가하는 performance.now() 다(무장 시각·진행 마킹·tick 전부 같은 시계).
매장 단말은 24/7 켜져 있어 NTP 보정·수동 시각 변경이 실제로 일어나는데, 벽시계(Date.now())로
경과를 재면 시계가 앞으로 점프한 순간 정상 재생 중인 방송이 stall·절대 상한으로 강제 종료되고
(강제 종료는 FAILED ack + 쿨다운이라 BE 재전달 대상이 되지만, 소리가 정상적으로 나던 방송이
본사 이력에 실패로 기록되고 재전달도 3회로 제한되므로 오탐 비용은 여전히 크다), 뒤로 점프하면
진짜 hang 을 그만큼 늦게 잡는다.
여기서 재는 것은 절대 시각이 아니라 경과 시간이므로 단조 시계가 옳다(크로스페이드·더킹 램프·
안내방송 실패 쿨다운도 동일).
tick 은 경과만 보지 않는다 — 엘리먼트 상태 두 가지를 먼저 확인하고, 그 어느 것도 아닐 때만 무진행으로 센다:
paused— 재생 자체가 멈춰 있으면 stall(=재생 중인데 바이트가 안 옴)이 아니다. 기준선을 다시 무장한다. 실브라우저는play()즉시paused=false라 잡으려던 hang 은 그대로 잡힌다.currentTime변화 — 직전 tick 대비 진행했으면timeupdate이벤트가 유실돼도 살아 있는 것이다.- 위 둘 다 아니면서 25초 무진행이면 연속 2 tick(CONFIRM_TICKS) 확인 후 종료한다.
이 세 가지가 없으면 단말 절전/suspend·통화 등 OS 오디오 인터럽트로 25초 이상 탭이 멈췄다 돌아온
순간(복귀 tick 이 미디어의 첫 timeupdate 보다 먼저 돈다) 정상 방송이 잘린다 — 아래처럼 강제 종료는
ack(=FAILED) + 쿨다운이라 BE 재전달 대상이 되긴 하나, 소리가 정상적으로 나던 방송이 본사 이력에
실패로 기록되고 재전달 기회도 3회뿐이라 오탐 비용이 크다.
절대 상한 15분(BROADCAST_ABSOLUTE_MAX_MS) — stall 워치독만 두면 timeupdate 가 오는 한 영원히
개입하지 않는데, 본사 CM 은 길이 제한이 코드 어디에도 없다(점장 녹음 30초·TTS 200자와 달리).
잘못 업로드된 장시간 파일 1건이면 더킹 고정 + 이후 모든 방송 차단이 무기한 재현된다. 그래서 stall
감시는 그대로 두고, 무장 이후 15분이 지나면 진행 여부와 무관하게 종료하는 넉넉한 상한을 별도로
둔다(정상 방송은 절대 닿지 않고 최악만 봉인).
- 종전의 총 길이 상한(
durationSeconds + 20s(grace)· 미상이면 60s · 180s clamp)은 정상 재생을 자르는 오탐 경로가 둘 있었다: (1)durationSeconds는 nullable(Typecast 가 줄 때만)인데 방송 본문은@maxLength 1000이라 한국어 3~5분짜리가 정상 등록 가능 → 60초에서 잘림. (2) 180s clamp 가duration + grace에 적용돼 duration 이 정상이어도 ~160초 넘는 방송은 끝나기 전에 종료됐다. - 오탐은 무해하지 않다 — 강제 종료는 그 방송을 그 자리에서 끝내고 실패로 보고한다. stall 기준이면
소리가 흐르는 동안에는 길이와 무관하게 개입하지 않는다(실브라우저
timeupdate는 재생 중 초당 4회 안팎). - 발동 시 로드/재생 실패와 같은 경로로 흡수한다(
handleAnnouncementError→ ack(FAILED) + 쿨다운 + 해제, CM 은handleCommercialError). 더킹은 활성 방송 해제로 자동 복원된다. - outcome 은
FAILED(감사 #11 로 해소된 종전 TODO). 워치독이 잡는 실패 모드는 “바이트가 안 와서 진행이 멈춘 hang” 과 “절대 상한 초과” 이고, 둘 다 재생을 시도했으나 소리가 나지 않은 상태다 — 의미상 재생 실패이지, “재생을 시도조차 않고 폐기”(SKIPPED)가 아니다.FAILED는 즉시 종착이 아니라 BE 가 3회까지 재전달하므로, hang 의 지배적 원인인 blob/CDN 일시 장애라면 재전달로 방송이 실제로 살아난다. 3회째에 MISSED(PLAYBACK_FAILED)로 종결되어 본사에는 “재생 실패로 미도달” 이라는 정확한 사유가 남는다(종전에는 PLAYED 로 기록돼 잘린 방송이 정상 재생으로 보고됐다).
본사 원격 즉시중단(revoke) 수신 (SPEC #077 확장)
본사가 /admin/announcements 송출 이력에서 원격 중단하면 BE 가 그 dispatch 를 PENDING→CANCELED
전이시키고, 점장 GET /api/v1/store/announcements/pending 응답에 revokedDispatchIds(오늘 KST 윈도우
본인 매장에서 본사가 revoke 한 dispatchId 목록)를 함께 내려준다. 전달 채널은 pending 응답이고,
그 응답을 언제 받는가는 SSE 신호(즉시) + 폴링(안전망) 이중화를 따른다 — SPEC #180 D7 로 BE 가
revoke 커밋 후에도 통지하므로, 연결돼 있으면 폴링 tick 을 기다리지 않고 곧바로 반영된다(미연결이면
20초 폴링). player 가 이 신호를 소비한다(revokedDispatchIds 를 useMemo 로 ReadonlySet 화).
- 현재 재생 중 오버레이 즉시 정지: 폴링 갱신마다(
revokedDispatchIds·activeAnnouncement변화) 현재 재생 중 오버레이의 dispatchId 가 revoke 목록에 있으면 즉시 정지한다(stopRevokedAnnouncement).activeAnnouncement를 null 로 만들면overlaySrc가 사라져 오버레이<audio>가 pause·리셋되고,isDucking이 false 가 되어 음악 더킹이 복원된다. 음악은 동시재생 모델이라 정지하지 않으므로 더킹 복원만으로 원음으로 계속 흐른다(곡 src·currentTime 유지). - best-effort — ack 하지 않음: revoke 는 BE 가 이미 CANCELED 로 전이했으므로 ack 는 404 멱등이라 무의미
하다. player 는 로컬 정지만 수행하고 ack 호출을 하지 않는다(
finishAnnouncement의 ack 경로와 다른 점). 무한반복 가드는 동일하게 적용 — 정지한 dispatchId 를finishedAnnouncementsRef에 마킹 +blockVersionbump 으로nextPlayable*메모 즉시 재계산해 재선출을 차단한다. revoke 된 dispatch 는 CANCELED 라 곧 pending 에서 빠지고, 그때 기존 prune effect 가finishedRef에서 정리한다. - pending 후보 선출 제외:
nextPlayableAnnouncement·nextPlayableEmergency메모가revokedDispatchIds에 든 후보를 종착 집합·실패 쿨다운과 함께 선출 제외한다(긴급/일반 공통). 같은 폴링 응답에 revoke 안 된 다른 후보가 있으면 그것은 정상 재생된다(선택적 제외). - CM 은 대상 아님: CM(
activeCommercial)은 dispatchId 가 없어 revoke 대상이 아니다. - 기존 무한반복 가드(
finishedRef·prune·blockVersion)·동시재생·시작 게이트·backlog drain·크로스페이드와 정합한다(중복 정지는activeAnnouncement일치 검사로 가드 — 정지 후 null 이라 재진입해도 no-op).
긴급 안내방송 (danger 톤 · SPEC #090, #082 F1)
PendingAnnouncementItem.isEmergency=true row 도 동일하게 음악 위에 동시 재생한다(긴급도 음악을 멈추지
않는다 — 더킹 + 오버레이). 곡 끝 대기 없이 due 즉시 재생하며 우선순위 최상위(preempt).
- UI 표시 (D3): 본문 상단 Banner danger ”🚨 긴급 안내 재생 중 — 음악 위에서 함께 재생됩니다”(role=alert)
- 커버 아래 방송 카드의 배지를 danger 톤·🚨 prefix 로 노출(카드 테두리·배경도 danger 톤). 다중 긴급 대기 시 Banner 우측 “대기 N건” pill. 우측 컨트롤 컬럼(h1·배지)은 음악 전용이라 긴급 중에도 음악 곡 제목을 유지한다. 일반 컨트롤·볼륨도 그대로 유지(완전 비활성화 X).
- 다중 긴급 순차 처리 (D4):
createdAt ASC순. 첫 row 종료 → ack settle → invalidate → 폴링 응답이 다음 긴급으로 좁아지면 같은 effect 가 즉시 이어받아 두 번째 오버레이로 진입. 다 소진되면 음악 복원. - 오버레이 에러 (D5): 긴급 오버레이
error→ ackoutcome=FAILED(BE 에 재생 실패 보고) +announcementRetryAtRef쿨다운(즉시 재진입 차단, 영구 차단 아님) + Banner danger 노출 + 음악 복원. 배너 문구는 실제 동작에 맞춰 “긴급 안내를 재생하지 못했습니다 — 본사에 실패로 기록됩니다” 이고, 본문에 “잠시 후 자동으로 다시 시도하고, 계속 실패하면 본사 송출 이력에 ‘미재생(재생 실패)‘로 남습니다” 를 덧붙인다(감사 #11 · 문서 §4 — 종전 “본사로 자동 보고됩니다” 는 보고 경로가 없어 허위 카피였다). - 긴급은 backlog drain 대상에서 제외된다(감사 #11). 지난 긴급도 무의미하다고 보고 전부 비우던 종전 정책을 뒤집은 것으로, 시작 직후 그대로 재생된다.
- 종료 마킹 (
finishedAnnouncementsRef): 종착이 확정된 ack(PLAYED·SKIPPED)·revoke 시점에 dispatchId 를 추가하고 삭제하지 않는다(한 dispatch 1회 재생 · 위 “무한 반복 가드” 참조). 긴급도 같은 차단 집합을 쓰므로 동일하게 ack 실패/refetch 지연에도 같은 긴급이 재선출되지 않는다. 정리는 prune effect 가 pending 에서 사라진 뒤 수행한다. 재생 실패(FAILED)는 이 집합에 넣지 않는다 — BE 가 재전달할 수 있으므로 쿨다운으로만 관리한다.
후속(범위 밖): F1 본사 audit 누적(HqAuditAction.STORE_EMERGENCY_BROADCAST_DISPATCHED) · F2 사용 빈도
rate limit · 본사 원격 중단.
재생 상태·로그 보고 — 본사 재생 가시성 (SPEC #172 FE-A)
본사가 “내 매장들이 지금 음악을 틀고 있나”를 볼 수 있게, player 가 재생 상태와 곡 재생 로그를
POST /api/v1/store/playback/report(useReportStorePlayback)로 보고한다. 전담 훅
useStorePlaybackReport(apps/space/src/app/store/use-store-playback-report.ts)가 담당한다. 본사
대시보드는 이 데이터를 재생 상태 카드로
표면화한다(FE-B).
⚠️ 최우선 불변식 — 오디오 파이프라인 무간섭: report 는 fire-and-forget side-channel 이다.
<audio>·크로스페이드·더킹·play()/워치독 등 재생 경로를 절대 await·차단·throw 로 방해하지 않는다. report 실패·네트워크 오류가 재생에 어떤 영향도 주면 안 된다(무인 매장 음악 끊김 방지 = 사용자 핵심 우려). 훅은mutate(비-await)만 호출하고 즉시 반환하며, 방어적try/catch+onError로 모든 예외를 삼킨다. 이 불변식은use-store-playback-report.test.ts의 “report 실패해도 throw 하지 않고 로그를 다음 report 에 재전송한다”(mutate 가 동기 throw 하는 최악 케이스에서도reportSongCompleted가 throw 하지 않음을 단언)로 명시 검증한다.
보고 트리거 (3종):
- 곡 전환/종료 — player 의 곡 identity(
current.musicId)·큐 세대(queueEpoch)가 바뀌면soundingSongRef마커로 직전 곡의 play-log{musicId·libraryId?·startedAt·playedMs}를 방출한다 (reportSongCompleted). 이 로그를 실어 즉시 report + idle 타이머 리셋 → 정상 재생(곡 5분 미만) 중엔 이게 liveness 를 겸해 idle report 가 거의 안 뜬다(트래픽 최소화).playedMs는 곡 시작 이후 wall-clock 경과를 duration 으로 clamp(일시정지 과대·크로스페이드 조기 swap 흡수), 문턱(1초) 미만은 즉시 스킵된 트랙이라 로그하지 않는다.libraryId는 곡 시작 시점의 현재 슬롯 라이브러리. - 상태 전환 — PLAYING↔PAUSED↔SILENT 가 바뀌면 즉시 state report(본사 대시보드 즉응). 상태 산출:
재생 대상(
current) 없음=SILENT · 재생 중=PLAYING · 일시정지=PAUSED(derivePlaybackState). OFFLINE 은 보내지 않는다 — 서버가 마지막 heartbeat staleness 로 파생하는 값이라 보고하면 400PLAYBACK_INVALID_STATE. - idle 5분 타이머 — 곡이 안 바뀐 채(일시정지·무음·아주 긴 곡) 마지막 report 이후 5분이 지나면
state-only report. 모든 report 가 타이머를 리셋하므로 “마지막 신호 이후 경과”를 잰다.
setTimeout기반이라 탭 hidden 백그라운드에서도 발화한다(react-queryrefetchInterval은 hidden 시 tick 을 건너뛰지만 무인 매장은 항상 보고돼야 함 —.claude/rules/frontend.md#17 취지).
play-log 로컬 큐잉·재전송: 네트워크 실패 시 그 report 의 logs 를 로컬 큐 앞으로 되돌려 다음
report 에 합류시킨다(감사 무음 근절 관례 재사용). 큐는 상한 200(BE @maxItems 일치)으로 오래된 것부터
버려 과적재를 막는다. requeue 는 idempotent(onError·catch 이중 발화에도 1회만).
게이트: started(사용자 제스처로 재생 시작) 전엔 보고하지 않는다 — 시작 안 한 무인 단말은 신호가
없어 서버가 OFFLINE 으로 파생한다(그 자체가 본사가 봐야 할 “안 틀고 있음” 신호).
기기 스코프 (SPEC #178): 기기 등록이 됐으면 report body 에 deviceId 를 함께 실어 상태·로그가
기기별로 기록되게 한다(store_device_playback_status 1행 + play_log.device_id). 종전엔
store_playback_status PK 가 store_id 라 마지막에 보고한 PC 가 앞 PC 를 덮어썼고, play_log 는
기기 수만큼 부풀면서 어느 기기 것인지 구분되지 않아 신탁 신고 근거가 왜곡됐다. 미등록 기기는 종전대로
매장 단위 기록이다(하위호환).
⚠️ 이중 적재 가드는 양방향이다. play_log 멱등키가 partial unique 두 개(device_id IS NULL / IS NOT NULL)로 완전히 disjoint 라, 같은 재생이 두 행으로 들어갈 수 있는 경로가 두 방향 있다. 종전엔 한 방향
(구버전 row 가 먼저 있고 기기 보고가 뒤늦게)만 막혀 있었는데, 반대 방향도 실제 경로가 있다 — 기기 D 가
보고한 뒤 응답이 유실돼 FE 로컬 큐에 남고, 그사이 점장이 기기 D 를 회수하면 재전송 시 서버가 무효
deviceId 를 조용히 null 로 접어 매장 단위 경로로 들어가 row 2건 + 롤업 2배 가산이 된다. play_log
2행과 playback_daily_rollup 2배는 신탁 신고 근거이자 월 청구 근거라 정확도가 곧 계약 리스크다. 이제
insertIfAbsentWithoutDevice 에 같은 (store, music, startedAt) 의 기기 row 존재를 보는 대칭 가드가
붙었고(V65 보조 인덱스가 그 조건을 커버한다), 매장 전체가 아니라 같은 키만 보므로 다른 구버전 PC 의
정상 동시 재생은 삼키지 않는다.
BE 적재 계약(멱등·신탁 파생·롤업)은 PlaybackReportRequest DTO· data-schema V55 참조.
CM송 사이클 동시재생 (SPEC #094 · SPEC #095 · SPEC #103 · SPEC #104 · #141)
본사가 등록한 CM송(#093)을 음악 큐 사이에 주기적으로 자동 재생한다. 음악 곡 ended 카운터
songsSinceCommercialRef 가 effective 빈도 (useGetStoreMe() 응답의 commercialCycleSongs,
SPEC #095 본사 default + #103 매장 override 의 BE 측 계산 결과) 에 도달하면 1회 본사 CM 1건을
getStoreNextCommercial(GET /api/v1/store/commercial-song/next, generated raw fetcher imperative
호출 — react-query 캐시 미사용으로 매 호출이 BE 의 새 라운드로빈 상태를 반영, SPEC #104) 으로 가져와
오버레이 트랙(store-player-overlay-audio)으로 음악 위에 동시재생한다(SPEC #141 — 음악 정지 안 함).
me 응답이 도착하기 전 또는 값이 0(레거시 본사 — V32 default 5 이전) 이면 모듈 fallback
상수 SONGS_BETWEEN_COMMERCIALS_FALLBACK = 5 를 쓴다. #103 도착으로 점장 client 는 effective 값
단일 필드만 소비(storeCommercialCycleSongs ?? hqCommercialCycleSongs 분기 없음 — BE 가 계산).
본사/매장 override 변경 시 me query 갱신 시점부터 새 빈도가 적용되며, 변경 시점의 누적 카운터는
유지된다(다음 임계치 도달 판정에서 새 값 비교).
200(CM 있음) → CM 을 오버레이 트랙으로 동시재생(음악은 그대로 다음 곡 진행), CM ended → 카운터 0
리셋 + CM 해제(음악 더킹 복원). 204(CM 없음) → 카운터 0 리셋(본사가 CM 미등록한 경우 호환). fetch
실패 → silent + 카운터 그대로(다음 곡 끝에 재시도). 안내방송(긴급·일반) 재생은 카운팅에서 제외 — 본
사이클은 음악 곡만 카운팅한다.
CM 진입은 안내방송이 활성/대기면 하지 않는다(우선순위: 안내방송 > CM).
곡 종료 공용 처리 onSongCompleted — 두 전환 경로 공유 (2026-07 감사 #3)
곡 종료 경로는 두 개다:
| 경로 | 함수 | 언제 |
|---|---|---|
자연 ended | handleMusicEnded | 큐 경계(gapless refetch) · 크로스페이드 미진입 케이스 |
| 크로스페이드 swap | finishCrossfadeSwap | 큐 내부 전환은 전부 이 경로 |
두 경로 모두 onSongCompleted() 하나를 호출한다 — 카운터 +1 과 CM 임계치 검사가 이 함수
안에 함께 있다. finishCrossfadeSwap 은 선언 순서상 이 콜백보다 먼저라 최신 참조를 ref
(onSongCompletedRef)로 받는다(handleMusicErrorRef 와 같은 패턴 — swap 시점의 최신 방송 상태로
검사하기 위해서이기도 하다). handleMusicEnded 는 크로스페이드 중이면 M1 가드로 early-return 하므로,
곡당 정확히 1회만 계수·검사된다.
swap 직후의 늦은 ended 래치(suppressMusicEndedRef) — swap 은 crossfadingRef 를 내린 뒤
setIndex 를 커밋하므로, 그 이후 도착하는 outgoing 의 늦은 ended 는 M1 가드를 통과해 카운트를
초과시키고 곡을 하나 건너뛴다. 그래서 swap 시 래치를 걸고, 메인 트랙이 새 곡 재생을 실제로 시작
하면(loadedmetadata·playing, 로드 실패는 error) 해제한다 — “다음 곡이 시작되기 전의 늦은
ended 는 전부 무시” 가 된다. 인덱스 비교(종전 completedIndexRef)는 리셋이 passive effect 라
setIndex 커밋(= onEnded prop 이 새 클로저로 교체되는 시점)과 리셋 사이에 창이 남아 이 경로를
막지 못했다. 다음 곡 URL 이 현재와 같으면(인접 중복 편성) 로드/재생 시작 이벤트가 오지 않아 래치가
풀리지 않으므로 그 경우엔 무장하지 않는다(무음으로 굳는 쪽이 더 나쁘다).
회귀 배경: 종전엔 카운터
+1만 두 경로가 공유하고 CM 임계치 검사는handleMusicEnded에만 있었다.startCrossfade는 큐 경계(index + 1 >= items.length)에서만 진입을 막으므로 큐 내부 전환은 전부 크로스페이드다 → CM 체크가 사실상 큐 1랩당 1회(마지막 곡) 만 돌았다. 큐 상한 500곡·빈도 5면 기대 100회 대비 실제 1회(최대 100배 결손). 게다가 그 마지막 곡 끝에 방송이 재생 중이면!activeAnnouncement가드에 막혀 그 랩의 유일한 기회마저 사라졌다.테스트가 못 잡은 이유: CM 테스트가
audio.dispatchEvent(new Event("ended"))만 쐈는데, jsdom 은duration이 NaN 이라onTimeUpdate의Number.isFinite(dur)가드에 걸려startCrossfade가 한 번도 실행되지 않았다 — 실브라우저에 없는 경로만 검증하고 있었다. 지금은duration을 stub 해 자연 ended 없이 크로스페이드 swap 만으로 CM 이 뜨는지 보는 회귀 테스트가 있다.
카운터는 임계치에 걸린 시도가 가드(방송 활성 등)로 무산돼도 리셋하지 않는다 — 다음 곡 종료에서 다시 검사되어 방송이 끝난 직후 사이클을 따라잡는다(빈도 이하로 늦춰질 뿐 소실되지 않음).
인터럽트 우선순위 (D8): 긴급 안내방송(#090) > 일반 안내방송(#061) > CM송. CM 오버레이 재생 중 폴링
응답에 긴급/일반 안내방송이 도착하면 CM 폐기(activeCommercial=null + 카운터 0 리셋) → 안내방송 due
effect 가 즉시 이어받는다. CM 은 ack 계약이 없으므로(서버 비-stateful) 단순 폐기.
더킹 — 방송 중 음악 트랙 감쇠 (SPEC #141 · 부드러운 램프)
방송(긴급/일반 안내방송·CM)이 오버레이로 재생되는 동안, 연속 재생 중인 음악 트랙의 볼륨만 부드럽게 낮춘다(음악은 정지하지 않음). 방송이 끝나면 음악을 100% 복원한다. 더킹은 모든 방송에 적용된다(긴급 포함 — 음악은 작게 깔린 채로 긴급이 그 위에서 재생).
- effective 값(
useGetStoreMe()): BE 가 per-field 로매장 override ?? 본사 default를 계산해StoreMeResponse로 전달한다(점장 화면은 분기 로직 없이 한 필드씩만 읽음). me 도착 전엔 모듈 fallback(더킹 OFF) 로 안전하게 종전 동작을 유지한다.duckEnabled— 더킹 사용 여부(true 면 음악 감쇠, false 면 음악 원음 유지). fallbackfalse.duckVolumePercent— 방송 중 음악 목표 볼륨(정상 대비 %, 0~100). fallback20. 음악 트랙의 실제 목표 볼륨 =사용자 볼륨 × (duckVolumePercent / 100).duckFadeMs— 감쇠/복원 fade 시간(ms, 0~5000). fallback400.- ⚠️ DUCK 제약 상수(
0..100·0..5000)는 본사/매장 설정 화면과 함께@/lib/ducking한 곳에서 export 해 재사용한다(BE OpenAPIUpdate{Hq,Store}DuckingRequest와 정합 · drift 방지).
- 더킹 멀티플라이어(daiso 곱 램프):
duckMultiplierRef(1=원음 ·duckVolumePercent/100=감쇠)를 방송 시작 시 감쇠치로, 종료 시 1.0 으로 fade 하고 음악 트랙(메인 · 크로스페이드 중이면 incoming)에volume * multiplier(* fade계수)로 곱해 적용한다. 부드러운 램프: daisouse-crossfade-engine(DUCK_FADE_STEP_MS=30)식 미세 30ms step + ease-in-out(cubic) 보간으로 시작/끝 급변을 없앤다 (종전 40ms 선형 램프가 “부자연스럽다”는 피드백 반영).duckFadeMs=0이면 즉시 적용. - 진행도는 경과시간 기준(
performance.now()— 단조 증가 시계다. 벽시계Date.now()는 24/7 구동 매장 단말의 NTP 보정·수동 시각 변경에 앞뒤로 점프해 램프가 즉시 완료되거나 지연된다)이다 — 30ms 는 해상도일 뿐 램프 길이의 분모가 아니다 (2026-07 감사 #20). 종전엔step / ceil(duckFadeMs / 30)이라 “틱이 정확히 30ms 마다 온다”는 가정 위에 있었는데, 브라우저는 백그라운드 탭의 타이머를 최소 1초로 클램프한다(오디오 재생 중이라 분 단위 intensive throttling 은 면제되지만 1초 클램프는 적용). 그러면 400ms 페이드가 14틱 × 1초 = 14초가 되어 긴급 방송이 나가는 동안 음악이 거의 줄지 않고, 방송이 끝나도 복원이 14초 지연됐다. 경과시간 기준이면 틱이 몇 번 오든duckFadeMs안에 목표치에 도달한다(최악 지연이 페이드 길이가 아니라 틱 간격 1회로 제한된다). 매장 단말은 다른 탭·창을 띄운 채 오래 도는 게 정상이라 현실적인 경로였다. WebAudioGainNode.linearRampToValueAtTime으로 옮기면 오디오 스레드가 처리해 스로틀 자체에 면역이지만,el.volume을 쓰는 볼륨 계통(더킹·뮤트·크로스페이드 계수) 전체를 갈아야 해 후속 결정으로 남긴다 — 램프 길이 정확도는 경과시간 보간으로 충분하다. - 더킹 활성 조건 =
duckEnabled && 방송 재생 중 && 깔 음악 곡 존재(current). 음악 큐가 비어 깔 곡이 없으면 비활성(방송만 오버레이 재생). 오버레이 트랙은 풀볼륨(1)이라 더킹 멀티플라이어를 곱하지 않는다. - 상태 표현(G3): 더킹 중에는 커버 아래 방송 카드가 라벨 “현재 방송” + 방송 제목
(
store-player-broadcast-title— 안내방송 제목, CM 이면 “본사 CM송”) + 우측 “재생중”StatusPill(store-player-announcement-badge, 긴급이면data-emergency="true"+ danger 톤)로 바뀐다. 음악 커버는 그대로 남아 계속 재생 중임을 보여주는 게 “함께 재생” 의 시각 근거다(별도 안내 문구는 두지 않는다 — 매장 화면 문구 최소화). 종전 더킹 정보 패널·EQ 미터·[멘트 중단] 버튼(store-player-ment-controls·store-player-stop-ment)과 별도 더킹 bed 트랙 (store-player-duck-audio)은 제거됐다(SPEC #141 — 방송 전용 UI 최소화).
음악 크로스페이드 — 곡↔곡 매끄러운 전환 (SPEC #139 Part B · 큐 내부만)
곡 끝 무렵 다음 곡과 볼륨을 교차(crossfade)해 끊김을 줄인다. daiso use-crossfade-engine 컨셉을
우리 구조로 변형 — 음악 재생에만 적용하고 방송 오버레이 경로는 전혀 건드리지 않는다(회귀 0).
- 트리거: 음악 재생 중(방송 없음) + 큐 내부 다음 곡 존재(
index+1 < items.length) + 곡 끝CROSSFADE_SECONDS(3s·FE 상수) 전.onTimeUpdate가 1회 진입 표시(setIsCrossfading(true)). - incoming 트랙: 진입 시 두 번째 음악
<audio>(store-player-crossfade-audio)가 렌더되어 다음 곡을 0:00 부터 페이드인하고, 메인<audio>(outgoing)는 페이드아웃한다(50ms 틱 fade interval). 완료 시 메인을 다음 곡으로 swap 하고 incoming 이 도달한 위치를 시드해(pendingSeekAtRef·onLoadedMetadata1회 적용) seamless 로 이어받는다(0:00 재시작 아님). swap 후 incoming 은 언마운트. - 큐 경계 gapless 유지: 현재가 마지막 곡이면 다음 곡을 미리 알 수 없으므로(서버 refetch·재셔플)
크로스페이드하지 않고 종전 gapless
advanceToNext로 진행한다. - plan 3초 고정 창(동작 명세): 크로스페이드 대상(
resolveNextTransition()— in-slot 다음 곡 vs 경계 전환nextSlot첫 곡)은 곡 끝CROSSFADE_SECONDS(3s) 전 확정된다. 따라서 그 3초 창 안에서 시간대 경계가 넘어가면 이미 in-slot 으로 확정돼 경계 전환이 한 곡 늦춰진다(현재 곡 다음에 in-slot 으로 한 곡 더 튼 뒤 그 곡 종료 시 경계 전환). 30분 슬롯 대비 3초라 무해하다(경계 정밀도 = 최대 한 곡). - 방송 abort: 방송(안내방송·CM)이 활성화되면 진행 중 크로스페이드를 즉시 abort(fade interval clear + incoming 정지)하고 메인 단일 트랙에 더킹을 적용한다. 방송은 음악 src 를 교체하지 않으므로 메인은 그대로 현재 곡을 계속 재생한다(SPEC #141 — 음악 연속 재생).
- 더킹과 직교(B-D2): 음악 트랙 실효 볼륨 =
volume * duckMultiplier * fade계수. 더킹 감쇠를 daiso 식 멀티플라이어(duckMultiplierRef) 곱 램프로 통일해 안전하게 직교 보장한다. - [일시정지]는 크로스페이드도 멈춘다. 재생 제어 effect 는 메인(outgoing)
<audio>만 pause 하므로, 곡 끝 3초 안에 일시정지하면 incoming 이 계속 소리를 내고 아래 경과시간 상한 때문에 3초 뒤 정지 상태에서 swap 이 일어나 곡이 하나 넘어갔다(점장이 재생을 재개하면 듣던 곡이 사라져 있다).togglePlay가 정지로 전환할 때abortCrossfade()를 먼저 호출한다 — outgoing 은 자기 tail 을 남긴 채 멈춰 있다가 재개 시 이어서 재생되고, 정상ended→advanceToNext로 이어진다. - 진행도 기준(2026-07 감사 #20): 정상 경로는 미디어 시간(
1 - (duration - currentTime) / 3) 이라 타이머 스로틀에도 페이드 길이가 늘어나지 않는다. 여기에 경과시간 상한(performance.now()기준 — 더킹 램프와 같은 단조 시계)을 더해, 어느 경로든 3초가 지나면 완료로 본다. duration 미확정(메타데이터 전·jsdom)일 때의 fallback 도 종전incoming.currentTime대신 경과시간을 쓴다 — incoming 이 실제로 진행하지 않으면(버퍼링 정지) 진행도가 0 에 고정돼 swap 이 영영 일어나지 않고, 메인의ended는 M1 가드에 삼켜져 재생이 통째로 멈췄다. 남는 한계는 길이가 아니라 해상도다(백그라운드 1초 클램프면 3초 동안 틱 3회 = 계단식 볼륨). 이건 타이머 기반 페이드의 구조적 한계라 WebAudio 램프 이관 시에만 해소된다(위 더킹 절 참고). - config: 현재 FE 상수(3s) 고정. 본사 default/매장 override 화는 B-D3 후속 결정.
방송 중 UI — 음악/방송 표기 분리 (SPEC #141 개편)
방송 표기는 음악 컨트롤과 분리한다. 우측 컨트롤 컬럼은 음악 전용, 방송 표기는 커버 아래 방송 카드로 내린다.
- 우측 컨트롤 컬럼(음악 전용): h1(
store-player-title)은 항상 현재 음악 곡(current?.title)을 표기한다(방송 중에도). 배지/컨텍스트 줄은 음악 재생 상태(재생중/일시정지) + 플레이리스트명/곡수만. 방송 배지(info/danger/CM pill)·“본사 안내방송 재생 중” 류는 우측에서 제거됐다. 진행바·트랜스포트· 볼륨은 음악 기준 그대로. 커버(CoverPlaceholder)도 상태/제목을 음악 기준(music/none)으로만 분기. - 단일 방송 카드(
store-player-broadcast-card— 항상 1개·클릭 시 “다음 방송” 목록 모달store-player-next-broadcasts-modal을 연다): 커버 컬럼 커버 아래에 항상 1개를 노출하며, 방송 재생 여부로 내용을 분기한다(종전store-player-broadcast-card+store-player-next-broadcast2개 카드를 1개로 병합). 카드 자체가<div role="button">(모달 열기)이고, TTS 음소거 토글<button>은 그 자식으로 제목 행 왼쪽에 인라인 배치된다 —<div role="button">안의<button>은 중첩<button>위반이 아니며, 토글의onClick stopPropagation이 뮤트 클릭과 카드(모달 열기) 클릭을 분리한다.- 방송 재생 중(
activeAnnouncement || activeCommercial): 라벨 “현재 방송” + 방송 제목 (store-player-broadcast-title=activeAnnouncement?.title ?? "본사 CM송") + 우측 “재생중”StatusPill. 배지 testid 는 안내방송·CM 공용 하나(store-player-announcement-badge)이며, 긴급이면data-emergency="true"+ danger 톤, CM 포함 그 외는 playing 톤이다 (store-player-commercial-badge같은 별도 CM 배지는 없다). 패널·EQ·보조 문구는 추가하지 않는다. - 방송 아님: 라벨 “다음 방송” +
pendingItems[0]미리보기(제목·긴급 pill) 또는 “예정된 방송 없음”(store-player-broadcast-title). - 모달 내용(
StoreScheduledListClient embedded defaultScope="today"): 카드 본문 클릭 시 열리는 다음 방송 목록은 2탭(오늘 남은 / 전체, SPEC #142)을 노출하며 기본 탭 “오늘 남은 방송” 은now <= scheduledAt <= 오늘 영업종료만 보여준다. 영업종료 기준은 매장 도메인에 영업시간 필드가 없어 오늘 KST 자정(23:59:59.999) 으로 정의한다(Asia/SeoulUTC+9 고정 비교). “전체 방송 목록” 탭은 BE SCHEDULED 전체(필터 없음·페이지네이션)를 보여준다. 별도 페이지 라우트 (/store/broadcast/scheduled)도 동일 2탭(기본 “오늘 남은 방송”). “오늘 남은 방송” 탭은 오늘 남은 게 없으면 “이번 영업시간 내 남은 예약이 없습니다.” 빈 상태를 노출하고 페이지네이션을 숨긴다. 각 행 [즉시 방송] 버튼으로 그 예약 안내방송을 지금 1회 추가 송출할 수 있다(원래 예약은 예정대로 유지, SPEC #142 — 자세히는 Broadcast).
- 방송 재생 중(
- 진행바(
store-player-progress)는 음악(메인) 트랙 기준으로 상시 노출한다(방송 중에도 — 음악은 연속 재생되므로 진행 그대로). 비인터랙티브(role=progressbar·seek 미지원). 트랜스포트(이전/재생/ 다음)·볼륨은 음악 제어로 유지한다(긴급 중에도 비활성화 X). - TTS(방송) 음소거 토글(
store-player-tts-mute): 방송 카드 제목 행 왼쪽 인라인(종전 Megaphone 아이콘 자리 · “현재 방송”/“다음 방송” 둘 다 같은 위치 · 종전 볼륨 컨트롤 옆에서 이동). 카드가<div role="button">이라 그 안의 이<button>은 중첩 위반이 아니고, onClickstopPropagation으로 뮤트 클릭이 [다음 방송] 모달을 열지 않는다.ttsMuted(기본 false)를 토글하면 방송 오버레이<audio>볼륨을 0(뮤트)/1로 적용한다(소리만 0 — 재생·ack·소비는 그대로 진행되어 방송이 정상 종료되고 pending 이 쌓이지 않음). 더킹(음악 볼륨)과는 독립. aria-label “안내방송 음소거”/“안내방송 음소거 해제” +aria-pressed. - 종전 더킹 정보 패널·EQ 미터·[멘트 중단] 버튼(
store-player-ment-controls·store-player-stop-ment)· 더킹 bed 트랙(store-player-duck-audio)은 제거됐다.
플랜(plan) 위반 음원 큐 제외 (SPEC #122 · #060 F1 · #169 계층화)
큐를 빌드해 점장에게 전달할 때 plan 위반 음원을 서버가 제외한다(“애초에 전달할 때 빼고 전달”).
점장은 위반 음원을 애초에 받지 않으므로 FE 코드 변경은 없다(계약 shape 불변 — getStorePlaybackQueue
응답이 필터된 큐를 반환). 동작은 전부 BE(StorePlaybackQueueService)에서 일어난다.
- effective plan =
store.plan ?? hq.plan: 매장 plan 우선, 없으면 본사 plan. - 필터 단위 = 라이브러리:
LibraryService.addMusic가 음원 type=라이브러리 type 을 강제하므로 한 라이브러리의 전 음원은 라이브러리 타입과 동일 → 큐 빌드의 라이브러리 병합 루프에서 effective plan 이 허용하지 않는 libraryType 의 라이브러리를 통째로 건너뛴다(추가 쿼리 0). - 계층 게이팅(#169 — 정확일치 → 계층): 정확일치(
libraryType == effectivePlan)가 아니라 상위 plan 이 하위를 포함한다.TRUSTplan 은 TRUST + AI 라이브러리 모두 허용(TRUST ⊇ AI),AIplan 은 AI 라이브러리만 허용, plannull은 전곡 통과(무제약). 즉 TRUST 매장은 AI 라이브러리 음원도 재생할 수 있고, AI 매장은 TRUST 라이브러리 음원이 제외된다. - plan null = 무제약: effective plan 이 null(INDEPENDENT 본사 등)이면 필터 미적용(전곡 통과). plan 미설정 매장에 무음을 만들지 않는다(무음=C·안전).
- 전부 위반 시 =
EMPTY_PLAYLIST: 필터 후 큐가 비면 기존 빈 큐 동작으로 수렴(reason=EMPTY_PLAYLIST→ 아래 dead-end CTA 무음+연락). 새 reason code 를 만들지 않는다(PLAN_FILTERED차등 안내는 후속). 운영사가 plan 호환 PL 을 걸어야 한다(운영 절차). - 활성/기본 PL 동일 필터: 활성 PL·기본 PL fallback 이 같은 병합 루프를 공유하므로 양 경로에 같은 필터가 자동 적용된다(일관성). 단, 활성 PL 이 필터로 비어도 기본 PL 로 자동 폴백하지는 않는다(v1 — 흐름 재구성 필요한 후속). plan↔type 게이팅 모델은 domain/status-lifecycle· domain/enums 참조.
source / 빈 상태 (SPEC #064 §D5)
| 조건 | 표시 |
|---|---|
source = DEFAULT | cover 우측에 “기본 재생목록 재생 중” 안내(본사 기본 PL fallback). |
| 곡 있음 | hero 에 현재곡 제목·플레이리스트명. 하단 재생 큐 목록·총 곡 수·[이전]/[다음]은 현재 렌더되지 않는다 — SHOW_HIDDEN_PLAYER_SECTIONS = false(사용자 요청 임시 숨김, 코드는 보존). |
active=false + reason=NO_ACTIVE_PLAYLIST(또는 그 외) | 빈 상태 “본사가 플레이리스트를 준비 중입니다” + dead-end CTA(아래). |
reason=EMPTY_PLAYLIST | 빈 상태 “재생할 곡이 없습니다” + dead-end CTA. |
reason=UNPLAYABLE_SOURCES | 빈 상태 “플레이리스트의 음원 파일을 재생할 수 없습니다” — PL 에 곡은 있으나 서버가 재생 불가 URL(http(s) 아닌 local:// 등)을 전부 걸러낸 경우. 본문에 플레이리스트명을 노출하고(어느 PL 인지 특정) 매장에서 해결 불가임을 알린 뒤 dead-end CTA 로 문의를 유도한다. |
| 로딩 | ”재생 큐를 불러오는 중…” (transient — CTA 없음). |
| 에러(query.isError) | “재생 큐를 불러올 수 없습니다” + “[새로고침]으로 재시도, 계속되면 고객지원 문의” + dead-end CTA. |
playlistName 은 곡이 있을 때 hero 상단에, 곡이 없을 때는 UNPLAYABLE_SOURCES 빈 상태 본문에
노출한다. reason 은 BE 계약상 string | null 이라 위 3종 외 값은 NO_ACTIVE_PLAYLIST 와 같은
“본사 준비 중” 기본 분기로 흘러간다(fallthrough).
dead-end CTA (SPEC #118 §D2)
콘텐츠 미셋업 신규 매장은 무음 + “본사가 준비 중” 메시지에서 멈추는 dead-end 였다. 빈/에러 상태 카드 본문(로딩 제외)에 다음 행동 CTA 2개를 붙여 동선을 연다(무음 자체 제거는 아님 — UX dead-end 완화. 무음 콘텐츠 시드는 roadmap §범위 밖 후속):
| CTA | 동작 | testid |
|---|---|---|
| [새로고침] | queueQuery.refetch() — 본사가 PL 지정·곡 추가했으면 즉시 반영(handleRestart 재사용). | store-player-empty-refresh |
| [고객지원 문의] | 고객지원 모달을 작성 뷰 + PLAYBACK 사전선택으로 연다(openSupportNewModal). | store-player-empty-support |
[고객지원 문의]는 종전 <Link href="/store/support/new?category=PLAYBACK"> 라우트 이동이었으나,
빈 큐 + 방송 재생 중에 누르면 player 가 언마운트돼 방송이 즉시 끊기고 ack 도 누락됐다(grace 안
복귀 시 중복 재생·초과 시 MISSED 소실 — 2026-07 재감사 N1). 다른 보조 액션과 동일하게 모달로
전환해 player 마운트를 유지한다(오디오 연속). 목록을 거치지 않고 작성 뷰로 바로 진입하며
분류를 PLAYBACK 로 사전선택한다. 사전선택은 SupportView new 뷰의 category 를
StoreTicketNewClient 의 initialCategory prop 으로 넘겨 적용한다(모달은 URL query 를 못 쓰므로).
라우트 진입(/store/support/new 딥링크)은 종전대로 ?category= query param 을 useSearchParams() 로
읽는다(generated TicketCategory enum 에 없는 값은 무시 — 미선택 유지). 헤더의 [고객지원] 상시
진입점은 그대로 유지(빈 상태 CTA 는 추가).
CS 새 답변 dot (SPEC #119 F5 · D2)
헤더 [고객지원] 버튼(store-player-support-link — 모달 트리거)에 새 운영자 답변 dot 을 붙인다.
getStoreSupportUnreadSignal(StoreSupportUnreadSignalResponse.latestOperatorReplyAt)을 60초 폴링
(refetchInterval:60s·refetchIntervalInBackground:false — 점장 안내방송 폴링과 별개 query)
하고, localStorage lastSeen(lm.support.lastSeen.store.<storeId> — useGetStoreMe().id)보다 최신
운영자 REPLY 가 있으면 버튼 우상단에 primary 도트(store-player-support-dot)를 표시한다(boolean dot —
카운트 아님, D2). 고객지원 목록이 열릴 때(모달·페이지 공통 — StoreTicketListClient mount) lastSeen=now 로 갱신해 dot 을 해소한다(D5,
markSupportSeen — apps/space/src/lib/support-last-seen.ts). localStorage 실패(시크릿모드 등)
시 보수적으로 dot 을 유지(깨지지 않음, D1 리스크 1). 점장 안내방송 배지는 만들지 않는다(D3 —
player 가 PENDING 안내방송을 이미 자동 재생·소비, 중복). 헤더 [고객지원] 버튼·CS 새 답변 dot 의
시각은 design_14 §8G-① 정합 완료(시각만 교체·dot 동작·폴링·lastSeen 로직 보존).
보조 액션 (SPEC #064 §D6)
보조 액션은 모달(Radix Dialog)로 연다 — player 위 portal 오버레이라 player·<audio> 가 유지되어
오디오가 끊기지 않는다. 각 버튼은 <button aria-haspopup="dialog"> 이며 openModal state 로 어느
Dialog 가 열렸는지 관리한다.
| 액션 | testid | 동작 |
|---|---|---|
| 상단 [음악이 이상하면 재시작] | store-player-restart | 큐 refetch + 재생 위치 리셋(in-app 복구 — window.reload 대신 세션 유지). |
| 헤더 우측 [로그아웃] | — | StoreLogoutButton(store-logout-button) — 운영사 AccountMenu 미러: POST /api/auth/logout(best-effort) → router.replace("/login") + refresh. 점장 세션 쿠키 lm_space_session 을 BFF 가 session.destroy() 로 비운다. 시안 부재라 프로필 옆 pill atom-grounded. |
| [즉시 방송 보내기] | store-player-broadcast-now | 즉시방송 모달(store-player-broadcast-modal)을 tts 탭으로 연다. |
| 자주 쓰는 방송 | store-player-action-broadcast-templates | 즉시방송 모달을 templates 탭으로 연다(이전 stub 연결). |
| 예약 방송 | store-player-action-broadcast-schedule | 즉시방송 모달을 **예약 모드(SCHEDULED)**로 연다(이전 stub 연결). |
| 플레이리스트 | store-player-action-playlist | 플레이리스트 선택 모달(store-player-playlist-modal)을 연다. 적용 성공 시 큐 invalidate → player 가 새 활성 PL 반영. SPEC #178: deviceId 를 prop 으로 넘겨 기기가 등록됐으면 선택이 이 PC 에만 적용되게 한다(미등록이면 종전 매장 단위). |
| 시간표 | store-player-action-schedule | 시간표 편집 모달(store-player-schedule-modal)을 연다(SPEC #171 FE-2). 종전엔 full-page 라우트 /store/schedule 로 이동하는 <Link> 였으나, 라우트 이동이 player 를 언마운트해 오디오 정지 + 재생 중 방송 중단·ack 소실 + 백그라운드 방송 폴링 정지(PENDING→MISSED 영구 소실)를 일으켜(N1 회귀) 형제 [플레이리스트]와 동일하게 모달로 전환했다. embedded StoreScheduleClient 가 DialogBody 본문 + DialogFooter 저장 바를 렌더한다(폭 w-[960px] — 그리드가 넓다). /store/schedule 라우트는 딥링크로 유지(비-embedded full-page). 보조 버튼 그리드는 4개 = grid-cols-2(2×2). 저장·커스텀·되돌리기 진행 중에는 닫기 경로를 전부 잠근다(감사 #18 · 아래). SPEC #178: 이 모달에도 deviceId 를 넘긴다 — 상단 PL 드롭다운이 형제 [플레이리스트] 모달과 같은 분기로 갈려 이 PC 에만 적용된다(종전엔 여기만 매장 활성 PL 을 바꿔, PC-1 에서 시간표를 열어 PL 을 고르면 다른 PC 들의 재생이 통째로 갈아엎혔다 — 고객사가 제보한 증상이 바로 옆 버튼에서 재현). 화면 상세는 시간표 override. |
| 단일 방송 카드(현재/다음) | store-player-broadcast-card | ”다음 방송” 모달(store-player-next-broadcasts-modal)을 연다(목록=scope="today" — 오늘 영업종료(KST 자정)까지 남은 예약만). 방송 중=현재 방송, 비방송=다음 방송/예정 없음. |
| 방송 음소거 토글 | store-player-tts-mute | 방송 카드 제목 행 왼쪽 인라인(종전 Megaphone 아이콘 자리 · “현재 방송”/“다음 방송” 둘 다 같은 위치). 카드가 <div role="button"> 이라 그 안의 <button> 은 중첩 위반이 아니며, onClick stopPropagation 으로 모달이 열리지 않게 막는다. 방송 오버레이 볼륨 0/1 토글(ttsMuted). 소리만 차단 — 재생·ack·소비는 진행. |
| 헤더 [고객지원] | store-player-support-link | 고객지원 모달(store-player-support-modal)을 연다. <Link> 가 아니라 <button> — 라우트 이동이면 player 가 언마운트돼 음악이 끊긴다(2026-07 감사 #5). |
| 기기 상한 배너 [기기 정리하기] | store-player-device-limit-manage | SPEC #178 — 재생 기기 관리 모달(store-player-devices-modal)을 연다(StoreDevicesClient embedded + currentDeviceId 주입 → “이 기기” 배지). 기기 등록이 상한(4대)으로 거절됐을 때만 배너가 뜬다. 라우트(/store/devices)로 가면 player 가 언마운트돼 음악이 끊기므로 평상시 진입은 모달이다. 화면 상세는 Store Devices. |
| 프로필 모달 → [재생 기기 관리] | store-profile-devices-link | 같은 기기 관리 모달로 전환한다(onOpenDevices 콜백 — 모달 안 링크를 누르면 라우트 이동으로 음악이 끊긴다). 상한에 걸리기 전에도 찾을 수 있는 상시 진입점. |
| 계정 메뉴 → [프로필] | store-account-profile | 프로필 모달(store-player-profile-modal)을 연다. StoreAccountMenu/ShellAccountMenu 의 onProfileSelect prop 주입으로 라우트 이동을 대체한다(prop 미지정인 다른 사용처 — 본사 셸·점장 서브페이지 헤더 — 는 종전대로 router.push). |
player 오디오 유지 불변식: 모든 보조 액션(즉시방송·자주쓰는방송·예약방송·플레이리스트·시간표· 다음 방송·고객지원·프로필·재생 기기)은 라우트 이동이 아니라 portal Dialog 이므로 열려 있어도
store-player-audio(및 더킹·크로스페이드 트랙)가 마운트된 채 유지된다. player 테스트가 각 버튼 클릭 → 해당 모달 열림(초기 탭/모드) + audio 마운트 유지를 검증한다. 예외 없음 — [시간표]도 종전 route 진입이 오디오·방송을 끊던 N1 회귀를 모달 전환으로 해소했다(/store/schedule라우트는 딥링크로 유지하되 HERO 진입은 모달). 진입점이 라우트 이동인 항목은 남아 있지 않다.
모달 닫기 — 닫기(X) 버튼 + 진행 중 잠금 (2026-07 감사 #13)
- 우상단 닫기(X) — 모달 7종(즉시방송·플레이리스트·시간표·다음 방송·고객지원·프로필·
재생 기기) 모두
DialogClose기반 44×44 버튼(aria-label="닫기")을DialogContent우상단에 고정한다. testid 는store-player-{broadcast|playlist|schedule|next-broadcasts|support|profile|devices}-modal-close. 종전엔 어느 모달에도DialogClose가 없어 Esc 아니면 바깥 여백 탭이 유일한 탈출구였고, 키보드 없는 터치 POS 단말에서 모달 본문이 화면을 채우면 닫을 방법이 사라졌다. - 진행 중(pending) 잠금 — 즉시방송 모달은 합성(미리듣기)·녹음 업로드·송출이 진행 중이면
onEscapeKeyDown·onPointerDownOutside·onInteractOutside를 전부preventDefault하고 닫기(X)를 잠근다(onOpenChange가드까지 이중 방어).BroadcastNowClient가onPendingChange(pending, kind)로 신호를 올리고(녹음 탭은onUploadingChange로 부모에 위임), 언마운트 시(false, null)로 정리한다.kind는"send" | "preview" | "upload"(우선순위 send > upload > preview)로 원인을 함께 전달해 아래 상한 경고 문구를 가른다. 요청이 나간 뒤 모달이 닫히면 client 가 언마운트돼 성공/에러가 어디에도 남지 않고, 점장이 재작성·재송출해 같은 안내가 두 번 방송됐다. - 잠금 상한 30초(
MODAL_PENDING_LOCK_MAX_MS) —apiFetch에는 timeout/AbortSignal 이 없어서, 매장 Wi-Fi 가 끊겨 요청이 error 도 success 도 없이 정지하면 pending 이 무기한 true 로 남아 Esc·바깥탭·X 가 전부 막힌 dead-end 가 된다(새로고침 말고 탈출구 없음). pending 진입 후 30초가 지나면 잠금을 풀어 닫기(X)·Esc 를 다시 열고, 경고 배너store-player-broadcast-lock-warning을 노출한다. 문구는 pending 의 원인(kind)별로 다르다:send면 “전송 결과를 확인하지 못했습니다. 닫으면 중복 송출 위험이 있습니다.”,preview·upload면 “요청이 지연되고 있습니다. 닫고 다시 시도해도 됩니다.” — 합성/업로드가 지연된 상황에서는 아직 아무것도 송출되지 않았으므로, 중복 송출 경고는 무인 매장 점장에게 확인 비용만 큰 오경보다. 바깥 포인터/인터랙션은 터치 POS 의 우발적 닫힘이라 상한 후에도 계속 막는다. mutator 에 전역 timeout 을 거는 방식은 채택하지 않았다 — 녹음 업로드가 느린 매장 Wi-Fi 에서 30초를 정당하게 넘길 수 있어 정상 업로드를 끊는 회귀 위험이 크다(요청 자체는 취소하지 않는다). - 잠긴 닫기(X)의 a11y —
disabled대신aria-disabled+title(이유) + onClickpreventDefault다.disabled버튼은 포커스를 받지 못해 스크린리더가 존재도 이유도 읽어주지 못했다. RadixDialogClose는composeEventHandlers가defaultPrevented를 확인하므로 클릭이 무력화된다. - 시간표 모달의 진행 중 잠금(SPEC #171 FE-2) — 시간표 모달도 같은 정책을 따른다. embedded
StoreScheduleClient가onPendingChange(pending)로 저장(PUT)·커스텀(POST)·되돌리기(DELETE) 진행 중 신호를 올리면 player 가store-player-schedule-modal의 Esc·바깥탭·닫기(X)·탭 전환을 전부 잠근다. 되돌리기 mutation 은 자식RevertScheduleDialog에서 실행되지만deleteMutation을 부모(StoreScheduleClient)가 소유해 그 pending 도 함께 집계한다(자식 언마운트로 잠금이 풀리지 않게). FE 통합리뷰 P2 — 시간표 모달도 즉시방송 모달과 동일한 30초 잠금 상한 (MODAL_PENDING_LOCK_MAX_MS)을 둔다. 종전엔 rawschedulePending으로만 잠가, 저장/되돌리기 요청이 네트워크 hang(error 도 success 도 없이 정지)에 걸리면 잠금이 무기한 유지돼 새로고침 말고 탈출구가 없는 dead-end 였다.scheduleLockExpired(30초 타이머) →scheduleLocked = schedulePending && !scheduleLockExpired로 닫기 판정을 교체해, 상한 후 닫기(X)·Esc 로 탈출할 수 있게 한다(바깥 포인터/인터랙션은 터치 POS 우발 닫힘 방지로 계속 막음 — 즉시방송 모달과 동일한 혼합). 시간표 mutation 은 멱등(전체 교체 PUT·되돌리기 DELETE)이라 상한 후 재시도해도 중복 손상이 없다. 시간표 모달의 pending 집계에는 PL 전환(useSetStoreDevicePlaylist/useSetStoreOwnActivePlaylist)도 포함된다. - [플레이리스트]·[재생 기기] 모달까지 확대 + 잠금 로직 훅화(SPEC #178 통합 검토) — 네 모달(즉시방송·
시간표·플레이리스트·기기)이 같은 규칙을 쓰도록
잠금 + 30초 상한을 공용 훅 (useModalPendingLock(pending) → { locked, expired })으로 묶었다. 종전엔 같은 블록이 복붙돼 있어 새 모달이 추가될 때마다 조용히 누락됐고, 실제로 두 모달에 잠금이 없었다. react-query v5 는 컴포넌트가 언마운트되면mutate의onSuccess/onError를 호출하지 않으므로 요청 중 모달이 닫히면:- 플레이리스트 — 서버에는 PL 이 바뀐 채 커밋되는데 큐를 포함한 5개 invalidate 와 “0번부터” 리셋 의도가 통째로 유실된다 = 고객사 제보(“바꿨는데 음악이 안 바뀐다”)의 재현 경로.
- 기기 관리 — 토스트·목록 invalidate 가 유실된다. 회수는 되돌릴 수 없어 결과를 못 본 점장이 다시 시도하기 쉽다. 진행 상태는 행이 아니라 목록이 소유한다(회수 성공 시 그 행이 언마운트되며 잠금이 풀리지 않게).
- 플레이리스트 화면의 에러 매핑에는
STORE_DEVICE_NOT_FOUND가 추가됐다 — 회수된 기기로 적용하면 404 폴백에 걸려 “선택한 플레이리스트를 찾을 수 없습니다”라는 틀린 원인이 안내됐다(목록을 새로고침해도 낫지 않는다). 이제 시간표 화면과 문구를 통일하고 기기 자가복구(재등록)를 요청한다.
인가·세션 (SPEC #064 §D7)
/store/* 는 app/store/layout.tsx(server)가 STORE_MANAGER role 가드를 담당한다(세션 없음→
/login, HQ_MANAGER→/admin, 그 외→fail-closed /login). 데이터는 refresh-aware mutator
(BFF)가 처리한다. 모달이 재사용·딥링크로 유지되는 페이지 라우트(broadcast/now·playlist·schedule·
broadcast/scheduled)도 같은 layout 가드 아래에 있고, 레거시 stub broadcast/templates·
broadcast/schedule 는 /store 로 redirect 한다.
무인 24/7 기기라서 세션 경로에 세 가지 불변식이 있다(Auth Flow 참조):
- 임퍼소네이션 비채택 — catch-all BFF proxy 는 임퍼소네이션이 유효하다고 명시 등록한 경로
(
api/v1/hq/**·api/v1/auth/me)만 임퍼소네이션 우선이고, 점장이 쓰는 나머지 경로는 전부getActiveSession({ scope: "real-login" })이라 임퍼소네이션 cookie 를 읽지 않는다. 공용 단말에 운영사 임퍼소네이션 세션이 남아 있어도 점장 호출은 항상 점장 토큰으로 나간다(그렇지 않으면 backendhasRole("STORE_MANAGER")가드로 점장 API 전체가 403 → 임퍼소네이션 창 60분간 잠긴다). 점장 셸의 신원 표시·이름 편집도 실 세션 고정 BFF/api/auth/me(lib/use-me.ts의useMe)를 쓴다 — generateduseGetMe()를 쓰면 임퍼소네이션 대상 본사 관리자의 신원이 노출·저장된다. Auth Flow §3-1 참조. - 일시 장애로 로그아웃하지 않음 — refresh 실패가 backend 5xx·네트워크(
transient)면 세션을 유지하고 502BACKEND_ERROR만 돌려준다. 세션을 파기하면 mutator 가/login으로 풀 리로드해 플레이어가 언마운트되고 음악이 멈춘다. - XHR 에도 proactive refresh — player 는 화면 전환이 전부 모달이라 페이지 네비게이션이 없어,
middleware 가
/api/backend/*요청에서도 만료 임박 access 를 미리 갱신한다.
미구현 / 후속
⚠️ 이 절은 아직 없는 것만 적는다. 위 본문에 절이 있는 기능은 라이브라는 뜻이므로 여기에 중복 기재하지 않는다(예: 본사 원격 즉시중단(revoke)은 위 절대로 라이브).
- 미구현: 커버 이미지(음원 메타 enrich 선행) · 방송 우선순위 큐 · LLM 자동멘트 · 매장 장애 자동복구/원격 진단.
- 라이브(과거 이 목록에 있었으나 구현됨): 즉시방송 작성 3탭(TTS·녹음·자주 쓰는 방송) · 점장 활성 PL 선택(SPEC #129) · 예약/반복 예약·미리듣기 게이트·STORES 개별선택·REGION(지역) 대상 송출(SPEC #144 — 본사 송출 다이얼로그에서 시/도 선택. player 는 대상 산정 결과인 pending 만 소비하므로 player 측 분기는 없다) · 송출 감사 · 본사 원격 즉시중단(revoke, SPEC #077 확장) · 온보딩 · 방송 동시재생(더킹 오버레이 · SPEC #141) · 음악 크로스페이드 · 실시간 통지(SSE) 구독(SPEC #180 — 위 §실시간 통지).
- 부채(계약): 빈 큐 사유
reason이 OpenAPI enum 이 아니라string | null(generated/schemas/storePlaybackQueueResponse.ts)이라, FE 가reason === "UNPLAYABLE_SOURCES"처럼 매직 스트링으로 비교한다. BE 가 값을 바꾸거나 오타가 나도 컴파일 에러 없이 기본 문구 (“본사가 플레이리스트를 준비 중입니다”)로 조용히 fallthrough 한다 — 이 슬라이스가 없애려던 실패 모드 그대로다. BE 에서 enum 으로 승격 →/sync-api재생성 → generated union 으로 좁히는 것이 계약 단일 소스 원칙(.claude/rules/frontend.md§15)에 맞다. BE 변경 선행 후속 SPEC 대상. - 점장 모드 전 화면의 stub ↔ 기획(PRD Page 1~9) 대응·막힌 선행조건은 Store Surface Matrix 에서 추적한다.