← 기술 블로그

공공 API ETL의 현실 — 워터마크 불가와 관대한 파서

Phase: 8 — 운영 최적화 시리즈: 운영 최적화

이 글을 읽으면 알 수 있는 것


배경: 매일 115만 건을 전부 다시 받는다

이 프로젝트의 ETL은 식약처 API에서 115만 건의 식품 데이터를 매일 수집합니다. 매일. 전부.

“변경된 것만 받으면 되지 않나?”는 자연스러운 질문입니다. ETL 설계에서 가장 먼저 고려하는 것이 증분 적재이고, 그 핵심 메커니즘이 워터마크(Watermark) — “마지막으로 처리한 시점” 이후의 데이터만 요청하는 방식입니다.

결론부터 말하면, 이 API에서는 워터마크가 작동하지 않습니다. 세 가지 이유가 있습니다.


문제 1: 워터마크가 작동하지 않는 세 가지 이유

날짜 필터가 없다

식약처 API는 페이지 기반 요청만 지원합니다.

GET /api/I1250/1/1000     -- 1~1,000번 행
GET /api/I1250/1001/2000  -- 1,001~2,000번 행

“2026년 3월 이후 변경된 데이터만 주세요”라는 요청은 불가능합니다. 수정일 필드(LAST_UPDT_DTM)가 응답에 포함되어 있지만, 이를 요청 파라미터로 사용하는 기능이 제공되지 않습니다.

수정일 필드의 신뢰 범위

LAST_UPDT_DTM이나 CHNG_DT 같은 날짜 필드는 존재하지만, 이 필드가 “모든 종류의 변경을 빠짐없이 반영하는가”는 별개의 문제입니다. 내부 시스템의 메타데이터 변경이나 연관 테이블의 수정이 해당 필드에 반영되지 않을 가능성을 배제할 수 없습니다.

식품 성분 정보를 다루는 서비스 특성상, 한 건의 누락이 사용자에게 잘못된 성분 정보를 보여주는 결과로 이어집니다. 성능 이득보다 데이터 정합성의 리스크가 더 큽니다.

페이지네이션 중 데이터 변경

1,040페이지를 순차적으로 요청하는 동안, 원본 데이터가 변경될 수 있습니다. 500페이지를 처리하는 중에 100페이지 영역의 데이터가 수정되면, 이미 처리한 구간에서 누락이 발생합니다. 워터마크를 “이번 수집 시작 시점”으로 기록해도, 수집 도중의 변경은 잡아낼 수 없습니다.


대안 비교: 그래서 어떻게 할 것인가

워터마크가 안 된다면, 선택지는 세 가지입니다.

전략방식장점단점
워터마크 skipLAST_UPDT_DTM < 마지막 수집일이면 skip빠름날짜 필터 미지원, 필드 신뢰성 미보장
해시 기반 skip행 전체의 해시를 저장, 변경 없으면 skip정확해시 비교를 위해 결국 전체를 읽어야 함
전체 적재 (upsert)매번 전체를 받아서 덮어쓰기단순, 누락 없음변경 없는 행도 매번 갱신

해시 기반 skip은 정확하지만, “전체를 읽어서 해시를 비교한 뒤 변경분만 쓴다”는 것이 결국 전체를 읽는 것과 같습니다. 네트워크 비용은 동일하고, 비교 로직만 추가됩니다.

전체 적재를 선택했습니다.

PostgreSQL의 ON CONFLICT ... DO UPDATE (upsert)는 115만 건 규모에서 충분히 효율적입니다. 1,000건 단위 배치 upsert로 전체 적재에 40~60분. 매일 이 시간을 쓰는 것이 “혹시 누락될 수 있는 증분 적재”보다 안전합니다.

INSERT INTO product (report_no, name, company, ...)
VALUES (...), (...), ...
ON CONFLICT (report_no)
DO UPDATE SET name = EXCLUDED.name,
             company = EXCLUDED.company, ...

이 전략이 유효한 조건은 “현재 규모”입니다. 1,000만 건이 되면 다시 평가해야 합니다. 하지만 그때의 제약과 인프라도 지금과는 다를 것이므로, 현 시점에서 과도한 최적화를 할 이유가 없었습니다.


문제 2: 괄호가 맞지 않는 데이터

ETL의 두 번째 현실은 데이터 품질입니다. 식품의 원재료명에는 화학물질 표기가 포함됩니다.

정제수, 수크랄로스, 프로필렌글리콜, dl-사과산나트륨[인산이수소칼륨(제이), ...]

