개발 기록 2026-10-17 docs/devlog/2026-10-17-10-GL-CSV-적재.md GitHub에서 보기

GL CSV 적재

목표

설계 문서 9장의 GL CSV 적재 경로를 만든다. 업로드된 파일을 보관하고, Oban job 이 파일을 검증하면서 전표와 라인을 적재한다. 파일 하나는 전부 들어가거나 전혀 들어가지 않는다. 등록, 성공, 실패는 모두 감사 로그에 남는다.

완료 조건은 MIX_TEST_PARTITION=_b mix precommit 통과(기존 145개 포함)다.

한 일

모듈 역할
Tickmark.Ledger.CsvParser 파일을 스트림으로 읽어 헤더, 행, 전표 단위로 검증한다. 전표를 하나씩 콜백에 넘기고 요약(건수, 합계, 오류, 경고)을 돌려준다
Tickmark.Ledger.CsvParser.Row 셀 하나하나를 타입 값으로 바꾼다. 파서가 300줄을 넘어 분리했다
Tickmark.Ledger.Importer 파서가 넘긴 전표를 1000건씩 모아 insert_all로 넣는다
Tickmark.Ledger 공개 API: create_import/3, list_imports/2, get_import!/2, get_entry!/2, process_import/1, mark_import_failed/2
Tickmark.Ledger.ImportWorker ingest 큐, max_attempts: 3, timeout 10분. 도메인 결과를 job 상태로 바꾼다
LedgerImport 상태 전이 changeset 3개(processing, succeeded, failed)와 전이 표를 추가했다

흐름:

  1. create_import/3: :import_ledger 권한 확인, 20,000,000바이트 초과 거부, import_dir에 <uuid>.csv로 복사, sha256 계산. 그다음 pending 행, ledger_import.created 감사 이벤트, ImportWorker job 을 한 Ecto.Multi로 커밋한다. 커밋이 실패하면 복사본을 지운다. 커밋 뒤에 {:import_updated, ...}를 보낸다.
  2. 행 등록 부분(파일이 이미 import_dir에 있을 때 행, 감사, job 을 남기는 부분)은 private register_import/3로 떼었다. 메인 세션이 만들 generate_sample_import/3가 이 함수로 끝나면 된다.
  3. process_import/1: 행을 processing으로 바꿔 알린 뒤, 한 트랜잭션 안에서 행 잠금, 파싱과 적재, succeeded 전환, ledger_import.succeeded 감사 이벤트를 처리한다. 검증에 실패하면 롤백하고, 별도 트랜잭션에서 failed와 오류 100건, 전체 오류 수, ledger_import.failed를 남긴다.

테스트 47개를 추가했다. 전체 192개가 통과한다.

파일 개수 내용
csv_parser_test.exs 25 오류 코드 전부, period_mismatch 경고, BOM, CRLF, 따옴표 안 쉼표와 줄바꿈, 헤더 순서 바꿈, 빈 줄, 오류 100건 상한, 첫 오류 뒤 콜백 중단
ledger_test.exs 16 권한(reviewer, 비회원, scope 없음), 복사본과 sha256, job 등록, 감사 이벤트, 브로드캐스트, 20MB 초과, 빈 파일, 정상 적재, 1001건 뒤 오류의 전부 롤백, 재호출, processing 재시작, 파일 없음, mark_import_failed, 목록과 단건 조회
import_storage_test.exs 2 복사본이 정확히 하나 남는지, 거부나 행 검증 실패 뒤 복사본이 남지 않는지
import_worker_test.exs 4 perform_job/2로 :ok, {:cancel, :import_failed}, {:cancel, :not_found}, 재실행, 큐와 timeout 설정

결정과 트레이드오프

파일 하나를 한 트랜잭션으로 적재

  • 선택: 파싱과 insert_all 전부, 상태 전환, 감사 이벤트를 하나의 Repo.transact/1에 넣었다. 검증 오류가 나오면 앞서 넣은 청크까지 롤백된다.
  • 대안: 청크마다 커밋하고, 실패하면 그 import 의 행을 지운다. 트랜잭션은 짧아지지만 적재 도중의 반쪽 데이터를 룰 실행이나 화면이 볼 수 있다. 지우는 단계가 실패하면 데이터가 남는다.
  • 대가: 10만 행 파일이면 트랜잭션이 몇 초간 열려 있고 import 행 잠금도 그동안 잡힌다. 읽기는 MVCC 라 막히지 않는다. 2만 행 측정은 0.7초였다.
  • 실패 기록을 별도 트랜잭션으로 둔 이유: 같은 트랜잭션에 쓰면 롤백이 실패 기록까지 지운다.

