move_detect/CHANGELOG.md
유창욱 3aca674a78
Some checks failed
verify / edge (push) Has been cancelled
verify / server (push) Has been cancelled
feat: 이해관계자 페르소나 검증 4건 — 열람 감사 · 증거 보존 · 크론 생존 · 증거 정책 (1.3.0)
앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.

읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.

크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.

증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.

증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.

마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).

검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-25 18:34:26 +09:00

18 KiB

CHANGELOG — move-detect (작업장 안전 AI CCTV)

버전 표기는 저장소 루트 VERSION 파일이 단일 출처입니다. 납품 산출물 파일명에도 같은 버전이 들어갑니다: move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip / move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>.tar.gz


1.3.0 — 2026-09-25 · 읽기 감사 · 증거 보존 · 감시자 감시

이해관계자 5인(작업자·노조·안전관리자·공장장·설치기사) 관점으로 제품을 다시 검증한 결과 나온 네 가지입니다. 넷 다 같은 모양이었습니다 — 구현도 테스트도 초록인데, 그 기능이 존재해야 할 이유가 닿는 지점에서 아무 일도 일어나지 않는 상태.

추가 — 열람·반출 감사기록

이 시스템은 쓰기를 전부 감사했습니다. 임계값 한 번 바꾼 것까지 해시 체인에 들어갑니다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았습니다.

