개발 기록 2026-10-19 docs/devlog/2026-10-19-2-Testing-실행-경로.md GitHub에서 보기

Testing 실행 경로

목표

계약 5.3, 5.4, 6장의 테스트 실행 경로를 만든다. 사람이 실행을 시작하면 룰마다 Oban job 이 하나씩 돌고, 마지막 룰이 끝날 때 전표별 finding 을 집계해 실행을 마감한다. 룰이 실패해도 실행은 반드시 끝나고, 모든 상태 변화는 커밋 뒤에만 화면에 알린다.

완료 조건은 MIX_TEST_PARTITION=2 mix precommit 통과와 mix.lock 무변경이다.

한 일

파일 역할
lib/tickmark/testing/runner.ex start_test_run/3, run_rule/2, fail_rule/3. 실행 시작, 룰 하나의 마감, 실행 마감(finding 집계)과 브로드캐스트
lib/tickmark/testing/rule_worker.ex rules 큐, max_attempts: 3, timeout 5분. Testing.run_rule/2 결과를 job 결과로 바꾼다
lib/tickmark/jobs/failure_handler.ex [:oban, :job, :exception] 핸들러. 마지막 시도의 raise 와 timeout 을 mark_import_failed/2, fail_rule/3 로 기록한다
test/tickmark/testing/runner_test.exs 23개 (sandbox 22개 + sandbox 밖 동시성 1개)
test/tickmark/testing/rule_worker_test.exs 4개
test/tickmark/jobs/failure_handler_test.exs 7개

start_test_run/3 흐름 (계약 5.3):

  1. Engagements.authorize(scope, engagement_id, :start_test_run).
  2. import 를 다시 읽어 status != :succeeded 면 {:error, :import_not_ready}. 화면이 들고 있던 struct 는 오래됐을 수 있다.
  3. :all 은 Rules.ids(). 빈 목록, 모르는 id, 중복 id 는 ArgumentError. 사용자 입력이 아니라 프로그래밍 오류라서 반환값 대신 raise 다.
  4. run id 를 미리 만들고 Ecto.Multi 하나로 test_runs insert, test_run_rules insert_all(params 는 module.default_params() 스냅샷, status queued), test_run.started 감사 이벤트, Oban.insert_all 로 룰 수만큼 RuleWorker job. partial unique index 위반은 changeset 의 constraint_name 을 보고 {:error, :run_in_progress} 로 바꾼다.
  5. 커밋 뒤 Engagements.broadcast 로 {:test_run_updated, ...}.

run_rule/2 흐름 (계약 5.4):

  1. 룰 행과 run 을 읽어 룰이 terminal 이거나 run 이 running 이 아니면 {:ok, :noop}. 모르는 룰 id 는 {:error, :unknown_rule}, 형식이 틀린 run id 는 {:ok, :noop}.
  2. queued -> running 을 짧은 트랜잭션으로 커밋한다. started_at 은 COALESCE(started_at, now) 라서 재시도에도 첫 시작 시각이 남는다. 커밋 뒤 :rule_progress, :test_run_updated 브로드캐스트.
  3. 룰은 트랜잭션 밖에서 RuleContext(engagement preload, 저장된 params) 로 돌린다.
  4. {:ok, hits} 는 한 트랜잭션에서 run 행 FOR UPDATE -> mark_rule(pending 행만 갱신, 0행이면 롤백하고 {:ok, :noop}) -> rule_results 1000건씩 insert_all(score 는 module.weight()) -> pending 수 세기 -> pending 0 이고 run 이 running 이면 finalize. {:skip, reason} 은 insert 없이 같은 순서, {:error, reason} 은 DB 를 건드리지 않고 그대로 반환, raise 는 잡지 않는다.
  5. finalize 는 rule_results 를 journal_entry_id 로 묶어 insert_all(Finding, query) 로 넣는다. hit_count = count, score = LEAST(100, SUM(score)), rule_ids = ARRAY_AGG(rule_id ORDER BY rule_id). 실패 룰이 없으면 completed, 있으면 completed_with_errors. test_run.completed 감사 이벤트를 남긴다.
  6. 커밋 뒤 :rule_progress, :test_run_updated, 마감했으면 :run_completed 순서로 보낸다.

fail_rule/3 은 같은 lock -> mark -> count -> finalize 순서로 failed 를 쓰고 error 에 inspect(reason) 500자를 남기며 test_run_rule.failed 이벤트(payload 는 rule_id 만)를 기록한다. 이미 terminal 이면 아무것도 쓰지 않는다. 항상 :ok 다.

결정과 트레이드오프