중복 적재 방지: Oban unique 대신 행 잠금

  • SELECT ... FOR UPDATE로 import 행을 잠근 뒤 상태를 본다. 두 job 이 같은 import 를 동시에 잡아도 뒤에 온 쪽은 앞쪽 커밋을 기다렸다가 succeeded를 보고 멈춘다.
  • processing -> processing은 허용했다. 앞선 시도가 중간에 죽었다면 그 적재는 이미 롤백되었으므로 처음부터 다시 하면 된다.
  • 브리핑대로 unique 옵션은 쓰지 않았다. unique 는 job 중복만 막고, 같은 job 의 재시도가 두 번 적재하는 경우는 못 막는다.

process_import/1의 반환 규칙

상황 반환 ImportWorker
적재 성공 {:ok, %{status: :succeeded}} :ok
파일 검증 실패, 저장 파일 없음 {:ok, %{status: :failed}} {:cancel, :import_failed}
이미 끝난 import (성공이든 실패든) {:ok, 그 행 그대로}, 아무것도 쓰지 않고 브로드캐스트도 없음 상태에 따라 위와 같음
없는 id, 형식이 틀린 id {:error, :not_found} {:cancel, :not_found}
그 밖의 {:error, reason} 그대로 마지막 시도면 mark_import_failed/2 후 {:cancel, reason}, 아니면 {:error, reason}(재시도)
DB 연결 끊김 등 예외 raise Oban 이 재시도한다. 마지막 시도는 10/19 실패 핸들러가 mark_import_failed/2를 부른다
  • 검증 실패를 {:error, summary}로 돌려주는 안도 검토했다. 그러면 워커가 재시도 대상과 구분해야 하고, 도메인 결과(failed 기록)는 이미 남았는데 job 은 오류처럼 보인다. "결과가 기록됐으면 {:ok, import}"로 통일했다.
  • failed 감사 이벤트 payload 의 reason은 validation_failed, file_missing, worker_failed 셋이다. 설계 문서 4.2에 payload 형태가 없어 이렇게 정했다.
  • processing 전환에는 감사 이벤트를 남기지 않았다. 4.2 표에 없는 action 이고, 결과(성공/실패)만 감사 대상이라고 봤다.

셀 길이와 오류 코드

  • 셀 길이 상한: description 500자, 나머지 200자(code point). 먼저 바이트 수로 빠르게 거르고, 넘을 때만 글자 수를 센다. 한 물리 줄이 65,536바이트를 넘으면 NimbleCSV 에 넘기기 전에 parse_error로 멈춘다. 따옴표가 안 닫힌 파일이 메모리를 끝없이 쓰지 않게 하기 위해서다.
  • 9.2의 코드 목록에는 "너무 긴 셀", "읽을 수 없는 문자(UTF-8 아님, NUL)", "허용값이 아닌 source", "모르는 헤더나 중복 헤더"를 가리킬 코드가 없다. 코드를 새로 만들면 계약 위반이라 모두 parse_error로 냈다. 대신 field에 컬럼명을 넣어 어디가 문제인지는 보이게 했다(헤더 오류는 field: nil, 1행). 셀 내용은 오류에도 로그에도 넣지 않는다.
  • 같은 전표 안의 line_number 중복은 invalid_number(field line_number)로 냈다.
  • 계약에 코드 추가를 요청했다: too_long, invalid_value, unknown_column (필요하면 duplicate_line_number). Row 모듈의 @too_long_code 등 세 속성만 바꾸면 된다.

큰 파일 테스트

  • 20MB 를 실제로 쓰지 않고 sparse 파일을 만들었다. :file.position(file, 20_000_000)으로 위치를 옮겨 1바이트를 쓰면 크기는 20,000,001바이트지만 디스크에는 거의 안 쓴다.
  • 크기 검사를 복사보다 먼저 하므로 거부되는 파일은 복사되지 않는다. 정확히 20,000,000바이트가 통과하는지는 테스트하지 않았다. 통과하면 20MB 를 복사하고 해시하게 된다.
  • "복사본이 남지 않는다"는 검증은 공유 import_dir에서는 할 수 없다. 다른 async 테스트와 다른 파티션도 같은 디렉터리를 쓰기 때문이다. 그래서 import_storage_test.exs를 sync 모듈로 두고, 테스트마다 Application.put_env로 빈 디렉터리를 가리키게 한 뒤 원래 값으로 돌렸다. ExUnit 은 async 모듈을 다 돌린 뒤 sync 모듈을 돌리므로 다른 테스트에 영향이 없다. 설정 파일은 바꾸지 않았다.

