개발 기록 2026-10-19 docs/devlog/2026-10-19-3-룰-7종과-정답-라벨-시험.md GitHub에서 보기

룰 7종과 정답 라벨 시험

목표

도메인 정의 2.2-2.9의 남은 룰 7개를 구현하고, 참조 룰을 포함한 8개 룰의 결과를 합성 GL 정답 라벨과 직접 대조한다. Runner 통합 없이도 정의와 판정이 일치함을 확인하는 WBS 2.2, 2.4 작업이다.

한 일

  • WeekendHolidayPosting, OffHoursEntry, RoundAmount, PeriodEndManual, BackdatedPosting, RareAccountPair, DuplicateEntry를 Tickmark.Testing.Rule behaviour로 구현했다.
  • 레지스트리에 도메인 순서대로 8개 룰을 등록했다. extra_rules, all/0, ids/0, fetch/1의 처리 방식은 유지했다.
  • 룰별로 경계값, source 필터, 다른 import 제외, 전표당 1회 반환, 문자열 키와 JSON 값, 한국어 한 줄 근거를 검증했다. 희귀 계정쌍은 모집단 499/500건과 쌍 빈도 1/2/3건, 중복 전표는 날짜/입력일시/번호 순서와 연쇄 중복의 원본 선택도 검증했다.
  • 두 시드 20251231, 7에서 Ledger.generate_sample_import/3와 Ledger.process_import/1로 각각 한 번 생성·적재했다. 각 적재 결과에 대해 모든 전표의 적중 룰 집합, 가중치 합의 상한 100 점수, 25점 이상 triage 집합을 라벨 CSV와 대조했다.
  • 기본 시드에서 8,341 전표, 19,804 라인, triage 52건, 경미 적중 213건을 확인했다. 생성기와 라벨은 수정하지 않았다.

변경 파일

결정과 트레이드오프

  1. 날짜·시간·source는 전표 헤더에서 읽고, 새 금액 판정은 라인 차변 합계를 사용한다. 헤더에 저장된 합계를 재사용하는 대안보다 집계 비용은 들지만 배정된 계약대로 금액의 근거를 라인에 둔다. 테스트에서는 헤더 합계를 달리 준 경우에도 라인 합계를 쓰는지 확인했다.
  2. 휴일표는 Holidays.all/0을 ISO 날짜 문자열 목록으로 만들어 기본 파라미터에 넣는다. 요일은 ISO 번호 6/7, 업무시간은 08:00:00/20:00:00이다. JSON 왕복이 가능하고, 휴일표가 없는 연도는 주말 판정을 계속하면서 경고를 남긴다. entered_at은 이미 KST 벽시계인 NaiveDateTime이므로 9시간을 더하지 않는다.
  3. 희귀 계정쌍은 전표별 차변·대변 계정 집합을 DB에서 만든 뒤 LATERAL unnest로 해당 전표 안의 조합만 펼친다. 쌍별 전표 수를 집계하고 희귀 쌍의 ID만 반환받아 헤더를 읽는다. apply_sources는 적중 반환에 적용하고, 빈도와 최소 모집단은 import 전체를 기준으로 한다.
  4. 중복 후보는 DB에서 거래처, 차변 합계, 정렬된 차변/대변 계정 집합으로 묶는다. 계정 코드를 이어 붙인 해시 대신 두 배열을 쓰므로 구분자 충돌이 없고, 라인 순서·같은 계정의 반복·계정별 금액 배분은 판정에 영향을 주지 않는다.
  5. 중복 후보 그룹은 (전기일, 입력일시, 전표번호) 순으로 정렬한다. 날짜 구간 안의 앞선 전표만 FIFO에 유지하며, 그중 가장 이른 전표번호를 duplicate_of에 남긴다. 모든 쌍을 비교하는 대안보다 후보가 많을 때 반복 비교가 적고, 전표당 결과도 하나다.
  6. 정답 시험은 시드당 하나의 시험 안에서 생성·적재와 두 판정 기준을 공유한다. 매 assertion마다 다시 적재하는 방식을 피했고, 제외 태그 없이 precommit에 포함했다. 실패 메시지에는 시드, 전표번호, 시나리오, 기대/실제 룰 목록을 넣었다.

