게임 랭킹 데이터를 매일 긁어 DB에 쌓는 잡을 짜고 나서 며칠 뒤, 대시보드의 일별 유저 수가 정확히 두 배로 튀는 걸 봤다. 원인은 단순했다. 아침에 잡이 한 번 실패해서 재실행했는데, 실패한 게 아니라 “적재까지는 됐고 알림만 못 보낸” 상태였던 거다. 같은 데이터가 두 번 들어갔다. 이날 이후로 나는 모든 적재 잡을 멱등(idempotent)하게, 즉 몇 번을 돌려도 결과가 같게 만드는 걸 원칙으로 삼았다. 그 정리를 남긴다.

flowchart TD
  A["① 크롤링 원본"] --> B["② 스테이징 테이블<br/>(매번 비우고 적재)"]
  B --> C{"③ 키 기준 비교"}
  C -->|"신규"| D["④ INSERT"]
  C -->|"기존"| E["⑤ UPDATE"]
  D --> F["⑥ 최종 테이블<br/>(중복 0 보장)"]
  E --> F
  classDef a fill:#e7f5ff,stroke:#1c7ed6,color:#10548f;
  classDef b fill:#fff3bf,stroke:#f08c00,color:#8a5a00;
  classDef c fill:#d3f9d8,stroke:#2f9e44,color:#1a6a2e;
  class A,B a;
  class C b;
  class D,E,F c;

왜 append만 하면 중복이 쌓이나?

가장 흔한 초기 코드는 긁은 걸 그냥 INSERT INTO 하는 형태다. 문제는 파이프라인이 “정확히 한 번” 도는 보장이 세상에 거의 없다는 점이다. 재시도, 수동 재실행, 스케줄러 중복 트리거, 네트워크 타임아웃 후 부분 성공 — 전부 같은 데이터를 두 번 넣는 경로가 된다.

상황append-only 결과멱등 설계 결과
정상 1회 실행정상정상
실패 후 재실행중복 2배그대로
스케줄러 중복 트리거중복그대로
어제치 다시 채우기(백필)중복그대로

핵심은 “실행 횟수”가 아니라 “데이터의 최종 상태”를 기준으로 생각하는 것이다. 멱등성(idempotency)이란, 같은 입력으로 여러 번 실행해도 상태가 한 번 실행한 것과 동일하다는 성질이다.

무엇을 기준으로 같은 행이라 볼까?

멱등의 출발점은 자연 키(natural key), 곧 “이 행을 유일하게 식별하는 값”을 정하는 일이다. 게임 랭킹이라면 (수집일자, 유저ID) 조합이 그렇다. 이걸 DB 제약으로 못박아야 upsert가 작동한다.

-- 최종 테이블: 자연 키에 UNIQUE 제약을 건다
CREATE TABLE rank_daily (
    snapshot_date DATE      NOT NULL,
    user_id       BIGINT    NOT NULL,
    rank_no       INT       NOT NULL,
    score         INT       NOT NULL,
    updated_at    TIMESTAMP DEFAULT now(),
    CONSTRAINT uq_rank UNIQUE (snapshot_date, user_id)
);

UNIQUE (snapshot_date, user_id)가 없으면 아무리 좋은 upsert 문법을 써도 DB가 “같은 행”을 판단할 근거가 없다. 제약이 곧 계약이다.

upsert는 어떻게 쓰나?

PostgreSQL 기준 INSERT ... ON CONFLICT가 가장 깔끔하다. 충돌(같은 키)이 나면 넣는 대신 갱신한다.

INSERT INTO rank_daily (snapshot_date, user_id, rank_no, score)
VALUES ('2026-07-13', 1001, 3, 15200)
ON CONFLICT (snapshot_date, user_id)
DO UPDATE SET
    rank_no    = EXCLUDED.rank_no,
    score      = EXCLUDED.score,
    updated_at = now();

EXCLUDED는 “넣으려다 충돌난 새 값”을 가리키는 특수 별칭이다. 이 문장은 몇 번을 돌려도 최종 행은 하나, 값은 항상 최신이다. 그게 멱등이다.

