HTTP HEAD 사전 체크 · High-Water Mark 역순 스캔 · Score 임계값 노이즈 필터링
공공데이터포털 81MB CSV → PostgreSQL 서버리스 동기화 실전기

문제 정의 — 왜 최적화가 필요했나

건축 법규 검토 AI 시스템에서 “지구단위계획 고시 매칭” 기능을 구현하고 있었습니다. VWorld API로 특정 주소의 지구단위계획 정보를 조회하면 ntfc_sn(고시 일련번호)이 반환되는데, 이 번호만으로는 실제 고시 문서에 접근할 수 없습니다.

토지이음(eum.go.kr)에 고시 목록이 있지만, 해당 사이트의 WAF가 AWS IP 대역을 차단하고 있어 직접 API 호출이 불가능했습니다. 대안으로 공공데이터포털에서 제공하는 고시목록 CSV 파일을 PostgreSQL DB에 넣고 매칭하는 전략을 선택했습니다.

데이터 규모

항목
CSV 파일 크기 약 81MB
전체 레코드 수 448,100건
그 중 지구단위계획 관련 약 28,300건
CSV 갱신 주기 월 1~2회 (공공데이터포털 업데이트)
신규 추가량 수십~수백 건/월

문제는 이 448K건을 매번 전체 다운로드하고 파싱하면 약 52초가 걸린다는 것이었습니다. 에이전트가 사용자 질문에 응답하는 과정에서 52초를 기다리게 할 수는 없었습니다.

제약 조건 — 서버리스 환경의 한계

시스템은 AWS Bedrock AgentCore 위에서 서버리스로 동작합니다. 이 환경에서는 다음 제약이 있습니다:

로컬 파일 시스템 없음: 컨테이너가 매번 새로 뜨기 때문에 CSV를 로컬에 캐싱할 수 없습니다.

Cold Start 고려: 첫 호출 시에도 합리적인 응답 시간이 필요합니다.

DB는 유일한 영속 저장소: PostgreSQL만이 상태를 유지할 수 있는 곳입니다.

HTTP Range 미지원: 공공데이터포털(data.go.kr)이 Range 요청을 지원하지 않아 부분 다운로드가 불가합니다.

핵심 질문: “매달 수십 건만 추가되는데, 매번 448K건을 전부 처리해야 할까?”

전체 아키텍처 — 3-Layer 최적화 전략

“할 수 있는 한 아무것도 하지 않는다"를 원칙으로 3단계 최적화를 설계했습니다. 각 Layer에서 조기에 빠져나올수록 전체 비용이 줄어듭니다.

Layer 전략 비용 스킵 조건
Layer 1 HEAD 요청 → Content-Length 비교 0.3초 파일 크기 동일하면 즉시 종료
Layer 2 전체 다운로드 + 역순 라인 스캔 ~2초 max_seq 경계에서 스캔 중단
Layer 3 신규분만 csv.reader 파싱 + INSERT ~0.01초 신규 건만 처리

이 전략의 핵심은 “대부분의 호출에서 Layer 1에서 끝난다"는 점입니다. CSV가 갱신되지 않았다면 HEAD 요청 한 번(0.3초)으로 전체 프로세스가 종료됩니다.

호출 흐름:

  [HEAD 요청 0.3초]  
       ├─ Content-Length 동일? ──→ 종료 (0.3초)     ← 90% 케이스  
       ├─ 다름 → [전체 다운로드 ~1.5초]  
       │              │  
       │         [역순 스캔 ~0.1초]  
       │              │  
       │              ├─ 신규 0건? ──→ CL 저장 후 종료 (~2초)  
       │              │  
       │              └─ 신규 N건 → [파싱+INSERT ~0.01초] ──→ 종료 (~2초)  
       └─ HEAD 실패 → 전체 다운로드 진행 (fallback)

Layer 1: HEAD 요청 사전 체크

아이디어

HTTP HEAD 요청은 응답 본문 없이 헤더만 반환합니다. Content-Length를 DB에 저장해두고, 다음 호출 시 비교하면 파일이 변경되었는지 81MB를 다운로드하지 않고도 알 수 있습니다.

사전 조사 — data.go.kr 응답 헤더

실제 HEAD 요청으로 확인한 결과:

