S5 결론과 검토 동작
- 작성일: 2026-10-20
- 작성: 워커 W6 (Codex
gpt-6.1-sol, effort max) - 브랜치:
feature/m4-finding-actions - WBS: 4.3 일부 (S5 결론 제출과 승인/반려)
- 관련 문서: 시스템 구조와 데이터 모델 (2.1, 6, 8, 12.3), 화면설계서 (S5, 부록 A, C-10, C-11), 라운드 4 계약과 통합, S5 finding 상세 표시, Review와 직무분리
목표
W4의 S5 읽기 화면에 W2의 Review 컨텍스트를 연결한다. 작성자는 결론을 제출하고, 검토자는 두 단계로 승인하거나 사유를 적어 반려한다. 화면이 본 결론과 AI 제안 ID를 전달하고, 다른 사람의 재제출 알림은 검토 단계를 닫되 작성자의 초안을 지우지 않아야 한다.
완료 조건은 컴포넌트 분리의 독립 커밋, 화면 동작과 LiveViewTest, partition 7의 전체 precommit 통과, 이 문서와 구현 커밋이다.
한 일
동작을 유지한 컴포넌트 분리
610줄이던 finding_live/components.ex를 영역별 모듈로 나눴다. 분리 커밋에서는 기존 테스트, 마크업과 동작을 유지했고, 전체 473 passed를 확인했다.
파일 (lib/tickmark_web/live/finding_live/ 아래) |
역할 |
|---|---|
badges.ex |
점수 미터와 AI 위험도, 작성자 판단, 검토 상태 배지 |
entry_panels.ex |
전표 사실과 행, 차대 합계 |
rule_evidence.ex |
룰 적중과 근거 값, 점수 식 |
ai_suggestion.ex |
AI 제안 카드 |
conclusion_panel.ex |
작성자 판단 폼과 읽기 요약 |
history.ex |
결론과 검토 결정의 시간순 이력 |
sign_bar.ex |
검토 서명과 직무분리 안내, 승인 확인과 반려 폼 |
components.ex |
빈 값과 금액 표시를 위한 작은 공통 컴포넌트 |
actions.ex |
폼 검증, Review 호출과 반환값 처리, 확인 단계 초기화 |
state.ex |
저장된 finding 재조회, 화면 값과 stream 갱신, 초안 유지 |
show.ex |
mount, 이벤트 연결, 알림 처리와 화면 조합 |
최종 구현의 각 소스 파일도 300줄 이하로 유지했다. 컨텍스트, 스키마, 라우터, 의존성, 공유 파일은 고치지 않았다.
결론 제출과 검토
#conclusion-form은phx-change="validate",phx-submit="submit"이다. 편집 가능한 작성자에게 제출 단추를 켜고 제출 중에는phx-disable-with로 잠근다.Review.submit_conclusion/3에 브라우저의conclusion값을 넘긴다. 제출 시 숨은ai_triage_id를 서버의 최신 assign으로 대체하지 않는다. 입력 검증은 다음에 그릴 카드 ID로 숨은 입력을 맞춰, 옛 폼의 늦은 검증 이벤트가 새 카드와 과거 ID를 짝짓지 않게 한다. 다른 finding의 제안은 컨텍스트가 거절하고 숨은 입력 바로 아래에 한국어 오류를 보인다.- 성공하면 finding을 다시 읽고 판단, 코멘트와 글자 수를 빈 폼으로 초기화한다. 성공 flash, 검토 대기 상태와 새 이력을 보인다. 승인된 항목의 제출 시도는 다시 읽어 잠그고, 권한 오류는 지정된 한국어 flash로 보인다.
decision_mode의 값은:idle,:approve,:reject다. 승인 단추는 같은 서명 바 안에 확인 문구, 취소와 승인 확정 단추를 연다. 반려 단추는 필수 사유, 글자 수, 취소와 반려 확정 폼을 연다.- 승인 확정 단추는
phx-value-conclusion-id로 화면에 그린 결론 ID를 보낸다. 반려 확정 단추와 폼에도 같은 속성을 둔다. LiveView의 form submit은 폼의phx-value-*를 전송하므로 폼에도 결론 ID가 필요하다. - 반려 사유는
ReviewDecision.changeset/2로 검증한다. 공백 사유는 "반려 사유를 입력하세요", 2000자 초과는 "반려 사유는 2000자 이하로 입력하세요"다. 저장 검증 오류는 사유 폼을 유지하고 그 아래에 보인다. - 승인/반려는
Review.decide/4만 호출한다. 성공하면 확인 단계를 닫고 서명과 이력을 갱신한다.:invalid_state,:stale은 다시 읽고 오류를 보이며,:forbidden,:self_review도 확인 단계를 닫고 정해진 문구를 보인다.
이벤트와 화면 값
| 이벤트 | 화면에서 보내는 값 | 처리 |
|---|---|---|
validate |
conclusion[outcome], conclusion[comment], conclusion[ai_triage_id] |
changeset 검증과 글자 수 |
submit |
같은 conclusion 폼 값 |
결론 제출 |
start_approve / start_reject |
없음 | can_decide 확인 뒤 확인 단계 열기 |
cancel_decision |
없음 | 확인 단계와 사유 초기화 |
validate_rejection |
review_decision[comment] |
반려 사유 검증과 글자 수 |
confirm_approve |
conclusion-id |
decision = approved로 결정 |
confirm_reject |
conclusion-id, review_decision[comment] |
decision = rejected로 결정 |
같은 finding의 finding_updated는 기존대로 다시 읽는다. 검토자의 확인 단계와 사유 폼은 닫고, 새 결론 ID가 생긴 경우에만 "작성자가 결론을 다시 제출했습니다. 최신 결론을 확인한 뒤 결정하세요." 경고를 설정한다. AI 결과만 바뀐 알림에는 새 결론 경고를 만들지 않는다. 작성자의 폼 값과 오류는 보존하고 화면에 보이는 AI 제안 참조만 갱신한다. 다른 finding과 기존 engagement 토픽의 나머지 메시지는 무시한다.
결정과 트레이드오프
- 화면 값과 서버 assign을 구별한다 (C-11). 대안은 확정 시점에 서버가 아는 최신 ID를 쓰는 것이었다. PubSub가 assign을 바꾼 직후 옛 화면에서 보낸 클릭도 새 결론에 붙을 수 있어 채택하지 않았다. 브라우저가 보낸 ID를 그대로 넘기고 Review가 최신성, 소유와 권한을 확인한다.
- 반려 ID는 확정 단추와 폼에 둔다. 승인과 같은
phx-value-conclusion-id를 확정 단추에 명시하되, form submit의 실제 전송 원본인 폼에도 둔다. 별도 JS나 서버의 임시 결론 ID 저장은 필요 없다. - 결정 단계는 assign으로만 관리한다. 모달이나 LiveComponent 대신 서명 바의 함수 컴포넌트와 세 상태로 표현했다. 별도 프로세스와 부모/자식 이벤트 전달 없이 취소, 성공, 오류와 외부 갱신의 초기화 경로를 설명할 수 있다.
- 조회와 사용자 입력은 따로 갱신한다. 알림마다 새 changeset을 만드는 대안은 작성 중인 초안과 오류를 지운다. State는 기존 폼을 유지하고 제출 성공 때만 새 폼으로 바꾼다.
- 경미 적중 카드에 숨겨진 AI 제안은 연결하지 않는다. 점수가 기준보다 낮으면 카드에 제안 본문 대신 대상 아님 안내를 그린다. 성공 제안 행이 DB에 있어도 화면에 보이지 않으므로 숨은 ID는 빈 값이다.
- 저장 규칙은 기존 Review에 둔다. LiveView에서 상태를 직접 쓰거나 감사 이벤트를 별도로 만들지 않는다. 기존 컨텍스트의 트랜잭션, 부모 잠금, append-only 이벤트와 커밋 뒤 broadcast를 재사용한다.
막힌 점과 해결
- 동작 연결 후 기존 화면 테스트는
Result: 22/24 passed였다. 실패 두 건은 앞 라운드의 비활성 제출/결정 단추를 계속 기대했다. 새 요구에 맞춰 두 테스트의 활성 단추 기대값만 갱신했고 나머지 기존 테스트는 유지했다. - 역할 변경 API 없이도
:self_review화면 경로를 확인했다. 기존insert_conclusion/3으로 현재 검토자가 과거에 작성했던 결론을 재현한 뒤 직접 확정 이벤트를 보냈다. 비활성 단추와 한국어 오류, 결정 미저장과 상태 유지를 확인했다. 실제 역할 변경 동작을 구현하거나 테스트한 것은 아니다. - 마지막 AI 참조 회귀 테스트를 추가하기 전에는 전체 precommit이
499 passed였다. 회귀 테스트 추가 후 seed 50255에서는496/500 passed,Failed: 4 tests였다. 실패는 RulesLabelTest의 seed 7과 20251231 두 건, AuditTrailTest의DB triggers reject mutation TRUNCATE, RunnerTest의fail_rule/3 marks the rule failed with the reason cut to 500 characters네 건뿐이었다. 모두 DB 연결 15초 타임아웃 또는 그 뒤의 query cancel이었고 S5 대상 51개는 통과했다. - 네 실패 테스트는 단독 실행에서 모두 통과했고, 같은 seed의 전체 재실행도
500 passed로 통과했다. 메인이 확인한 원인은 async TRUNCATE가 느린 DuplicateEntry 테스트의 트랜잭션 뒤에서 ACCESS EXCLUSIVE 잠금을 기다리고, audit_events를 쓰는 다른 테스트까지 뒤에 줄을 서는 것이다. W6에서 실제 잠금 그래프를 수집한 것은 아니며 이 원인 확인은 코디네이터의 회신에 따른다. 메인이 통합 브랜치에서 TRUNCATE 테스트를async: false모듈로 옮기기로 했다. 소유 범위 밖 테스트와 쿼리는 고치지 않았다.
해당 전체 실행의 실제 마지막 출력:
$ MIX_TEST_PARTITION=7 mix precommit
Finished in 13.7 seconds (12.9s async, 0.7s sync)
Result: 499 passed
$ MIX_TEST_PARTITION=7 mix precommit
Running ExUnit with seed: 50255, max_cases: 24
Finished in 33.6 seconds (32.8s async, 0.7s sync)
Result: 496/500 passed
Failed: 4 tests
검증
분리 커밋 전, 기존 테스트를 바꾸지 않은 전체 검증:
$ MIX_TEST_PARTITION=7 mix precommit
Finished in 5.4 seconds (4.7s async, 0.7s sync)
Result: 473 passed
동작 구현의 대상 테스트:
$ MIX_TEST_PARTITION=7 mix test test/tickmark_web/live/finding_live_test.exs test/tickmark_web/live/finding_live
Finished in 0.8 seconds (0.8s async, 0.00s sync)
Result: 51 passed
최종 구현의 전체 precommit 실제 마지막 출력:
$ MIX_TEST_PARTITION=7 mix precommit --seed 50255
Finished in 5.9 seconds (5.2s async, 0.7s sync)
Result: 500 passed
간헐 실패한 기존 테스트의 단독 재검증 실제 출력:
$ MIX_TEST_PARTITION=7 mix test test/tickmark/audit_trail_test.exs:361 test/tickmark/testing/runner_test.exs:294
Finished in 0.3 seconds (0.3s async, 0.00s sync)
Result: 2 passed, 43 excluded
$ MIX_TEST_PARTITION=7 mix test test/tickmark/testing/rules_label_test.exs
Finished in 2.0 seconds (2.0s async, 0.00s sync)
Result: 2 passed
- 기존 473개에 결론 동작 11개, 결정 동작 11개, 실시간 동작 5개를 추가했다. 새 테스트는
test/tickmark_web/live/finding_live/의 세 파일에 둔다. - 제출 성공/초기화, 빈 값과 길이 오류, 승인 후 잠금과 제출 거부, 검토자의 제출 거부, 다른 finding의 AI 제안 거부, 표시한 제안 ID 저장과 옛 DOM 제출 값 보존, 늦은 검증 이벤트 뒤 카드와 숨은 ID의 일치, 반려 후 재제출을 확인했다.
- 승인 확인과 취소, 승인 저장과 서명, 반려 취소/검증/저장과 이력,
:invalid_state,:forbidden,:self_review, 승인과 반려 각각의:stale을 확인했다.:stale테스트는 PubSub가 서버 assign을 최신으로 갱신한 뒤에도 옛conclusion-id로 직접 확정 이벤트를 보낸다. - 두 LiveView에서 작성자 재제출이 검토자의 승인 확인과 반려 폼을 닫는지 확인했다. 다른 작성자의 실제 제출 알림 뒤에도 로컬 초안과 필드 오류가 남는다.
- 전체 검증 종료 코드 0, 컴파일 경고 0,
mix.lock무변경,git diff --check통과. 테스트에는 partition 7만 썼고 Application env를 바꾸지 않았다. - 실제 브라우저의 시각 품질과 좁은 화면 배치는 확인하지 않았다. Ctrl/Cmd+Enter hook은 선택 범위라 추가하지 않았다. 운영 반영은 이 작업 범위에 없다.
Elixir 학습 포인트
- phx-value와 assign은 서로 다른 시점을 가리킨다. assign은 서버가 마지막으로 읽은 상태이고 이벤트 값은 브라우저가 보낸 상태다. 검토자의 결정을 연결할 결론은 후자여야 C-11의
:stale검사가 의미를 가진다. - 확인 단계도 값으로 관리한다.
decision_mode하나로 기본 단추, 승인 확인, 반려 폼 중 하나를 고른다. 새 프로세스 없이assign/2로 단계를 바꾸고 같은 초기화 함수를 취소와 오류, 외부 갱신에 쓴다. - changeset과 to_form은 저장과 분리된다. 반려 입력 중에는 changeset만 검사한다. 실제 제출에는 Review가 반환한 changeset을 다시
to_form/1으로 바꿔 같은 필드 오류 경로를 쓴다. 제출 성공은 새 changeset으로 판단과 코멘트를 비운다. - PubSub 테스트는 화면의 두 프로세스로 확인할 수 있다. 작성자 view의
render_submit/1뒤 검토자 view의 DOM과 상태를 검사한다. 임의 sleep이나 Application env 변경 없이 실제 구독과 컨텍스트 broadcast를 사용했다. 별도 DB 연결의 커밋 가시성이나 락 경합 테스트를 대신하는 증거는 아니다. - 함수 컴포넌트는 표시 영역별로 나눌 수 있다. 각 모듈이 필요한 배지, 시간과 공통 표시 함수만 import한다. Show는 화면을 조합하고 Actions와 State는 입력 동작과 조회 갱신을 맡아 한 파일을 따라 읽어야 하는 양을 줄였다.
다음 할 일
- 메인 세션이 두 커밋을 통합하고 전체 검증과 실제 브라우저 확인을 수행한다.
- 메인이
docs/README.md에 이 문서를 등록한다. - 계약 12.3대로 AI 요청 단추, Presence, 검토 큐와 다음 미판단 이동은 메인 범위로 남는다.
- 기존 async TRUNCATE의 연쇄 타임아웃은 메인이 통합 브랜치에서 테스트를 분리해 수정한다. 이 브랜치의 동일 seed 재실행 통과만으로 원인이 해결됐다고 보지는 않는다.
- 계약 변경 요청: 없음. 공유 파일과 메인 소유 파일 변경 요청도 없음.
관련 커밋
- 기준:
cff18a4(feature/m2-rule-engine). 0212604refactor(finding): S5 컴포넌트를 영역별 모듈로 분리.649a0b1feat(finding): 결론 제출과 승인 반려 흐름 연결.- 이 문서의 후속 검증 기록 커밋:
docs(devlog): S5 전체 검증의 간헐 타임아웃 기록 보강.