락을 rule_results insert 보다 먼저 잡는다 (계약 5.4 순서 수정 요청)

  • 계약은 "rule_results insert_all -> run FOR UPDATE -> mark_rule" 순서였다. PostgreSQL 은 자식 행을 insert 할 때 FK 검사로 부모 행(test_runs)에 FOR KEY SHARE 를 잡고, FOR KEY SHARE 는 FOR UPDATE 와 충돌한다. 두 룰이 동시에 끝나면 둘 다 KEY SHARE 를 쥔 채 상대가 놓기를 기다리는 교착이 된다. PostgreSQL 이 1초 뒤 한쪽을 죽이고 Oban 이 재시도하므로 결과는 맞지만, 재시도 하나가 매번 낭비된다.
  • 선택: FOR UPDATE -> mark_rule -> insert -> count -> finalize. 코디네이터에게 물어 승인받았다. 락을 쥔 시간이 insert 만큼 길어지지만 같은 run 의 다른 룰만 기다리고, 읽기는 MVCC 라 막히지 않는다.
  • 대안: FOR NO KEY UPDATE 로 insert 를 병렬로 두는 방법. KEY SHARE 와 충돌하지 않아 교착은 없지만 "락 먼저" 보다 설명하기 어렵다. 면접에서 한 줄로 설명할 수 있는 쪽을 골랐다.
  • mark 를 insert 보다 앞에 둔 이유: 0행(이미 terminal)이면 hits 를 넣기 전에 롤백한다.

running 전환은 별도 트랜잭션

  • 룰이 수십 초 돌 수 있으므로 running 전환을 먼저 커밋하고 룰은 트랜잭션 밖에서 돌린다. 화면은 "돌고 있음" 을 바로 보고, 룰이 도는 동안 run 행에 락이 없다.
  • 대가: 룰이 {:error, _} 를 돌려주거나 raise 하면 running 인 채 남는다. 재시도는 pending(queued 또는 running) 행을 다시 running 으로 갱신하므로 문제없고, 마지막 시도는 fail_rule/3 이 마감한다.

{:error, reason} 은 DB 를 건드리지 않는다

  • 재시도 대상이므로 룰 행을 그대로 두고 reason 을 worker 에 돌려준다. worker 가 마지막 시도인지 판단해 fail_rule/3 을 부른다.
  • 대안으로 run_rule 안에서 바로 failed 를 쓰는 안은 "일시적 오류도 한 번에 실패" 가 되어 버렸다.

finding 집계를 DB 에서 한다

  • insert_all(Finding, query) 로 rule_results 를 GROUP BY 해서 넣는다. 10만 전표 x 7 룰이어도 애플리케이션 메모리로 끌어오지 않는다. id, status, triage_status 는 DB 기본값에 맡긴다.
  • 점수 상한 100 은 LEAST(100, SUM(score)), 룰 id 정렬은 ARRAY_AGG(... ORDER BY ...) 로 SQL 에서 끝낸다. 테스트가 "alpha_rule", "approval_control_gap", "zeta" 순서와 60+90+25 -> 100 을 확인한다.

FailureHandler 는 마지막 시도만 본다

  • [:oban, :job, :exception] 은 매 실패 시도마다 온다. attempt >= max_attempts 일 때만 worker 문자열로 분기한다. 모르는 worker 는 무시한다(M3 의 Triage worker 는 그때 추가).
  • {:error, _} 를 돌려준 마지막 시도는 worker 가 먼저 fail_rule/3 을 부른 뒤 {:cancel, _} 로 끝내므로 핸들러까지 오지 않는다({:cancel, _} 는 :stop 이벤트). raise 와 timeout 만 핸들러 몫이다. 둘 다 호출돼도 fail_rule/3 과 mark_import_failed/2 가 멱등이라 안전하다.
  • 핸들러 본문은 catch kind, reason 으로 감싸 Logger 만 남긴다. telemetry 는 raise 한 핸들러를 떼어 버리므로, 한 번 터지면 이후 실패가 영영 기록되지 않는다.

RuleWorker: max_attempts 3, timeout 5분

  • 3회: 1회면 일시적 DB 오류에도 바로 실패하고, 5회 이상이면 결정적 오류(룰 버그)에 수 분을 낭비한다. 기본 backoff(지수)로 3회면 대략 1분 안에 끝난다. ImportWorker 와 같은 값이라 설명도 하나로 된다.
  • 5분: 룰 하나는 import 하나에 대한 읽기 쿼리 몇 개다. 10만 전표 기준 수 초가 정상이고 5분을 넘으면 쿼리가 잘못된 것이다. ImportWorker(10분) 보다 짧게 둔 이유는 적재와 달리 쓰기가 거의 없어서다. Oban 은 timeout 에 TimeoutError 로 프로세스를 죽이고 이는 exception 이벤트로 핸들러에 온다.