헤더 활용 가능 여부
Content-Length 84,733,371 (약 81MB) O — 파일 변경 감지에 사용
Last-Modified (없음) X — 제공하지 않음
ETag (없음) X — 제공하지 않음

Content-Length만 제공되지만, CSV에 행이 추가되면 파일 크기가 반드시 변하므로 충분합니다. ETag나 Last-Modified가 있었다면 더 정확했겠지만, 공공데이터포털 특성상 기대하기 어렵습니다.

Layer 1 구현

# app_settings 테이블에 Content-Length 저장  
CREATE TABLE IF NOT EXISTS app_settings (  
    setting_key VARCHAR(100) PRIMARY KEY,  
    setting_value TEXT,  
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP  
);  
# HEAD 요청 → 비교 → 스킵 판단  
req = urllib.request.Request(url, method='HEAD')  
with urllib.request.urlopen(req, timeout=30) as resp:  
    remote_cl = int(resp.headers.get('Content-Length', 0))  
  
stored_cl = get_stored_content_length()  # DB에서 조회  
  
if remote_cl > 0 and remote_cl == stored_cl:  
    return {"status": "ok", "message": "변경 없음"}  # 0.3초에 종료

주의: HEAD 요청이 실패할 수 있으므로(타임아웃, 서버 오류 등) fallback으로 전체 다운로드를 진행합니다. 최적화가 실패해도 기능 자체는 동작해야 합니다.

Layer 2: 역순 라인 스캔 (High-Water Mark)

High-Water Mark 패턴

CDC(Change Data Capture)에서 흔히 사용되는 패턴입니다. “마지막으로 처리한 지점"을 기록해두고, 다음에는 그 지점 이후만 처리합니다.

이 CSV의 경우, 각 행의 eum_url에 seq=NNNNN 형태의 일련번호가 있고, 새로운 고시가 추가될수록 seq가 증가합니다. DB에서 MAX(seq)를 조회하면 “어디까지 처리했는지” 알 수 있습니다.

CSV 구조 예시:

─────────────────────────────────────────────────────  
행 1:     "서울특별시고시 제2020-001호", ..., seq=100001  
행 2:     "부산광역시고시 제2020-002호", ..., seq=100002  
...  
행 448098: "경기도고시 제2025-311호",    ..., seq=611080  ← DB max_seq  
행 448099: "국토교통부고시 제2025-598호", ..., seq=611081  ← 신규  
행 448100: "서울특별시고시 제2025-155호", ..., seq=611082  ← 신규  
─────────────────────────────────────────────────────

왜 역순(뒤에서부터) 스캔하는가?

정순(앞에서부터) 스캔하면 448,100행을 모두 확인해야 신규분을 찾을 수 있습니다. 하지만 신규 행은 항상 파일 끝에 추가되므로, 뒤에서부터 스캔하면 기존 데이터(max_seq 이하)를 만나는 즉시 중단할 수 있습니다.

스캔 방향 신규 0건 신규 5건 신규 1000건
정순 (앞→뒤) 448,100행 전체 스캔 448,100행 전체 스캔 448,100행 전체 스캔
역순 (뒤→앞) 1행 스캔 후 중단 6행 스캔 후 중단 1,001행 스캔 후 중단

월 수십~수백 건 추가되는 현실에서, 역순 스캔은 거의 상수 시간(O(1)에 가까운)에 동작합니다.

Layer 2 구현

# DB에서 마지막 처리 지점 조회  
max_seq = db.query("SELECT MAX(seq) FROM gosi_info WHERE eum_url LIKE '%seq=%'")  
  
# CSV 전체를 메모리에 로드 (로컬 파일 없이)  
csv_text = urllib.request.urlopen(url).read().decode('utf-8-sig')  
lines = csv_text.split('\n')  
  
# 뒤에서부터 스캔 — max_seq 이하 만나면 즉시 중단  
new_line_indices = []  
for i in range(len(lines) - 1, 0, -1):  
    line = lines[i].strip()  
    if not line:  
        continue  
  
    seq_pos = line.find('seq=')  
    if seq_pos == -1:  
        continue  
  
    seq = int(line[seq_pos + 4:].split(',')[0].split('"')[0])  
  
    if seq <= max_seq:  
        break              # 여기서부터는 이미 DB에 있음 → 스캔 종료  
  
    new_line_indices.append(i)  
  
