FeaturesStore (매장)Store Devices (/store/devices · 점장 재생 기기 관리)

Store Devices — /store/devices (점장 재생 기기 관리)

SPEC #178 도입. 한 매장에서 PC 여러 대가 같은 점장 계정으로 음악을 트는 현실을 제품 모델에 반영한다. 점장(STORE_MANAGER)이 자기 매장에 등록된 기기(PC) 목록을 보고 이름을 바꾸거나 쓰지 않는 기기를 회수하는 화면 + 그 기기를 만들어내는 자동 등록 흐름.

시안 출처: 본 화면 전용 시안 부재 — atom-grounded 합성이다. /store/support(#112) 목록의 점장 가독성 idiom(큰 폰트·큰 버튼·StoreSubHeader) + /store/profile(#087) 카드 idiom 을 그대로 미러했다. 전용 시안 도착 시 교체(시각만 — 동작·계약 보존).

Overview

문제: 제품에 “기기” 개념이 없어서 같은 매장의 모든 PC 가 store 행 하나(활성 PL·재생 상태)와 announcement_dispatch 행 하나(방송 ack)를 공유했다. 그 결과 고객사에서 (1) 재생목록이 자꾸 어긋나고 (2) 한 대에서 플레이리스트를 바꾸면 다른 대도 따라가는 문제가 보고됐다.

해결: 브라우저마다 UUID(deviceKey)를 만들어 localStorage 에 두고 서버에 등록해 기기를 식별한다. 매장 로그인은 계정 공유가 전제라 계정으로는 기기를 구분할 수 없기 때문이다(D1). 그 위에 기기별 활성 PL(D3) · 재생 상태·재생 로그(D5) · 방송 ack(D11)가 얹힌다.

근거
기기 식별클라 발급 UUID + 서버 등록(D1)계정 공유 전제 — 계정으로 기기 구분 불가
매장당 상한4대(D2 · StoreDevice.DEFAULT_DEVICE_LIMIT)사용자 확정(“3~4대 정도, 최대”) · 무한 증식 차단
슬롯 회수점장이 수동 정리(D14 · 서버 자동 회수 없음)오프라인 ≠ 폐기. 자동 회수는 잠깐 꺼둔 PC 의 슬롯을 뺏어 재생 중인 기기를 끊는다
하위호환deviceId전 경로 옵션(D12)재생 중인 구버전 클라이언트가 새로고침 전까지 계속 도는 것이 전제

기기 등록 (자동) — useStoreDevice

점장이 따로 하는 일은 없다. player 마운트 시 자동으로 1회 등록한다 (apps/space/src/app/store/use-store-device.ts).

  1. getOrCreateDeviceKey()(apps/space/src/lib/device-key.ts) — localStorage lm.device.key 를 읽고 없으면 UUID 를 만들어 저장한다. 같은 브라우저 프로필이면 재방문 시 같은 키가 나가므로 새로고침·재부팅으로 기기가 늘지 않는다(서버 등록이 멱등).
  2. POST /api/v1/store/devices(useRegisterStoreDevice) → deviceId 확보.
  3. 확보한 deviceId 를 큐 조회·재생 보고·방송 pending/ack·시간표·점장 즉시방송이 함께 보낸다.

등록은 네이티브 upsert 다 — 동시 요청에서 500 이 나지 않는다. 서버는 ON CONFLICT ... DO NOTHING (StoreDeviceRepository.insertIfAbsent)으로 INSERT 하고 affected 를 보지 않고 항상 재조회해 그 행을 돌려준다(신규든 남이 먼저 만들었든 결과가 같다 = 멱등 200). 종전의 “saveAndFlush → 제약 위반 catch → 재조회”는 flush 중 위반이 세션을 rollback-only 로 마킹해서, 재조회가 성공해도 커밋에서 UnexpectedRollbackException 이 터져 500 이 나갔다(같은 브라우저 탭 2개가 동시에 부팅하면 한쪽이 조용히 기기 없이 동작). 등록은 audit 하지 않는다 — player 마운트마다 일어나는 멱등 동작이라 남기면 감사 로그가 그것만으로 찬다.

기기 404 시 1회 자가 재등록 — 점장이 다른 PC 에서 이 기기를 [정리]하면 로컬 deviceId 가 stale 해져 기기 스코프 쓰기가 전부 404 가 되는데, 무인 매장에는 새로고침해 줄 사람이 없다. 404 를 본 호출부가 recoverFromNotFound() 를 부르면 확정 잠금을 풀고 세션당 한 번만 재등록한다(재등록이 또 404 를 부르는 루프를 구조적으로 차단).

저장소가 막힌 기기 — 등록 생략 + 매장 단위 폴백

시크릿 모드·키오스크 설정·쿼터 초과처럼 localStorage 에 쓸 수 없는 기기에서 매 로드마다 새 키를 만들면 매장당 4대를 유령 기기로 금방 소진한다(온보딩 완료 플래그에서 이미 겪은 실패 모드 — known-gaps). 그래서 쓰기 프로브로 먼저 판정한다:

  • canPersist() 가 실패하거나 저장 직후 되읽기가 어긋나면 getOrCreateDeviceKey()null 을 돌려주고, 훅은 등록 자체를 건너뛴다(storageBlocked=true).
  • 그 기기는 deviceId 없이 종전 매장 단위로 동작한다 — 음악은 정상 재생되고 기기별 재생목록만 못 쓴다. 유령 기기를 만드는 것보다 낫다.

등록 실패는 화면을 막지 않는다

deviceId 가 없으면 호출부는 그냥 보내지 않고 서버가 종전 경로로 처리한다(D12). 재생이 기기 등록에 인질로 잡히면 안 된다 — 등록 실패(네트워크·5xx)는 조용히 무시한다.

큐 조회는 등록 시도가 끝난 뒤(resolved=true) 시작한다. 등록 중에 큐를 먼저 부르면 deviceId 가 채워질 때 쿼리 키가 바뀌어 큐가 통째로 다시 로드되고 재생이 리셋되기 때문이다(등록은 수백 ms 라 체감 지연이 없고, 어차피 재생은 사용자가 [시작하기] 를 눌러야 시작된다).

또 훅은 “등록 시도가 일단락됐는가”(registrationSettled — 성공·상한 초과·저장소 차단·재시도 예산 소진)를 따로 내보낸다. 등록 결과에 의존하는 1회성 부수효과(방송 backlog drain 의 기기별 ack)가 등록 도중에 먼저 실행되면 deviceId 없는 SKIPPED ack 이 나가 BE 가 매장 단위로 처리하고, 그 ack 하나로 dispatch 가 종착해 이미 등록된 다른 PC 들까지 그 방송을 받지 못한다. 자세한 게이트는 Store Player §backlog drain.

상한 초과 (409 DEVICE_LIMIT_EXCEEDED)

등록이 409 로 거절되면 훅이 limitExceeded=true 를 올리고, player 상단에 warn Banner (store-player-device-limit)를 띄운다.

요소
제목”이 매장에 등록된 기기가 가득 찼습니다”
본문”음악은 정상 재생되지만, 이 PC 만의 재생목록은 설정할 수 없습니다. 사용하지 않는 기기를 정리한 뒤 화면을 새로고침해 주세요.”
액션[기기 정리하기](store-player-device-limit-manage) → 기기 관리 모달

정리 경로를 배너 안에 두는 이유는 여기가 점장이 상한을 처음 인지하는 지점이고, 정리 화면을 스스로 찾아가게 두면 그대로 방치되기 때문이다. 라우트 이동이 아니라 모달이라 정리하는 동안에도 음악이 끊기지 않는다.

핵심: 상한에 걸려도 음악은 정상 재생된다. 잃는 것은 기기별 재생목록뿐이다.

화면 (/store/devices)

apps/space/src/app/store/devices/page.tsx(server 셸 — force-dynamic, 본문만 렌더) + store-devices-client.tsx(client). /store layout(server)이 STORE_MANAGER role 가드를 담당한다.

  • 상한 안내 — 상한 미만이면 muted 한 줄(store-devices-remaining) “N / 4대 사용 중 · M대 더 등록할 수 있습니다.”, 도달했으면 warn Banner(store-devices-limit-banner)로 정리를 유도.
  • 기기 목록(store-devices-list) — useListStoreDevices()(등록 순). 행마다 이름 · 마지막 신호 (lastSeenAt) · 등록 시각(createdAt)을 KST 24시간제로 표시. 요청 파라미터가 없다(storeId 는 토큰 주체에서 도출 — 타 매장 기기 비노출).
  • “이 기기” 배지(store-device-current-badge) — 지금 보고 있는 PC 를 구분한다. 이게 없으면 어느 줄을 지워야 안전한지 알 수 없다. 모달 경로에서만 표시된다 — deviceId 는 브라우저 localStorage 기반이라 라우트(server)로 직접 들어오면 알 수 없다(currentDeviceId prop 미지정 → 배지 없이 목록만).
  • 이름 인라인 편집 — 행 [이름 변경] → input(1~50자 · BE RenameStoreDeviceRequest.label 제약과 일치) → 저장. Enter 저장 · Esc 취소. 보조 문구로 “PC 가 놓인 위치를 적어두면 나중에 정리하기 쉽습니다.”를 안내한다(기본값 “기기 1” 로는 정리 판단이 불가능하다). 실패해도 편집 상태를 유지한다(입력한 값을 잃지 않게).
  • 회수(2단계 확인) — 행 [정리] → 같은 줄 안에서 [취소]/[정리하기] 확인 (useRevokeStoreDevice). window.confirm 은 쓰지 않는다 — 이 화면이 player 위 모달로 열려 있을 때 브라우저 모달이 뜨면 그 뒤의 오디오·타이머가 걸린 이벤트 루프를 막는다.
  • 빈 상태(store-devices-empty) — “아직 등록된 기기가 없습니다. 매장 PC 에서 재생 화면을 열면 자동으로 등록됩니다.”
  • 에러 — 공용 ErrorState(store-devices-error) + 재시도.

회수의 의미

슬롯을 비우고 그 기기의 기기별 재생목록 설정을 버린다. 그 PC 가 다시 켜지면 같은 deviceKey새 기기 id 를 받아 재등록되므로(V57 partial unique 가 deleted_at IS NULL 조건) 재생이 막히지는 않는다 — 상한이 남아 있는 한. 그래서 “지금 이 기기” 회수도 허용하되 결과를 문구로 알린다:

대상확인 문구(store-device-revoke-hint-*)
이 기기”지금 보고 있는 기기입니다. 정리하면 이 PC 의 기기별 재생목록 설정이 사라지고, 다음 새로고침에 새 기기로 다시 등록됩니다.”
다른 기기”이 기기의 기기별 재생목록 설정이 사라지고 슬롯이 비워집니다. 그 PC 가 다시 켜지면 새 기기로 등록됩니다.”

진입점

라우트 이동은 player 를 언마운트해 음악을 끊으므로 평상시 진입은 전부 모달이다 (/store/support·/store/profile 와 같은 관례). /store/devices 라우트는 딥링크·북마크용으로 유지한다.

경로동작
player 상단 상한 배너 기기 정리하기기기 관리 모달(store-player-devices-modal) — embedded + currentDeviceId 주입
프로필 → [재생 기기 관리](store-profile-devices-link)같은 모달. 프로필 모달 안에서는 콜백(onOpenDevices)으로 모달 전환, 페이지 진입이면 /store/devices 링크
/store/devices 직접 진입페이지 셸(StoreSubHeader — [재생 화면으로]) + 목록. “이 기기” 배지 없음

프로필에 진입점을 둔 이유: 상한에 도달하기 전에도 찾을 수 있어야 한다. 배너에만 두면 이미 막힌 뒤에야 존재를 알게 되고, 그때는 어느 기기가 무엇인지 기억나지 않아 정리 판단이 불가능하다.

계약

endpointoperationId용도
POST /api/v1/store/devicesregisterStoreDevice기기 등록(멱등). 409 DEVICE_LIMIT_EXCEEDED
GET /api/v1/store/deviceslistStoreDevices목록(등록 순·회수분 제외)
PATCH /api/v1/store/devices/{id}renameStoreDevice이름 변경
DELETE /api/v1/store/devices/{id}revokeStoreDevice회수(204·soft-delete)
GET /api/v1/store/devices/{id}/active-playlistgetStoreDevicePlaylist기기별 활성 PL 조회(/store/playlist·/store/schedule 표시 근거)
PATCH /api/v1/store/devices/{id}/active-playlistsetStoreDevicePlaylist기기별 활성 PL 지정/해제

전부 STORE_MANAGER-only. /api/v1/store/** prefix 매처가 1차 경계고, service 가 PrincipalScopeGuard 로 claim↔DB 를 재검증한다(경로에 storeId 없음 — 토큰 주체 스코프). 미존재·회수됨·타 매장은 전부 404 STORE_DEVICE_NOT_FOUND(존재 은닉).

삭제된 PL 이 “지정됨”으로 남지 않는다 — PL soft-delete 트랜잭션이 매장 활성 PL(store.active_playlist_id) 뿐 아니라 기기 지정(store_device_playlist)도 함께 해제한다(clearByPlaylistId). 종전엔 기기 쪽 정리 호출부가 없어서 getStoreDevicePlaylist 가 존재하지 않는 playlistId 를 계속 돌려줬다(재생 자체는 큐 해석의 findActiveById 폴백으로 안전했지만 화면이 거짓말을 했다). 조회도 같은 기준으로 한 번 더 걸러 stale 이면 playlistId=null 로 정규화한다(이중 안전 — 화면과 재생의 판단이 어긋나지 않는다).

기기 스코프가 얹힌 다른 경로

경로기기 인지 후
재생 큐 GET /store/queue?deviceId=해석 우선순위 DEVICE → ACTIVE → DEFAULT → NONEStore Active Playlist
시간표 GET/PUT/DELETE /store/me/schedule·POST …/customize4종 모두 optional deviceId — 큐와 같은 3단 해석(공용 StorePlaylistResolver)으로 편집 대상 PL 결정 — Store Active Playlist
재생 보고 POST /store/playback/reportdeviceId 동반 → 기기별 상태·로그 — Store Player
방송 pending / ack전 기기 재생 보장 + 기기별 ack(정족수) — Store Broadcast
점장 즉시방송 POST /store/broadcasts/{id}/sendbody optional deviceIdannouncement_dispatch.target_device_id그 PC 1대만Store Broadcast
본사 대시보드 “지금 재생 중”store_device_playback_status 우선 집계(하나라도 PLAYING → PLAYING) — HQ Mode 대시보드

Decisions

  • 클라 발급 키 + 서버 등록(D1) — 서버는 deviceKey 를 만들지 않고 받기만 한다. 매장 계정 공유가 전제라 서버가 기기를 식별할 다른 축이 없다.
  • 상한 4대·서버 자동 회수 없음(D2·D14) — 서버는 어느 기기가 “죽었는지” 확신할 수 없다. 잠깐 껐거나 회선이 끊긴 것과 폐기를 구분할 수 없고, 잘못 회수하면 재생 중인 기기가 튕긴다. 정리는 점장이 명시적 으로 한다. 상한 자체의 race(동시 4→5)는 허용한다 — 상한은 무한 증식 방어이지 정확한 회계가 아니고, 초과분은 점장이 목록에서 정리한다.
  • DEVICE_LIMIT 상수 복제 — FE 는 안내 문구용으로 4 를 복제한다(OpenAPI 가 상수를 노출하지 않는다). 판정은 서버가 한다 — 값이 어긋나도 안내 문구만 틀리고 등록/회수 동작은 정확하다.
  • 저장 불가 기기는 등록 생략 — 새 키를 계속 만들면 상한을 유령 기기로 소진한다. 기기별 기능만 포기하고 매장 단위로 조용히 폴백한다.
  • 모달 우선 진입 — 라우트 이동 = player 언마운트 = 음악 정지. /store/devices 라우트는 유지하되 주 동선은 모달이다(player 오디오 유지 불변식).

감사 (audit)

점장 기기 관리 액션은 store_audit_log 에 남는다 — “저 PC 만 음악이 왜 바뀌었나” 류 CS 를 추적할 유일한 단서인데, 종전엔 매장 활성 PL 변경(STORE_ACTIVE_PLAYLIST_CHANGED)만 감사되고 기기 쪽은 비어 있었다.

action계기target기록 규칙
STORE_DEVICE_RENAMED이름 변경STORE_DEVICE(target_label = 변경 후 이름)detail = before->after. 같은 이름 재저장은 미기록(멱등 no-op 관례)
STORE_DEVICE_REVOKED회수(soft-delete)STORE_DEVICE(target_label 없음)원자적 조건부 UPDATE 의 affected=1 확정 직후. 사전 조회를 두지 않아 race window 0 을 유지하므로 이름은 lookup 하지 않는다(STORE_DISPATCH_CANCELED 관례 미러)
STORE_DEVICE_PLAYLIST_CHANGED기기별 활성 PL 지정·해제STORE_DEVICE(target_label = 기기 이름 스냅샷)detail = playlist=<id|none>-><id|none>. 같은 PL 재지정은 미기록
  • 등록(registerStoreDevice)은 감사하지 않는다 — player 마운트마다 일어나는 멱등 동작이라 남기면 감사 로그가 그것만으로 찬다.
  • 대상 유형 STORE_DEVICEStoreAuditTargetType 에 추가돼 5종이 됐다. 운영사 통합 매장 audit 화면(/audit/store)의 라벨은 기기 이름 변경 · 기기 정리 · 기기 PL 변경 이고, 회수만 warn 톤(되돌릴 수 없고 그 기기의 기기별 재생목록이 사라진다). 전체 목록은 Store 감사.

후속

  • 기기 목록에 재생 상태 표시store_device_playback_status 는 적재되고 본사 대시보드 집계에 쓰이지만(SPEC #178 통합 검토에서 조회 병합이 닫혔다) 점장 화면에는 아직 노출하지 않는다.
  • 기기 원격 제어(재시작·큐 푸시) · 기기 수 기반 과금 — SPEC #178 명시적 비포함.
  • 전용 시안 — 현재 화면은 atom-grounded 합성이다.

References

  • SPEC #178 — 매장 멀티 기기(기기 등록·상한·기기별 PL·기기별 보고/ack·방송 동시 재생 · 통합 검토 후속: 시간표 기기 스코프·즉시방송 기기 한정·본사 대시보드 기기 단위 집계·기기 관리 audit).
  • Flyway V57 store_device · V58 store_device_playlist · V59 play_log.device_id · store_device_playback_status · V60 dispatch_device_ack · V61 announcement_dispatch.play_at · V62 play_at 백필·중복 인덱스 정리 · V63 announcement_dispatch.target_device_id · V64·V65 폴링/보고 경로 partial index.
  • BE: api/store/StoreDeviceController.kt · application/store/StoreDeviceService.kt · application/store/StorePlaylistResolver.kt(큐·시간표 공용 3단 해석) · domain/entity/StoreDevice.kt.
  • FE: apps/space/src/app/store/devices/page.tsx · store-devices-client.tsx · apps/space/src/app/store/use-store-device.ts · apps/space/src/lib/device-key.ts.