동시성 테스트를 sandbox 밖에서 돌린다

  • Ecto sandbox 는 테스트 하나를 트랜잭션 하나로 감싸고 마지막에 롤백한다. 다른 연결은 그 안의 행을 볼 수 없고, "커밋 뒤에야 보인다" 와 "락을 기다린다" 를 보여줄 수 없다. 그래서 runner_test.exs 안에 RunnerConcurrencyTest 모듈을 async: false 로 따로 두고 Sandbox.checkout(Repo, sandbox: false) 로 진짜 연결을 쓴다.
  • 시나리오: 테스트 프로세스가 run 행을 FOR UPDATE 로 잡은 채 Task 두 개(각자 sandbox 없는 연결)로 run_rule/2 를 돌린다. 둘 다 running 전환까지는 커밋하지만(:rule_progress running 2건 수신) 마감은 못 한다(refute_receive succeeded/skipped). 락을 놓으면 둘이 차례로 끝나고 done 값은 1 과 2 로 하나씩 나온다.
  • 세 번째 룰(test_error)을 queued 로 남겨 마감이 일어나지 않게 했다. 마감하면 audit_events 가 생기는데 그 테이블은 트리거로 UPDATE/DELETE 를 막아 뒷정리가 불가능하다. 같은 이유로 run 생성도 start_test_run/3 대신 fixture 로 했다(test_run.started 이벤트를 남기지 않기 위해). on_exit 에서 결과, 룰, run, 전표, import, engagement, user 순서로 지운다. 테스트 중간에 실패해도 감사 이벤트만 없으면 정리된다.
  • 교착 재현 테스트는 만들지 않았다(코디네이터 답변). "finalize 가 정확히 한 번" 은 sandbox 안에서 룰 두 개를 차례로 끝내는 테스트가 test_run.completed 1건과 :run_completed 1건으로 확인한다.

그 밖의 결정

  • start_test_run/3 의 룰 id 중복은 ArgumentError 다. 그냥 두면 test_run_rules 의 unique 제약에 걸려 changeset 오류가 돌아오는데, 사용자에게 보일 오류가 아니다.
  • fail_rule/3 의 error 는 inspect(reason) 이라 문자열 reason 도 따옴표를 포함한 채 500자로 잘린다. 계약대로 개발자용 값이다.
  • :test_run_updated 는 running 전환, 룰 마감, 실행 마감 때마다 보낸다. 목록 화면은 finding_count 와 status 만 보므로 같은 값이 여러 번 가도 무해하다.

막힌 점과 해결

  • 첫 실행에서 점수 상한 테스트와 동시성 테스트가 Decimal.new(nil) 로 터졌다. 두 테스트는 run 을 start_test_run/3 이 아니라 fixture 로 만들어 test_run_rules.params 가 %{} 였고, approval_control_gap 이 params["approval_min_amount"] 를 읽다 nil 을 만났다. 테스트 helper 가 Rules.fetch/1 로 default_params() 를 넣게 고쳤다. 덕분에 "params 스냅샷이 없으면 룰이 못 돈다" 는 사실도 확인했다.
  • Oban.insert_all(multi, name, changesets) 의 3인자 clause 가 있는지 확실하지 않아 deps/oban/lib/oban.ex 에서 %Multi{} 패턴 clause 를 확인한 뒤 썼다.
  • FailureHandler 의 "raise 해도 떼어지지 않는다" 테스트는 핸들러를 어떻게 터뜨릴지가 문제였다. spawn_monitor 로 띄운 프로세스는 $callers 가 없어 sandbox 연결을 못 받으므로 fail_rule/3 의 Repo 호출이 DBConnection.OwnershipError 로 raise 한다. 그걸 그대로 썼다. Task 는 $callers 를 세팅해 연결을 물려받으므로 쓸 수 없었다.

검증

실제 출력(마지막 줄):

$ MIX_TEST_PARTITION=2 mix precommit
Finished in 5.5 seconds (4.8s async, 0.7s sync)
Result: 298 passed
  • 이번 라운드에서 추가한 34개(runner 23, rule_worker 4, failure_handler 7)를 포함한다. 기존 264개는 그대로 통과한다.
  • 컴파일 경고 0, mix.lock 무변경(git status 에 없음).
  • 동시성 테스트만 5번 반복: Result: 1 passed, 22 excluded x 5.
  • 테스트 뒤 tickmark_test2 의 test_runs, users, audit_events, engagements 행 수 모두 0 (sandbox 밖 테스트의 뒷정리 확인).

