FeaturesAudit (감사 로그)Store Audit (store_audit_log)

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_idstore_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)대상 targetTypedetail 스냅샷마감 tail
STORE_TICKET_CREATED점장 CS 티켓 생성StoreTicketService.createStoreTicketSUPPORT_TICKETtitle=...·category=NAME·bodyLength=N#112 audit tail
STORE_TICKET_COMMENT_ADDED점장 CS 티켓 댓글 추가StoreTicketService.addStoreTicketCommentSUPPORT_TICKETbodyLength=N#112 audit tail
STORE_TICKET_CLOSED점장 CS 티켓 확인 종료 (RESOLVED→CLOSED)StoreTicketService.closeStoreTicketSUPPORT_TICKETtitle 스냅샷 (target_label)#121
STORE_PROFILE_UPDATED점장 본인 매니저 이름 편집StoreMeService.updateMeSTORE_MANAGERchangedFields=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_emailOperatorAccountRepository.findById 스냅샷(계정 후속 변경에도 당시 행위자 보존).
  • principal.impersonatedBy == nullactor_role = STORE_MANAGER(직접 액션, impersonated_by_* null).
  • principal.impersonatedBy != nullactor_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 경계 정규화) · 액션(StoreAuditAction select) · 매장 ID(storeId UUID 자유 입력, 빈 값=전체 매장 통합) · 검색(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 탭)