WBS 2.3의 S3 GL 불러오기와 S4 테스트 실행 자리표시를 실제 LiveView로 교체한다.
Ledger와 Testing의 선행 공개 함수만 사용하고, 병렬 작업 중인 Runner를 기다리지 않고 화면 계약을 검증한다.
내부 오류와 파일 원문을 노출하지 않으면서 권한, 감사 대상 경계, 실시간 갱신을 확인한다.
한 일
ImportLive.New: CSV 1개, .csv, 20,000,000바이트 제한의 LiveView 업로드와 취소, 합성 GL 시드 입력과 생성을 연결했다. 공백 시드는 Generator.default_seed/0를 사용하고 잘못된 정수는 고정 필드 오류로 안내한다.
ImportLive.Components: 대기, 적재 중, 완료, 실패 패널과 전표/라인/금액/경고 요약을 만들었다. 오류 14종과 period_mismatch 경고를 고정 한국어로 표시하고, 최대 100건과 나머지 건수를 표시한다. 컬럼 이름도 GL 계약의 허용 목록으로 제한했다.
TestRunLive.Show: 룰 상태와 적중 수, 검토 큐/경미 적중/전체 탭, 룰 및 검토 상태 필터, 전표 링크를 연결했다. 탭 숫자는 Testing.count_findings/2, 목록은 Testing.list_findings/3를 사용한다.
TestRunLive.Components: 룰 아이콘과 진행률, 실패 안내, 점수 막대와 발견 표를 만들었다. failed의 error는 HTML에 넣지 않고, skipped의 한국어 사유만 표시한다.
TickmarkWeb.Format: 짧은 룰 이름 8개, 레지스트리 순 정렬, 금액/건수/날짜/KST/UTC 툴팁, 실행/룰/검토 상태 라벨을 제공한다.
기존 로그인 필수 :require_authenticated_user live_session의 경로를 사용한다. 라우터는 수정하지 않았고, 멤버십과 역할은 컨텍스트에서 확인한다.
신규 테스트 24개를 추가했다. 업로드 성공과 거부, 시드 생성, PubSub 갱신, 오류 문구, 권한, 임계 점수 경계, 탭/필터와 stream 교체, 다른 감사 대상의 실행 차단을 검증한다.
결정과 트레이드오프
금액/건수와 분 단위 KST 포맷은 기존 EngagementLive.Components 함수를 재사용했다. 공통 함수 전체를 새 모듈로 옮기는 대안은 S1/S2의 소유 파일까지 수정해야 하므로 이번 범위에서 제외했다. 기존 적재 상태 컴포넌트와 출처 표기도 그대로 쓴다.
룰 이름은 Rules.fetch/1과 룰 모듈의 name/0, 짧은 이름은 Format을 사용한다. finding.rule_ids의 저장 순서 대신 매번 Rules.ids/0 순서로 표시한다. 등록되지 않은 id는 뒤에 알파벳 순으로 두고 원문 id를 표시한다.
S4 발견 목록은 크기가 커지고 탭/필터/실시간 변경이 있으므로 LiveView stream으로 관리한다. 목록을 바꿀 때 컨텍스트에서 다시 읽고 reset: true로 교체하여 남아 있는 행과 순서 오류를 방지한다. 건수는 stream을 세지 않고 별도 assign으로 둔다.
룰 진행은 고정된 작은 목록이고, S3는 현재 적재 한 개와 최대 100개의 오류/경고만 다루므로 일반 assign을 쓴다. 두 목록까지 stream으로 바꾸는 대안은 메모리 이득보다 상태 관리가 늘어난다.
공개 list_findings/3에 페이지 옵션이 없어 더 보기 버튼을 만들지 않았다. stream은 소켓이 전체 행을 계속 보관하는 비용을 줄이지만 최초 조회와 브라우저 DOM 크기까지 제한하지는 않는다. 페이지 계약은 화면설계서 C-08의 후속 항목이다.
S3는 현재 보고 있는 적재의 :import_updated만 다시 읽는다. S4는 해당 실행의 :rule_progress, :run_completed에서 실행과 목록을 다시 읽고, :finding_updated에서도 필터 결과를 갱신한다. 메시지의 수치만 믿고 화면 상태를 조립하는 대안보다 DB 상태와 일치시키기 쉽다.
소스 탭은 ?source=upload|generated, S4 탭은 ?tab=queue|minor|all을 사용하고 필터는 rule_id, status로 보존한다. 입력한 필터는 허용된 상태와 해당 실행의 룰 id로 제한한다.
업로드 탭의 폼을 제거하는 대신 hidden으로 전환하여 탭을 바꿔도 선택한 파일과 업로드 상태를 유지한다.
권한 없는 S3 방문은 주소를 유지한 채 고정 안내와 이동 경로를 보여준다. S2로 자동 이동하는 방식은 화면설계서의 제안이므로 이번에는 요청된 권한 없음 상태를 직접 확인할 수 있게 했다. 제출 이벤트에서는 다시 권한을 확인한다.
S3 완료 후에는 S2로 가는 "감사 대상에서 테스트 실행" 링크를 둔다. 실제 실행 단추는 메인 소유 S2에 있으므로 Testing.start_test_run/3는 호출하지 않는다.
AI 위험도, 작성자 판단, 트리아지 요약과 요청 단추는 -로 표시하며 요청 단추는 비활성이다. Triage/Review 함수는 호출하지 않는다. 경미 적중에서는 AI 열과 트리아지 바를 숨긴다.
막힌 점과 해결
첫 경고 0 컴파일에서 기존 CoreComponents.button/1이 type attr를 받지 않아 경고가 발생했다. 공용 컴포넌트를 바꾸지 않고 폼 안의 기본 submit 동작을 사용하도록 해당 attr를 제거했다.
S4 첫 시험은 9개 중 7개가 실패했다. 테스트의 finished_at을 Ecto.Changeset.change/2로 쓸 때 :utc_datetime_usec가 요구하는 마이크로초 6자리 정밀도를 갖추지 못한 것이 원인이었다. ~U[2026-10-19 01:02:19.000000Z]로 수정했고, 재실행에서 9개 모두 통과했다.
upload_errors/1은 업로드 전체 오류만 반환하므로 버튼 비활성 판정에서는 UploadConfig.errors 전체를 확인한다. 개별 파일의 확장자/크기 오류는 upload_errors/2로 표시한다.
공유 파일이나 컨텍스트 계약을 바꿔야 하는 차단 요인은 없었다.
검증
MIX_TEST_PARTITION=4 MIX_ENV=test mix compile --warnings-as-errors: 종료 코드 0, 컴파일 경고 없음.
MIX_TEST_PARTITION=4 mix test test/tickmark_web/format_test.exs test/tickmark_web/live/import_live_test.exs: Result: 15 passed.
MIX_TEST_PARTITION=4 mix test test/tickmark_web/live/test_run_live_test.exs: 수정 후 Result: 9 passed.
최종 MIX_TEST_PARTITION=4 mix precommit: 종료 코드 0, 실제 마지막 출력은 아래와 같다. 미사용 의존성 정리와 포맷, 전체 테스트를 포함하며 mix.lock은 바뀌지 않았다.
Result: 288 passed
git diff --check: 출력 없이 종료 코드 0.
S3는 실제 업로드 파일이 저장 위치로 복사되고 Ledger.process_import/1로 완료되는 흐름도 검증했다. S4는 SchemaFixtures로 실행과 발견을 만들고 DB 변경 뒤 계약 6장의 메시지를 Phoenix.PubSub.broadcast/3로 직접 보내 검증했다.
브라우저와 실제 Runner 경로의 통합 검증은 수행하지 않았다. 작업 배정에 따라 병합 후 메인이 수행한다. 운영 반영은 하지 않았다.
Elixir 학습 포인트
LiveView mount/3는 정적 HTTP 렌더와 WebSocket 연결에서 각각 실행된다. 구독은 connected?/1이 참일 때만 수행하여 메시지를 받을 프로세스만 PubSub에 등록한다. 조회와 초기 assign은 두 번 모두 필요하다.
allow_upload/3는 파일 수/종류/크기와 업로드 상태를 관리한다. consume_uploaded_entries/3 콜백 안에서 Ledger.create_import/3를 호출해야 임시 파일이 살아 있을 때 영구 저장 위치로 복사된다. 콜백의 {:ok, 결과}와 컨텍스트의 {:ok, import}는 서로 다른 단계의 반환값이다.
handle_info/2는 브라우저 이벤트를 받는 handle_event/3와 달리 프로세스 메시지를 처리한다. 작은 PubSub 메시지의 id로 컨텍스트를 재조회하고 새 socket을 반환해야 변경이 렌더에 반영된다.
stream은 전체 목록을 일반 assign으로 보관하지 않으며, 각 항목의 고유 DOM id와 부모의 phx-update="stream"이 필요하다. 필터 변경에는 stream(..., reset: true)를 사용한다. 빈 상태와 건수는 stream 자체를 열거해서 계산하지 않는다.
Ecto의 cast와 change는 같지 않다. 이미 타입이 정해진 값을 change/2로 넣는 시험에서는 :utc_datetime_usec의 정밀도까지 직접 맞춰야 했다.
다음 할 일과 남은 제한
메인: S1/S2 실행 단추와 실행 목록 연결 후 실제 Runner/룰 8종을 병합하고 S3/S4를 브라우저에서 확인한다.