그 밖의 결정

  • 전표 id 는 Elixir 에서 Ecto.UUID.generate/0로 만든다. 라인이 RETURNING 왕복 없이 바로 전표를 가리킬 수 있다. returning: [:id, :entry_number]로 받아 매칭하는 안보다 단순하다.
  • sha256 은 64 KiB 블록으로 읽으며 계산한다. 20MB 파일을 메모리에 올리지 않는다.
  • original_filename은 Path.basename/1만 저장한다. 브라우저가 경로를 보내도 이름만 남는다.
  • 헤더만 있는 파일은 0건으로 succeeded가 된다. 계약에 따로 정해진 것이 없어 거부하지 않았다.
  • 오류 수가 0인 failed는 파일 문제가 아니라 시스템 문제(파일 없음, 워커 실패)라는 뜻이다. 10/19 ImportLive 가 문구를 나눌 때 쓸 수 있다.

막힌 점과 해결

  • guard 안에서 런타임 range 를 쓸 수 없었다(value when value in range 컴파일 오류). guard 의 in은 컴파일 시점에 알려진 range 나 리스트만 받는다. true <- value in range를 with 절로 옮겼다.
  • 파서가 528줄이 되어 셀 파싱을 CsvParser.Row로 떼었다. 파서는 행 사이 검사만, Row는 셀 하나 검사만 맡는다.
  • 10만 행 상한 테스트가 처음에는 남은 오류 코드만 보고 있어서 의미가 없었다. 상한 정확히(100,000행)일 때 error_count == 100_000이고 코드가 parse_error 하나뿐인지, 100,001행일 때 too_many_rows 1건뿐인지 보도록 고쳤다. 한 셀짜리 행은 같은 전표로 묶이고 셀 수 검사에서 바로 떨어지므로 빠르다.
  • 첫 구현에서 적재 트랜잭션이 이미 끝난 import 를 만나면(다른 워커가 먼저 끝낸 경우) {:finished, import}가 process_import/1 밖으로 그대로 나갈 뻔했다. with 본문의 반환값은 else를 거치지 않기 때문이다. 적재 시작 뒤에 만난 "이미 끝남"은 {:ok, import}로 바꾸는 함수를 따로 두었다.
  • mix format이 assert_enqueued/refute_enqueued에 괄호를 붙인다. .formatter.exs의 import_deps에 :oban이 없어서다. 공유 파일이라 고치지 않고 괄호를 둔 채로 두었다.

Elixir 학습 포인트

  • Stream 은 게으르다. File.stream! -> NimbleCSV.RFC4180.parse_stream -> Stream.chunk_by -> Enum.reduce_while 중 앞의 단계는 계산을 예약만 하고, 마지막 Enum 함수가 값을 당길 때 한 줄씩 흐른다. 그래서 파일 전체를 메모리에 올리지 않고, Stream.take(100_001)이 상한을 넘는 순간 파일 읽기 자체가 멈춘다. 반대로 예외가 스트림 안에서 나면 어느 행에서 났는지 알 수 없어서, 마지막으로 처리한 행 번호를 :counters에 적어 두었다.
  • NimbleCSV 의 필드는 sub-binary 다. 셀 값은 읽은 줄 binary 의 일부를 가리키는 참조라서, 짧은 셀 하나를 오래 들고 있으면 원래 줄 전체가 GC 되지 않는다. 연속성 검사에 쓰는 전표 번호 MapSet과 저장할 텍스트는 :binary.copy/1로 떼어 냈다.
  • insert_all placeholder. 모든 행에 같은 값(engagement_id, ledger_import_id, inserted_at)은 placeholders:로 한 번만 보내고 행에는 {:placeholder, key}를 둔다. 전표는 1000행 x 14개 값, 라인은 1000행 x 8개 값으로 PostgreSQL 파라미터 상한 65,535를 넘지 않는다. 전표와 라인이 쓰는 키가 달라 placeholder 맵도 따로 넘긴다. insert_all은 changeset 을 거치지 않아서 timestamps 를 채우지 않으므로 inserted_at을 직접 넣는다.
  • Oban 반환값. :ok는 completed, {:cancel, reason}은 재시도 없이 cancelled, {:error, reason}과 raise 는 attempt 를 하나 쓰고 backoff 뒤 재시도, max_attempts를 다 쓰면 discarded 다. 재시도해도 같은 결과가 나올 실패(파일 검증 실패)를 {:error, _}로 돌리면 같은 파일을 세 번 파싱하고 끝난다. 그래서 도메인 결과를 먼저 기록하고 {:cancel, _}를 돌려준다. timeout/1 콜백은 job 프로세스를 10분 뒤 종료시키고, 이것은 오류로 취급된다.
  • Ecto.Multi. 단계마다 이름을 붙여 쌓고 Repo.transact/1로 한 번에 실행한다. 실패하면 {:error, 실패한 단계, 값, 그 전까지의 결과}가 온다. 단계 이름으로 패턴 매칭해서 :summary 단계 실패(파일 검증 실패)와 다른 실패를 나눴다. Multi.run 안에서 부른 insert_all도 같은 트랜잭션이라 같이 롤백된다. 감사 이벤트(AuditTrail.append/3)와 job(Oban.insert/3)도 Multi 단계라서 "행은 생겼는데 job 이 없다" 같은 상태가 생기지 않는다.

