이 글의 배경이 되는 이야기는 5화: 식약처 API 11종과의 첫 만남과 20화: 식약처에 편지를 쓴다면에서 읽을 수 있습니다.
이 글을 읽으면 알 수 있는 것
- “변경분만 주세요”가 안 될 때의 ETL 설계 전략
- 1,000건씩 1,040번 요청하는 동안 발생하는 문제들과 대응
- 중단점 복구(resume)를 설계할 때 고려해야 할 것
문제: 1건 바뀌어도 104만 건을 처음부터
보통 대량 데이터를 정기적으로 수집할 때는 “증분 동기화”를 합니다. “지난번 이후로 변경된 것만 보내주세요.”
식품안전나라 API의 제품 데이터에는 수정일(LAST_UPDT_DTM) 필드가 있습니다.
각 제품이 언제 마지막으로 수정되었는지 기록되어 있다는 뜻입니다.
그런데 이 필드로 필터링하는 기능이 없습니다.
API에 요청할 수 있는 것은 “1번부터 1,000번까지 주세요”, “1,001번부터 2,000번까지 주세요” — 이것뿐입니다. “2026년 3월 1일 이후에 바뀐 것만 주세요”라고 요청할 수 없습니다.
이게 무슨 뜻이냐면:
제품 하나의 이름이 바뀌었든, 제조사 전화번호가 바뀌었든, 변경 사항을 확인하려면 매번 104만 건을 처음부터 전부 다시 받아야 합니다.
이 제약 아래서 할 수 있는 것
증분 동기화가 불가능하다는 사실을 받아들이고 나면, 질문이 바뀝니다. “변경분만 받으려면 어떻게 하지?”에서 “전체를 받되, 어떻게 안전하게 받지?”로.
104만 건을 1,000건씩 나눠 받으면 최소 1,040회 요청입니다. 이 과정에서 실제로 마주친 문제들:
| 문제 | 빈도 | 영향 |
|---|---|---|
| 타임아웃 (30~60초 무응답) | 뒤쪽 페이지에서 빈번 | 수집 중단 |
| 일일 API 한도 초과 | 대규모 적재 시 | 당일 수집 불가 |
| 네트워크 일시 장애 | 간헐적 | 단일 페이지 실패 |
| 프로그램 강제 종료 | 가끔 | 처음부터 다시 |
이 문제들을 해결하기 위해 세 가지 장치를 설계했습니다.
장치 1: 재시도 — 한 번 실패해도 바로 포기하지 않는다
API가 한 번 응답하지 않았다고 전체 수집을 중단하면, 매번 처음부터 다시 시작해야 합니다. 그래서 실패 시 자동으로 재시도하되, 간격을 점점 늘리는 방식을 택했습니다.
1차 실패 → 5초 대기 후 재시도
2차 실패 → 15초 대기 후 재시도
3차 실패 → 45초 대기 후 재시도
3차까지 실패 → 60초 쿨다운 후 같은 페이지 재시도 (최대 3회)
이 “지수 백오프(Exponential Backoff)“라는 방식은 네트워크 프로그래밍에서 흔히 쓰입니다. 서버가 일시적으로 바쁠 때, 바로 재시도하면 더 바빠지니까 점점 길게 기다려서 부담을 줄이는 겁니다.
실제로 이 장치 덕분에 뒤쪽 페이지의 간헐적 타임아웃 대부분을 자동으로 복구했습니다.
장치 2: 중단점 복구 — 어디까지 받았는지 기억한다
재시도로도 해결 안 되는 경우가 있습니다. 일일 한도에 걸리거나, 서버 자체가 내려가거나. 이때 중요한 건 처음부터 다시 받지 않는 것입니다.
50만 건까지 받았는데 한도에 걸렸다면, 다음 날 50만 1번부터 이어서 받아야 합니다.
이걸 위해 두 가지 정보를 기록합니다:
하나, 작업 기록. 매 수집 작업마다 “몇 건 받았고, 몇 건 저장했고, 성공/실패 여부”를 기록합니다.
둘, 실제 적재 건수. 재개할 때는 데이터베이스에 실제로 저장된 건수를 세어서 시작점을 계산합니다. “현재 DB에 52만 3천 건이 있으니, 52만 4천 번부터 시작.”
이 방식을 선택한 이유는 단순함 때문입니다. “마지막으로 성공한 페이지 번호”를 따로 저장하는 방법도 있지만, 실제 DB 건수를 세는 쪽이 더 신뢰할 수 있었습니다. 프로그램이 예상치 못하게 종료되어도, DB에 들어간 데이터는 남아 있으니까요.
장치 3: 원본 보존 — 나중에 다시 볼 수 있게 남겨둔다
API 응답은 일회성입니다. 같은 요청을 내일 보내도 같은 결과가 오리라는 보장이 없습니다. 제품 정보가 수정되면 어제의 응답은 더 이상 재현할 수 없습니다.
그래서 API에서 받아온 원본 JSON을 별도 테이블에 그대로 저장합니다. 가공하기 전의 원본 데이터입니다.
이게 도움이 된 건, 파싱 로직이나 저장 규칙을 나중에 바꿀 때였습니다. 로직을 수정한 후 API를 다시 호출하지 않아도, 저장해둔 원본을 꺼내서 새 로직으로 다시 처리할 수 있었습니다.
갈림길: upsert 전략
전체를 매번 받아야 한다면, 이미 있는 데이터와 새로 받은 데이터가 겹칠 때 어떻게 할지 정해야 합니다.
| 방법 | 동작 | 리스크 |
|---|---|---|
| 전체 삭제 후 다시 넣기 | 깔끔하지만 위험 | 수집 도중 장애 시 데이터 공백 발생 |
| 변경 감지 후 부분 갱신 | 효율적 | 비교 로직이 복잡, 필드별로 달라짐 |
| 무조건 덮어쓰기 (upsert) | 있으면 갱신, 없으면 추가 | 약간 비효율적이지만 안전 |
세 번째를 선택했습니다.
“있으면 업데이트하고, 없으면 새로 만든다” — 이 단순한 규칙을 모든 API에 동일하게 적용했습니다. DB에 이미 같은 제품이 있어도 에러가 나지 않고, 최신 정보로 갱신됩니다.
비효율적이라는 건 맞습니다. 104만 건 중 99만 건은 변하지 않았는데 전부 덮어쓰니까요. 하지만 “전체 삭제 후 다시 넣기”의 위험성(수집 중 장애 = 서비스 전체 데이터 없음)을 생각하면, upsert의 비효율은 감수할 만했습니다.
전체 흐름
세 가지 장치를 합치면 이런 흐름이 됩니다:
시작
↓
이전에 중단된 적이 있나? → 있으면 DB 건수 기반으로 시작점 계산
↓
페이지 루프 (1,000건씩)
├── API 호출 → 실패 시 재시도 (지수 백오프)
├── 원본 JSON 저장
├── 각 행을 DB에 upsert
└── 다음 페이지로
↓
전체 완료 → 작업 기록 저장 (건수, 성공/실패)
이 구조가 12개 데이터 소스에 동일하게 적용됩니다. API마다 달라지는 것은 “응답을 어떻게 파싱하는가”와 “한 건을 어떻게 저장하는가”뿐입니다.
결과
| 항목 | 수치 |
|---|---|
| 전체 적재 시 요청 수 | ~1,040회 (제품) + ~980회 (원재료) + … |
| 재시도로 복구된 비율 | 간헐적 타임아웃의 ~90% |
| 중단 후 재개 | --resume 플래그 하나로 자동 계산 |
| 원본 보존 | API 응답 전체를 해시 기반 중복 방지로 저장 |
한계
증분 동기화는 여전히 불가능합니다. API가 수정일 필터를 지원하지 않는 한, 매번 전체를 받을 수밖에 없습니다. 현재 워터마크(수정일)를 기록은 하고 있지만, API 요청에 필터로 활용하지 못합니다. 향후 API가 수정일 필터를 지원하게 되면 즉시 활용할 수 있도록 값은 추적하고 있습니다.
일일 한도와 전체 적재의 충돌. 104만 건을 처음 적재할 때, 일일 한도 때문에 며칠에 걸쳐 나눠 받아야 했습니다. 이 기간 동안 앞부분과 뒷부분의 데이터 시점이 다를 수 있다는 한계가 있습니다.
한 줄 교훈
증분 동기화가 안 되면, “전체를 안전하게 받는 구조”를 먼저 만들어야 합니다. 재시도, 중단점 복구, 원본 보존 — 이 세 가지가 기본입니다.