개발 기록 2026-10-20 docs/devlog/2026-10-20-4-감사-로그-조회와-S6.md GitHub에서 보기

감사 로그 조회와 S6

목표

감사 대상 멤버가 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_user live_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 영역 스크롤, 라이트/다크 대비를 확인한다.
  • 계약 변경 요청: 없음.

관련 커밋

  • fee6ba8 feat(audit): 감사 로그 조회와 S6 화면 구현
  • 이 문서는 별도 docs(devlog) 커밋으로 기록한다.