합성 GL 생성기
- 작성일: 2026-10-17
- 작성: Worker C (브랜치
feature/ledger-generator) - 관련 문서: 분개 테스트 룰과 감사 용어 2장, 3장, 7장, 시스템 구조와 데이터 모델 2.1, MVP 범위와 일정 5장
목표
도메인 문서 7장을 그대로 구현하는 결정적 합성 GL 생성기를 만든다. 출력은 10/19 "정답 라벨 대비 룰 적중" 시험의 입력과 정답이다. 계약은 설계 문서 2.1의 default_seed/0, generate/1이다.
한 일
Tickmark.Testing.Holidays: 2025년 공휴일표 19일 (all/0,holiday?/1). 룰과 생성기가 같이 쓴다.Tickmark.Ledger.Generator와 하위 모듈 7개. 파일마다 책임이 하나다.
| 모듈 | 책임 |
|---|---|
Generator |
공개 API. 정상, benign, 이상 주입 순서로 만들고 전표번호를 매긴 뒤 CSV 두 개를 돌려준다 |
Generator.Catalog |
데이터 표. 계정 32개, 사용자, 거래처, N01-N23, B1-B4, R1-R8, 시나리오별 기대 룰, 프로브 적요, 룰 가중치 |
Generator.Dates |
2025년 날짜 후보 튜플(수기 영업일, 주말, 평일 공휴일, 토요일)과 영업일 계산 |
Generator.Random |
시드 고정 난수. {값, 새 상태}를 돌려주는 integer/3, pick/2, shuffle/2 |
Generator.Entry |
전표 맵 조작. 금액, 중복 키, 전기일 이동, 비례 금액 조정 |
Generator.Builder |
패턴 하나를 건수만큼 전표로 만든다(7.3 공통 규칙) |
Generator.Scenarios |
이상 주입(7.4). 제자리 변이, I7/C3 추가, I8 복제, 프로브 |
Generator.Csv |
GL CSV, 라벨 CSV 직렬화 (NimbleCSV.RFC4180) |
- 테스트
test/tickmark/ledger/generator_test.exs7개,test/tickmark/testing/holidays_test.exs5개. 라벨, 배분, 룰 재계산, 불변식 시험은 시드 4개(기본값, 1, 42, 20261017)를for로 돈다.- GL 계약: BOM 없음, UTF-8, 헤더 16개, 행 형식, 한쪽만 0보다 큼, 전표 행 연속, 헤더 필드 일관, 차대 균형, 셀 길이
- 라벨: 전표와 1:1, 점수 = min(100, 가중치 합), triage = 점수 >= 25, triage 52건, 경미 213건, 시나리오별 건수, 프로브 5건
- 시나리오 안 배분: I1 주말 20, I2 야간 15/새벽 15, I4 기간 13 8건, I5 공란 15, I6 소급 18/선기표 7, C1 주말 4 등
- 참조 판정: GL CSV만 읽어 8개 룰을 다시 계산하고, 모든 전표에서
expected_rules와 같은지 본다 - 불변식 I-1 ~ I-4
기본 시드 실측 (seed 20251231)
$ MIX_TEST_PARTITION=_c mix run --no-start -e 'for _ <- 1..3 do {t, r} = :timer.tc(fn -> Tickmark.Ledger.Generator.generate() end); IO.puts("#{r.entry_count} #{r.line_count} #{div(t, 1000)}ms #{byte_size(r.gl_csv)} #{byte_size(r.labels_csv)}") end'
8341 19804 102ms 2709233 339614
8341 19804 77ms 2709233 339614
8341 19804 63ms 2709233 339614
전표 8,341건, 행 19,804행, 생성 시간 63-102ms(첫 호출에 모듈 로딩이 들어간다). GL CSV 2,709,233바이트, 라벨 CSV 339,614바이트. 세 번 모두 크기가 같다.
| 시드 | 전표 | 행 |
|---|---|---|
| 20251231 (기본) | 8,341 | 19,804 |
| 1 | 8,341 | 19,801 |
| 42 | 8,341 | 19,802 |
| 20261017 | 8,341 | 19,802 |
전표 수는 시드와 무관하게 8,341건이다. 행 수가 도메인 문서 추정 19,794와 다른 이유는 I8 복제본이다. 추정은 복제본 15건을 모두 2줄로 셌는데, 3줄 전표(N01, N03)를 복제하면 1줄씩 늘어난다. 기본 시드는 15건 중 10건이 3줄 전표 복제여서 19,804행이다.
MIX_TEST_PARTITION=_c mix precommit: 경고 0, 테스트 157개 통과(기존 145 + 12). mix.lock 변경 없음.
결정과 트레이드오프
| 주제 | 선택 | 검토한 대안 | 이유 |
|---|---|---|---|
| 난수 상태 | :rand.seed_s/2로 만든 상태를 함수 인자로 넘기고 돌려받는다 |
:rand.seed/2로 프로세스 사전에 두고 :rand.uniform/1 호출 |
프로세스 사전 방식은 코드가 짧지만, 같은 프로세스의 다른 코드가 :rand를 한 번만 불러도 결과가 바뀐다. 명시적 상태는 순수 함수라 호출 순서가 코드에 그대로 보인다. 대가는 모든 함수가 {값, 상태}를 돌려주는 장황함이다 |
| 건수 맞추기 | 정상 패턴은 쿼터만큼 만들고, 변이 대상은 후보를 시드로 섞어 앞에서 k건 | 전표마다 확률로 이상 여부를 정함 | 확률 방식이면 시드마다 건수가 달라 triage 52건 같은 정답을 고정할 수 없다. 섞어서 자르면 어느 시드든 건수가 같다 |
| 중복 전표 오탐 방지 | (거래처, 금액, 계정 구성) 키를 날짜와 상관없이 전체에서 한 번만 쓰게 등록하고, 겹치면 금액을 다시 뽑는다 | 7일 윈도 안에서만 비교 | 윈도 비교는 변이로 전기일을 옮길 때마다 다시 검사해야 한다. 전역 키는 더 엄격하지만 단순하고, 날짜를 옮겨도 깨지지 않는다. 금액 범위가 넓어(10원 단위) 재추출은 드물다. 예외는 I8 복제본과 B1(금액 고정, 월 1회라 윈도 밖)뿐이다 |
| 라벨 직렬화 | expected_rules는 rule_id를 정렬해 ;로 잇고 비면 빈 칸. 불리언은 true/false |
JSON 배열 문자열 | CSV 안의 JSON은 따옴표 이스케이프가 겹쳐 읽기 어렵다. rule_id에 ;가 없으니 String.split/2 한 번이면 된다. 정렬해 두면 바이트가 결정적이고 집합 비교가 쉽다 |
| 전표번호 | (전기일, 입력일시, 생성 순서)로 정렬해 JE2025-000001부터 |
생성 순서 그대로 | 원장이 시간순으로 읽히고, 중복 룰의 "나중 전표" 순서(전기일, 입력일시, 전표번호)와 일치한다 |
| 테스트의 참조 판정 | 테스트 파일 안에 8개 룰을 다시 구현해 라벨과 대조 | 라벨 건수만 확인 | 건수만 맞고 엉뚱한 전표가 걸리는 실수를 잡을 수 없다. 표(가중치, 카탈로그, 계정)도 문서에서 다시 옮겨 생성기와 같은 실수를 공유하지 않게 했다. 10/19 룰 엔진과 로직이 겹치지만, 룰 엔진이 생기면 같은 데이터로 두 구현을 서로 검증할 수 있다 |
명세 해석 (확인 필요)
도메인 문서가 정하지 않은 곳은 아래처럼 정했다. Generator moduledoc에도 같은 목록이 있다.
- N01, N03 금액 범위는 공급가액 기준이다. 부가세 10%를 더하면 합계가 최대 220,000,000 / 165,000,000원이 되어 N02, N04 범위와 맞는다.
- N05 급여는 총급여의 10%를 예수금(2140)으로 뗀다.
- 금액 단위는 10원이다. 라운드 금액이 의도인 B1, B4만 1,000,000원 단위다.
- B1-B4는 정상 패턴 쿼터 8,264건과 별도로 추가한다(전표 8,341 = 8,264 + 46 + I7 12 + I8 15 + C3 4).
- B2 금액 500,000-15,000,000원, B3 금액 100,000-20,000,000원은 가정이다.
- B1 거래처는 V030, 전기일은 매월 25일(영업일이 아니면 다음 영업일). B4는 거래처가 없다.
- N12 승인자 공란은 절반 확률. N21은 매월 10일 전기. 자동 전표는 2025년 아무 날에나 전기한다.
- I1, C1의 "휴일표 날짜"는 평일 공휴일 17일 중에서 고른다. 주말과 겹치는 공휴일(03-01, 10-05)을 고르면 주말 건수와 구분이 흐려진다.
- C1은 주말 4건, 평일 공휴일 4건이고 입력 시각은 22-24시와 0-6시를 번갈아 쓴다.
- C2는 12-29 ~ 12-31 순환, 기간 12, 승인자 = 입력자(자기승인). C4는 증빙일 31-90일 이전, 승인자 공란. C3는 12-29, 12-30 전기, 기간 12.
- 변이로 전기일이 바뀌면 회계기간은 새 전기일의 월이다(I4 기간 13은 예외). 증빙일은 2024년이나 2026년이 될 수 있다.
- 거래처 열에는 코드(C001, V001)만 쓴다.
- 5월 1일 근로자의 날은 휴일표에 넣지 않았다(도메인 문서 2.10의 확인 필요 항목).
- 불변식 I-2는 "카탈로그 밖의 모든 쌍이 6건 이상"으로 시험한다. benign 패턴 쌍(패턴당 8건 이상)도 포함한다.
- 도메인 문서 7.1의
target_lines인자는 구현하지 않았다. 계약(설계 문서 2.1)의 opts는seed뿐이다.
막힌 점과 해결
- 교차 적중을 설계 단계에서 먼저 막았다. 수기 정상 전표는 수기 영업일과 09:00-17:59에만 두고, 1,000만 원 이상이면 100만 원 배수를 피하고, 승인자(mgr01, mgr02)와 입력자(acct01-05)를 겹치지 않게 했다. 변이 후보도 조건을 걸었다. I3은 승인자가 있는 전표만(금액을 올리면 N12 공란 승인이 승인 통제 룰에 걸린다), I5는 100만 원 이상만 고른다. 그 결과 참조 판정 시험은 처음 실행에서 시드 4개 모두 불일치 0건으로 통과했다.
- 행 수가 문서 추정과 10행 달랐다. 원인은 위 "기본 시드 실측"의 I8 복제본이다. 명세 범위(약 20,000행 +-3%) 안이라 그대로 두고, 시험은 19,400-20,600 범위로 본다.
mix run --no-start로 GL CSV 앞부분을IO.puts하면 한글이\x{BCF4}처럼 찍혔다. 출력 장치 쪽 표시 문제로 보고, 바이트가 UTF-8인지는 시험의String.valid?/1로 확인한다.
Elixir 학습 포인트
- 명시적 난수 상태:
:rand.uniform_s(n, state)는{값, 새 상태}를 돌려준다. 상태는 불변 값이라 같은 상태로 두 번 부르면 같은 값이 나온다. 새 상태를 다음 호출에 넘기는 것을 잊으면 같은 값이 반복되므로, 함수마다 마지막 상태를 돌려주는 규칙을 지켰다. - 상태를 끼고 도는 순회:
Enum.map_reduce/3(전표 만들기),Enum.flat_map_reduce/3(패턴 목록),Enum.reduce/3(변이 시나리오 차례 적용),Enum.reduce_while/3(I8 복제를 15건에서 멈춤)으로 난수 상태와 생성 상태를 넘긴다. 가변 변수가 없어도 루프 상태를 표현하는 방법이다. - 모듈 속성 데이터 표:
@manual_days처럼 컴파일 시점에 계산해 튜플로 저장한다. 런타임에는elem/2로 O(1) 선택만 한다.Dates는 컴파일할 때Holidays.all/0을 부르므로 컴파일 의존이 생기고, 휴일표가 바뀌면 Mix가Dates를 다시 컴파일한다. - iodata:
NimbleCSV.RFC4180.dump_to_iodata/1은 중첩 리스트를 돌려준다. 2만 행을 문자열 이어 붙이기로 만들면 매번 복사가 생기지만, iodata는 마지막에IO.iodata_to_binary/1한 번만 복사한다. - 패턴으로 거르는 comprehension:
for {_code, :debit, amount} <- lines는 패턴이 맞지 않는 원소를 건너뛴다. 익명 함수 절의{code, ^side, amount}처럼 핀 연산자로 바깥 변수와 같은 값만 맞출 수도 있다. - 큰 맵의 순서: 키가 32개를 넘는 맵은 내부 구조가 바뀌어 순회 순서를 믿을 수 없다. 생성기는 맵을 조회(
Map.get)에만 쓰고, 순서가 중요한 곳은 리스트를 정렬한다.
다음 할 일
- 10/19 룰 엔진: 룰 모듈의
weight/0을Catalog.rule_weights/0(도메인 문서 2.1)과 대조하고, 생성기 출력으로 "모든 전표에서 실제 적중 =expected_rules" 시험을 쓴다. 지금 테스트의 참조 판정과 결과가 같아야 한다. - 메인 세션:
generate_sample_import/3로gl_csv,labels_csv를 import 디렉터리에 쓰고 적재 경로와 연결(설계 문서 12장). - 필요하면
target_lines지원(계약 변경 필요).
관련 커밋
88b6451feat(testing): 2025년 공휴일표 Holidays 모듈 추가3c8bbe0feat(ledger): 합성 GL 생성기 데이터 표 추가1e3b9aefeat(ledger): 결정적 합성 GL 생성기와 정답 라벨 추가