룰 7종과 정답 라벨 시험
- 작성일: 2026-10-19
- 작성: 워커 B (Codex
gpt-6-astra, effort xhigh), 브랜치feature/m2-rules, WBS 2.2, 2.4 - 관련 문서: 시스템 구조와 데이터 모델 (12.1 라운드 3), WBS와 마일스톤
목표
도메인 정의 2.2-2.9의 남은 룰 7개를 구현하고, 참조 룰을 포함한 8개 룰의 결과를 합성 GL 정답 라벨과 직접 대조한다. Runner 통합 없이도 정의와 판정이 일치함을 확인하는 WBS 2.2, 2.4 작업이다.
한 일
WeekendHolidayPosting,OffHoursEntry,RoundAmount,PeriodEndManual,BackdatedPosting,RareAccountPair,DuplicateEntry를Tickmark.Testing.Rulebehaviour로 구현했다.- 레지스트리에 도메인 순서대로 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건을 확인했다. 생성기와 라벨은 수정하지 않았다.
변경 파일
- 구현: 룰 레지스트리, 주말/공휴일, 업무시간 외, 라운드 금액, 기말 수기, 증빙일 괴리, 희귀 계정쌍, 중복 전표.
- 룰별 시험: 주말/공휴일, 업무시간 외, 라운드 금액, 기말 수기, 증빙일 괴리, 희귀 계정쌍, 중복 전표.
- 공통 시험: fixture helper, 정답 라벨 시험, 레지스트리 시험 추가.
- 문서: 이 devlog. 공유 파일, 참조 승인 룰, 스키마, 생성기,
mix.lock은 변경하지 않았다.
결정과 트레이드오프
- 날짜·시간·source는 전표 헤더에서 읽고, 새 금액 판정은 라인 차변 합계를 사용한다. 헤더에 저장된 합계를 재사용하는 대안보다 집계 비용은 들지만 배정된 계약대로 금액의 근거를 라인에 둔다. 테스트에서는 헤더 합계를 달리 준 경우에도 라인 합계를 쓰는지 확인했다.
- 휴일표는
Holidays.all/0을 ISO 날짜 문자열 목록으로 만들어 기본 파라미터에 넣는다. 요일은 ISO 번호 6/7, 업무시간은08:00:00/20:00:00이다. JSON 왕복이 가능하고, 휴일표가 없는 연도는 주말 판정을 계속하면서 경고를 남긴다.entered_at은 이미 KST 벽시계인NaiveDateTime이므로 9시간을 더하지 않는다. - 희귀 계정쌍은 전표별 차변·대변 계정 집합을 DB에서 만든 뒤
LATERAL unnest로 해당 전표 안의 조합만 펼친다. 쌍별 전표 수를 집계하고 희귀 쌍의 ID만 반환받아 헤더를 읽는다.apply_sources는 적중 반환에 적용하고, 빈도와 최소 모집단은 import 전체를 기준으로 한다. - 중복 후보는 DB에서 거래처, 차변 합계, 정렬된 차변/대변 계정 집합으로 묶는다. 계정 코드를 이어 붙인 해시 대신 두 배열을 쓰므로 구분자 충돌이 없고, 라인 순서·같은 계정의 반복·계정별 금액 배분은 판정에 영향을 주지 않는다.
- 중복 후보 그룹은
(전기일, 입력일시, 전표번호)순으로 정렬한다. 날짜 구간 안의 앞선 전표만 FIFO에 유지하며, 그중 가장 이른 전표번호를duplicate_of에 남긴다. 모든 쌍을 비교하는 대안보다 후보가 많을 때 반복 비교가 적고, 전표당 결과도 하나다. - 정답 시험은 시드당 하나의 시험 안에서 생성·적재와 두 판정 기준을 공유한다. 매 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로 룰을 직접 실행한 결과다. - 화면 연결 후 실제 서비스의 실행/진행률/검토 큐를 확인한다.
- 사용자와 위 학습 포인트, 특히 희귀 계정쌍 집계와 중복 후보의 날짜 구간 처리를 코드로 함께 확인한다.
관련 커밋
24c2536feat(testing): 룰 7종과 정답 라벨 검증 구현- 이 문서를 포함한
docs(devlog): 룰 구현과 라벨 검증 결과 기록커밋.