Elixir 학습 포인트

  • SELECT ... FOR UPDATE 와 READ COMMITTED: PostgreSQL 기본 격리 수준(READ COMMITTED)에서는 "pending 수를 센다" 와 "0 이면 마감한다" 사이에 다른 트랜잭션이 끼어들 수 있다. 두 룰이 동시에 끝나면 둘 다 pending 1 을 보거나 둘 다 0 을 봐서 마감이 0번 또는 2번 된다. run 행을 FOR UPDATE 로 잡으면 같은 run 의 마감 트랜잭션이 한 줄로 서고, 뒤에 온 쪽은 앞쪽이 커밋한 상태를 보고 센다. 락은 트랜잭션 끝까지 유지된다. 한 가지 더: FK 가 있는 자식 행 insert 는 부모 행에 FOR KEY SHARE 를 잡고 이것은 FOR UPDATE 와 충돌한다. 그래서 락을 insert 보다 먼저 잡아야 교착이 없다.
  • Ecto.Multi 와 Repo.transact/1: Multi 는 트랜잭션 안에서 할 일을 이름 붙인 단계로 쌓아 두는 자료구조고, Repo.transact/1 이 그것을 한 트랜잭션에서 실행한다. 실패하면 {:error, 단계이름, 값, 지금까지의_changes} 로 어느 단계가 왜 실패했는지 온다. start_test_run/3 은 :test_run 단계의 changeset 에서 constraint_name 을 보고 :run_in_progress 로 바꾼다. 함수 버전 Repo.transact(fn -> ... end) 는 {:ok, v} 면 커밋, {:error, r} 면 롤백이라 close_rule 처럼 "0행이면 롤백" 이 자연스럽다.
  • Oban 재시도와 cancel: perform/1 이 {:error, _} 를 돌려주거나 raise 하면 Oban 이 backoff 뒤 다시 돌린다(attempt 가 1씩 는다). max_attempts 에 닿으면 discarded 가 된다. {:cancel, _} 은 "더 돌려도 소용없다" 는 뜻으로 즉시 cancelled 로 끝내며 :stop 이벤트가 난다. 그래서 마지막 시도의 {:error, _} 는 먼저 도메인에 실패를 기록하고 {:cancel, _} 로 끝낸다.
  • telemetry 핸들러 분리 규칙: :telemetry.attach/4 로 붙인 핸들러가 raise 하면 telemetry 가 그 핸들러를 떼어 낸다(한 핸들러가 다른 핸들러까지 막지 않도록). 그래서 핸들러 안은 반드시 잡고 로그만 남긴다. 같은 id 로 두 번 붙이면 {:error, :already_exists} 가 오므로 attach/0 은 그것을 :ok 로 취급한다.
  • Ecto sandbox 와 $callers: sandbox 는 테스트 프로세스가 연결 주인이고 Task 는 $callers 로 주인을 찾아 같은 연결을 쓴다. 연결이 하나라 Task 와 테스트가 동시에 쿼리하면 직렬화된다. 진짜 동시성을 보려면 프로세스마다 Sandbox.checkout(Repo, sandbox: false) 로 자기 연결을 받아야 하고, 그때는 롤백이 없으니 뒷정리를 직접 한다.
  • insert_all 과 query source: Repo.insert_all(Finding, query) 는 INSERT ... SELECT 가 된다. select 의 key 는 스키마 필드명이어야 하고, ^run.id 같은 바인딩은 type(^v, :binary_id) 로 타입을 알려 줘야 한다. 리스트로 넣을 때와 달리 inserted_at 이 자동으로 들어가지 않아 select 에 직접 넣었다.

다음 할 일

  • 메인 세션: 계약 5.4 성공 경로 순서를 "run FOR UPDATE -> mark_rule -> rule_results insert_all -> pending count -> finalize" 로 고친다.
  • 워커 B 의 룰 7종이 합쳐지면 :all 테스트가 자동으로 그 룰들을 포함한다. 룰마다 params 를 쓰는 방식이 다르면 RuleContext.params 스냅샷이 맞는지 다시 본다.
  • 화면(워커 C)에서 :rule_progress 의 done/total 과 :run_completed 를 받아 진행률을 그린다.
  • M3: FailureHandler 에 Triage worker 분기를 추가한다.

관련 커밋

  • 1d28488 feat(testing): 룰 실행 경로 구현
  • 2436619 feat(jobs): 마지막 시도 실패를 기록하는 telemetry 핸들러
  • 이 문서: docs(devlog): Testing 실행 경로 기록