← 기술 블로그

ETL 45개 커맨드를 하나의 패턴으로: BaseEtlCommand 설계

Phase: 6 — 아키텍처 시리즈: 공공데이터 레시피 대응 스토리: 운영 블로그 5화, 운영 블로그 6화

이 글의 배경이 되는 이야기는 5화: 식약처 API 11종과의 첫 만남6화: 제품 100만 개를 담을 그릇에서 읽을 수 있습니다.

이 글을 읽으면 알 수 있는 것


문제: 12개 소스, 같은 문제의 반복

이 프로젝트에서 데이터를 가져오는 소스는 12개입니다.

소스유형건수
식품안전나라 API 9종REST API~104만 건
공공데이터포털 API 3종REST API~150만 건
식품영양성분 CSV파일~6천 건
첨가물 고시 hwpx문서 파싱~780 건

이 소스들은 데이터의 내용과 형식이 전부 다릅니다. 하지만 수집 과정에서 해결해야 하는 문제는 놀라울 정도로 비슷했습니다.

처음에는 API마다 이 로직을 각각 구현했습니다. 세 번째 API를 연결할 때쯤, 코드의 70% 이상이 복붙이라는 것을 깨달았습니다.


갈림길: 중복을 어떻게 없앨 것인가

방법장점단점
API마다 개별 스크립트단순 명확공통 로직이 12번 중복
하나의 범용 스크립트코드 하나유형 A~D 분기가 뒤섞임
공통 처리기 + 개별 정의공통 흐름 재사용, 차이만 분리초기 설계 필요

세 번째를 선택했습니다.

Django의 management command 패턴을 활용했습니다. python manage.py import_i1250 같은 형태로 각 데이터 소스를 실행하되, 공통 로직은 부모 클래스에 모아두는 구조입니다.


공통 처리기의 7가지 책임

부모 클래스가 담당하는 것:

#책임동작
1페이지네이션시작/끝 번호를 계산하고, 반복 호출
2재시도실패 시 지수 백오프 (5→15→45초)
3쿨다운3회 연속 실패 시 60초 대기 후 재시도
4중단점 복구--resume 시 DB 건수 기반으로 시작점 계산
5원본 보존API 응답 JSON을 해시 기반으로 별도 저장
6작업 기록시작 시간, 건수, 성공/실패 여부 기록
7워터마크각 행의 수정일 최대값 추적

개별 API가 정의하는 것:

항목예시
서비스 이름I1250, C002
한 건을 어떻게 저장하는가upsert_row() 구현
응답을 어떻게 파싱하는가출처별 부모 클래스가 담당

계층 구조

공통 처리기
├── 식품안전나라 전용 (유형 A 파싱)
│   ├── import_i1250 (제품)
│   ├── import_c002 (원재료)
│   ├── import_i2520 (표준 사전)
│   └── ... (6개 더)
└── 공공데이터포털 전용 (유형 B~D 파싱)
    ├── import_food_nutrition (영양성분)
    ├── import_haccp (HACCP)
    └── import_rawmaterial (원재료 정보)

3단 구조입니다. 최상위 공통 처리기는 재시도, 중단점 복구, 원본 보존 등 모든 데이터 소스에 공통되는 로직을 담당합니다. 중간 계층은 출처별 응답 파싱 차이를 흡수합니다. 최하위의 개별 커맨드는 “이 데이터를 DB에 어떻게 저장하는가”만 정의합니다.

이 구조 덕분에 새 API를 추가할 때 작성해야 하는 코드는 40~70줄 정도입니다. 서비스 이름, 모델 매핑, upsert 로직 — 이 세 가지만 정의하면, 나머지는 부모 클래스가 알아서 합니다.


이 구조가 해결한 문제들

재시도 로직의 일관성

12개 소스에서 모두 같은 재시도 정책이 적용됩니다. 한 곳에서 재시도 간격을 수정하면 전체에 반영됩니다.

작업 기록의 표준화

모든 수집 작업이 같은 형식으로 기록됩니다. “언제 실행했고, 몇 건 받았고, 몇 건 저장했고, 성공인가 실패인가.” 이 기록을 비교하면 “어제와 오늘의 건수 차이”를 볼 수 있습니다.

중단점 복구의 자동화

--resume 플래그 하나로, 어떤 API든 중단된 지점에서 이어받습니다. DB에 실제로 저장된 건수를 세어서 시작점을 계산하는 로직이 공통 처리기에 있으므로, 개별 API가 이 로직을 신경 쓸 필요가 없습니다.


갈림길: 응답 파싱을 어디에 둘 것인가

공통 처리기가 응답 파싱까지 담당하면 어떨까? 유형 A~D를 분기 처리하면 하나의 클래스에서 모든 API를 처리할 수 있습니다.

방식장점단점
공통 처리기에서 분기클래스 하나if/elif 체인이 길어짐, 새 유형 추가 시 위험
출처별 중간 계층유형별 책임 분리클래스가 하나 더 필요

중간 계층을 선택한 이유는 첫 번째 글에서 이야기한 것과 같습니다. 식품안전나라와 공공데이터포털의 응답 구조가 근본적으로 다르고, 이 차이를 하나의 분기문에 우겨넣으면 코드가 취약해집니다.


결과

항목수치
공통 처리기1개 (~300줄)
출처별 중간 계층2개 (식품안전나라, 공공데이터포털)
개별 커맨드12개 (각 40~70줄)
새 API 추가 시 작성량40~70줄
공통으로 제공되는 기능재시도, 중단점 복구, 원본 보존, 작업 기록, 워터마크, 페이지네이션, 쿨다운

한계

공통 처리기가 하는 일이 너무 많습니다. 300줄 안에 7가지 책임이 들어 있습니다. 단일 책임 원칙에서 벗어나 있고, 수정 시 영향 범위가 넓습니다. 하지만 현재 규모에서는 클래스를 더 분리하는 것이 오히려 복잡도를 높인다고 판단했습니다.

CSV와 hwpx 소스는 이 패턴에 완벽하게 맞지 않습니다. API 기반 페이지네이션을 전제로 설계된 구조라, 파일 기반 소스에서는 일부 기능(페이지네이션, 재시도)이 불필요합니다. 공통 처리기의 일부를 건너뛰는 방식으로 대응하고 있습니다.

한 줄 교훈

12개 소스에서 같은 문제를 같은 방식으로 풀고 싶다면, “어떻게 풀 것인가”를 한 곳에 두고 “무엇을 풀 것인가”만 각자 정의하게 해야 합니다.


다음 글: 19개 테이블, 원본은 절대 건드리지 않는다 — 데이터 모델 설계 원칙