고용산재보험 가입현황 CSV(2022·2023)를 사업장 마스터로 동기화하는 파이프라인. 테이블 6개가 4개 계층으로 나뉘고, 예시 값은 전부 실제 소스 데이터와 검증된 실행 결과에서 가져왔다.
raw_workplaceCSV를 한 글자도 안 고치고 보관. 규칙이 바뀌면 여기서 재계산한다.
stg_workplace타입 확정 + 품질 판정. 키 유효성과 값 유효성을 따로 매긴다.
master_workplace · master_change_log과제의 결과물. 사업장별 최신 상태와 컬럼 단위 변경 이력.
ops_quarantine · ops_sync_run품질 문제 기록과 회차별 실행 결과·검증 요약.
핵심: ops_sync_run이 가장 먼저(RUNNING)·가장 나중(SUCCESS/FAILED)에 쓰이고,
스테이징과 품질 기록은 같은 트랜잭션에서 함께 커밋된다. 마지막 초록 박스는 병합·이력·비활성 표시·검증 8종이
전부 한 트랜잭션이라 검사가 하나라도 어긋나면 마스터와 이력이 통째로 롤백된다.
CSV 원문을 그대로 보관. 같은 연도를 다시 돌리면 그 연도만 지우고 다시 넣는다(delete-insert, 한 트랜잭션).
| 컬럼 | 타입 | CSV 원본 헤더 | 설명 |
|---|---|---|---|
| run_id | VARCHAR | — | 넣은 회차 (Airflow run id) |
| source_year | INTEGER | — | 소스 연도 (2022 / 2023) |
| source_file | VARCHAR | — | 원본 파일명 |
| source_row_no | BIGINT | — | CSV 본문 행번호 (헤더 제외, 1부터) |
| business_registration_no | VARCHAR | 사업자등록번호 | 원문 문자열 |
| workplace_name | VARCHAR | 사업장명 | 원문 문자열 |
| workplace_address | VARCHAR | 사업장 주소 | 원문 문자열 |
| industry_name | VARCHAR | 고용보험 업종명 | 원문 문자열 |
| regular_employee_count | VARCHAR | 고용보험 상시근로자수 | 일부러 문자열 — 연도별로 "1" / "1.0"이 섞여 있음 |
| insurance_established_date | VARCHAR | 고용보험 성립일자 | 원문 문자열 |
| ingested_at | TIMESTAMP | — | 적재 시각 |
| source_year | row_no | business_registration_no | workplace_name | workplace_address | employee_count | established_date |
|---|---|---|---|---|---|---|
| 2022 | … | 5515700180 | 신흥해운 | 부산 서구 대티로 161 | "1" | "2017-08-24" |
| 2023 | … | 5515700180 | 신흥해운 | 부산 서구 대티로 161 | "1.0" | "2017-08-24" |
| 2023 | 1 | 3490100367 | 바른하우징/건설본사 | "" (빈 값) | "2.0" | "2022-07-01" |
근로자수를 문자열로 비교하면 공통 사업장 21,087건이 전부 "변경"으로 오판된다("1" ≠ "1.0"). 그래서 raw는 판단 없이 보관만 하고, 타입 해석은 다음 계층으로 넘긴다.
정규화 + 타입 확정 + 품질 판정. 회차 재실행 시 해당 연도만 교체.
| 컬럼 | 타입 | 설명 |
|---|---|---|
| run_id · source_year · source_row_no | — | raw와 동일한 출처 추적 컬럼 |
| business_registration_no | VARCHAR | 공백·하이픈 제거 후. 9자리에 0을 채우지 않음(다른 사업자 키와 충돌 위험) |
| workplace_name / workplace_address / industry_name | VARCHAR | 공백 정리, 주소는 시도명 축약형 통일 (경상북도→경북) |
| regular_employee_count | INTEGER | "1.0" → 1 로 정수 변환 |
| insurance_established_date | DATE | 날짜 변환 |
| has_valid_key | BOOLEAN | 키로 마스터와 대조 가능한가 → 등장/사라짐 판정에 사용 |
| is_valid | BOOLEAN | 값을 마스터에 반영해도 되는가 → Insert/Update에 사용 |
| source_year | business_registration_no | employee_count | established_date | has_valid_key | is_valid | 비고 |
|---|---|---|---|---|---|---|
| 2023 | 5515700180 | 1 | 2017-08-24 | true | true | "1.0" → 정수 1 |
| 2023 | 8118800373 | 0 | 2024-01-01 | true | true | 미래 날짜지만 병합 허용, WARN 기록 |
플래그를 하나로 묶으면 값 하나 깨진 행이 병합에서 빠지면서 소스에 존재하는 사업장이 "사라진 것"으로 오판된다. 그래서 "등장 판정"(has_valid_key)과 "값 반영"(is_valid)을 분리했다.
과제의 결과물. 사업자등록번호 1건 = 1행, 사업장별 최신 상태.
| 컬럼 | 타입 | 설명 |
|---|---|---|
| business_registration_noPK | VARCHAR | 업무 키. 연도 간 Insert/Update 판정 기준 |
| workplace_name / workplace_address / industry_name | VARCHAR | 최신 사업장명 / 주소 / 업종명 |
| regular_employee_count | INTEGER | 최신 상시근로자수 |
| insurance_established_date | DATE | 최신 성립일자 |
| is_active | BOOLEAN | 최근 소스에 존재하는가. Delete 규칙이 없어 지우지 않고 표시만 (소프트 삭제) |
| absent_since_year | INTEGER | 소스에서 사라지기 시작한 연도 |
| first_seen_year / last_seen_year | INTEGER | 처음 / 마지막으로 소스에 등장한 연도 |
| last_changed_source_year | INTEGER | 값이 마지막으로 바뀐 연도 (행 단위 — 컬럼별 출처는 change_log에서) |
| first_loaded_at / last_updated_at / last_updated_run_id | TIMESTAMP·VARCHAR | 적재·갱신 시각과 갱신한 회차 |
| business_registration_no | workplace_name | workplace_address | emp | established | is_active | absent_since | first_seen | last_seen | 운명 |
|---|---|---|---|---|---|---|---|---|---|
| 5515700180 | 신흥해운 | 부산 서구 대티로 161 | 1 | 2017-08-24 | true | NULL | 2022 | 2023 | 무변경 (17,087 중 1) |
| 5546500544 | 티에스티/본사 | 경북 구미시 산동읍 첨단기업5로 109-56 | 1 | 2022-05-01 | true | NULL | 2022 | 2023 | 주소 이전 UPDATE |
| 3490100367 | 바른하우징/건설본사 | NULL ← 2023 빈 값 반영 | 2 | 2022-07-01 | true | NULL | 2022 | 2023 | NULL 갱신 UPDATE |
| 8118800373 | 유한회사 라루건설 | 서울 강서구 공항대로 227 | 0 | 2024-01-01 | true | NULL | 2023 | 2023 | 신규 INSERT (2,174 중 1) |
| 1010429797 | 학전/예술인 | 서울 종로구 대학로12길 46 | 0 | 2020-12-10 | false | 2023 | 2022 | 2022 | 사라짐 ABSENT (9,426 중 1) |
변경 감지는 해시가 아니라 컬럼 단위 IS DISTINCT FROM 비교.
해시를 쓰면 Python(정규화)과 SQL(병합)에 같은 규칙을 두 번 구현하게 되고, 둘이 갈라지는 순간 "변경 없음"으로 조용히 오판된다.
티에스티의 2023 원문 주소는 "경상북도 …"였지만 시도 정규화로 "경북 …"이 저장돼 표기 차이가 변경으로 잡히지 않는다.
"이 사업장 주소가 언제, 뭐에서 뭐로, 왜 바뀌었나"에 답하는 컬럼 단위 변경 이력. SCD Type 2 대신 선택.
| 컬럼 | 타입 | 설명 |
|---|---|---|
| change_id | BIGINT | 시퀀스 순번 |
| run_id / source_year | VARCHAR·INTEGER | 변경을 만든 회차와 근거 연도 |
| business_registration_no | VARCHAR | 대상 사업장 |
| change_type | VARCHAR | INSERT · UPDATE · ABSENT · REACTIVATE |
| column_name | VARCHAR | 바뀐 컬럼 — UPDATE는 컬럼명, ABSENT는 is_active, INSERT는 NULL |
| old_value / new_value | VARCHAR | 변경 전 / 후 값 |
| change_reason | VARCHAR | 변경 근거 (정책 개입 여부 포함) |
| changed_at | TIMESTAMP | 변경 시각 |
| business_registration_no | change_type | column_name | old_value | new_value | change_reason |
|---|---|---|---|---|---|
| 8118800373 | INSERT | NULL | NULL | NULL | 소스에 처음 등장 |
| 5546500544 | UPDATE | workplace_address | 경북 구미시 고아읍 원대로13길 82 | 경북 구미시 산동읍 첨단기업5로 109-56 | 소스 최신값으로 갱신 |
| 3490100367 | UPDATE | workplace_address | 경남 거제시 사등면 두동로 54-8 | NULL | 소스 값이 비어 있어 NULL로 갱신(보존 정책 off) |
| 1010429797 | ABSENT | is_active | true | false | 2023년 소스에 없음 — 삭제하지 않고 비활성으로 표시 |
한 사업장의 UPDATE는 바뀐 컬럼 수만큼 행이 생긴다. 2023 회차에서 변경 사업장 4,000곳이 남긴 주소 변경 이력은 2,876행. 신흥해운처럼 값이 같은 행은 이력을 남기지 않는다.
두 종류를 담는다 — 키를 못 써서 마스터에서 뺀 행(ERROR=격리), 넣긴 했지만 사람이 봐야 하는 행(WARN/INFO=확인 필요).
| 컬럼 | 타입 | 설명 |
|---|---|---|
| quarantine_id | BIGINT | 시퀀스 순번 |
| run_id / source_year / source_row_no | — | 판정한 회차와 원본 위치 |
| business_registration_no | VARCHAR | 대상 (키 자체가 깨졌으면 정규화 전 값) |
| rule_id | VARCHAR | 어긴 규칙 — PK_MISSING · PK_FORMAT · PK_CHECKSUM · PK_DUPLICATE · NAME_MISSING · ADDR_MISSING · INDUSTRY_MISSING · EMP_INVALID · EMP_MISSING · DATE_INVALID · DATE_MISSING · DATE_FUTURE |
| severity | VARCHAR | ERROR 격리 · WARN / INFO 확인 필요 |
| message | VARCHAR | 사람이 읽을 설명 |
| raw_payload | VARCHAR | 원본 행 전체를 JSON으로 — 원인 추적용 |
| detected_at | TIMESTAMP | 판정 시각 |
| business_registration_no | rule_id | severity | message |
|---|---|---|---|
| 8118800373 | DATE_FUTURE | WARN | 소스 연도(2023)를 넘어서는 성립일자: 2024-01-01 |
| 3490100367 | ADDR_MISSING | INFO | 사업장 주소 결측 (2023년 총 2,249건) |
| (9건) | PK_CHECKSUM | WARN | 확인 필요 — 사업자등록번호 검증번호 계산식과 맞지 않음 (양 연도 동일하게 존재) |
실제 두 CSV에는 10자리가 아닌 키가 0건이라 ERROR 격리는 발생하지 않았다. 검증번호 불일치 9건은 두 연도에 똑같이 반복돼 파이프라인이 임의로 "오탈자"로 확정하지 않고 WARN으로만 남긴다.
회차마다 1행 — 무엇이 들어와서 무엇이 되었는지. 가장 먼저 RUNNING으로 쓰이고 가장 나중에 SUCCESS/FAILED로 닫힌다.
| 컬럼 | 타입 | 설명 |
|---|---|---|
| run_idPK | VARCHAR | Airflow run id — 예: scheduled__2023-01-01T00:00:00+00:00 |
| dag_id / source_year / source_file | — | 어떤 DAG이 어떤 연도 파일을 처리했나 |
| source_file_sha256 / source_file_bytes | VARCHAR·BIGINT | 파일 지문 — 같은 파일 재적재 여부를 눈으로 확인 |
| source_row_count / staged_valid_count / staged_error_count | BIGINT | 읽은 행수 / 반영 가능 / 격리 |
| inserted / updated / unchanged / absent_flagged / reactivated / preserved_null _count | BIGINT | 병합 결과 분류 건수 (preserved_null은 보존 정책이 지킨 건수) |
| master_rows_before / after | BIGINT | 병합 전후 마스터 행수 |
| policy_json / issue_summary_json / reconcile_json | VARCHAR | 적용 정책 / 품질 이슈 요약 / 정합성 검사 8종 결과 |
| status / failure_reason | VARCHAR | RUNNING → SUCCESS 또는 FAILED(+사유) |
| started_at / finished_at | TIMESTAMP | 회차 시작·종료 |
| source_year | source_rows | inserted | updated | unchanged | absent | master before→after | status |
|---|---|---|---|---|---|---|---|
| 2022 | 30,513 | 30,513 | 0 | 0 | 0 | 0 → 30,513 | SUCCESS |
| 2023 | 23,261 | 2,174 | 4,000 | 17,087 | 9,426 | 30,513 → 32,687 | SUCCESS |
검산: 2,174 + 4,000 + 17,087 = 23,261 = 소스 행수 (CLASSIFICATION_TOTAL 검사),
30,513 + 2,174 = 32,687 (ROW_COUNT_DELTA 검사). 정합성 검사 8종이 이 관계를 매 회차 강제한다.
| 파일 | 역할 | 주요 함수 |
|---|---|---|
| up.sh | 검토자용 원커맨드. 이전 생성물만 정리 → 이미지 빌드 → PostgreSQL·Airflow 기동 → DAG 등록 대기 → 2022·2023 회차 SUCCESS 대기 → 로그·조회 출력 → 완성된 DuckDB를 호스트로 내보내기 | — |
| down.sh | 컨테이너·볼륨 정리 | — |
| dags/employment_insurance_master_sync.py | Airflow DAG (@yearly, catchup=true, max_active_runs=1). 정책을 한 번만 해석해 typed payload로 태스크 4개에 전달 | resolve_source · land_raw · stage_and_validate · apply_and_finish · _finish_unexpected_failure |
| scripts/run_pipeline.py | Airflow 없이 같은 적재 함수를 순서대로 실행하는 CLI (--years 2022 2023, --preserve-null, --fresh) | run_year · main |
| scripts/inspect_db.py | 결과 DuckDB를 표로 출력. 쿼리 하나라도 실패하면 비-0 종료라 검증 자동화에 물릴 수 있음 | _print_table · main |
DAG과 CLI가 같은 merge.* 함수를 호출하므로 두 경로의 결과가 같아야 정상이다.
정책 기본값이 세 파일(SyncPolicy·docker-compose.yaml·.env.example)에 있어 갈라질 수 있는데, tests/test_config_consistency.py가 셋을 함께 잠근다.
resolve_source입력 CSV와 연도 확인. 파일이 없으면 AirflowSkipException — 어느 테이블에도 쓰지 않고 SKIPPED.land_raw 태스크 → init_schema → start_run → merge.land_raw스키마 보장 후 ops_sync_run에 RUNNING을 먼저 기록. assert_source_schema(헤더 6컬럼 검사)와 빈 파일 검사를 통과하면 해당 연도를 delete-insert로 원문 적재.stage_and_validate 태스크 → merge.stageraw를 행 순서대로 읽어 행마다 normalize_row(정규화 + 이슈 수집 + 플래그 판정) → resolve_duplicate_keys(중복 키는 마지막 행 채택) → evaluate_gate(ERROR 비율 1% 초과·빈 소스면 회차 실패). 통과하면 stg_workplace와 ops_quarantine을 한 트랜잭션으로 교체.apply_and_finish 태스크 → merge.apply_masteratomic() 트랜잭션 안에서: _merge_master(build_merge_candidates → collect_candidate_metrics → record_change_history → apply_candidate_rows) → _flag_absent → reconcile 8종. 하나라도 실패하면 ReconciliationFailedError로 전부 롤백.finish_run처리 건수와 policy/issue_summary/reconcile JSON, SUCCESS 또는 FAILED(+사유)로 회차 마감. 태스크가 비정상 종료해도 _finish_unexpected_failure가 RUNNING으로 남은 회차를 FAILED로 닫는다.| 파일 | 함수 | 기능 |
|---|---|---|
| normalize.py | normalize_business_no | 공백·하이픈만 제거. 9자리에 0을 채우지 않음(다른 사업자 키와 충돌 위험) |
| normalize_employee_count | "1.0" → 정수 1. 비유한·지수 표기는 값 대신 오류를 반환 | |
| normalize_address / normalize_name / normalize_industry / normalize_date / clean_text | 공백 정리, 주소 시도명 축약형 통일(경상북도→경북), 날짜 변환 | |
| is_valid_business_no_checksum | 사업자등록번호 검증번호 계산 — 불일치는 WARN(격리 아님) | |
| quality.py | normalize_row | 원문 한 행 → StagedRow. 규칙 12종으로 이슈를 모으고 has_valid_key(키 대조 가능?)와 is_valid(ERROR 이슈 없음?)를 판정 |
| resolve_duplicate_keys | 파일 내 같은 키는 마지막 행만 반영 후보(원본 행 순서가 tie-breaker). 앞 행은 is_valid=false + PK_DUPLICATE ERROR | |
| evaluate_gate | ERROR 비율이 1%를 넘거나 소스가 비어 있으면 회차 실패 | |
| summarize_issues | 규칙별 (심각도, 건수) 집계 → issue_summary_json이 된다 | |
| staging.py | stage | 위 셋을 엮는 오케스트레이터. stg + quarantine을 한 트랜잭션으로 교체 |
| 파일 | 함수 | 기능 |
|---|---|---|
| master_candidates.py | build_merge_candidates | stg 유효 행 LEFT JOIN master로 TEMP merge_candidate 생성 — 신규 여부, NULL 보존 정책이 반영된 목표값, old_* 값. merge_changed는 IS DISTINCT FROM으로 실제 바뀐 행만 추림 |
| collect_candidate_metrics | 신규/변경/기존/재활성/보존 건수 집계 → 나중에 정합성 검사의 기대값 | |
| master_history.py | record_change_history | INSERT·REACTIVATE·컬럼별 UPDATE 이력을 change_log에 기록 (change_reason에 정책 개입 근거 포함) |
| master_mutations.py | apply_candidate_rows | 후보 임시 테이블을 근거로 master에 실제 INSERT·UPDATE 실행 |
| master_sync.py | apply_master | 병합 → 부재 표시 → 검증 8종을 한 트랜잭션으로. 따로 커밋하면 "검증 실패 회차가 마스터에 절반만 반영된 채" 남기 때문(모듈 docstring 그대로) |
| _flag_absent | 이번 소스에 없는 활성 행을 is_active=false + absent_since_year + ABSENT 이력으로 표시 | |
| reconciliation.py | reconcile | PK_UNIQUE · ROW_COUNT_DELTA · CLASSIFICATION_TOTAL · ALL_SOURCE_KEYS_PRESENT · VALUE_MATCHES_POLICY · NO_SILENT_NULL_OVERWRITE · ACTIVE_FLAG_CONSISTENT · NO_FUTURE_LAST_SEEN — 8종을 실행해 통과/실패 반환 |
| run_state.py | start_run / finish_run | 회차의 첫 기록(RUNNING UPSERT)과 마지막 기록(건수·JSON·상태) |
| 파일 | 함수 / 내용 | 기능 |
|---|---|---|
| ingestion.py | assert_source_schema · assert_year_is_not_regressive · land_raw | 헤더 6컬럼 검사, 연도 역행 가드(마스터가 이미 더 새 연도면 거부), 원문 delete-insert 적재 |
| merge.py | (재수출만, 39줄) | 공개 파사드. 로직 없이 하위 모듈의 공개 함수·타입만 모아 DAG·CLI·테스트가 쓰는 진입점을 고정 |
| config.py | SyncPolicy | SYNC_* 환경변수 5개를 명시적 true/false 허용목록으로 해석. BUSINESS_KEY·ATTRIBUTE_COLUMNS 상수의 단일 출처 |
| db.py | connect · init_schema · file_sha256 · scalar_int | DuckDB 연결, ddl.sql 실행, 파일 지문, 스칼라 조회 헬퍼 |
| transactions.py | atomic | BEGIN → COMMIT / 예외 시 ROLLBACK 컨텍스트 매니저 — 트랜잭션 경계가 코드에 명시적으로 보이게 |
| errors.py | 예외 5종 | SourceSchemaError · EmptySourceError · YearRegressionError · UnknownRunCounterError · ReconciliationFailedError |
| results.py / report.py / timestamps.py | MergeResult 등 · run_summary · utc_now | 결과 타입, 사람용 요약 출력, 시각 헬퍼 |
변경 이유가 서로 다른 코드를 한 파일에 두지 않으려고 나눴습니다. 병합이 한 덩어리였을 때는 정책을 한 줄 바꾸는 작업이 이력 기록과 검증 쿼리까지 같이 열게 만들었습니다. 지금은 후보 생성(master_candidates)은 변경 감지 규칙이나 정책이 바뀔 때, 이력(master_history)은 남길 항목이 바뀔 때, 변이(master_mutations)는 마스터 컬럼이 바뀔 때, 검증(reconciliation)은 검증 항목이 늘 때 각각 열립니다.
나눈 뒤 얻은 것이 하나 더 있습니다. 이 파이프라인은 순서가 설계입니다. 건수는 반영 전에 세야 하고, 이력은 갱신 전에 써야 하고, 검증까지 한 트랜잭션이어야 합니다. master_sync는 그 순서만 조립하고 각 단계 구현을 갖고 있지 않아서, 181줄 한 파일을 읽으면 순서가 그대로 보입니다.
트랜잭션 경계도 구조로 고정했습니다. atomic을 여는 곳은 apply_master 하나뿐이고 나머지 모듈은 BEGIN·COMMIT을 모릅니다. 커밋 지점이 여러 곳에 생기는 사고를 파일 경계로 막았습니다. 테스트 표면도 함께 갈라집니다. 정규화·품질은 DB 없이 순수 함수로, 후보 생성은 임시 테이블 결과로, 검증은 숫자만으로 확인합니다. 한 덩어리면 이 전부를 통합 테스트를 통해서만 볼 수 있습니다.
merge.py는 로직 없는 공개 파사드입니다(39줄, 재수출과 __all__뿐). 내부를 어떻게 나눠도 DAG·CLI·테스트가 부르는 진입점(merge.stage, merge.apply_master 등)은 그대로 두려는 경계이고, __all__로 "여기까지가 쓰라고 만든 표면"을 코드로 표시했습니다.
비용도 있습니다. 파일이 늘어 호출 흐름을 따라가려면 한 번 더 점프해야 합니다. 그래서 순서를 master_sync 한 곳에 모아 그 비용을 줄였습니다. 처음부터 이 구조는 아니었고 구현 중에는 한 모듈이었습니다. 트랜잭션 경계와 이력 순서가 코드에서 드러나지 않는다고 판단해 제출 전에 책임별로 나눴습니다. 현재 include/pipeline/ 19개 모듈에서 가장 큰 파일이 quality.py 209줄이고, 병합 관련 4개는 59~159줄입니다.
아닙니다. is_valid = not any(issue.severity == ERROR) — "이 행에 ERROR급 문제가 하나라도 있나"를 접은 종합 플래그입니다. 중복 키(PK_DUPLICATE)는 ERROR를 만드는 원인 중 하나일 뿐이고, 키 형식 오류(PK_FORMAT)나 근로자수 파싱 불가(EMP_INVALID)도 같은 플래그를 내립니다. 검증번호 불일치나 미래 성립일자는 WARN이라 is_valid에 영향이 없습니다.
두 말은 충돌하지 않고 같은 기준의 두 표현입니다. is_valid = not any(issue.severity == ERROR)이므로 is_valid=false ⇔ 그 행에 ERROR 이슈가 1개 이상 있음 — "is_valid=false면 값 반영 제외"와 "ERROR일 때만 제외"는 동일한 문장입니다. 흐름도의 is_valid 마름모는 ERROR 판정을 다시 하는 게 아니라 이미 내려진 판정의 요약 플래그를 읽을 뿐입니다.
혼동 포인트는 "quarantine에 기록됨 = 제외"가 아니라는 것. ops_quarantine은 두 종류를 담습니다 — ERROR는 격리(마스터에서 뺀 행), WARN/INFO는 확인 필요(넣긴 했지만 사람이 봐야 하는 행). 그래서 키 형식 오류(PK_FORMAT)·중복 키 앞 행(PK_DUPLICATE)·근로자수 파싱 불가(EMP_INVALID)는 ERROR라 값 반영에서 빠지지만, 검증번호 불일치와 성립일자 후퇴 17건(DATE_REGRESSION)은 WARN이라 quarantine에 기록되면서도 마스터에는 최신 값이 반영됩니다. 제외 기준은 severity=ERROR 하나입니다.
"아니오" 분기가 "등장으로만 집계"인 이유는 has_valid_key가 별도 플래그이기 때문입니다. 중복 키 앞 행처럼 is_valid=false여도 has_valid_key=true면 "값은 못 쓰지만 이 사업장이 이번 소스에 등장한 건 사실"이라 값 반영만 빠지고 등장으로는 집계됩니다 — 멀쩡히 존재하는 사업장이 ABSENT로 오판되는 걸 막는 장치입니다. 키 자체를 못 쓰는 행(has_valid_key=false)은 이 분기에 오기 전에 이미 ERROR 격리로 빠져 있습니다.
스테이징의 resolve_duplicate_keys가 마지막 행만 반영 후보로 남깁니다. 원천에 갱신 시각이 없어 원본 행 순서를 결정적 tie-breaker로 썼고, 앞 행들은 is_valid=false + PK_DUPLICATE ERROR로 격리 기록에 어느 행을 채택했는지까지 남깁니다. 단, has_valid_key는 true 유지 — 중복이어도 그 사업장이 소스에 등장한 건 사실이니까요.
실데이터엔 파일 내 중복이 0건이라 발동한 적은 없고, 그래도 master PK 제약과 검증 8종 중 PK_UNIQUE가 이중으로 막습니다.
규칙 12종은 quality.py 코드에 있고, 규칙 정의 테이블은 없습니다. 테이블에는 정의가 아니라 발동 결과만 남습니다 — 행 단위는 ops_quarantine.rule_id, 회차 집계는 ops_sync_run.issue_summary_json.
규칙을 DB로 빼는 건 규칙이 런타임에 바뀌거나 비개발자가 관리할 때 가치가 있는데, 이 과제는 12개 전부가 코드 리뷰·테스트 대상이고 배포와 함께 바뀌는 게 안전해서 코드 정의를 택했습니다. 변경 이력도 git이 남깁니다.
해시를 쓰면 Python(정규화)과 SQL(병합) 양쪽에 같은 직렬화 규칙을 두 번 구현하게 되고, 두 구현이 갈라지는 순간 "변경 없음"으로 조용히 오판됩니다. 어차피 변경 이력에 컬럼별 전/후 값을 남겨야 해서 컬럼 단위 IS DISTINCT FROM 비교는 중복 비용도 아닙니다. NULL 안전 비교라 결측 ↔ 값 전환도 정확히 잡습니다.
세 덩어리입니다: ① raw 연도 교체, ② 스테이징+품질 기록, ③ 병합+이력+비활성 표시+검증 8종. 특히 ③을 apply_master가 atomic() 하나로 묶는 이유는 — 따로 커밋하면 검증 실패 회차가 마스터에 절반만 반영된 채 남기 때문입니다. 검사가 하나라도 어긋나면 ReconciliationFailedError로 마스터와 이력이 통째로 롤백되고 회차는 FAILED로 마감됩니다.
Insert/Update/무변경 분류는 집합 연산이라 SQL이 자연스럽습니다. build_merge_candidates가 stg를 master에 LEFT JOIN해 TEMP merge_candidate(신규 여부·정책 반영 목표값·old 값)를 만들고, 이력·변이·집계가 전부 이 한 임시 테이블을 근거로 움직여 서로 어긋날 수 없습니다. 행 단위 파이썬 루프는 30K 행에서도 느리고, 정책(NULL 보존)을 CASE 식으로 한 곳에 박기도 어렵습니다.
두 겹입니다. 데이터는 트랜잭션 경계 덕에 반쯤 반영된 상태가 없고 — 커밋 전이면 이전 스냅샷 그대로입니다. 회차 기록은 _finish_unexpected_failure가 RUNNING으로 남은 ops_sync_run 행을 FAILED로 닫아서, "영원히 RUNNING인 유령 회차"가 남지 않습니다.
raw·stg는 해당 연도 delete-insert라 멱등이고, ops_sync_run은 run_id 기준 UPSERT입니다. 단 마스터가 이미 더 새 연도까지 반영된 상태에서 과거 연도를 다시 돌리는 건 assert_year_is_not_regressive가 의도적으로 거부합니다 — 2023이 반영된 마스터에 2022를 덮으면 최신 상태가 과거로 후퇴하니까요. 전체 재현은 ./up.sh(볼륨 초기화 포함)로 합니다.
네, 그리고 그게 한 번 어긋났던 적이 있어 특히 신경 썼습니다. 초기엔 테스트 헬퍼가 병합·부재·검증을 세 트랜잭션으로 따로 부르고 있었는데, DAG과 CLI는 apply_master 한 트랜잭션을 탑니다. 리뷰에서 발견해 헬퍼도 apply_master를 직접 호출하게 고치고, 운영이 안 쓰는 래퍼 함수 2개를 삭제했습니다. 최종적으로 로컬 101 passed / 컨테이너 107 passed이고, 실제 CSV로 문서의 모든 건수가 재현됩니다.
고용보험 성립일자는 사업장이 고용보험 관계를 성립한 날짜라 원래 바뀔 이유가 없는 값인데, 두 연도에 모두 있는 21,087곳 중 17곳은 2023 파일의 성립일자가 2022 파일 값보다 더 과거로 바뀌어 있었습니다. 실제 예 — 강원과학고 방과후학교 강사(2248303794)는 딱 하루(2022-04-20 → 2022-04-19, 오탈자 냄새), 인천병방초 방과후학교 강사(1228302844)는 8개월(2022-03-01 → 2021-07-01, 소급 정정 냄새), 에스코프(3488102699)는 13일.
"2023이 소급 정정"(새 값이 맞음)과 "2023이 추출 오류"(옛 값이 맞음) 두 해석이 모두 그럴듯한데 파이프라인엔 판정 근거가 없습니다. 그래서 임의로 확정하지 않고 — 과제 규칙대로 최신 소스 값을 마스터에 반영하되, 병합 단계에서 DATE_REGRESSION WARN 17건을 quarantine에 남겼습니다(메시지: "고용보험 성립일자가 과거로 이동: 2022-04-20 -> 2022-04-19"). 이 규칙은 마스터의 기존 값과 비교해야 해서 스테이징 12종과 달리 병합 단계에서 기록됩니다.
덧붙일 관찰 하나 — 17건에 방과후학교 강사·특고 계약 건이 유난히 많습니다. 계약 단위 재산정 같은 원천 특성일 수 있지만 데이터로 확증되지 않으므로, 면접에서는 "원천 부서에 확인해볼 지점"까지만 말하는 게 정확합니다.
학교 전학 처리로 설명하면 — 우리 학교 출석부(마스터)에 30,513명이 있는데 교육청에서 올해 명단(새 CSV)이 왔습니다. 전학생은 등록하고, 정보 바뀐 학생은 고치고, 명단에 없는 학생은 "전출 간 듯"으로 표시했습니다. 이 작업이 진짜 제대로 됐는지 끝나자마자 8가지를 직접 세어보고, 하나라도 안 맞으면 오늘 작업 전체를 없던 일로 되돌리는(롤백+FAILED) 게 정합성 검사 8종입니다.
앞 4개는 숫자 세어보기(검산): ① PK_UNIQUE — 출석부에 같은 학생(사업자번호)이 두 줄인가(중복 INSERT 사고). ② ROW_COUNT_DELTA — 아침 30,513명 + 전학생 2,174명 = 지금 32,687명이 맞는가. 한 명이라도 다르면 어디선가 새거나 끼어든 것. ③ CLASSIFICATION_TOTAL — 올해 명단 23,261명을 "전학 2,174 + 변경 4,000 + 그대로 17,087"로 나눴는데 합이 안 맞으면 누락 또는 이중 분류. ④ ALL_SOURCE_KEYS_PRESENT — 숫자만 맞으면 될까? 박영희가 빠지고 이민수가 잘못 들어와도 총원은 같다. 그래서 개수가 아니라 이름(키)을 하나하나 대조해 명단의 유효 키가 전부 마스터에 실재하는지 본다.
뒤 4개는 말이 되는지 확인(모순 스캔): ⑤ VALUE_MATCHES_POLICY — 제3자가 명단+규칙집으로 기대값을 처음부터 다시 계산해 마스터 실제 값과 대조. 시험 채점을 다른 선생님이 한 번 더 하는 것 — 병합 CASE 로직 버그가 여기서 걸린다. ⑥ NO_SILENT_NULL_OVERWRITE — "빈칸이면 기존 값을 지우지 말라"는 보존 정책인데 값이 NULL로 조용히 지워진 갱신이 있는가(정책 스위치 무시 감지). ⑦ ACTIVE_FLAG_CONSISTENT — 오늘 명단에 분명히 있는데 "전출 감"(비활성)으로 남은 행이 있는가. ⑧ NO_FUTURE_LAST_SEEN — 2023년 명단을 처리 중인데 last_seen_year가 2024인 행이 있는가(연도 로직 붕괴 감지).
압축 멘트: "①~④는 개수와 명단의 검산, ⑤~⑧은 정책·상태의 모순 스캔이고, 병합과 같은 트랜잭션이라 하나라도 실패하면 마스터가 오염되기 전에 통째로 되돌아갑니다." 검사를 나중에 따로 하면 "틀린 걸 발견은 했는데 이미 절반은 적어버린" 상태가 생기는데, 같은 묶음이면 발견 즉시 통째로 취소되므로 마스터는 항상 검사를 통과한 상태로만 존재합니다.
이 과제의 질문은 전부 "열 하나를 위에서 아래로 쭉 훑는" 것입니다 — 두 연도 키 집합이 몇 개 겹치나, 주소 열 21,087개를 비교하면 몇 개가 다른가, 신규+변경+무변경 합이 소스 행수와 맞나. 한 건 콕 집어 갱신하는 은행 창구식(행 지향: SQLite·MySQL)이 아니라 시험지 1번 문제만 전체를 쭉 채점하는 방식(컬럼 지향)이고, DuckDB가 정확히 그 용도의 "분석용 SQLite"입니다.
실제로 얻은 것 셋 — ① read_csv로 CSV를 SQL로 바로 적재, ② 변경 감지(IS DISTINCT FROM)·신규 판정(LEFT JOIN ... IS NULL)·검증(FILTER 집계)까지 한 엔진에서 처리(도구를 갈아타는 지점이 없어 어긋날 곳도 없음), ③ 결과물이 파일 하나라 검토자는 duckdb -readonly로 열기만 하면 됨. PostgreSQL은 서버 운영을 요구해 과하고(구성의 PostgreSQL은 Airflow 메타데이터 전용), pandas는 트랜잭션과 SQL 검증이 없어 검증 8종을 코드로 다시 짜야 합니다.
단점(다중 프로세스 쓰기 불가)은 알고 골랐고, 태스크 직렬화 + max_active_runs=1로 단일 writer를 구조로 보장했습니다. 3만 행 규모라 속도보다 "적재와 검증을 한 엔진에서 잇는 일관성"을 봤습니다.
DuckDB는 다중 프로세스 동시 쓰기가 제한적이라, DAG 태스크를 직렬로 연결하고 max_active_runs=1로 회차 중첩을 막았습니다. 실행 중 DB는 Docker 이름 볼륨에 두고 두 회차가 성공한 뒤에만 호스트로 내보냅니다. 쓰기 주체가 항상 한 프로세스가 되도록 구조로 보장한 셈입니다.
SQL에서 ''는 "길이 0인 문자열"이라는 엄연한 값이고, NULL은 값이 없음입니다. NULL은 = 비교가 항상 UNKNOWN이라 IS NULL·IS DISTINCT FROM이 필요하고 COUNT(col)에서 빠지는 등 동작 자체가 다릅니다. (Oracle은 ''를 NULL로 취급하지만 표준 SQL·DuckDB는 구분합니다.)
"값이 제공되지 않음"이 원천에서는 빈 문자열, 공백, 전각 공백(17건) 등 여러 표기로 오는데 그대로 두면 결측 집계와 변경 감지가 오염됩니다. 그래서 clean_text가 stg에서 표현을 NULL 하나로 표준화하고(raw는 원문 보존이라 손대지 않음), 그 NULL을 마스터에 덮을지·기존 값을 보존할지의 해석은 정책(SYNC_PRESERVE_EXISTING_ON_NULL)으로 분리했습니다. 두 정책의 실제 차이가 마스터 주소 결측 2,249 vs 156입니다.
"어차피 통일할 거면 왜 ''가 아니라 NULL이냐"고 물으면 — ''로 통일하는 순간 모든 쿼리가 이중 장부가 됩니다. "주소 없는 회사 몇 개"가 IS NULL 한 번으로 안 끝나고 = '' OR IS NULL이 되어 "없음"이 두 가지가 되고(실수로 진짜 NULL이 하나라도 생기면 3중 상태), COUNT(addr)는 ''를 세어 "채워진 회사 수"가 부풉니다. 검산 8종 중 NO_SILENT_NULL_OVERWRITE("보존 정책인데 값이 지워졌나")도 "지워짐 = NULL"이라는 단일 정의가 있어야 검사식이 성립하고, 보존 정책의 판정("소스 값이 없으면 기존 값을 지켜라")도 '없으면'을 IS NULL 한 번으로 검사할 수 있습니다. ''는 '없음'이라는 업무 의미를 문자열 값에 몰래 실어두는 셈입니다.
압축 멘트: "원천은 '없음'을 여러 표기로 주지만, 파이프라인 안에서는 '없음'이 단 하나의 모양이어야 집계·비교·정책·검산이 전부 한 가지 검사식으로 성립합니다. SQL에서 그 모양은 NULL이고, 원문 표기는 raw에 그대로 남아 있습니다."
둘은 별개 메커니즘입니다. PK는 테이블 목적에서 나왔습니다 — master는 "사업장 1건=1행"이 핵심 무결성이라 PK, ops_sync_run은 run_id 기준 UPSERT 대상이라 PK. 반면 raw/stg는 중복까지 원문대로 담아야 해서 PK를 걸 수 없고 회차 교체로 관리하며, quarantine/change_log는 append 로그라 시퀀스 순번이면 충분합니다.
롤백도 6개 전체가 아닙니다. 트랜잭션 경계는 3개(① raw 교체 ② stg+quarantine ③ 병합+이력+비활성+검증)이고, 검증 실패 시 되돌아가는 건 ③ 안의 master·change_log 변경분뿐입니다. raw·stg·quarantine은 커밋된 채 남는데 이게 의도입니다 — 실패 원인을 보려면 "무엇이 들어와 어떻게 판정됐는지"가 남아 있어야 하니까요. ops_sync_run은 롤백 대상이 아니라 FAILED로 기록됩니다.
아닙니다 — 비교 대상은 "이번 소스 vs 마스터"이고 방식은 도장 찍기입니다. 병합 때 이번 소스에 등장한 모든 유효 키(값이 안 바뀐 행 포함)에 last_seen_year=소스연도, is_active=TRUE, absent_since_year=NULL을 찍고, 그다음 _flag_absent가 "아직 활성인데 이번 도장이 없는 행"(is_active=TRUE AND last_seen_year < 소스연도)을 골라 비활성 + ABSENT 이력을 남깁니다.
마스터 기준인 이유는 3년째를 상상하면 명확합니다 — 2024 파일의 비교 상대는 2023 CSV가 아니라 누적된 마스터 전체여야 하고, is_active=TRUE 조건 덕에 이미 비활성인 행이 매년 다시 ABSENT로 기록되는 중복도 없습니다. 비활성 사업장이 재등장하면 같은 도장 단계가 되살리며 REACTIVATE('inactive'→'active') 이력을 남기는데, 회차가 2022·2023 둘뿐이라 실행에선 0건이었습니다.
태스크 실패 시 retries: 1로 1회 자동 재시도하고, 그래도 실패하면 그 회차는 FAILED로 남습니다. 다음 스케줄 run(2024)은 별개로 진행되고(depends_on_past 미설정) 2024 파일이 없으니 SKIPPED가 됩니다. 실패한 2023은 자동 재실행되지 않으며 Airflow UI에서 clear로 수동 재실행합니다.
재실행은 안전합니다 — 같은 run_id로 RUNNING부터 UPSERT, raw/stg는 연도 교체라 멱등, 마스터는 롤백돼 있어 정상 재병합. 단 마스터가 이미 2023까지 반영된 상태에서 2022를 다시 돌리는 건 연도 역행 가드가 의도적으로 거부하므로, 전체 재현은 ./up.sh(볼륨 초기화 포함)로 합니다.