DB문법
PostgreSQLINSERT ... ON CONFLICT ... DO UPDATE
MySQLINSERT ... ON DUPLICATE KEY UPDATE
SQLiteINSERT ... ON CONFLICT ... DO UPDATE
표준/대용량MERGE INTO ... WHEN MATCHED / NOT MATCHED

스테이징 테이블은 왜 한 겹 더 두나?

행 하나씩 upsert하면 되지만, 수만 행을 크롤링해 넣을 땐 두 가지가 걸린다. 크롤링 도중 절반만 들어가다 죽으면 최종 테이블이 어중간해지고, 건별 upsert는 느리다. 그래서 나는 스테이징(staging) 테이블, 곧 “잠깐 쌓았다 옮기는 임시 창고”를 한 겹 둔다.

flowchart LR
  subgraph S1["ⓐ 적재 단계"]
    A["크롤링 결과"] --> B["TRUNCATE staging"]
    B --> C["staging에<br/>통째로 INSERT"]
  end
  subgraph S2["ⓑ 반영 단계 (트랜잭션)"]
    C --> D["staging → 최종<br/>MERGE / upsert"]
    D --> E["COMMIT"]
  end
  classDef a fill:#e7f5ff,stroke:#1c7ed6,color:#10548f;
  classDef b fill:#d3f9d8,stroke:#2f9e44,color:#1a6a2e;
  class A,B,C a;
  class D,E b;

포인트는 세 가지다. 스테이징은 매 실행 첫머리에 비운다(TRUNCATE) — 이전 실행 찌꺼기를 원천 차단하니 그 자체로 멱등하다. 크롤링이 도중에 죽어도 최종 테이블은 손대지 않았으니 무사하다. 그리고 스테이징이 온전히 다 찬 뒤에야 한 번의 트랜잭션으로 최종 테이블에 반영한다.

BEGIN;
INSERT INTO rank_daily AS t (snapshot_date, user_id, rank_no, score)
SELECT snapshot_date, user_id, rank_no, score
FROM   rank_staging
ON CONFLICT (snapshot_date, user_id)
DO UPDATE SET
    rank_no = EXCLUDED.rank_no,
    score   = EXCLUDED.score,
    updated_at = now();
COMMIT;

set 기반 일괄 처리라 건별 루프보다 훨씬 빠르고, 트랜잭션으로 감싸 “전부 반영 or 전혀 반영 안 됨”을 보장한다.

실행 파이썬 쪽은 어떻게 감싸나?

파이썬 오케스트레이션에서도 순서를 지켜야 멱등이 유지된다. 합성 예시다.

def load_ranking(rows):  # rows: 크롤링한 더미 리스트
    with conn:                      # 트랜잭션 컨텍스트
        cur = conn.cursor()
        cur.execute("TRUNCATE rank_staging;")   # ① 매번 비움
        cur.executemany(
            "INSERT INTO rank_staging "
            "(snapshot_date, user_id, rank_no, score) "
            "VALUES (%s, %s, %s, %s);", rows)    # ② 통째 적재
        cur.execute(MERGE_SQL)                   # ③ 최종 반영(upsert)
    # with 블록 정상 종료 시에만 COMMIT

여기서 실수하기 쉬운 함정 하나. 스테이징에 넣기 전에 크롤링 결과 자체의 키 중복을 먼저 제거해야 한다. 같은 배치 안에 같은 키가 두 번 있으면 ON CONFLICT가 한 문장 안에서 두 번 갱신을 시도하다 에러가 난다. dict로 키를 눌러 마지막 값만 남기는 식으로 미리 정리한다.

정리 — 체크리스트

항목확인
자연 키 정의 + UNIQUE 제약최종 테이블에 걸었나
스테이징 매 실행 TRUNCATE첫 줄에서 비우나
반영은 upsert/MERGEappend-only 아님
트랜잭션으로 감쌈부분 반영 없음
배치 내부 키 중복 선제거upsert 전에 처리

이 다섯 줄만 지키면 아침에 잡이 죽어도 마음 편히 재실행 버튼을 누를 수 있다. “몇 번 돌렸나”를 세는 대신 “최종 상태가 맞나”만 보면 되는, 그 홀가분함이 멱등 설계의 진짜 이득이다.