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).
getOrCreateDeviceKey()(apps/space/src/lib/device-key.ts) — localStoragelm.device.key를 읽고 없으면 UUID 를 만들어 저장한다. 같은 브라우저 프로필이면 재방문 시 같은 키가 나가므로 새로고침·재부팅으로 기기가 늘지 않는다(서버 등록이 멱등).POST /api/v1/store/devices(useRegisterStoreDevice) →deviceId확보.- 확보한
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)로 직접 들어오면 알 수 없다(currentDeviceIdprop 미지정 → 배지 없이 목록만). - 이름 인라인 편집 — 행 [이름 변경] → 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 — [재생 화면으로]) + 목록. “이 기기” 배지 없음 |
프로필에 진입점을 둔 이유: 상한에 도달하기 전에도 찾을 수 있어야 한다. 배너에만 두면 이미 막힌 뒤에야 존재를 알게 되고, 그때는 어느 기기가 무엇인지 기억나지 않아 정리 판단이 불가능하다.
계약
| endpoint | operationId | 용도 |
|---|---|---|
POST /api/v1/store/devices | registerStoreDevice | 기기 등록(멱등). 409 DEVICE_LIMIT_EXCEEDED |
GET /api/v1/store/devices | listStoreDevices | 목록(등록 순·회수분 제외) |
PATCH /api/v1/store/devices/{id} | renameStoreDevice | 이름 변경 |
DELETE /api/v1/store/devices/{id} | revokeStoreDevice | 회수(204·soft-delete) |
GET /api/v1/store/devices/{id}/active-playlist | getStoreDevicePlaylist | 기기별 활성 PL 조회(/store/playlist·/store/schedule 표시 근거) |
PATCH /api/v1/store/devices/{id}/active-playlist | setStoreDevicePlaylist | 기기별 활성 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 로 정규화한다(이중 안전 — 화면과 재생의 판단이 어긋나지 않는다).
- 계약 상세: Endpoints · Store Device DTOs · Error Codes
- 스키마: store_device · store_device_playlist
기기 스코프가 얹힌 다른 경로
| 경로 | 기기 인지 후 |
|---|---|
재생 큐 GET /store/queue?deviceId= | 해석 우선순위 DEVICE → ACTIVE → DEFAULT → NONE — Store Active Playlist |
시간표 GET/PUT/DELETE /store/me/schedule·POST …/customize | 4종 모두 optional deviceId — 큐와 같은 3단 해석(공용 StorePlaylistResolver)으로 편집 대상 PL 결정 — Store Active Playlist |
재생 보고 POST /store/playback/report | deviceId 동반 → 기기별 상태·로그 — Store Player |
| 방송 pending / ack | 전 기기 재생 보장 + 기기별 ack(정족수) — Store Broadcast |
점장 즉시방송 POST /store/broadcasts/{id}/send | body optional deviceId → announcement_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_DEVICE가StoreAuditTargetType에 추가돼 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· V58store_device_playlist· V59play_log.device_id·store_device_playback_status· V60dispatch_device_ack· V61announcement_dispatch.play_at· V62play_at백필·중복 인덱스 정리 · V63announcement_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.