Store Audit — store_audit_log (점장 액션 감사 백본)
SPEC #114. 점장(STORE_MANAGER) 액션 감사 백본 + 대기 중이던 점장 audit tail 4건 일괄 마감
(#112 ticket · #087 F4 프로필 · #100 F2 비밀번호 · #092 F2 예약 취소). 본사 감사
(HQ Audit · hq_audit_log, SPEC #067) 의 1:1 미러 백본.
Overview
점장이 수행하는 핵심 변경 액션을 actor·시각·대상 스냅샷으로 append-only 기록하는 BE 인프라.
“누가(actorEmail)·언제(occurredAt)·무엇을(action)·어느 대상에(targetType + targetLabel)·어느
매장에서(storeId)” 를 추적한다. 본사 audit(hq_audit_log, V27) 를 1:1 미러한 백본
(store_audit_log, V36)으로, 테넌트 스코프 키만 hq_id → store_id 로 바뀌고 actor 컬럼·임퍼소네이션
정합성·인덱스 전략이 동일하다.
#114 = 기록만 (reader 없이 BE 인프라 + 4 hook). 점장 본인은 자기 audit 을 볼 필요가 없다. #115 에서 운영자 백오피스 조회 view 가 도착해 reader 후속을 마감했다(아래 조회 view). 본사가 자기 산하 매장 audit 을 보는 view 는 여전히 후속(별 SPEC).
hq_audit_log의 기록 먼저·view 나중 패턴을 그대로 따랐다.
별도 테이블인 이유
operator_audit_log(운영자 액션, #026)·hq_audit_log(본사 액션, #067) 와 의미가 분리된 별도 테이블이다 —
actor·테넌트 스코프(store_id)·UI 권한 모델이 다르다. 운영자/본사 audit 에 점장 액션을 섞으면 OPERATOR-only /
HQ_MANAGER-only 로 굳은 조회 권한 의미가 오염된다. HqAudit 가 operator_audit_log 확장 대신 신규 테이블을
택한 것과 같은 판단(#067).
기록 액션 (StoreAuditAction · 8종)
각 계기 service 의 액션 성공 직후 같은 트랜잭션 안에서 StoreAuditService.record(...) 를 1줄 호출한다 —
액션과 감사의 원자성(audit INSERT 실패 시 본 작업도 함께 롤백, #026 패턴). DB 에는 enum name(varchar)으로
저장하고, 새 점장 액션은 이 enum 을 확장한다(VARCHAR(48) 영속이라 마이그레이션 무관).
| action | 한글 의미 | 계기 (BE service) | 대상 targetType | detail 스냅샷 | 마감 tail |
|---|---|---|---|---|---|
STORE_TICKET_CREATED | 점장 CS 티켓 생성 | StoreTicketService.createStoreTicket | SUPPORT_TICKET | title=...·category=NAME·bodyLength=N | #112 audit tail |
STORE_TICKET_COMMENT_ADDED | 점장 CS 티켓 댓글 추가 | StoreTicketService.addStoreTicketComment | SUPPORT_TICKET | bodyLength=N | #112 audit tail |
STORE_TICKET_CLOSED | 점장 CS 티켓 확인 종료 (RESOLVED→CLOSED) | StoreTicketService.closeStoreTicket | SUPPORT_TICKET | title 스냅샷 (target_label) | #121 |
STORE_PROFILE_UPDATED | 점장 본인 매니저 이름 편집 | StoreMeService.updateMe | STORE_MANAGER | changedFields=name·name:before->after | #087 F4 |
STORE_PASSWORD_CHANGED | 점장 본인 비밀번호 변경 | 공용 AuthService.changePassword (role 분기) | STORE_MANAGER | 없음 (보안) | #100 F2 |
STORE_DISPATCH_CANCELED | 점장 본인 예약 송출 취소 | StoreBroadcastService (cancel) | DISPATCH | 없음 | #092 F2 |
STORE_ACTIVE_PLAYLIST_CHANGED | 점장 본인 매장 활성 PL 선택·해제 | StoreOwnPlaylistService (set active) | PLAYLIST | 선택/해제 스냅샷 | #129 |
STORE_DISPATCH_BROADCAST_NOW | 점장 예약 송출 즉시 방송(원본 SCHEDULED 유지·새 IMMEDIATE 1회) | StoreBroadcastService (broadcast-now) | DISPATCH | 없음 | #142 |
세부 기록 규칙:
- ticket 생성/댓글 —
target_id = ticket.id(댓글 audit 도 ticket scope — 댓글 id 아님),target_label = ticket.title스냅샷. 본문 자체는 audit 에 두지 않고bodyLength=N시그널만 기록한다. - 프로필 편집 —
target_id = 점장 계정 id,target_label = 변경 후 name. 실제 변경 0(멱등 no-op)이면 audit row 를 만들지 않는다(HqMe 멱등 관례 미러 — 변경 없는 PATCH 의 audit 노이즈 차단). - 비밀번호 변경 — 공용
POST /api/v1/auth/change-password경로에서principal.role == STORE_MANAGER분기로만 store audit 을 기록한다(HQ_MANAGER 경로의 본사 비번 변경 #099 동작·audit 은 현행 유지 — 회귀 0).detail없음(비밀번호 자체·길이를 audit 에 두지 않음 — 보안). change-password 정책상 임퍼소네이션이 차단되므로 이 액션의 actor 는 항상 본인이다. - 예약 송출 취소 — SCHEDULED dispatch row 를 CANCELED 로 단방향 전이.
target_id = dispatch.id,detail없음. 원자적 UPDATE 후 affected=1 이 확정될 때에만 기록한다(사전 조회 없이 race window 0 유지).
actor 임퍼소네이션 분기 (SPEC #114 D2)
점장 권한으로 한 액션의 주체가 점장 본인인지, 운영자가 점장 모드로 위장(임퍼소네이션)했는지 구분한다 — HqAuditActorRole 분기 동일.
actor_account_id = principal.accountId(점장 계정),actor_email은OperatorAccountRepository.findById스냅샷(계정 후속 변경에도 당시 행위자 보존).principal.impersonatedBy == null→actor_role = STORE_MANAGER(직접 액션,impersonated_by_*null).principal.impersonatedBy != null→actor_role = OPERATOR_IMPERSONATING+impersonated_by_operator_id/impersonated_by_email(원본 운영자 스냅샷) 동시 기록 — 운영자가 점장 모드로 한 액션도 누가 했는지 정확히 추적한다.- actor·impersonator 어느 쪽이든 lookup 실패 시
IllegalStateException→ 본 액션까지 롤백(actor 미상으로 기록하면 감사 신뢰성이 깨지므로 진행 거부, #026 정합성 원칙). - DB CHECK 제약
(actor_role='OPERATOR_IMPERSONATING') = (impersonated_by_operator_id IS NOT NULL)으로 두 컬럼의 정합성을 DB 차원에서 강제(V27 미러).
테넌트 격리 · append-only
store_id가 필수다 — 호출 service 가PrincipalScopeGuard.verifyStoreScope로 검증한 storeId 를 그대로 넘긴다. 후속 조회 view 는 모든 쿼리에WHERE store_id = :storeId를 강제한다(타 매장 격리).- append-only — UPDATE/soft-delete 경로가 없다(기록 hook 의 단순 INSERT 만). 불변성을 schema 로 강제해
updated_at·deleted_at컬럼을 두지 않는다.target_label/detail은 DB 컬럼 길이에 맞춰 방어적 truncate(긴 입력이 audit INSERT 를 깨뜨려 정상 액션까지 롤백되는 일을 막음).
Service
StoreAuditService(HqAuditService 1:1 미러) — 점장 액션 service 가 액션 성공 직후
record(...) 를 호출한다. 별도 @Transactional 을 두지 않아 호출 service 의 진행 중인 트랜잭션에 그대로
참여한다(트랜잭션 경계는 호출 service 소유). recordAndFlush 도 미러 일관성을 위해 제공하지만 v1 store hook
에는 audit id 를 같은 트랜잭션 내 FK 에 set 하는 케이스가 없어 호출자는 전부 record 를 쓴다(HqAudit 의
dispatch fan-out 같은 구조가 store 에는 없음).
조회 view (운영자 백오피스) — SPEC #115
#114 의 write-only 백본에 reader 를 붙여 운영자가 점장 액션을 조사·추적한다. 운영자 audit 화면의 hq audit 탭(HQ Audit, #081)을 1:1 미러한 매장 audit 탭을 추가했다.
- surface = 운영자 백오피스(
apps/admin)만(v1, SPEC #115 D1). 운영자가 전 매장 store audit 통합 조회(WHERE store_id가드 없음). 본사가 자기 산하 매장 audit 을 보는 view 는 후속(별 SPEC). - read-only · 권한 변경 0(D3). OPERATOR-only —
store_audit_log는 append-only(#114), 조회만. - 라우트
/audit/store(apps/admin/.../audit/store/page.tsx+audit-store-client.tsx). 탭 네비 (audit-tabs.tsx)에 “매장 audit” 항목 추가. - 목록 컬럼: 발생 시각(KST) · 매장명(
storeName, store JOIN) · 액션 배지(7종 한글 라벨·톤) · 대상(targetType + targetLabel) · 행위자(actorEmail + role 배지, 임퍼소네이션이면 “운영자 위장” + 원본 운영자 이메일 보조 줄) · 상세(detail). - 필터: 기간(from/to, KST 09:00 경계 정규화) · 액션(
StoreAuditActionselect) · 매장 ID(storeIdUUID 자유 입력, 빈 값=전체 매장 통합) · 검색(q, 대상 라벨·상세 부분 검색 max 100). 정렬은 backend 고정 (occurred_at DESC, id DESC). - CSV 내보내기 — 현재 적용 필터 전체 매칭 행을 한 파일로(
exportStoreAudit). 상한 초과 시 응답 헤더X-Export-Truncated: true→ 잘림 경고 배너. hq audit CSV(#088) 정확 미러(BFF blob passthrough · UTF-8 BOM · RFC5987 파일명매장감사_{yyyyMMdd_HHmmss}.csv). - 계약:
listStoreAudit·exportStoreAudit·StoreAuditListItem·StoreAuditListResponse.
Roadmap (후속)
- 본사 매장 audit view — 본사가 자기 산하 매장의 점장 액션 audit 을 조회하는 view(별 SPEC). 운영자
view(#115)와 달리
WHERE store_id IN (본사 산하 매장)스코프 가드 필요. - 점장 액션 enum 확장 — 새 점장 write 경로 도착 시 hook + enum 추가.
- 실시간 audit · 보존 정책 — 본사·운영사 audit 와 공통 후속(#067 F6·F7).
References
- SPEC #114 (store audit 백본 + 점장 audit tail 4 마감) · #115 (운영자 백오피스 조회 view — reader 마감) · #112(점장 CS — ticket/댓글 audit) · #087 F4(프로필) · #100 F2(비밀번호) · #092 F2(예약 취소) · #081(운영사 hq audit 통합 view — 미러 원본) · #067(본사 audit 백본) · #026(운영자 audit — 원자성·actor 정합성 원칙)
- 스키마 StoreAuditLog · enum StoreAuditAction · StoreAuditActorRole · StoreAuditTargetType
- BE:
application/store/StoreAuditService.kt·domain/entity/StoreAuditLog.kt·domain/enums/StoreAuditAction.kt·StoreAuditActorRole.kt·StoreAuditTargetType.kt·db/migration/V36__store_audit_log.sql· hook:StoreTicketService·StoreMeService·AuthService(change-password 분기) ·StoreBroadcastService(cancel) · 조회(#115):StoreAuditQueryService·AuditController.listStoreAudit/exportStoreAudit - FE(#115):
apps/admin/.../audit/store/page.tsx·audit-store-client.tsx·audit-tabs.tsx(매장 audit 탭)