감시 시스템과 안전 시스템을 가르는 선이 정확히 거기입니다. 나머지 프라이버시 설계 (RAM 링버퍼 7.5초·전송 전 얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장 에 머물러 있었습니다. 교섭에서 "지난달에 우리 조합원 사진 누가 봤습니까"에 답이 없으면 "안 봤습니다"는 증명이 아니라 주장입니다.

  • 스냅샷 열람 → 감사 동작 snapshot_view (세션당 사진 1회)
  • 라벨 데이터셋 대량 반출 → label_export (건수·기간과 함께)

추가 — 증거 보존 보류 (hold_until)

스냅샷은 나이만 보고 지워졌습니다. 기본 7일인데 중대재해 조사는 몇 주에서 몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라지는 상태였습니다.

admin 만, 건별로, 최대 1년, 감사기록과 함께 보류를 겁니다. 확인 처리만으로 자동 연장되지 않습니다 — 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다. 만료되면 사람이 해제하지 않아도 다시 삭제 대상이 됩니다. 사건 상세 화면에 버튼이 생깁니다.

추가 — 통보 디스패처 생존 표시

죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정합니다. 그런데 그 크론이 멈추면 아무도 판정하지 않습니다 — 통보·5분 미확인 확대·장치 이상 발행이 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과 구별되지 않습니다. 관리자 화면이 이에 대해 하던 일은 "크론으로 실행 중이어야 합니다"라는 안내문 한 줄이었습니다.

이제 크론이 매 실행마다 시각을 남기고, 대시보드가 5분 넘게 갱신이 없으면 배너를 띄웁니다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 하기 때문입니다. 현장 경광등은 GPIO 직결이라 이 상황에서도 영향받지 않습니다.

변경 — 체류 상한 초과에는 사진을 붙이지 않습니다

증거 프레임은 ergo_report 하나만 빼고 모든 이벤트에 붙었습니다. 체류 상한 초과 (overstay)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안 주기적으로 본인 사진이 서버로 가는 상태였습니다. 위험해서가 아니라 오래 있어서.

기준을 명문화했습니다 — 시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다. 규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이므로 그대로 둡니다.

마이그레이션

2026-09-25-evidence-hold-and-view-audit.sql — move_events.hold_until 컬럼과 move_app_state 표. README-UPLOAD.md 의 10번입니다.


1.2.1 — 2026-09-21 · 납품 정비 (기능 변경 없음)

감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.

추가 — 자격증명 회전 도구

tools/rotate_credentials.php. 장치 HMAC 키와 관리자 비밀번호 교체를 명령 하나로 만듭니다. DB 에 접속하지 않고 phpMyAdmin 에 붙여넣을 SQL 만 출력하므로, 접속 정보 없이 어느 머신에서나 실행할 수 있습니다.

php tools/rotate_credentials.php --device=pi-01   # 장치 HMAC 키
php tools/rotate_credentials.php --admin=admin    # 대시보드 비밀번호

장치 키는 서버와 Pi 를 같이 바꿔야 합니다. 한쪽만 바꾸면 그 장치의 이벤트가 전부 거부되고, 화면에는 device_fault 하나만 뜹니다. 출력에 양쪽 절차가 함께 나옵니다. 비밀번호는 STDIN 으로만 받습니다 — 명령행에 적으면 ps 와 셸 히스토리에 남기 때문입니다.

복구 — MoveNet CPU 폴백

Hailo 없이 돌리는 CPU 폴백 모델의 배포 원본이 사라져 있어(원 저장소가 모델을 외부 저장소로 옮김) 번들에 넣을 수 없었습니다. 유효한 출처를 찾아 sha256 으로 고정했습니다. Hailo 납품에는 영향이 없습니다(폴백은 선택).

Pi 번들

1.2.0 에서 edge/ 가 바뀌었는데 번들이 재빌드되지 않은 상태였습니다. 재빌드했습니다 — move-detect-pi-1.2.1-<날짜>-aarch64-cp311-hailo8.tar.gz.


1.2.0 — 2026-09-05 · 추락 · 동시 다발 쓰러짐 · 구역 프리셋

AI CCTV 의 목적을 원론부터 다시 점검한 결과입니다. 셋 다 새 추론 모델 없이 지금 있는 자세추정·구역·트랙 이력만으로 구현했습니다.

추가 — 추락 (fall_from_height)

이 제품이 잡던 것은 같은 높이에서의 넘어짐이었습니다. 산업재해 사망 1위 유형인 떨어짐은 잡지 못했습니다 — GhostIncidentDetector 가 트랙이 이미 위험 자세일 때만 무장하므로, 발판 위에 서 있다가 떨어지는 사람은 소실 순간 정상 자세라 아무것도 걸리지 않았습니다.

  • 판정은 bbox 중심이 아니라 발 밑선(y2) 으로 합니다. 쭈그려 앉으면 중심은 내려가도 발은 그 자리이고, 추락은 발 밑선이 통째로 내려갑니다. 중심으로 재면 쭈그려 앉아 작업하는 사람이 전부 추락이 됩니다. 원근 접근은 높이 증가 가드로 막습니다.
  • 프레임 안에서 확정되면 넘어짐 유형을 승격합니다 — 한 번의 사고는 한 건입니다.
  • 화각 밖으로 사라지면 ghost 경로로 발보합니다. 떨어져 나간 사람에게는 이쪽이 유일한 경로입니다.
  • 심각도 위급. 척추·두부 손상 가능성이 있어 함부로 옮기면 안 되고, 그 주의가 통보에 실리려면 넘어짐(높음)과 등급이 갈라져야 합니다.
  • 구역 규칙 elevated: true 로 발판·개구부를 표시하면 정확해집니다. 구역이 없어도 동작합니다.

추가 — 동시 다발 쓰러짐 (multi_down)

같은 구역에서 짧은 시간에 두 명 이상이 쓰러진 것은 넘어짐 두 건이 아니라 질식·가스 누출· 폭발입니다. 그리고 이때 사람이 가장 많이 죽는 방식은 첫 번째 사람이 왜 쓰러졌는지 모르고 구조하러 들어간 사람이 같이 죽는 것입니다.

그래서 이 기능의 본체는 감지가 아니라 통보 문구입니다. 메일 본문에 링크를 열기 전에 "공기호흡기 없이 진입하지 마십시오"가 바로 읽힙니다.

  • 서버에서 판단합니다 — 가스는 카메라 화각을 존중하지 않고, 현장 즉시 대응은 첫 쓰러짐의 경광등이 이미 확보합니다.
  • 오탐 방어의 핵심은 근접성입니다. 넓은 공장에서 2분 안의 무관한 넘어짐 두 건은 실제로 일어나므로, 같은 구역이거나 좌표가 가까워야 묶습니다. 한 사람이 두 번 넘어진 것은 한 명입니다.

추가 — 용도별 구역 프리셋

밀폐공간 감시에 필요한 것(제한구역 + 2인1조 + 체류 상한)은 이미 전부 있었는데, 화면이 "최소 인원을 넣으세요"라고만 물어서 아무도 그렇게 쓰지 못했습니다. 안전관리자는 min_workers 가 아니라 밀폐공간을 생각합니다.

밀폐공간 · 고소작업 개구부 · 프레스 협착 · 출입 금지(크레인 하부·선회 반경) · 지게차 통로 · 화기작업 6종. 고른 뒤 값을 바꾸는 것은 그대로 허용합니다.

수정 — "아무도 있으면 안 되는 구역"이 조용히 꺼져 있었습니다

max_workers: 0 이 falsy 로 걸러져 규칙이 통째로 동작하지 않았습니다. 크레인으로 든 짐 아래, 굴착기 선회 반경처럼 아무도 있어서는 안 되는 범위가 정확히 그 규칙이고 깔림 사고의 전형입니다. 운영자가 저장해 놓고도 아무 일이 일어나지 않았습니다.

네 곳에서 막혀 있었습니다 — 감지기(and 검사), 입력란(min="1"), 저장 직렬화(> 0), 목록 표시. 전부 고쳤습니다.


1.1.0 — 2026-09-04 · 감시 자신의 고장을 실제로 신고하기

1.0.0 은 "감지에 대응하는 시스템"이었습니다. 그런데 감시 장비 자신이 멈춘 것은 여전히 아무도 신고하지 않았습니다 — 타입은 있는데 발행하는 코드가 없었습니다. 1.1.0 이 그것을 채웁니다.

소개서가 내세우던 "감지 17종"은 그때까지 실제로 15종이었습니다. 이제 17종이 사실입니다.

재감사에서 잡은 것 (3차, 14건 전량 해소)

위 기능을 넣으면서 만든 결함들입니다. 상세는 docs/delivery-audit.md 3차 재감사 절.

  • 통보가 통째로 멈출 수 있었습니다. 장치 건전성 단계가 예외 처리 밖에 있어, 그 단계에서 난 오류가 통보와 재시도까지 죽였습니다. 새벽에 넘어진 사람이 통보되지 않고 콘솔은 멀쩡해 보이는 상태가 매분 반복됩니다. 단계를 격리했습니다.
  • 간헐 고장을 못 잡거나, 반대로 쏟아냈습니다. 카메라가 깜빡이며 살아났다 죽으면 (리본 접촉 불량의 전형) 저하 구간이 계속 끊겨 두 시간을 고장 나 있어도 아무것도 올라오지 않고, 동시에 같은 고장이 수십 건으로 갈라졌습니다. 회복을 '지속된 정상'으로만 인정하고, 재고지 주기당 1건으로 묶었습니다.
  • __device__ 라는 내부 값이 메일·웹훅으로 회사 밖에 나갔습니다. 이제 어디서나 "장치 전체"로 표시됩니다.
  • 사건 상세에서 존재하지 않는 카메라에 위험구역을 저장할 수 있었습니다(저장되면 아무것도 지키지 않는 구역이 '활성'으로 남습니다). 막았습니다.
  • 카메라별 위험 순위·카메라 대수·오탐율에 유령 카메라가 섞이던 것을 제외했습니다.

수정 — 아무도 발행하지 않던 이벤트 2종

monitoring_degraded(감시 성능 저하)와 device_fault(장치 이상)는 타입 정의·서버 검증· 콘솔 필터·통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었습니다. 실제로 값을 넣는 것은 데모 시더뿐이었고, 소개서가 내세우던 "17종"은 실제로 15종이었습니다.

엣지가 낼 수 없는 유형입니다 — 전원이나 회선이 끊긴 노드는 자기가 죽었다고 신고할 수 없습니다. 그래서 서버가 하트비트의 부재로 판정합니다.

  • App\Alert\DeviceHealthWatch 추가. 1분 크론(dispatch_alerts.php)의 첫 단계로 실행되어, 같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 됩니다.
  • 장애당 1건. event_uuid 앵커가 장애 구간의 시작(device_fault 는 마지막 하트비트 시각)이라 크론이 60번 돌아도 이벤트는 하나입니다. 복구 후 재발은 별건으로 잡힙니다.
  • 성능 저하는 지속을 요구합니다(기본 5분). 한 번 튄 프레임률로 알람이 울리면 운영자는 이 유형을 무시하게 되고, 그러면 정작 감시가 멈춘 날에도 아무도 보지 않습니다.
  • 한 번도 하트비트를 보낸 적 없는 장치는 장애가 아닙니다(미설치 노드). 새 현장 첫날이 빨간 벽이 되지 않도록.
  • 설정: device_stale_seconds(180) · degraded_sustain_seconds(300) · degraded_fps_ratio(0.6) · target_fps(12). 노드를 낮은 프레임으로 운용하는 현장은 target_fps 를 실제 값으로 맞춰야 합니다.

장치 단위 이벤트는 카메라가 없으므로 camera_id 에 __device__ 센티넬을 씁니다. 콘솔은 "장치 전체"로 표시하고, 구역 편집기의 카메라 목록에서는 제외합니다(존재하지 않는 카메라에 구역을 그리려는 일이 없도록).


1.0.0 — 2026-08-18 · 첫 정식 납품 버전

0.9.0 까지가 "감지하는 시스템"이었다면, 1.0.0 은 "감지에 대응하는 시스템" 입니다. 그 차이를 만드는 것이 아래 첫 항목입니다.

추가 — 통보 (이 버전의 이유)

이전까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐습니다. 새벽 3시에 넘어진 작업자는 DB 에 행 하나를 남기고 아침을 맞았습니다.

  • 현장 경광등·부저 (엣지 GPIO). 서버도 인터넷도 거치지 않고 감지한 노드가 직접 울립니다. 정전·회선 장애로 업링크가 끊긴 순간은 오히려 위험이 높아지는 때이므로, 어떤 현장이든 이것부터 설정합니다.
  • 메일 · 웹훅 채널과 1분 주기 디스패처. 알림에는 현장 이미지를 싣지 않고 링크만 보냅니다 (스냅샷은 개인정보이고, 메일링 리스트로 나가는 순간 인증 프록시의 통제를 벗어납니다).
  • 미확인 경보 에스컬레이션. 위급/높음이 정해진 시간 동안 확인되지 않으면 그 사실 자체가 사건이 되어 상위 담당자에게 통보됩니다. "아무도 보지 않았다"가 처음으로 기록에 남습니다.

추가 — 조용한 실패 드러내기

  • 카메라 화각 변경 · 가림 · 조도 저하 감지. 위험구역 판정은 카메라가 어제와 같은 곳을 본다는 전제 위에 서 있는데, 청소하다 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속 초록불이었습니다.
  • 저장 공간 부족 경고. 디스크가 차면 이미지 저장만 조용히 실패하고 화면은 멀쩡해 보입니다.
  • 설정 지연 표시가 실제로 동작하게 됐습니다. 지표는 있었지만 그것을 켜는 사건이 없었습니다.

추가 — 관리 화면 (그전에는 DB 직접 수정이 유일한 방법)

  • 감지 임계값 편집 (허용 범위·단위·설명을 화면이 강제)
  • 통보 경로 설정
  • 정비 중 알람 억제 — 사유 필수, 최대 8시간, 자동 만료. 감지와 기록은 그대로 계속되고 통보만 보류되며, 보류했다는 사실도 기록에 남습니다.
  • 위험성평가 연계 · 교대 인수인계 · 설비 성능 추이 · 설치 점검(/setup)

추가 — 증거와 규정

  • 감사로그 위·변조 탐지 (해시체인). 수정·삭제·우회삽입이 전부 드러납니다.
  • 사고조사 패키지 1클릭 — 전후 타임라인, 통보 이력, 위험요인, 조치 이력, 서명란. 인쇄해서 그대로 제출할 수 있습니다.
  • 현장별 현황 (본사 계정)

변경 — 화면 (UI/UX 점검 21건)

  • 감지 스트림이 다시 시간순이 됐습니다. 확인 여부가 순서를 바꾸던 탓에 사고 재구성이 불가능했고, 확인하면 그 행이 사라지며 아래가 밀려 올라와 다른 이벤트를 잘못 확인할 수 있었습니다. 확인은 이제 제자리에서 처리됩니다.
  • 이벤트 상세에서 바로 확인/오탐 판정 · 구역 설정에 카메라 선택기 · 날짜로 조회 · 경보음 음소거(새 위급 오면 자동 해제) · 일괄 확인(위급 제외) · 키보드 조작
  • 표본이 부족한 수치는 비율 대신 원시 숫자만 보여줍니다. 6건→8건을 "▲33% 악화"로 칠하면 안전회의에서 반박당하고, 그 순간 그 화면 전체가 신뢰를 잃습니다.

도구 (move_detect_app/tools/, 웹에서 접근 불가)

도구 용도
dispatch_alerts.php 알림 발송 (크론 1분) · --dry-run 지원
maintain.php 보존기간 정리 (크론 일 1회)
backup.php DB 덤프 + 사진 아카이브 + 복구 안내
verify_audit_chain.php 감사로그 무결성 검증
seed_demo.php 시연·시험용 데모 데이터 (운영 DB 실행 금지)

업그레이드 (0.9.x → 1.0.0)

migrations/2026-08-09-safety-platform.sql 을 적용합니다. 파일 안의 9번 블록은 DB 종류에 따라 문법이 다릅니다 — MySQL 8.0.16+ / MariaDB / 제약 없음 세 경우의 안내가 파일에 적혀 있으니 SELECT VERSION(); 으로 확인 후 맞는 것을 실행하세요.

적용 후 config.php 에 public_base_url, timezone, escalate_after_seconds 를 추가하고 (템플릿 참조), 자동실행(크론) 2개를 등록해야 알림이 동작합니다 — README-UPLOAD.md 8번.

알려진 제약

  • 보호구 미착용 · 중장비 근접 · 화재 감지: 감지기 배선과 이벤트 계약은 준비되어 있으나 추론 모델이 확보되지 않아 기본 비활성입니다. 라이선스 조건을 만족하는 가중치와 Hailo 컴파일이 선행되어야 합니다. 켜져 있지 않다는 사실을 화면에서도 숨기지 않습니다.
  • 실장비 검증 잔여 항목은 docs/delivery-audit.md 의 "OFF-HW 잔여" 절을 참조하세요.

0.9.0 — 2026-08-08

납품 감사(43건) 대응. CSP nonce 도입, 보안 헤더 PHP 이관, /logout CSRF, 계정 잠금 응답에서 사용자명 열거 제거. 엣지 큐 flush 상한, 타이머 env 전수 검증, 프레임 스킵 시 미블러 얼굴 유출 차단, systemd 워치독. PDO 통합 테스트 복구, 라우트 테이블 대조, 산출물↔소스 패리티 검사, CI 신설. 납품물에 version/git_commit/built_at 스탬프.