이 글은 누구를 위한 것인가
- 여러 공급처·제휴사에서 상품이나 장소 데이터를 받아 통합하는 팀
- 피드가 조용히 깨졌는데 며칠 뒤에야 알게 된 경험이 있는 경우
- "데이터가 이상하다"는 CS를 받고 원인 추적에 하루를 쓴 개발자
들어가며
카탈로그가 수만 건이 되고 출처가 여럿이 되면, 문제의 성격이 바뀐다. 단일 소스일 때는 "데이터가 맞는가"만 보면 되지만, 다중 소스에서는 **"어느 출처가 언제 무엇을 보냈는가"**를 추적하지 못하면 어떤 것도 고칠 수 없다.
전형적인 사고는 이렇게 난다.
월요일 공급사 A가 필드명을 바꿈 (price → salePrice)
월요일 파서가 price를 못 찾아 null 반환 → 0원으로 저장
월요일 0원 상품 1,200건이 노출됨
수요일 CS 폭주로 인지
수요일 급하게 롤백 — 그런데 어느 시점 데이터로 되돌릴지 모름
이 사고의 원인은 파서가 아니다. 계약이 없었고, 검증이 없었고, 되돌릴 지점이 없었던 것이다.
1. 데이터 계약을 문서가 아니라 코드로
"공급사와 협의한 스펙"이 위키에만 있으면 지켜지지 않는다. 계약은 적재 파이프라인에서 강제되는 스키마여야 한다. 아래 예시는 Pydantic 검증기를 쓰지만, 공급사와 스펙을 주고받는 문서로는 JSON Schema처럼 언어 중립적인 형식이 협의에 유리하다.
from pydantic import BaseModel, Field, field_validator
from datetime import datetime
class PlaceFeedItem(BaseModel):
# 필수 — 없으면 이 레코드는 거부
source_id: str = Field(min_length=1, max_length=128)
name: str = Field(min_length=1, max_length=200)
address: str = Field(min_length=5)
# 선택 — 없어도 되지만, 있으면 형식을 지켜야 함
phone: str | None = None
lat: float | None = Field(default=None, ge=33.0, le=39.0) # 국내 범위
lng: float | None = Field(default=None, ge=124.0, le=132.0)
closed_at: datetime | None = None
@field_validator("phone")
@classmethod
def normalize_phone(cls, v: str | None) -> str | None:
if v is None:
return None
digits = "".join(c for c in v if c.isdigit())
if not (9 <= len(digits) <= 11):
raise ValueError(f"전화번호 자릿수 이상: {v}")
return digits
좌표에 범위 제약을 거는 것이 실제로 여러 번 사고를 막는다. 위경도가 뒤바뀌어 들어오거나(lat=127, lng=37), 0으로 채워진 레코드가 아프리카 앞바다에 찍히는 일은 생각보다 자주 일어난다. 도메인 범위를 아는 쪽에서 걸러야 한다.
세 축이 명확히 나뉜다.
[필수 / 선택 / 파생]
필수 — 없으면 레코드 자체가 무의미. 거부.
source_id, name, address
선택 — 없어도 되지만 있으면 검증. 위반 시 필드만 버리고 레코드는 살림.
phone, lat/lng, 영업시간
파생 — 우리가 계산. 공급사가 보내와도 신뢰하지 않음.
정규화된 상호명, 지오코딩 결과, 카테고리 매핑
공급사가 보낸 파생 값을 그대로 쓰지 않는 것이 중요하다. 카테고리 분류 기준은 공급사마다 다르고, 그걸 그대로 저장하면 우리 서비스의 분류 체계가 공급사 수만큼 갈라진다.
2. 검증은 두 지점에서 — 레코드와 배치
레코드 단위 검증만으로는 부족하다. 개별 레코드가 전부 유효한데 배치 전체가 이상한 경우가 있다.
[배치 단위 이상 신호]
건수 급변 전일 12,400건 → 오늘 3,100건 (-75%)
→ 공급사 쪽 부분 장애 가능성. 그대로 반영하면 9,300건이 사라짐
필드 결측률 급변 phone 결측률 8% → 71%
→ 필드명 변경 의심
중복 급증 source_id 중복 0.1% → 14%
→ 페이지네이션 버그로 같은 페이지 반복 수신
값 분포 이동 평균 가격 32,000원 → 320원
→ 단위 변경(원↔천원) 의심
이런 신호는 적재를 막는 근거가 되어야 한다.
def validate_batch(items: list[PlaceFeedItem], baseline: BatchStats) -> None:
stats = compute_stats(items)
if stats.count < baseline.count * 0.7:
raise BatchRejected(
f"건수 급감: {baseline.count} → {stats.count}"
)
for field, rate in stats.null_rates.items():
prev = baseline.null_rates.get(field, 0.0)
if rate > prev + 0.3:
raise BatchRejected(
f"{field} 결측률 급증: {prev:.1%} → {rate:.1%}"
)
if stats.dup_rate > 0.05:
raise BatchRejected(f"중복률 이상: {stats.dup_rate:.1%}")
임계값은 감이 아니라 과거 분포에서 뽑는다. 최근 14일치 통계의 평균과 표준편차를 기준선으로 두고, 그 범위를 벗어나면 막는다. 처음에는 알림만 보내고 2~3주 관찰한 뒤 자동 차단으로 승격하는 편이 안전하다.
지역·업종 축으로 장소 정보를 모으는 플림의 안전 정보 안내처럼 여러 출처의 정보를 한 화면에 합치는 서비스에서는, 어느 항목이 언제 확인된 정보인지가 사용자 신뢰에 직결된다. 배치 검증은 그 신뢰의 최소 방어선이다.
3. 부분 실패 — 전부 아니면 전무는 답이 아니다
12,000건 중 40건이 검증에 실패했을 때, 배치 전체를 버리는 것도 40건을 조용히 버리는 것도 옳지 않다.
-- 실패 레코드를 격리 테이블에 남긴다
CREATE TABLE feed_rejects (
id BIGSERIAL PRIMARY KEY,
batch_id UUID NOT NULL,
source TEXT NOT NULL,
source_id TEXT,
raw JSONB NOT NULL, -- 원본 그대로
error_code TEXT NOT NULL, -- 'MISSING_FIELD' | 'INVALID_COORD' | ...
error_detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX ON feed_rejects (source, error_code, created_at DESC)
WHERE resolved_at IS NULL;
원본(raw)을 통째로 남기는 이유는, 파서를 고친 뒤 재처리해야 하기 때문이다. 에러 메시지만 남기면 무엇이 들어왔는지 알 수 없다.
[거부 처리 정책]
거부율 < 1% 정상 범위. 격리만 하고 배치는 진행
1% ~ 5% 배치는 진행하되 알림
> 5% 배치 중단. 사람이 확인 후 재개
같은 error_code가 100건 이상 → 개별 데이터 문제가 아니라 규칙 문제
마지막 줄이 실용적이다. 한 종류의 에러가 대량으로 나오면 공급사 데이터가 아니라 우리 파서나 계약이 현실과 어긋난 것일 가능성이 높다. 이때 필요한 건 데이터 정정 요청이 아니라 계약 재협의다.
4. 신선도 — 언제 받았는지가 데이터의 일부다
카탈로그에서 가장 흔하게 방치되는 것이 신선도다. 3년 전에 들어온 레코드와 어제 갱신된 레코드가 화면에서 똑같이 보인다.
ALTER TABLE places
ADD COLUMN last_seen_at TIMESTAMPTZ NOT NULL, -- 피드에 마지막으로 등장
ADD COLUMN last_changed_at TIMESTAMPTZ NOT NULL, -- 내용이 마지막으로 바뀜
ADD COLUMN verified_at TIMESTAMPTZ; -- 사람이 마지막으로 확인
세 값이 다른 질문에 답한다.
last_seen_at— 아직 존재하는가. 피드에 계속 나오면 갱신last_changed_at— 내용이 바뀌었는가. 사이트맵lastmod의 근거verified_at— 사람이 확인했는가. 자동 수집과 검증된 정보의 구분
last_seen_at이 가장 중요한데 가장 자주 빠진다. 이 값이 없으면 소멸을 감지할 수 없다.
-- 폐업/삭제 후보: 최근 피드에 계속 안 나온 항목
SELECT id, name, last_seen_at
FROM places
WHERE source = 'partner_a'
AND published = true
AND last_seen_at < now() - interval '14 days'
ORDER BY last_seen_at;
공급사 피드는 대개 삭제를 명시적으로 알려주지 않는다. 그냥 목록에서 빠질 뿐이다. 이걸 감지하지 못하면 폐업한 가게가 몇 년째 노출된다.
[소멸 처리 단계]
7일 미출현 내부 플래그만 (노출 유지)
14일 미출현 목록 하단으로, "정보 확인 필요" 표시
30일 미출현 비공개 전환 (삭제 아님)
90일 미출현 아카이브
※ 즉시 삭제하지 않는 이유:
공급사 일시 장애로 며칠 누락되는 일이 흔하다
5. 되돌릴 수 있는 적재
사고가 났을 때 "어느 시점으로 되돌릴 것인가"에 답하려면, 적재 자체가 이력을 남겨야 한다.
CREATE TABLE feed_batches (
batch_id UUID PRIMARY KEY,
source TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
item_count INT NOT NULL,
reject_count INT NOT NULL,
status TEXT NOT NULL, -- 'applied' | 'rejected' | 'rolled_back'
stats JSONB NOT NULL -- 결측률·분포 등 검증 지표
);
-- 각 필드 변경에 어느 배치가 원인인지 남긴다
CREATE TABLE place_field_history (
place_id BIGINT NOT NULL,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
batch_id UUID NOT NULL REFERENCES feed_batches(batch_id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
이 구조가 있으면 앞의 "0원 사고"가 이렇게 처리된다.
1. feed_batches에서 이상 배치 식별 (price 결측률 급증한 batch_id)
2. place_field_history에서 그 배치가 바꾼 필드만 조회
3. old_value로 되돌림 — 전체 롤백이 아니라 영향받은 필드만
전체 스냅샷 복원보다 필드 단위 되돌리기가 낫다. 그 사이 정상적으로 들어온 다른 변경을 날리지 않기 때문이다.
이력 테이블이 커지는 것은 파티셔닝으로 관리한다. 월 단위로 나누고 오래된 파티션은 콜드 스토리지로 옮기면, 조회 성능을 유지하면서 감사 추적을 남길 수 있다. 조회 계획을 확인할 때는 PostgreSQL의 MVCC 문서에서 장기 트랜잭션이 테이블 팽창에 미치는 영향을 함께 보면 좋다.
6. 공급사와 합의해야 하는 것들
기술 설계만으로는 안 되는 부분이 있다. 계약서에 들어가야 하는 항목이 따로 있다.
[사전 합의 목록]
□ 스키마 변경 시 사전 통보 기간 (최소 2주 권장)
□ 필드 추가는 자유 / 필드 제거·개명은 통보 필수
□ 삭제 표현 방식 — 목록에서 빼기 vs deleted 플래그
□ 피드 제공 주기와 지연 시 알림 채널
□ 전량 피드인가 증분인가 (증분이면 삭제를 어떻게 알리는가)
□ 테스트 환경 제공 여부
세 번째가 특히 중요하다. deleted: true 플래그로 알려주면 4절의 소멸 추론이 전부 불필요해진다. 협의 한 번으로 복잡한 휴리스틱을 없앨 수 있는데, 기술로 해결하려다 놓치는 경우가 많다.
두 번째의 "필드 추가는 자유"는 우리 파서가 모르는 필드를 만나도 실패하지 않아야 한다는 뜻이기도 하다. 엄격한 스키마 검증이 오히려 취약점이 되는 지점이다.
class PlaceFeedItem(BaseModel):
model_config = {"extra": "ignore"} # 모르는 필드는 무시 (거부 아님)
7. 정리
1. 계약을 코드로 강제한다 (필수/선택/파생 구분)
2. 레코드 검증과 배치 검증을 둘 다 한다
3. 실패 레코드는 원본과 함께 격리 — 파서 수정 후 재처리 가능하게
4. last_seen_at으로 소멸을 감지한다 (즉시 삭제 금지)
5. 배치와 필드 변경 이력을 남겨 필드 단위 롤백을 가능하게
6. 삭제 표현 방식은 기술이 아니라 협의로 해결한다
이 중 하나를 먼저 넣는다면 **4번의 last_seen_at**이다. 한 컬럼으로 "언제부터 이 항목이 안 보였는가"에 답할 수 있고, 카탈로그 신뢰도를 갉아먹는 가장 큰 원인인 유령 데이터를 잡을 수 있다.