2만 행 측정

  • 파일: 합성 GL 7,000전표, 20,000라인(전표당 2, 3, 4, 2, 3, 4, 2라인 반복), 2,955,935바이트. Python 표준 라이브러리 스크립트로 시드를 고정해 만들었고, 스크립트와 CSV 는 저장소 밖 스크래치 디렉터리에 두고 커밋하지 않았다.
  • 방법: MIX_ENV=test MIX_TEST_PARTITION=_b mix run <스크래치>/measure_import.exs <스크래치>/gl-20k.csv. 스크립트는 Ecto.Adapters.SQL.Sandbox.checkout/1을 한 번 잡고 그 안에서 사용자, engagement, 멤버를 만든 뒤 create_import/3 -> :timer.tc(fn -> Ledger.process_import(id) end)를 3번 돌린다. 매번 새 import 이고, 복사본은 매번 지운다.
  • Sandbox 를 쓴 이유: audit_events는 트리거로 DELETE 가 막혀 있어 Sandbox 밖에서 돌리면 측정 행을 지울 수 없다. 남은 행은 행 수를 세는 테스트를 깨뜨린다. 끝난 뒤 Sandbox 없이 tickmark_test_b의 ledger_imports, journal_entries, journal_lines, audit_events, users, oban_jobs를 세어 모두 0건임을 확인했다.
  • 한계: Sandbox 안에서는 process_import/1의 트랜잭션이 savepoint 가 되어 실제 COMMIT(WAL flush)이 빠진다. 실제 운영 값은 이보다 조금 더 걸린다.
  • 환경: Apple M3 Pro, 메모리 36GB, Elixir 1.20.4 / OTP 29, 로컬 PostgreSQL 16.4.
실행 process_import/1 결과
1 748.3 ms succeeded, 전표 7,000 / 라인 20,000 (DB 행 수 일치)
2 663.6 ms 같음
3 663.3 ms 같음

같은 파일을 DB 없이 CsvParser.parse/3로만 3번 돌리면 121.3 / 113.3 / 110.5 ms 였다. 적재 시간의 약 5/6이 insert_all이다. 1회차가 느린 것은 코드 로딩과 쿼리 준비 때문으로 보인다(확인하지 않음). 10만 행 상한 파일이면 같은 비율로 3-4초로 예상한다(측정하지 않음).

다음 할 일

  • 메인 세션: generate_sample_import/3를 register_import/3 위에 구현.
  • 10/19: job 실패 핸들러(raise, timeout 의 마지막 시도에서 mark_import_failed/2 호출), ImportLive 업로드와 오류 표시.
  • 계약 결정 대기: 새 오류 코드, ledger_import.* 감사 payload 형태를 설계 문서 4.2에 적기, 헤더만 있는 파일 처리.
  • ImportWorker의 {:error, reason} 분기(마지막 시도 처리)는 지금 코드로는 일으킬 방법이 없어 테스트하지 못했다. 장애 주입 수단이 생기면 추가한다.

관련 커밋

  • 829807a feat(ledger): GL CSV 파서와 검증 구현
  • 2ff521d feat(ledger): GL 적재 컨텍스트와 ImportWorker 구현
  • 이 문서: docs(devlog) 커밋