개발 기록 2026-10-17 docs/devlog/2026-10-17-11-합성-GL-생성기.md GitHub에서 보기

합성 GL 생성기

목표

도메인 문서 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.exs 7개, test/tickmark/testing/holidays_test.exs 5개. 라벨, 배분, 룰 재계산, 불변식 시험은 시드 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에도 같은 목록이 있다.

  1. N01, N03 금액 범위는 공급가액 기준이다. 부가세 10%를 더하면 합계가 최대 220,000,000 / 165,000,000원이 되어 N02, N04 범위와 맞는다.
  2. N05 급여는 총급여의 10%를 예수금(2140)으로 뗀다.
  3. 금액 단위는 10원이다. 라운드 금액이 의도인 B1, B4만 1,000,000원 단위다.
  4. B1-B4는 정상 패턴 쿼터 8,264건과 별도로 추가한다(전표 8,341 = 8,264 + 46 + I7 12 + I8 15 + C3 4).
  5. B2 금액 500,000-15,000,000원, B3 금액 100,000-20,000,000원은 가정이다.
  6. B1 거래처는 V030, 전기일은 매월 25일(영업일이 아니면 다음 영업일). B4는 거래처가 없다.
  7. N12 승인자 공란은 절반 확률. N21은 매월 10일 전기. 자동 전표는 2025년 아무 날에나 전기한다.
  8. I1, C1의 "휴일표 날짜"는 평일 공휴일 17일 중에서 고른다. 주말과 겹치는 공휴일(03-01, 10-05)을 고르면 주말 건수와 구분이 흐려진다.
  9. C1은 주말 4건, 평일 공휴일 4건이고 입력 시각은 22-24시와 0-6시를 번갈아 쓴다.
  10. C2는 12-29 ~ 12-31 순환, 기간 12, 승인자 = 입력자(자기승인). C4는 증빙일 31-90일 이전, 승인자 공란. C3는 12-29, 12-30 전기, 기간 12.
  11. 변이로 전기일이 바뀌면 회계기간은 새 전기일의 월이다(I4 기간 13은 예외). 증빙일은 2024년이나 2026년이 될 수 있다.
  12. 거래처 열에는 코드(C001, V001)만 쓴다.
  13. 5월 1일 근로자의 날은 휴일표에 넣지 않았다(도메인 문서 2.10의 확인 필요 항목).
  14. 불변식 I-2는 "카탈로그 밖의 모든 쌍이 6건 이상"으로 시험한다. benign 패턴 쌍(패턴당 8건 이상)도 포함한다.
  15. 도메인 문서 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 지원(계약 변경 필요).

관련 커밋

  • 88b6451 feat(testing): 2025년 공휴일표 Holidays 모듈 추가
  • 3c8bbe0 feat(ledger): 합성 GL 생성기 데이터 표 추가
  • 1e3b9ae feat(ledger): 결정적 합성 GL 생성기와 정답 라벨 추가