GL CSV 적재
- 작성일: 2026-10-17 (워커 B, 브랜치
feature/ledger-import) - 관련 문서: 시스템 구조와 데이터 모델 2.1, 4.2, 5.4, 9장, MVP 범위와 일정 5장
목표
설계 문서 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)와 전이 표를 추가했다 |
흐름:
create_import/3::import_ledger권한 확인, 20,000,000바이트 초과 거부,import_dir에<uuid>.csv로 복사, sha256 계산. 그다음 pending 행,ledger_import.created감사 이벤트, ImportWorker job 을 한Ecto.Multi로 커밋한다. 커밋이 실패하면 복사본을 지운다. 커밋 뒤에{:import_updated, ...}를 보낸다.- 행 등록 부분(파일이 이미
import_dir에 있을 때 행, 감사, job 을 남기는 부분)은 privateregister_import/3로 떼었다. 메인 세션이 만들generate_sample_import/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 이고, 결과(성공/실패)만 감사 대상이라고 봤다.
셀 길이와 오류 코드
- 셀 길이 상한:
description500자, 나머지 200자(code point). 먼저 바이트 수로 빠르게 거르고, 넘을 때만 글자 수를 센다. 한 물리 줄이 65,536바이트를 넘으면 NimbleCSV 에 넘기기 전에parse_error로 멈춘다. 따옴표가 안 닫힌 파일이 메모리를 끝없이 쓰지 않게 하기 위해서다. - 9.2의 코드 목록에는 "너무 긴 셀", "읽을 수 없는 문자(UTF-8 아님, NUL)", "허용값이 아닌
source", "모르는 헤더나 중복 헤더"를 가리킬 코드가 없다. 코드를 새로 만들면 계약 위반이라 모두parse_error로 냈다. 대신field에 컬럼명을 넣어 어디가 문제인지는 보이게 했다(헤더 오류는field: nil, 1행). 셀 내용은 오류에도 로그에도 넣지 않는다. - 같은 전표 안의
line_number중복은invalid_number(fieldline_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_rows1건뿐인지 보도록 고쳤다. 한 셀짜리 행은 같은 전표로 묶이고 셀 수 검사에서 바로 떨어지므로 빠르다. - 첫 구현에서 적재 트랜잭션이 이미 끝난 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_allplaceholder. 모든 행에 같은 값(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}분기(마지막 시도 처리)는 지금 코드로는 일으킬 방법이 없어 테스트하지 못했다. 장애 주입 수단이 생기면 추가한다.
관련 커밋
829807afeat(ledger): GL CSV 파서와 검증 구현2ff521dfeat(ledger): GL 적재 컨텍스트와 ImportWorker 구현- 이 문서: docs(devlog) 커밋