여러 곳에서 다른 이름으로 들어오는 데이터를 '결국 같은 대상인지' 판단해 연결해 온 데이터 엔지니어입니다.
초기 멤버로 합류한 보안 데이터 회사에서 5년간, 오픈소스 제품과 버전, 취약점, 12종 리눅스 배포판의 보안 정보를 수집하고 연결하는 파이프라인을 만들었습니다. 같은 제품이 표기 차이로 갈라지고 다른 제품이 하나로 합쳐지는 문제를 식별 규칙부터 원본 보존, 변경 이력, 재처리 구조까지 고쳤고, 그 위에서 수십억 행 MySQL과 80개 넘는 크롤러가 도는 시스템을 안정화했습니다.
이제는 이 노하우를 '일하는 사람들의 커리어와 기회를 연결하는' 리멤버의 데이터 위에서 쓰고 싶습니다. 명함·프로필·회사 데이터는 제가 해 온 식별과 정합성 문제가 그대로 있으면서, 그 품질이 직장인들의 실제 기회로 이어지는 데이터입니다. 행동 로그 집계를 넘어 그 신뢰를 떠받치는 데이터 전문가로 리멤버의 성장에 함께하고 싶어 지원했습니다.
| # | 항목 | 숫자 1개 |
|---|---|---|
| 01 | OS 패키지 취약점 수집 정확도 — 고쳐진 버전까지 맞아야 스캐너가 신뢰된다 | 12종 배포판 × 4가지 제공 방식 |
| 02 | 라이브러리 컴포넌트 테이블 재설계 — 슬로우 쿼리→실행계획→원인은 collation·식별자 정책 | 예상 스캔 행 130만 → 6,000 |
| 03 | 인덱스 최적화·용량 절감 — 지울 근거(95개 쿼리 실행계획)를 먼저 만들고 지웠다 | 311개 중 224개 삭제 · 9.6→4TB |
| 04 | RAW 보존·재파싱 ETL — 외부 재요청 없이 재처리. 이번 과제 설계의 원형 | 재처리 외부 요청 0건 |
| 05 | K8s·Airflow 운영 — 노드 증설 + pool·backoff·resource request를 함께 조정 | Pod 할당 실패 주 70~100 → 1~5건 |
| 06 | DML Broker — 80개 크롤러의 DB 쓰기를 공통 규칙 하나로 (bounded queue + 429) | lock 오류 일 3~4건 → 4개월 0건 |
| 07 | AWS→IDC 이전 — 수집 시간 분산으로 버티고, 준비 후 DB 역할 분리 (임시복구→구조개선) | 장애 월 10~20건 → 1~2건 |
| 08 | Grafana 모니터링 — 서버 자원·DB·수집 누락·고객사 동기화를 같은 시간축에서 | 10~20대 서버 |
단위 방어 — 130만→6,000은 실행계획상 예상 스캔 행 수(응답시간 아님) · "2일→5분"은 설계 산정 예시(실측 아님) · IDC 이관 확인 범위는 전후 행 수 대조까지.
IS DISTINCT FROM) → 마스터+이력+부재 표시+검산 8종을 한 트랜잭션. 하나라도 틀리면 통째로 롤백. 단일 writer라 직렬 + max_active_runs=1.
30,513 + 2,174 = 32,687 초기+신규=최종 21,087 = 4,000 + 17,087 공통키=변경+no-op 23,261 + 9,426 = 32,687 활성+부재 이력 5,196 (컬럼별 1건씩) 8/8 통과 · 불일치 0 · ERROR 0먼저 인정할 한계 3개 실행 기록은 트랜잭션 밖(exactly-once는 업무 데이터까지) · 동시 backfill 미보장 · stage()가 한 해 분량 메모리 적재.
정규화가 없었다면 근로자수 1과 1.0 표기 차이만으로 공통 21,087건이 전부 변경으로 잡혔습니다. 주소 표기 차이도 210건, 사업장명 전각 공백은 30건 실측.
네 태스크가 맡는 일은 처리 대상 결정, 원문 확보, 값 판정, 마스터 반영입니다. 앞 태스크의 결과가 XCom으로 누적돼 마지막 태스크가 전 구간 값을 들고 있습니다. 커넥션은 태스크마다 열고 닫습니다(단일 writer).
| 태스크 | 하는 일 | 말할 포인트 하나 |
|---|---|---|
| resolve_source | 회차의 데이터 구간에서 연도를 뽑고, 파일이 없으면 SKIP. 정책(SyncPolicy)을 여기서 한 번만 확정해 흘려보냄 | 실행 시각과 무관하게 회차 연도가 고정된다. 파일 없는 2024·2025 회차는 실패가 아니라 SKIP |
| land_raw | 실행 기록을 RUNNING으로 열고(파일 SHA-256·크기·정책 JSON 포함), 연도 역행과 헤더를 검증한 뒤 임시 테이블로 파싱, 그다음 해당 연도만 교체 | 검증이 먼저, 교체가 나중. 그래서 실패해도 기존 스냅샷이 남는다. 0건 소스는 여기서 끊는다 |
| stage_and_validate | 행별 정규화(파이썬 순수 함수), 중복 키는 마지막 행 채택, 이슈를 격리 테이블에 기록, 마지막에 품질 게이트 | has_valid_key(존재했다)와 is_valid(반영해도 된다)를 분리. 묶으면 값 하나 깨진 행이 "사라진 것"이 된다 |
| apply_and_finish | 후보 임시 테이블 3개로 무엇을 바꿀지 확정 → 건수 집계 → 이력 기록 → 마스터 반영 → 부재 표시 → 검산 8개. 전부 한 트랜잭션 | 건수는 반영 전에(반영 후엔 신규·변경 구분 불가), 이력은 갱신 전에(갱신 후엔 이전 값 못 읽음) |
./up.sh 다섯 단계 (물어보면)이전 산출물 초기화 · 이미지 빌드와 기동 · 준비 대기 · 파이프라인 완료 대기 · 결과 확인과 DB 반출. 스크립트가 DAG를 직접 trigger하지 않습니다 — DAGS_ARE_PAUSED_AT_CREATION=false와 catchup=True로 스케줄러가 회차를 만들고, 스크립트는 ops_sync_run 상태를 2초 간격으로 폴링합니다. Airflow API 대신 결과 테이블을 보므로 "태스크는 성공인데 데이터가 안 들어간" 상황을 구분할 수 있습니다. 대기를 health · DAG 등록 · 파이프라인 완료 세 구간으로 나눈 이유는 실패 지점을 구분하려는 것입니다.
갱신한 다음 프로그램이 스스로 8가지를 검산합니다. 하나라도 틀리면 그 회차를 통째로 되돌립니다. 가계부에 옮겨 적고 합계를 맞춰보는 것과 같은데, 코드가 매번 하고 안 맞으면 저장을 안 합니다.
| 묶음 | 검산 | 쉬운 말 / 틀리면 무슨 뜻인가 |
|---|---|---|
| 개수가 맞나 | ROW_COUNT_DELTA | 명부가 2,174줄 늘었으면 새로 추가한 것도 정확히 2,174개 — 갱신하다 줄을 잘못 만들거나 지웠는지 |
CLASSIFICATION_TOTAL | 2,174 + 4,000 + 17,087 = 23,261 — 빠뜨린 회사도, 두 번 센 회사도 없는지 | |
PK_UNIQUE | 같은 사업자번호가 두 줄 있는지 — 회사당 한 줄이라는 전제가 깨졌는지 | |
| 값이 맞나 가장 중요 | ALL_SOURCE_KEYS_PRESENT | 파일에 있던 회사 중 명부에서 못 찾는 게 0건 — 반영 누락 |
VALUE_MATCHES_POLICY | 규칙을 처음부터 다시 적용해 나온 값과 명부의 실제 값을 대조 — 조인·조건문 실수. 개수는 맞는데 값이 틀린 경우를 이것만 잡는다 | |
| 상태 표시가 맞나 | ACTIVE_FLAG_CONSISTENT | 올해 파일에 있는데 "최근 명부에 없음"으로 찍혀 있으면 모순 — 되살리기 누락 |
NO_FUTURE_LAST_SEEN | 2023년 작업인데 2024가 적혀 있으면 안 됨 — 연도를 거꾸로 실행 | |
NO_SILENT_NULL_OVERWRITE | 보존 정책을 켰는데 값을 NULL로 지운 기록이 있으면 안 됨 (기본 정책에서는 의도된 동작이라 건너뜀) |
8개는 어떻게 정했나 — 처음부터 8개로 정한 것이 없고, 구현하면서 "이게 틀리면 조용히 넘어간다" 싶은 지점마다 하나씩 추가한 결과입니다(개수 3 · 값 2 · 상태 3).
2023년 파일의 빈 값을 최신 상태로 볼지 추출 누락으로 볼지 확정할 수 없어 정책으로 분리했습니다. 두 정책을 각각 실행해 대조한 값입니다. 소스와 코드는 같고 플래그 하나만 다릅니다.
| 정책 | 주소 결측 | 마스터행 |
|---|---|---|
| 기본(덮어쓰기) | 2,249 | 32,687 |
| 보존(preserve) | 156 | 32,687 |
2023년에 주소가 비어 온 회사가 2,249건. 그중 2,093건은 2022년에 주소가 있던 회사(정책이 갈리는 구간), 나머지 156건은 2022년에도 없던 회사입니다. 2,249 − 2,093 = 156. 156은 하한선 — 지킬 기존 값이 없어 어느 정책이든 NULL입니다.
| 정책 | 변경 | 변경없음 | 공백보존 |
|---|---|---|---|
| 기본 | 4,000 | 17,087 | 0 |
| 보존 | 2,903 | 18,184 | 2,095 |
보존이 2,095건 일어났는데 변경은 1,097건만 줄었습니다. 보존은 컬럼 단위, 분류는 행 단위이기 때문입니다. 주소만 비어 온 행은 변경없음으로 넘어가고(17,087+1,097=18,184), 주소와 근로자수가 같이 바뀐 행은 여전히 변경입니다. 2,095 = 주소 2,093 + 업종 1 + 성립일자 1.
'', ' ', NULL이 서로 다른 값이 되어 표기 차이만으로 UPDATE로 잡힙니다. 빈 문자열보다 NULL을 고른 것은 SQL에서 ''가 "값이 있다"로 취급되기 때문입니다.
NO_SILENT_NULL_OVERWRITE가 잡습니다.
CASE에 넣어 기대값을 다시 계산하기 때문에 두 경로가 모두 통과합니다.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_active를 넣었나요
2022년에만 있는 9,426건을 어떻게든 처리해야 하고 물리 삭제는 되돌릴 수 없습니다. 폐업인지 이번 공시에서 빠진 것인지 구분할 근거가 없어 사실만 기록했습니다. SYNC_ABSENT_ACTION=ignore로 끄면 명세 그대로 Insert·Update만 하고, 물리 삭제는 아예 허용하지 않습니다.
PK_DUPLICATE로 격리하고 메시지에 채택된 행 번호를 남깁니다. 실데이터에는 0건이라 발동한 적은 없고, 마스터 PK 제약과 PK_UNIQUE 검산이 이중으로 막습니다.
run_id면 UPSERT로 초기화합니다. 그래서 두 번째 실행은 신규·변경 0건입니다. 연도 역행은 YearRegressionError로 막습니다 — 최신 값이 조용히 과거 값으로 덮이는 것보다 실패가 낫습니다.
stage()가 한 해 분량을 메모리에서 정규화하는 지점이 먼저 걸립니다. 정규화를 SQL로 내리거나 청크로 나눠야 하고, 그때는 단위 테스트 가능한 순수 함수라는 지금의 장점을 일부 포기합니다. 저장소도 단일 writer 파일에서 벗어나야 합니다. 지금 구조는 연 1회 3만 행 배치에 맞춘 선택입니다.
많이 씁니다. 특히 팀이 쓰도록 파이프라인에 넣은 쪽을 신경 썼습니다. 사내 CI/CD 중앙 kit에 Claude 기반 PR 리뷰를 넣어 여러 저장소에 같은 방식으로 적용했습니다. Jira 브랜치, 테스트, PR 자동 생성, AI 리뷰, Slack 알림, 이미지 푸시가 한 흐름으로 돕니다.
오래 고민한 것은 프롬프트보다 경계였습니다. diff에 secret scan을 먼저 돌리고 통과한 뒤에만 외부 모델을 호출합니다. 실행 kit 버전은 커밋 SHA로 못 박아 main 같은 움직이는 참조를 신뢰하지 않고, 변경 경로가 매핑되지 않으면 기본 실패로 둬서 조용한 초록불을 막았습니다.
IS DISTINCT FROM을 쓴 이유는 <>가 NULL을 만나면 변경을 놓치기 때문이고, 2023년 주소가 비어 온 2,093건이 실제로 그 경우.
직무 키워드(공고): 정교한 매핑 · MDM · 데이터 정합성 — "같은 회사가 여러 이름으로 들어와도 한 회사로 알아보고, 잘못 연결하지 않게 관리하는 일".
| 기술 | 어떤 기술인가 |
|---|---|
| Aurora MySQL + binlog | 운영 DB와 그 변경 기록. 모든 동기화의 원천 |
| Debezium · MSK(Kafka) | binlog를 변경 이벤트로 바꿔(CDC) 컨베이어 벨트(topic·partition)로 흘리는 실시간 스트리밍 |
| Glue PySpark Full Load | 전용 replica에서 초기 전량 적재 — 원본 영향 격리가 목적. 기준점(binlog position) 고정 후 CDC로 이어붙임 |
| S3 Tables · Iceberg | Parquet 파일 더미를 목록표(metadata·snapshot)로 테이블처럼 관리하는 레이크하우스. CDC는 PK upsert로 반영 |
| 운영 유지보수 | compaction(작은 파일 합치기) · snapshot 만료 · orphan file 정리 — 파일을 테이블처럼 쓰는 대가 |
| PyIceberg | 쿼리 엔진 밖에서 파일 수·크기·스냅샷 상태를 관측하는 Python 도구 |
| StarRocks on EKS · Athena | 자주 보는 걸 앞 진열대에 정리해 저지연 서빙(OLAP) / 애드혹·BI 조회 |
| Lake Formation | IAM(출입증)과 별개로 DB·테이블·컬럼·행 단위 데이터 권한을 판정 |