막힌 점과 해결

  • 처음 작성한 모집단 JOIN 쿼리는 작은 fixture에서는 통과했지만 기본 시드에서 15초 DB 제한을 초과했다. Repo.explain/3 출력에는 헤더를 1행, 라인 집계를 9행으로 추정한 Nested Loop가 있었고, 테스트 DB의 두 테이블은 분석 이력이 없었다. 특히 라인 집계가 헤더마다 반복될 수 있는 쿼리 형태가 문제였다.
  • 제한 시간을 늘리거나 테스트용 ANALYZE로 우회하지 않았다. 라운드 금액은 먼저 적중 금액을 집계한 뒤 해당 헤더만 조회한다. 희귀 계정쌍은 라인 테이블 자기 JOIN 대신 전표 안의 계정 배열을 펼친다. 중복 전표는 라인을 먼저 한 번 집계하며 헤더 거래처를 기본키 상관 서브쿼리로 읽는다. 최종 실측은 아래 표와 같다.
  • Ecto의 서브쿼리 select 맵에 상관 서브쿼리를 직접 넣으면 Ecto.SubQueryError가 발생했다. coalesce(subquery(counterparty), "")로 NULL 거래처와 제외된 source를 공란으로 정규화해 명시적인 스칼라 표현식으로 만들었다. 공란은 중복 후보에서 제외한다.
  • 공통 fixture 파일의 일반 .ex/.exs 이름은 Mix 테스트 로더의 경고를 유발했다. 로컬 mix help test에서 _helper.exs가 기본 제외 규칙임을 확인하고 해당 이름으로 바꿨다. 공유 프로젝트 설정을 바꿀 필요는 없었다.
  • 라벨과 어긋난 생성기 동작은 발견하지 않았다. 남은 이슈와 계약 변경 요청은 없음이다.

검증

최종 코드에서 실행한 전체 검증:

MIX_TEST_PARTITION=3 mix precommit
Running ExUnit with seed: 846005, max_cases: 24
Finished in 4.7 seconds (4.6s async, 0.09s sync)
Result: 290 passed

컴파일 경고, 테스트 실패, git diff --check 오류가 없었고 mix.lock 변경도 없었다. 레지스트리의 기존 시험은 유지하고 순서·이름·가중치·JSON 왕복 시험만 추가했다.

기본 시드 룰별 실행 시간

실행 명령은 RULE_TIMINGS=1 MIX_TEST_PARTITION=3 mix test test/tickmark/testing/rules_label_test.exs --trace이고 마지막 출력은 Result: 2 passed였다. 아래는 그 실행의 기본 시드 8,341 전표 / 19,804 라인에서 :timer.tc/1로 측정한 값이다. SQL 조회와 hit 변환을 포함하고 GL 생성·적재는 제외한 1회 측정이며 성능 보장치는 아니다.

룰 적중 전표 실행 시간(ms)
weekend_holiday_posting 46 7.207
off_hours_entry 42 6.901
round_amount 68 12.748
period_end_manual 45 1.771
approval_control_gap 40 1.803
backdated_posting 29 1.829
rare_account_pair 16 40.981
duplicate_entry 15 39.625

Elixir 학습 포인트

  • behaviour와 콜백: @behaviour Tickmark.Testing.Rule은 룰이 구현할 함수의 공통 계약이다. @impl true로 그 구현임을 명시하고, 레지스트리의 모듈 목록을 순회해 같은 run/1을 호출한다. 룰마다 상속이나 매크로를 만들지 않아도 플러그인 형태가 된다.
  • Ecto 쿼리 조합: group_by는 라인들을 전표나 계정쌍 단위로 묶고 having은 집계 결과를 거른다. subquery는 전표별 집계를 모집단 집계의 입력으로 재사용한다. parent_as(:lines)는 상관 서브쿼리에서 바깥 전표 ID를 참조한다. fragment는 Ecto 기본 함수에 없는 numeric 나머지, 배열 집계와 unnest, 날짜/시각 연산에만 쓰며 값은 바인딩한다.
  • LATERAL의 범위: 희귀 계정쌍의 cross_lateral_join은 각 전표의 두 계정 배열을 행으로 펼친다. 전체 라인 테이블끼리 곱하지 않고, 차변 계정 2개와 대변 계정 2개가 있으면 그 전표의 4개 조합만 만든다. 같은 계정의 반복 라인은 DISTINCT로 제거한다.
  • Decimal 비교: 금액은 Decimal.new/1로 만든 임계값과 PostgreSQL numeric 합계/나머지로 비교한다. Elixir의 구조체 ==나 float 변환으로 금액을 비교하지 않는다. JSON 근거에는 Rule.amount_string/1로 "1000000.00" 대신 "1000000"을 남긴다.
  • 불변 자료구조: Enum.reduce는 바뀐 누산기를 반환해야 다음 단계에 전달된다. 중복 룰의 :queue도 새 값을 돌려주는 FIFO 자료구조이며 Oban 실행 큐나 별도 프로세스가 아니다. 오래된 전표를 앞에서 제거하면 각 전표를 한 번 넣고 한 번 뺀다.

다음 할 일

  • 메인 세션에서 Runner 브랜치와 통합한 뒤 같은 합성 GL에 대해 저장된 rule_results/findings와 종료 상태를 확인한다. 이번 시험은 RuleContext로 룰을 직접 실행한 결과다.
  • 화면 연결 후 실제 서비스의 실행/진행률/검토 큐를 확인한다.
  • 사용자와 위 학습 포인트, 특히 희귀 계정쌍 집계와 중복 후보의 날짜 구간 처리를 코드로 함께 확인한다.

관련 커밋

  • 24c2536 feat(testing): 룰 7종과 정답 라벨 검증 구현
  • 이 문서를 포함한 docs(devlog): 룰 구현과 라벨 검증 결과 기록 커밋.