new_line_indices.reverse()  # 원래 순서로 복원

핵심: CSV를 DictReader로 전체 파싱하지 않고, raw 문자열에서 seq= 만 추출합니다. 파싱은 신규분에 대해서만 수행합니다. 448,100행 전체 DictReader 파싱 1.60초 vs 역순 스캔 0.14초로, 11배 빠릅니다.

Layer 3: 경량 파싱 + 배치 INSERT

역순 스캔으로 신규 행의 인덱스만 확보한 뒤, 해당 행만 csv.reader로 파싱합니다.

csv.DictReader vs csv.reader

DictReader는 매 행마다 딕셔너리를 생성하므로 오버헤드가 있습니다. 컬럼 순서가 고정된 CSV에서는 인덱스 기반 접근이 더 효율적입니다.

# CSV 컬럼 순서: 고시번호[0], 고시일[1], URL[2], 고시명[3]  
for i in new_line_indices:  
    parsed = list(csv.reader([lines[i]]))[0]  
    gosi_no    = parsed[0].strip()  
    gosi_date  = parsed[1].strip()  
    eum_url    = parsed[2].strip()  
    gosi_title = parsed[3].strip()  
  
    new_rows.append((gosi_no, gosi_date, gosi_title, eum_url))

csv.reader를 사용하는 이유: str.split(”,")이 더 빠르지만 값 안에 쉼표가 포함된 경우(예: “서울특별시 강남구, 서초구”) 깨집니다. csv.reader는 RFC 4180 규격대로 quoted 값을 안전하게 처리합니다.

배치 INSERT + ON CONFLICT

from psycopg2.extras import execute_batch  
  
execute_batch(cursor,  
    """INSERT INTO gosi_info (gosi_no, gosi_date, gosi_date_raw, gosi_title, eum_url,  
                              data_source, updated_at, created_at)  
       VALUES (%s, %s, %s, %s, %s, 'csv', NOW(), NOW())  
       ON CONFLICT (eum_url) DO NOTHING""",  
    new_rows,  
    page_size=5000,  
)

ON CONFLICT DO NOTHING: 역순 스캔이 완벽히 동작하면 중복 INSERT는 발생하지 않지만, eum_url에 UNIQUE 인덱스를 걸고 ON CONFLICT를 추가하여 안전망을 둡니다. 방어적 프로그래밍입니다.

PoC 기반 의사결정 — sido/sigungu 컬럼 제거

초기 설계에서는 gosi_info 테이블에 sido(시도명), sigungu(시군구명) 컬럼을 두고, 고시번호에서 파싱하여 저장하는 구조였습니다. “서울특별시고시 제2025-155호"에서 “서울특별시"를 추출하여 sido 컬럼에 저장하는 방식이었습니다.

데이터 품질 문제 발견

실제 데이터를 검증해보니 파싱 품질에 문제가 있었습니다:

유형 비율 예시
정상 (시도명만) ~30% “서울특별시”
시도+시군구 혼합 ~65% “경기도 용인시”, “충청남도 아산시”
파싱 불가 ~5% 발행기관 미기재

65%가 잘못된 데이터라면 정제 로직이 필요합니다. 하지만 정말 이 컬럼이 필요할까요?

PoC: sido 없이도 동일한 결과가 나오는가?

“대구광역시 수성구 연호동 203-2” 주소에 대해, ntfc_sn 27000NTC202411080001로 매칭한 결과:

검색 방식 결과 수 상위 1건
sido 컬럼 필터 5건 대구연호 공공주택지구 지구계획 변경(4차) [score=0.75]
gosi_no LIKE 필터 5건 대구연호 공공주택지구 지구계획 변경(4차) [score=0.75]
필터 없이 (날짜+제목만) 50건 대구연호 공공주택지구 지구계획 변경(4차) [score=0.75]

결론: 세 가지 방식 모두 동일한 상위 결과를 반환했습니다. sido 컬럼은 SQL 필터로 후보를 줄여주지만, 점수 기반 정렬에서 어차피 동일한 결과가 올라옵니다. 특히 min_score=0.3 임계값을 적용하면 50건 → 2건으로 노이즈가 제거됩니다.

제거의 이점

  • 전처리 시간 단축: 448K건에 대한 sido/sigungu UPDATE 불필요
  • INSERT 시 컬럼 2개 감소 → 약간의 I/O 절감
  • 데이터 정합성 문제(65% 오류) 자체가 사라짐
  • 코드 단순화: _update_sido_sigungu() 함수 삭제

교훈: “필요할 것 같다"로 컬럼을 추가하기 전에, PoC로 실제 필요 여부를 검증하세요. 이 경우 65%가 오류인 데이터를 정제하는 비용보다, 컬럼 자체를 없애는 것이 더 나은 선택이었습니다.

Score 임계값으로 검색 노이즈 제거

DB 동기화를 최적화한 뒤, 다음 문제는 검색 품질이었습니다. ntfc_sn에서 파싱한 날짜(±7일)와 “지구단위계획” 키워드로 후보를 추린 뒤, 다중 요소 점수를 매겨 정렬하는 구조인데 — 임계값 없이 상위 10건을 반환하면 노이즈가 심각했습니다.

문제: 상위 10건 중 8건이 노이즈

아래는 “대구광역시 수성구 연호동” 주소에 대해 임계값 없이 검색한 결과입니다:

순위 고시명 (요약) score
1 대구연호 공공주택지구 지구계획 변경(4차) 0.75
2 대구연호 공공주택지구 지구계획 변경(4차) 지형도면 0.75
3 울산광역시 중구 반구동 일원 지구단위계획… 0.10
4 강원도 원주시 행구동 일원 지구단위계획… 0.10
5 경상남도 거제시 장평동 지구단위계획… 0.10
(같은 패턴의 무관한 고시들) 0.10
10 서울특별시 서초구 방배동 지구단위계획… 0.10

상위 2건(score=0.75)만 실제 관련 고시이고, 나머지 8건(score=0.10)은 단지 같은 기간에 고시된 전혀 다른 지역의 데이터입니다. 0.10점은 “날짜 범위 안에 존재한다"는 기본 점수일 뿐, 실질적 매칭이 아닙니다.

점수 구성 분석

매칭 점수는 4개 요소의 합산입니다:

요소 점수 조건
기본 (날짜 범위 매칭) +0.10 고시일이 ntfc_sn 날짜 ±7일 이내
시도명 매칭 +0.30 gosi_no에 시도명 포함 (예: “대구광역시”)
동 이름 매칭 +0.40 gosi_title에 동명 포함 (예: “연호”)
시군구 보너스 +0.20 gosi_title에 시군구명 포함 (예: “수성구”)

점수 분포의 Gap: 관련 고시(0.75)와 무관 고시(0.10) 사이에 뚜렷한 간극이 있습니다. 시도명, 동이름, 시군구 중 하나라도 매칭되면 최소 0.40 이상이 되므로, 0.10~0.30 구간은 “날짜만 겹치는 노이즈"입니다.

해결: min_score=0.3 임계값 적용

def search_gosi_by_ntfc_sn(  
    ntfc_sn: str,  
    address: str = None,  
    limit: int = 10,  
    min_score: float = 0.3    # 이 한 줄이 핵심  
) -> List[Dict[str, Any]]:  
  
    # ... 후보 조회 + 점수 계산 ...  
  
    for row in rows:  
        score = calculate_match_score(row, sido_name, dong_name, address)  
        if score >= min_score:     # 임계값 미만은 버림  
            results.append({...})

적용 결과

min_score 없음 min_score=0.3
반환 건수 10건 2건
관련 고시 2건 (score=0.75) 2건 (score=0.75)
노이즈 8건 (score=0.10) 0건
정밀도 (Precision) 20% 100%

왜 0.3인가: 가장 약한 의미 있는 매칭은 “시도명만 일치"하는 경우(0.10 + 0.30 = 0.40)입니다. 0.3은 이 최소 의미 매칭보다 약간 낮은 값으로, 날짜만 겹치는 순수 노이즈(0.10)를 확실히 걸러내면서도 약한 매칭은 놓치지 않는 보수적 임계값입니다. 실제 테스트에서 0.10과 0.40 사이의 점수는 관찰되지 않았으므로, 0.2~0.35 범위의 어떤 값이든 동일한 효과를 냅니다.

교훈: 검색 결과를 무조건 limit 수만큼 채워서 반환하는 것보다, 품질 기준 미달인 결과를 아예 반환하지 않는 것이 사용자 경험에 훨씬 좋습니다. “없는 것이 틀린 것보다 낫다.” 특히 AI 에이전트가 이 결과를 소비하는 경우, 노이즈는 잘못된 추론의 원인이 됩니다.

성능 측정 결과

시나리오별 응답 시간

시나리오 소요 시간 설명
최초 풀 로드 52.2초 다운로드(1.5s) + 파싱(4.2s) + INSERT(46.4s)
증분 (HEAD 스킵) 0.32초 Content-Length 동일 → 즉시 종료
증분 (신규 5건) 2.26초 다운로드(1.5s) + 스캔(0.1s) + INSERT(0.01s)
증분 (신규 0건, CL 변경) 2.02초 다운로드(1.5s) + 스캔(0.1s) + 신규 없음

Layer별 시간 분해

단계 시간 비고
HEAD 요청 0.14초 네트워크 지연만큼
전체 다운로드 (81MB) 1.5초 서버→서버 (AWS 내)
역순 스캔 (448K행) 0.14초 신규 0건 → 1행만 확인
DictReader 전체 파싱 (비교용) 1.60초 최적화 전 방식
csv.reader 5건 파싱 < 0.01초 무시할 수 있는 수준
execute_batch 5건 INSERT < 0.01초 무시할 수 있는 수준
execute_batch 448K건 INSERT 46.4초 최초 로드 시에만

병목 분석: 최초 풀 로드의 병목은 446.4초의 INSERT입니다(전체의 89%). 하지만 최초 로드는 1회만 발생하고, 이후에는 증분 동기화(0.3~2초)만 필요하므로 최적화 대상에서 제외했습니다. COPY FROM 방식으로 개선 가능하지만, ROI가 낮습니다.

최적화 전후 비교 (일반적인 호출)

최적화 전 최적화 후 개선율
변경 없을 때 52초 (매번 풀 로드) 0.3초 (HEAD 스킵) 173배
신규 5건 있을 때 52초 (풀 파싱 + 전체 비교) 2.3초 (역순 스캔 + 증분) 23배

핵심 교훈

HTTP 헤더를 먼저 확인하라

데이터를 다운로드하기 전에 HEAD 요청으로 “다운로드할 필요가 있는지"를 먼저 판단하세요. Content-Length, ETag, Last-Modified 중 하나만 있어도 충분합니다. 공공데이터 API가 이 헤더들을 잘 안 주는 경우가 많지만, Content-Length는 대부분 제공합니다.

데이터 특성을 활용한 알고리즘을 설계하라

“새 데이터는 항상 끝에 추가된다"는 도메인 지식이 역순 스캔을 가능하게 했습니다. 범용 알고리즘(전체 스캔, 해시 비교)보다 데이터 특성에 맞는 알고리즘이 훨씬 효율적입니다.

PoC로 “필요할 것 같은” 기능을 검증하라

sido/sigungu 컬럼은 직관적으로 필요해 보였지만, 실제 PoC 결과 없어도 동일한 결과를 얻을 수 있었습니다. 추가적인 컬럼/인덱스는 유지보수 비용과 데이터 정합성 리스크를 수반합니다. 코드를 작성하기 전에 PoC 한 번이면 불필요한 복잡도를 피할 수 있습니다.

최적화 레이어를 분리하라

한 번에 완벽한 최적화를 하려 하지 말고, 레이어별로 분리하면 각 레이어를 독립적으로 테스트하고 개선할 수 있습니다. Layer 1이 실패해도 Layer 2가 동작하는 구조가 안정적입니다.

방어적으로 코딩하되, 성능을 포기하지 마라

ON CONFLICT DO NOTHING, HEAD 실패 시 fallback, 날짜 파싱 보정(“2020-03-00” → “2020-03-01”) 등 안전망을 두면서도 happy path의 성능을 최대화했습니다.

검색 결과는 양보다 질이다

임계값 하나(min_score=0.3)로 Precision이 20%에서 100%가 되었습니다. 특히 AI 에이전트가 결과를 소비하는 파이프라인에서는 노이즈가 잘못된 추론으로 이어집니다. “없는 것이 틀린 것보다 낫다"는 원칙을 적용하세요.