감사 로그 조회와 S6
- 작성일: 2026-10-20
- 작성: 워커 W3 (Codex
gpt-6.1-sol, effort max), 브랜치feature/m4-audit-log, WBS 4.2 - 관련 문서: 시스템 구조와 데이터 모델 (2.1, 3.13, 4, 6, 8, 12.2), 화면설계서 (3장, S6, 부록 A-C), 디자인 시스템, 라운드 4 계약과 통합
목표
감사 대상 멤버가 append-only 감사 로그를 필터링하고 과거 기록까지 읽는 S6 화면을 구현한다. 기록을 읽는 동안 새 이벤트가 와도 위치가 밀리지 않아야 하고, 저장된 payload만으로 당시 결과를 설명해야 한다.
완료 조건은 조회 함수와 LiveView 테스트, MIX_TEST_PARTITION=4 mix precommit 통과, 개발 기록과 브랜치 커밋이다.
한 일
| 파일 | 변경 |
|---|---|
lib/tickmark/audit_trail.ex |
list_events/3와 조회용 private 함수 추가. 기존 쓰기, actor, broadcast 함수는 그대로 유지 |
lib/tickmark_web/live/audit_live/index.ex |
S6 자리 표시를 멤버 확인, URL 필터, stream, 새 이벤트 알림, 더 보기 화면으로 교체 |
lib/tickmark_web/live/audit_live/components.ex |
이벤트 struct에서 행위자, 시각, 동작, 대상 링크, 요약, payload를 그리는 함수 컴포넌트 |
test/tickmark/audit_trail_test.exs |
조회 테스트 10개 추가. 기존 12개 테스트 유지 |
test/tickmark_web/live/audit_live_test.exs |
S6 테스트 15개 추가 |
조회
Engagements.authorize(scope, engagement_id, :view)를 먼저 확인한다. 비멤버, 없는 감사 대상, 잘못된 UUID는 모두[]다. 다른 감사 대상과 engagement가 없는 이벤트는 제외한다.seq DESC로 조회한다. 기본 50건, 정수 limit는 1-200으로 제한한다. 정수가 아닌 limit는 기본값을 쓴다.before_seq는 미만 조건이다. 행위자,{subject_type, subject_id}, payload의 전표번호 조건을 함께 주면 모두 AND로 묶는다.- 전표번호는 바인딩된
fragment("?->>'entry_number' = ?", ...)로 비교한다. SQL처럼 보이는 문자열도 값 그대로 비교하는 테스트가 통과했다.
S6
- 라우터의 기존
/engagements/:id/audit경로를 사용한다. 이미:require_authenticated_userlive_session 안에 있으므로 라우터 변경 없이 로그인 보호가 적용된다. mount의get_engagement!/2가 비멤버, 없는 대상, 잘못된 UUID를 같은 404로 처리한다. - 연결된 mount에서만 engagement 토픽을 구독한다. 멤버 목록은 mount에서 한 번 읽어 user id와 email의 맵으로 보관한다. agent는
triage:<model>, system은 작업 이름을 그대로 보인다. - 행위자 단추는
aria-pressed하나만 켠다. 전표번호 폼과 함께 patch하고,handle_params/3에서 다시 검증한 뒤 stream을 초기화한다. 전표번호의 선행 0, 공백, 문장부호는 보존한다. 빈 값, 제어 문자, 배열, 200 code point 초과 값은 무시한다. 200은 CSV 셀 제한과 같다. - 선택 기능인
?run=<UUID>도 구현했다.subject: {"test_run", id}로 조회하고 실행 필터 해제 링크를 제공한다. 잘못된 UUID는 무시한다. - 새 이벤트는 행에 바로 넣지 않는다. 보이는 첫 seq보다 크면서 현재 actor, 전표번호, 실행 조건에 모두 맞는 seq만
MapSet에 담아 센다. 같은 알림을 두 번 받아도 한 건이고, 빈 필터 결과에서도 새 이벤트를 센다. - 새 이벤트 단추를 누르면 현재 필터로 50건을 다시 읽고 알림 수를 지운다. 새 첫 행에
tabindex="-1"을 두고, 서버 이벤트와 colocated hook으로 포커스를 보낸다. - 더 보기는 마지막 seq 이전 50건을 stream 뒤에 붙인다. 50건 미만이면 단추를 숨기고 "처음 기록까지 모두 보았습니다."를 보인다. 마지막 페이지가 정확히 50건인 경우 다음 빈 페이지에서 종료하는 것도 테스트했다.
- finding은 S5, test_run은 S4, ledger_import는 S2의 해당
imports-<id>행, engagement는 S2로 연결한다. 관련 finding이나 test_run을 다시 읽지 않는다. 존재하지 않는 subject UUID로 행을 그리는 테스트도 통과한다. - 시각은
TickmarkWeb.Format으로 KST 초 단위, title은 UTC로 보인다. 계약 4.2의 outcome, risk_level, reused, comment, enqueued, rule_ids, triage_threshold, finding_count 등으로 한 줄 요약을 만든다. 반려 사유는 첫 줄만 요약하고 payload에는 전체를 남긴다.review.denied는 아이콘, 한글 라벨, 경계와 배경으로 강조한다. - payload는 접힌
<details>안에 JSON으로 출력한다. Decimal 문자열, 중첩 값, null, false를 바꾸지 않고 HTML은 이스케이프한다. 수정, 삭제, 쓰기 동작은 없다. import_updated,test_run_updated,finding_updated,triage_requested,member_added,rule_progress,run_completed는 목록과 스크롤을 바꾸지 않고 처리한다.
결정과 트레이드오프
- 알 수 없는 조회 옵션과 잘못된 필터 값은 무시한다. 대안은
ArgumentError나 오류 튜플이었다. 계약의 반환형이 이벤트 목록이고 다른 읽기 함수도 잘못된 필터를 무시하므로 일관된 방식을 선택했다. URL 값은 LiveView에서 먼저 검사하며, 이 정책을 조회 테스트로 고정했다. - 전표번호는 payload에서 읽는다. finding과 journal_entry를 조인하는 대안은 다른 context의 테이블에 의존하고 과거 로그를 현재 행 상태로 설명하게 된다. 계약 C-13대로 이벤트에 저장된 전표번호를 쓰면 같은 전표의 여러 finding 이벤트를 함께 찾고 PubSub 알림도 같은 조건으로 셀 수 있다.
- 새 이벤트는 수동 갱신한다. 즉시 stream에 끼우는 대안은 읽던 행을 밀어낸다. seq 집합은 중복 알림을 막고, 사용자가 누를 때 첫 페이지를 다시 읽는 방식은 커서를 단순하게 유지한다. 대가는 단추를 누르기 전에는 새 행이 보이지 않는다는 점이다.
- 행 요약은 순수 함수 컴포넌트로 분리했다. LiveComponent나 행별 조회 없이 struct와 mount의 멤버 맵만 받는다. 당시 payload가 부족하면 현재 도메인 행에서 값을 보충하지 않는다.
- 포커스 hook은 화면 파일에 둔다. 외부 JS 파일을 추가하는 대안은 소유 범위를 넓힌다. 프로젝트가 이미 colocated hooks를 app.js에서 불러오므로 화면 안의 작은 hook으로 구현했다.
막힌 점과 해결
- 처음에는 verified route의 동적 engagement 뒤에
#imports-를 붙여 컴파일 오류가 났다. S2 경로를~p로 검증하고 fragment를 뒤에 연결해 실제 적재 행으로 이동하게 했다. assert_push_event는 패턴 매칭 매크로라 문자열 보간 안의new.seq를 매치에 쓸 수 없었다. 기대 DOM id를 먼저 만들고^first_id로 검사하도록 바꿨다.- 위 컴파일 오류를 수정한 뒤 선택 테스트와 전체 검증이 통과했다. 외부 API나 Application env 변경은 사용하지 않았다.
검증
실제 출력:
$ MIX_TEST_PARTITION=4 mix test test/tickmark/audit_trail_test.exs test/tickmark_web/live/audit_live_test.exs
Finished in 1.1 seconds (1.1s async, 0.00s sync)
Result: 37 passed
$ MIX_TEST_PARTITION=4 mix precommit
Finished in 8.0 seconds (7.2s async, 0.7s sync)
Result: 414 passed
- 기존 389개와 이번 작업의 25개가 모두 통과했다. 컴파일 경고 0,
git diff --check통과,mix.lock무변경이다. - 권한과 다른 감사 대상 격리, 조회 필터 AND, limit, cursor, 3종 행위자, 한글 동작, 대상 링크, 원본 JSON, 필터 patch와 초기화, 잘못된 URL 값, 필터가 맞는 신규 알림과 중복, 갱신 뒤 포커스 이벤트, 페이지 경계, 다른 메시지 수신, 역할별 동일 표시를 검증했다.
- 실제 브라우저의 포커스 이동, 좁은 화면과 라이트/다크 시각 검토는 수행하지 않았다. 포커스의 서버 이벤트와 대상 행은 LiveView 테스트로 검증했다.
Elixir 학습 포인트
- Ecto.Query는 값이다. 옵션마다
where를 더한 query를 반환하고Enum.reduce/3로 이어 붙이면 조건들이 AND가 된다. 마지막Repo.all/1에서 한 번 실행한다. JSONB의->>연산자는 fragment로 쓰지만 값은^value로 바인딩한다. - stream의 행은 서버에 목록으로 남지 않는다. 그래서
Enum.count(@streams.events)로 개수를 세지 않고 빈 상태, 최신 seq, 마지막 seq, 다음 페이지 여부를 별도 assign으로 보관한다. 필터가 바뀌면 원본을 다시 읽고reset: true로 기존 행을 지운다. - MapSet은 중복 없는 값의 집합이다. 새 이벤트의 seq를 담으면 재전송과 같은 번호의 알림이 중복 집계되지 않는다. 필터 변경과 갱신 때 새 집합으로 바꾼다.
- 함수 컴포넌트에는 별도 프로세스가 없다. 이벤트 행은 조회 결과를 표현할 뿐이므로 LiveComponent의 상태와 생명주기가 필요하지 않다. 실시간 수신과 cursor는 부모 LiveView 한 곳에서 관리한다.
- PubSub 구독은 연결된 mount에서 한다. 정적 HTTP 렌더는 메시지를 받을 장기 프로세스가 아니다. 같은 토픽을 쓰는 다른 기능의 메시지도 명시적으로 처리해야 화면이 종료되지 않는다.
assert_push_event의 payload는 패턴이다. 계산은 먼저 하고 pin 연산자^로 기존 값을 검사한다. 일반 함수 인자에 문자열을 만드는 것과 다르다.
다음 할 일
- 메인 세션이 브랜치를 통합하고 전체 검증을 수행한다.
docs/README.md색인에 이 문서를 추가한다. - 공유
Layouts.app의 Tickmark 상단 바와 Presence는 기존 라운드 4b 범위다. 해당 파일은 소유 범위 밖이라 수정하지 않았다. - 통합 브라우저 확인 때 새 이벤트 단추 뒤 첫 행 포커스, 좁은 폭의 필터 줄바꿈과 JSON 영역 스크롤, 라이트/다크 대비를 확인한다.
- 계약 변경 요청: 없음.
관련 커밋
fee6ba8feat(audit): 감사 로그 조회와 S6 화면 구현- 이 문서는 별도
docs(devlog)커밋으로 기록한다.