FeaturesStore (매장)Store Active Playlist (/store/playlist · 점장 활성 PL 선택)

Store Active Playlist Select — /store/playlist (점장 활성 플레이리스트 선택)

SPEC #129. 점장(STORE_MANAGER)이 본사가 만든 플레이리스트 중 자기 매장의 활성 PL 을 직접 선택한다. 인터뷰 결정(2026-06-17): “점장이 직접 선택 가능”. 활성 PL 지정은 기존엔 운영사/본사 권한이었고, 본 SPEC 으로 점장 self 선택 경로를 신설했다.

Overview

플레이리스트 모델(음원→라이브러리→PL→매장 적용·본사 기본 PL fallback) 위에서 매장 자율성을 더한다. 점장은 자기 본사(store.hqId)의 PL 중에서만 활성 PL 을 고르거나, “본사 기본으로” 되돌릴 수 있다(활성 해제 → 본사 기본 PL fallback, #058 큐 동작). 마이그레이션 0 (기존 store.active_playlist_id 컬럼 재사용).

진입점 · 라우팅

  • 라우트 /store/playlist(apps/space/.../store/playlist/page.tsx + store-playlist-client.tsx). 이전 stub(_stub/store-stub-page.tsx)을 대체.
  • 진입점 = 점장 player 홈(store-player-client)의 보조 3버튼 중 “플레이리스트”(hint “활성 목록 선택”). /store layout(server)이 STORE_MANAGER 가드를 담당.

화면 구성 (design_14 정합 완료)

design_14 핸드오프(Phase 8 §8G-⑤ store-active-playlist.jsx)로 시안 정합 완료. 도입 시 점장 셸 idiom(헤더 + 홈 복귀 링크 · success Banner) + 본사 PL 목록 idiom(ListToolbar/ ListPagination/행 목록)을 미러한 atom-grounded 임시였으나, 정식 시각으로 교체했다 — 카드 리스트, active 행 “재생 중” 강조, 파생 상태 4종(ACTIVE success·FALLBACK info·EMPTY warn·UNUSED muted) 라벨 병기, [본사 기본으로]=활성 해제. 서브페이지 헤더는 신규 공용 atom StoreSubHeader. 시각만 교체 — 목록·선택·해제 동작·계약·격리 보존.

  • 목록useListStorePlaylists({ q, page, size })(store.hqId 격리, 정렬 updated_at DESC). 각 행: 이름 · 파생 상태 배지 · 라이브러리 수 · [선택].
  • 파생 상태 배지StoreOwnPlaylistListItemResponse.status(EMPTY/FALLBACK/ACTIVE/UNUSED, 배타적 우선순위) → 곡 없음(warn)·본사 기본(info)·운영 중(success)·미사용(muted). 저장 아닌 응답 계산값.
  • 현재 활성 표시active=true 행은 “재생 중”(playing) 배지 + [선택] 버튼 대신 비활성 표기. 상단 요약 카드에 현재 활성 이름. 활성 미선택(본사 기본 fallback) 이면 그 사실을 명시.
  • [선택]setStoreOwnActivePlaylist({ playlistId }). plan 위반 PL 을 선택해도 큐 빌드(#122) 가 위반 음원을 제외하므로 별도 차단 없음(큐 레벨 처리).
  • [본사 기본으로]setStoreOwnActivePlaylist({ playlistId: null })(활성 해제 → 본사 기본 PL fallback). 현재 활성이 없으면 비활성.
  • 성공 시 PL 목록 + 재생 큐(getStorePlaybackQueue) + me 캐시 invalidate → 활성 표시·다음 큐 갱신. 적용/해제 확인은 success 토스트(“선택한 플레이리스트를 적용했습니다.”/“본사 기본 플레이리스트로 되돌렸습니다.”, SPEC #163 — 인라인 배너 대신 공용 ToastHost). 적용 실패는 인라인 Banner danger(store-playlist-set-error) 유지.

계약 · 격리

기기별 활성 플레이리스트 (SPEC #178)

한 매장에서 PC 여러 대가 같은 점장 계정으로 음악을 트는데, 활성 PL 이 store.active_playlist_id 단일 값이라 한 대에서 바꾸면 다른 대도 따라갔다(고객사 제보). SPEC #178 은 기기(store_device)별로 PL 을 둘 수 있게 한다 — 상세는 Store Devices.

해석 우선순위 (4단)

GET /api/v1/store/queue?deviceId= 로 기기를 밝히면 기존 2단 폴백 위에 1단이 얹힌다.

순위source소스조건
1DEVICEstore_device_playlist.playlist_iddeviceId 를 보냈고 그 기기에 지정이 있을 때
2ACTIVEstore.active_playlist_id기기 지정 없음(또는 deviceId 미전송)
3DEFAULT본사 기본 PL(playlist.is_default)매장 활성 PL 도 없음
4NONE셋 다 없음 → 빈 큐(reason=NO_ACTIVE_PLAYLIST)
  • deviceId 를 생략하면 종전 2단 폴백 그대로다(D12 하위호환 — 구버전 클라이언트는 DEVICE 를 받지 않는다).
  • 기기가 타 매장이거나 회수됐으면 에러가 아니라 조용히 폴백한다 — 재생은 어떤 경우에도 멈추면 안 된다.
  • store.active_playlist_id그대로 유지한다(D13 expand→migrate→contract) — 기기 지정이 없는 기기와 구버전 클라이언트의 폴백 소스다.

화면 동작 (/store/playlist)

기기 등록이 돼 있으면(deviceId != null) [선택]·[본사 기본으로]가 매장 단위 setStoreOwnActivePlaylist 대신 setStoreDevicePlaylist(PATCH /store/devices/{id}/active-playlist) 를 호출한다 — 즉 이 PC 에만 적용되고 같은 매장의 다른 PC 재생에는 영향이 없다. 사용자 흐름·성공/실패 처리·캐시 무효화는 두 경로가 동일하며, 성공 토스트 문구만 갈린다:

상황토스트
기기 등록 O · PL 선택”이 기기에 선택한 플레이리스트를 적용했습니다.”
기기 등록 O · [본사 기본으로]“이 기기를 매장 기본 재생목록으로 되돌렸습니다.”(지정 해제 → 매장 활성 PL → 본사 기본 PL)
기기 등록 X(저장소 차단·등록 실패·페이지 직접 진입)종전 문구 — “선택한 플레이리스트를 적용했습니다.” / “본사 기본 플레이리스트로 되돌렸습니다.”

표시도 기기 스코프로 파생한다. 목록 응답의 active 는 “이 매장의 활성 PL 인지”라 (ListStorePlaylistsParamsdeviceId 가 없다) 기기 스코프에서는 화면 상태의 근거가 될 수 없다. 그래서 client 는 useGetStoreDevicePlaylist(deviceId)(enabled: !!id — 미등록 기기는 조회 0)로 이 기기의 지정 PL 을 읽고, selectedPlaylistId(기기 스코프면 지정 PL · 아니면 active=true 행) 한 값에서 요약 카드 · 행의 “재생 중” pill · [선택] 버튼 유무 · [본사 기본으로] 활성 여부를 모두 파생시킨다. mutation 만 기기별로 갈리고 표시가 매장 단위로 남으면 이런 실패 모드가 생긴다:

  • 적용 직후 모달을 다시 열면 여전히 “직접 선택하지 않았습니다” + 그 행에 [선택] → 적용 실패로 보인다.
  • 매장 활성 PL 이 없는 흔한 구성에서 이 기기만 지정하면 activeItem === undefined[본사 기본으로] 가 영구 disabled → 기기 지정을 되돌릴 UI 경로가 사라진다(localStorage 를 지워 새 기기로 재등록하는 것 외엔 없고 그건 상한을 소진한다).
  • 매장 활성 PL 과 같은 PL 을 이 기기에 명시 지정하려 해도 그 행은 active=true 라 “재생 중” pill 만 뜨고 버튼이 없다.

적용 성공 시 getStoreDevicePlaylist(deviceId) 캐시도 함께 무효화한다 — 빼면 방금 적용한 값이 화면에 반영되지 않는다.

시간표도 기기 스코프다 (SPEC #178 통합 검토)

시간표 endpoint 4종 전부(getStoreSchedule·customizeStoreSchedule·setStoreSchedule· deleteStoreSchedule)가 optional deviceId query 파라미터를 받는다. 주면 서버가 큐와 똑같은 3단 해석(기기 지정 PL → 매장 활성 PL → 본사 기본 PL)으로 편집 대상 PL 을 고르고, 생략하면 종전 2단 해석 (매장 활성 → 본사 기본)이라 구버전 클라이언트는 그대로 동작한다.

왜 필요했나store_library_schedule 의 스코프는 (store_id, playlist_id) 다. 종전에는 편집이 매장 활성 PL 부터 봤기 때문에, 기기 지정 PL 로 재생 중인 PC 에서 저장하면 편집은 (storeId, P_store) 에 들어가고 그 PC 의 큐는 (storeId, P_device) 를 읽어 빈 결과 → 본사 기본 시간표로 폴백했다. 점장은 200 · isOverride=true 를 받고도 음악이 안 바뀌는 걸 본다 — 화면상 정상이라 자가진단이 불가능한 조용한 no-op 였다.

공용 컴포넌트로 해석을 하나로 — 3단 해석은 application/store/StorePlaylistResolver 가 소유하고, 큐(StorePlaybackQueueService)와 시간표(StoreScheduleService)가 같은 함수를 부른다. 세 곳이 각자 판정하던 것이 이 드리프트의 근본 원인이라, “읽는 쪽과 쓰는 쪽이 같은 함수를 부른다”가 재발 방지책이다. 타 매장·회수된 기기 id 는 400 이 아니라 조용한 폴백이다(재생도 편집도 실패시키지 않는다). 지정 PL 이 soft-delete 됐으면 findActiveById 가 걸러 다음 단계로 내려간다.

FE 는 네 요청 전부에 싣는다 — 조회(useGetStoreSchedule(params))·저장(setSchedule)·커스텀 시작 (customize)·되돌리기까지. 되돌리기는 자식 다이얼로그(RevertScheduleDialog)가 실행하므로 같은 params 를 prop 으로 내려받아 부모의 조회·저장과 같은 PL 을 가리키게 한다. 기기가 없으면 (deviceId=null·저장소 차단) 파라미터를 생략해 종전 매장 스코프로 회귀한다.

어긋남 안내 배너(store-schedule-device-scope-notice)는 제거됐다. 편집 대상 PL 이 큐와 같은 3단 해석으로 정해지므로 어긋날 일이 없고, 배너 본문이 담고 있던 “기기별 시간표는 아직 지원하지 않아…” 는 이제 사실이 아니기 때문이다. 회귀 테스트는 배너가 뜨지 않는 것을 고정한다 — 되살아나면 조회에서 deviceId 가 빠져 조용한 no-op 이 부활했다는 뜻이다.

편집 대상 PL 이름은 기기 스코프일 때 store-schedule-editing-playlist 보조 텍스트로 계속 밝힌다 (드롭다운은 “이 기기가 트는 재생목록”, 보조 텍스트는 “아래 그리드가 편집하는 시간표의 PL”).

시간대별 시간표 override — /store/schedule (SPEC #171 FE-2)

활성 PL 위에서 시간대별로 어떤 라이브러리를 틀지 정하는 매장 맞춤 시간표. 점장이 세로 1열 타임라인에서 라이브러리를 고른 뒤 빈 곳을 드래그하면 그 범위로 블록 1개를 일회성 추가한다(한 드래그 제스처 = 블록 1개). 이후 블록 중앙(body)을 잡아 위아래로 이동하거나, 위·아래 edge(약 16px 히트존)의 ↕ 손잡이를 끌어 리사이즈(그 경계만 늘리기/줄이기 · 최소 1슬롯·반전 불가) · ✕ 로 삭제한다. 드래그하는 동안 반투명 미리보기(opacity)로 결과 위치를 보여주고 손을 떼면 확정한다(그 전엔 실제 entries 미변경) · 라이브러리 브러시 선택 상태에서 빈 셀 위에 마우스만 올려도 은은한 ”+ 라이브러리” hover 미리보기가 뜬다. ↕ 손잡이는 hover 아니어도 상시 표시 · 포인터 이벤트(touch-action:none·setPointerCapture)로 터치 POS·마우스 모두 지원한다(종전 48칸 2열 그리드· 클릭-per-칸/연속 페인팅을 SPEC #171 인터랙션 개정으로 폐기). 진입점 = 점장 player 홈 HERO 보조 버튼 [시간표]오디오 유지 모달(store-player-schedule-modal). 종전엔 full-page 라우트 /store/schedule 로 이동하는 <Link> 였으나 라우트 이동이 player 를 언마운트해 오디오·재생 중 방송을 끊고 백그라운드 방송 폴링을 멈춰(PENDING→MISSED) N1 회귀가 됐다 — 형제 [플레이리스트]처럼 모달로 전환했다. /store/schedule 라우트는 딥링크·비-embedded full-page 로 유지한다(같은 StoreScheduleClientembedded prop 으로 재사용: embedded 면 페이지 셸을 생략하고 저장 바를 DialogFooter 로 렌더). 편집기 본체는 점장·운영사·본사 공유 컴포넌트 @linkmusic/ui ScheduleGridEditor(surface=“store”).

시간 모델

  • 하루 KST 분 [0,1440) · 30분 배수 · [start,end) 반열림 · 한 30분 슬롯 = 최대 1 라이브러리 (겹침은 덮어쓰기라 구조적으로 불가).
  • 빈 시간대(미배치) = 플레이리스트 전체 재생(셔플) — 무음이 아님(범례로 명시). 같은 라이브러리를 여러 시간창에 재사용 가능. 저장은 48칸을 인접 동일 라이브러리 병합한 구간 배열로 전체 교체.
  • 영업시간 창(표시 범위) = 클라이언트 편집 편의일 뿐 BE 로 전송되지 않는다. 편집기 상단 영업시간 바에서 open/close(30분 스텝)를 조절하면 그 범위만 축·편집으로 노출된다(기본 09:00–23:00, 기존 entry 를 포함하도록 로드 시 한 번 넓혀짐 · surface+id 별 localStorage 지속). BE 계약은 여전히 하루 [0,1440) 48슬롯 전체다 — 창을 좁혀도 창 밖 슬롯의 기존 entry 는 삭제되지 않고 숨겨지기만 하며 (창을 넓히면 다시 보인다), 저장 시에도 보존된다. 영업시간 밖이 빈칸이면 종전대로 전체 셔플(24/7 유지).

3모드 (GET /store/me/schedule 응답 기준)

  • playlistId=null(활성 PL 미선택) → 편집 불가 빈 상태. CTA 는 라우트(full-page)면 /store/playlist <Link>, 모달(embedded)이면 onDone 으로 player 가 플레이리스트 모달로 전환(오디오 유지).
  • isOverride=false(본사 기본 따르는 중) → 읽기 표시 + info Banner + [눌러서 커스텀 시작하기] (useCustomizeStoreSchedule — 본사 기본을 매장 사본으로 복사·시딩). 이 CTA 는 편집 불가 안내 멘트와 같은 muted 색의 외곽선 버튼(밝은 primary 아님·앞 아이콘 없음, SPEC #171 인터랙션 개정). 타임라인 잠금(readOnly — 손잡이·이동·생성·hover 미리보기 전부 비활성, 블록 표시만).
  • isOverride=true(내 매장 커스텀) → 타임라인 편집 활성 + [저장](useSetStoreSchedule 전체 교체)
    • [본사 기본으로 되돌리기](useDeleteStoreSchedule — override 삭제 → 본사 기본 복귀, danger confirm 모달·진행 중 닫기 잠금). 저장 성공 시 “지금 곡은 끝까지·다음 곡(다음 30분 슬롯)부터 반영” success Banner(즉시 아님).

⚠️ 편집 잠금 판정은 isOverride 가 아니라 editing(= isOverride || localCustomizing) 이다. customize 는 본사 기본을 복사하는 연산이라 본사 기본이 비어 있으면 200 이어도 복사할 행이 없어 응답이 isOverride=false 로 돌아온다(BE 계약상 정상). 종전처럼 isOverride 로만 잠그면 [눌러서 커스텀 시작하기]를 눌러도 화면이 그대로여서, 본사가 시간표를 한 칸도 넣지 않으면 점장은 시간표를 만들 수 없었다(고객사 제보). 편집 경로 자체는 열려 있다 — PUT 은 override 행이 없어도 upsert 로 생성한다. 그래서 customize 성공 시 로컬 편집 모드(localCustomizing)로 진입하고, 저장이 성공하면 서버가 isOverride=true 를 돌려주며 플래그는 무의미해진다. 저장 전 상태(unsavedCustom)에서는 서버에 만든 게 없으므로 [되돌리기] 대신 [취소](로컬 리셋 · 요청 0)를 노출한다.

편집 대상 식별·전환 — 선택 가능한 PL 이 2개 이상이면 드롭다운(store-schedule-playlist-select) 으로 이 화면에서 바로 전환한다. 시간표는 PL 단위라 PL 이 바뀌면 편집 대상도 바뀌는데, 종전엔 어느 PL 인지 알 수 없었고 바꾸려면 /store/playlist 로 나갔다 와야 했다(고객사 제보).

  • 드롭다운은 두 의미를 겸하지 않는다 — 값·라벨은 “이 기기가 트는 재생목록”(기기 스코프면 devicePlaylistId ?? 매장 활성 PL)이고, 아래 그리드가 편집하는 시간표의 PL 은 보조 텍스트 (store-schedule-editing-playlist)로 따로 밝힌다. 표시를 mutation 과 같은 스코프에서 파생시키지 않으면 전환 직후 값이 원래대로 되돌아가고(재조회 응답의 매장 playlistId 는 안 바뀐다) 매장 기본으로 되돌릴 UI 경로도 사라진다(형제 [플레이리스트] 모달의 selectedPlaylistId 와 같은 방식).
  • 전환 mutation 도 스코프로 갈린다 — 기기가 있으면 useSetStoreDevicePlaylist(이 PC 에만), 없으면 useSetStoreOwnActivePlaylist(매장 단위). 404 STORE_DEVICE_NOT_FOUNDonDeviceNotFound 로 올려 player 가 1회 자가 재등록하게 한다.
  • 저장 안 한 편집이 있으면 확인 후 전환(draft 가 버려지므로). 전환 성공 시 시간표·기기 PL·큐를 무효화하고 draft 와 로컬 편집 모드를 명시적으로 리셋한다 — 기기 스코프 전환은 매장 활성 PL 을 바꾸지 않아 재조회 응답 signature 가 그대로라, 리셋하지 않으면 저장한 적 없는 편집이 남은 채 그리드가 잠긴다.
  • 전환 성공은 onPlaylistSwitched 로 올려 형제 모달과 같은 계약(명시적 의도 = 큐를 0번부터)을 세운다.
  • 전환도 진행 중 잠금 집계에 포함된다 — 요청 중 모달이 닫히면 react-query v5 가 onSuccess 를 호출 하지 않아 서버만 바뀌고 큐 무효화가 통째로 유실된다(“바꿨는데 음악이 안 바뀐다”의 재현 경로).
  • PL 후보가 1개뿐이면 드롭다운 대신 이름만 표시(store-schedule-playlist-name).

⚠️ 활성 PL 변경은 어느 화면에서 하든 시간표 쿼리를 무효화해야 한다. /store/playlist 에서 바꿀 때 이 무효화가 빠져 있어 시간표 화면이 옛 PL 것을 계속 보여줬다(새로고침 필요 — 고객사 제보).

팔레트 · stale entry 경계 (BE-4 P2)

  • 팔레트 = availableLibraries(ScheduleLibraryOption[]) — 활성 PL 에 담긴 라이브러리 전체 (position 순). 빈 곳 드래그로 블록을 추가할 브러시 후보(+ 지우기).
  • ⚠️ entrieslibraryId 는 쓰기 시점에만 팔레트 부분집합이 보장된다. 쓰기 후 라이브러리가 PL 에서 제거/soft-delete 되면 entries ⊄ availableLibraries 가 될 수 있다(그때 libraryName=null). “모든 entry 는 팔레트에 있다”를 전제하지 않는다 — 공유 편집기가 그런 stale 칸을 크래시 없이 회색 “삭제된 라이브러리” 로 방어 렌더하고, 저장 시 FE 가 팔레트 밖 libraryId 를 걸러 자연 제거한다 (SCHEDULE_LIBRARY_NOT_MEMBER 400 예방). BE @minItems 1 이라 걸러 빈 배열이 되면 저장을 막고 [되돌리기]로 유도한다.

계약

  • 4종 모두 optional query deviceId(SPEC #178) — 주면 큐와 같은 3단 해석으로 편집 대상 PL 을 고른다. FE 는 기기가 있으면 조회·시딩·저장·해제 전부에 싣는다(위 §시간표도 기기 스코프다).
  • GET /api/v1/store/me/schedulegetStoreScheduleStoreScheduleResponse { playlistId:UUID?, playlistName:string?, isOverride, entries: StoreScheduleEntry[], availableLibraries: ScheduleLibraryOption[] }.
  • POST /store/me/schedule/customize(useCustomizeStoreSchedule) — 본사 기본을 매장 사본으로 시딩. 본사 기본이 비어 있으면 복사할 행이 없어 200 + isOverride=false 로 돌아온다(정상) — FE 는 이때 로컬 편집 모드로 진입해 PUT 으로 직접 구성한다. 409 SCHEDULE_ALREADY_CUSTOMIZED·409 SCHEDULE_NO_ACTIVE_PLAYLIST(둘 다 FE 는 재조회로 자연 교정).
  • PUT /store/me/schedule(useSetStoreSchedule) — SetStoreScheduleRequest { entries: [{startMinute,endMinute,libraryId}] }(1~48개) 전체 교체. 400 SCHEDULE_INVALID_BOUNDS· 400 SCHEDULE_LIBRARY_NOT_MEMBER·409 SCHEDULE_TIME_OVERLAP.
  • DELETE /store/me/schedule(useDeleteStoreSchedule) — override 삭제, 204, 본사 기본 복귀.
  • 변경 성공 시 getStoreSchedule + getStorePlaybackQueue(다음 큐 빌드 영향) 캐시 invalidate.

범위 밖

  • 점장 PL 편집(곡 추가/제거 — 운영사 전용 유지). 점장은 선택만.
  • 셔플·default 지정 등 PL 내부 운영(기존, 운영사/본사).
  • 운영사 PL 상세 시간표 탭 + 본사 읽기(FE-3 — 같은 ScheduleGridEditor surface=“ops” 재사용).

References

  • SPEC #129 (점장 활성 플레이리스트 선택) · #055/#058(활성 PL 지정·기본 PL fallback) · #122(MISMATCH 큐 필터) · #114(store audit) · #129(STORE_ACTIVE_PLAYLIST_CHANGED audit)
  • SPEC #171 (시간대별 라이브러리 시간표 · 점장 override — 복사 후 수정·본사 기본 복귀 · 팔레트 · stale entry 경계)
  • SPEC #178 (매장 멀티 기기 — 기기별 활성 PL·큐 해석 우선순위 DEVICE→ACTIVE→DEFAULT · 시간표 4종의 deviceId 파라미터·공용 해석 컴포넌트 StorePlaylistResolver). Store Devices
  • 계약: listStorePlaylists·setStoreOwnActivePlaylist·getStoreSchedule·customizeStoreSchedule·setStoreSchedule·deleteStoreSchedule
  • BE: api/store/*Controller·DTO · application/store/*Service(본사 PL 목록·활성 지정·시간표 override·격리·audit) · application/store/StorePlaylistResolver(큐·시간표 공용 3단 해석) · domain/enums/StoreAuditAction.STORE_ACTIVE_PLAYLIST_CHANGED
  • FE: apps/space/.../store/playlist/page.tsx · store-playlist-client.tsx · apps/space/.../store/schedule/page.tsx · store-schedule-client.tsx · @linkmusic/ui ScheduleGridEditor(공유 편집기 · surface=“store”|“ops”)