괄호가 중첩되고, IUPAC 화학명이 섞여 있습니다. 원재료를 개별 성분으로 분리하려면 괄호의 깊이(depth)를 추적하는 파서가 필요합니다. (를 만나면 depth +1, )를 만나면 depth -1. depth가 0일 때의 쉼표가 성분 구분자입니다.

문제는 원본 데이터에서 괄호 유형이 일치하지 않는 경우가 있다는 점입니다.

97.8만 건 전수 조사

원재료명이 포함된 97.8만 건을 전수 조사했습니다. [로 열고 )로 닫는 표기 — 즉, 여는 괄호와 닫는 괄호의 유형이 불일치하는 경우가 7건 발견되었습니다.

항목수치
조사 대상978,000건
괄호 불일치7건
비율0.0007%

0.0007%. 이 숫자만 보면 무시해도 될 것 같습니다.

엄격한 파서 vs 관대한 파서

엄격한 파서(strict)는 괄호 유형을 정확히 매칭합니다. [는 반드시 ]로 닫아야 합니다. )가 오면 [에 대한 닫힘으로 인식하지 않으므로 depth가 감소하지 않습니다.

관대한 파서(lenient)는 닫는 괄호가 오면 유형에 관계없이 depth를 감소시킵니다. [로 열었든 (로 열었든, )가 오면 depth -1.

실제 데이터로 비교한 결과:

입력: "...인산이수소칼륨[제이인산칼슘(calcium phosphate, dibasic)]..."
                      ↑[로 열림          ↑ depth가 2인 구간    ↑?

문제가 되는 7건은 이런 패턴이었습니다. [로 열린 구간이 )로 닫히는 경우:

엄격 파서: [에 대한 닫힘이 없음 → depth 미해소
  → 이후 쉼표가 "괄호 안의 쉼표"로 처리
  → 다음 성분들이 현재 성분에 합쳐짐
  → 결과: 12개 성분이 10개로 줄어듦 (2개 손실)

관대 파서: )를 만나면 유형 무관하게 depth 감소
  → depth 정상 복구
  → 이후 쉼표가 정상적인 구분자로 처리
  → 결과: 12개 성분 모두 정상 분리

2개 성분이 손실된다는 것은, 해당 제품을 조회한 사용자가 실제로 포함된 성분을 볼 수 없다는 뜻입니다.


선택: 관대한 파서

관대한 파서를 선택했습니다.

이유는 단순합니다. 엄격한 파서는 0.0007%의 데이터에서 성분 손실이라는 치명적 오류를 만들고, 관대한 파서는 그 0.0007%를 정상 처리하면서 나머지 99.9993%에도 부작용이 없습니다.

# 관대한 파서 핵심 로직 (의사 코드)
def split_ingredients(raw_text):
    depth = 0
    for char in raw_text:
        if char in '([':
            depth += 1
        elif char in ')]':
            depth = max(0, depth - 1)
        elif char == ',' and depth == 0:
            # 여기가 실제 성분 구분자
            yield current_token

max(0, depth - 1)은 닫는 괄호가 여는 괄호보다 많은 경우(데이터 오류)에 depth가 음수가 되는 것을 방지합니다. 이 한 줄이 없으면, depth가 -1이 된 상태에서 이후의 모든 괄호 처리가 어긋납니다.


교훈

전체 적재가 최선인 경우가 있다

“증분 적재가 항상 더 좋다”는 전제는 API가 날짜 필터와 신뢰할 수 있는 변경 추적을 제공할 때에 한합니다. 이 두 조건이 충족되지 않으면, 전체 적재 + upsert가 더 단순하고 안전합니다.

0.0007%가 설계를 결정한다

97.8만 건 중 7건. 반올림하면 0%. 하지만 이 7건을 무시한 엄격한 파서는 성분을 잃어버리고, 수용한 관대한 파서는 전체를 정상 처리합니다. 파서 설계에서 “데이터가 완벽하다”는 가정은 기각해야 합니다. 특히 외부 소스의 데이터를 다룰 때는, 정상 범위를 벗어난 입력에 어떻게 반응하는지가 파서의 품질을 결정합니다.

한 줄 교훈

공공 API의 제약은 코드로 흡수해야 합니다. 워터마크가 안 되면 전체 적재를, 괄호가 안 맞으면 관대한 파서를 — 데이터에 맞춰 설계하는 것이 데이터에 가정을 강요하는 것보다 안전합니다.