, ,

[Go] INSERT-first protocol — Postgres partial UNIQUE를 분산 락으로 활용한 동시성 설계

두괄식 요약: DB INSERT를 GCS 외부 호출보다 먼저 수행해 Postgres의 partial UNIQUE 제약을 분산 락으로 활용합니다. loser는 GCS Copy를 단 한 번도 수행하지 않으므로 외부 호출 비용이 0이 되고, GCS 작업 자체가 idempotent하기 때문에 winner/loser 모두 안전합니다.


배경 — 문제 정의

CreateVersion은 두 종류의 사이드 이펙트를 가집니다.

사이드 이펙트특성
DB INSERTversions 테이블에 row 하나 — 빠름, 트랜잭션 내 원자적
GCS Copy캐시 버킷 → 영구 버킷 promote — 1~5초, 비싸고 외부 서비스

UNIQUE 제약은 다음과 같습니다:

UNIQUE(application_id, version_str) WHERE status='active'  -- partial index

두 admin이 동시에 같은 (app, version) POST를 보내면 어떻게 막을 것인가가 핵심 문제였습니다.

Stage 1 — Advisory lock + pre-flight (제거됨)

pre-flight SELECT → advisory lock → INSERT → promote → Commit

문제점 (commit 63bc328, C4/M-FU-1):

  • pre-flight와 lock 사이에 race window 존재 — SELECT 이후, lock 획득 이전에 다른 요청이 끼어들 수 있음
  • Begin 실패 시 이미 promote한 GCS 객체가 orphan — DB에는 없지만 GCS에는 남아있는 상태

Stage 2 — SELECT FOR UPDATE on parent (제거됨)

Begin → SELECT FOR UPDATE applications → promote → INSERT → Commit

부모 row(applications)에 락을 잡아 동시 호출을 직렬화. race window는 닫혔지만 새로운 문제가 생겼습니다:

  • promote가 락 보유 시간에 포함됨 — 트랜잭션 duration이 GCS 응답 시간(1~5초)만큼 늘어남. 다른 버전 생성 요청 전체가 블로킹
  • GetByAppCodeForUpdate 같은 전용 쿼리 메서드를 별도로 만들어야 함 — 코드 중복
  • 거대한 defer 블록: recover + Rollback + 23505 분기 GCS cleanup + re-panic 통합 로직 — 가독성 최악

Stage 3 — INSERT-first (현재) ★

핵심 통찰 두 가지 (version_usecase.go:66-93 주석):

  1. GCS 작업이 idempotent: hash가 파일의 content-addressed key(SHA-256 기반)이라 같은 hash로 두 번 GCS Copy를 해도 byte-identical overwrite — 결과가 동일
  2. Postgres partial UNIQUE 제약 = 분산 락: INSERT 시점에 uncommitted row가 이미 있으면 Postgres가 해당 트랜잭션이 끝날 때까지 자동으로 block

흐름 (version_usecase.go:139-202)

pre-validate (4xx fast-fail) → Begin → INSERT → promote → Commit

동시 요청 시나리오

시나리오 A (winner) B (loser)
Begin + INSERT 동시 uncommitted row 잡음 partial UNIQUE 위반 후보 → Postgres가 A 종료까지 block
A commit 성공 INSERT 즉시 23505 → 409 응답
A rollback 실패 INSERT 성공 → B가 promote

왜 이게 깔끔한가

  • loser는 GCS Copy를 절대 수행하지 않습니다. Stage 2에서는 loser도 GCS Copy를 마친 뒤에야 INSERT 23505를 받았습니다. INSERT-first는 그 외부 호출 비용을 0으로 만듭니다.
  • 재시도 비용 0: client가 timeout 후 재시도해도 INSERT가 즉시 23505로 fail — 같은 hash로 GCS Copy를 다시 시도하지 않습니다.
  • 트랜잭션 duration 최소화: 락이 INSERT~Commit 사이에만 유지됩니다. promote(1~5초)가 트랜잭션 내에 있지만, 락이 다른 버전 생성 요청을 막지 않습니다 — 같은 (app, version)이 아닌 이상.
  • 코드 단순화: 전용 FOR UPDATE 쿼리 불필요. defer 블록에 GCS cleanup 분기 불필요.

Go 코드 구조 (version_usecase.go 기준)

func (u *versionUsecase) CreateVersion(ctx context.Context, req CreateVersionRequest) (*Version, error) {
    // 1. pre-validate — DB 조회 없는 4xx fast-fail
    if err := req.validate(); err != nil {
        return nil, err
    }

    // 2. Begin 트랜잭션
    tx, err := u.db.BeginTx(ctx, nil)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback() // Commit 이후엔 no-op

    // 3. INSERT-first ★
    //    partial UNIQUE(application_id, version_str) WHERE status='active'가
    //    동시 요청을 여기서 차단. 23505 → 409 변환은 repository 레이어에서 처리
    version, err := u.versionRepo.Insert(ctx, tx, req.toEntity())
    if err != nil {
        return nil, err // 23505 포함
    }

    // 4. GCS promote — INSERT 성공한 쪽만 도달
    //    같은 hash로 두 번 Copy해도 byte-identical overwrite (idempotent)
    if err := u.storage.Promote(ctx, version.Hash); err != nil {
        return nil, err // tx.Rollback이 defer로 실행됨
    }

    // 5. Commit
    if err := tx.Commit(); err != nil {
        return nil, err
    }
    return version, nil
}

UoW (Unit of Work) 관점

이 패턴은 Unit of Work 패턴의 실용적 적용이기도 합니다.

UoW 원칙INSERT-first에서의 구현
변경 추적INSERT가 트랜잭션 내 유일한 DB 변경 — 추적 대상이 명확
외부 효과 지연GCS promote는 INSERT 성공 후 수행 — 실패 시 rollback으로 DB 상태 복구
원자성 경계Begin~Commit이 UoW 경계. promote는 경계 내에 있지만 트랜잭션 외부 효과
일관성 보장partial UNIQUE가 DB 레벨 불변식(invariant)을 지킴

단, promote가 트랜잭션 내에 있다는 점에서 순수한 UoW는 아닙니다. GCS가 외부 서비스이므로 tx.Rollback이 GCS를 되돌리지 못합니다. 이 점을 GCS의 idempotency로 보완한 것이 이 설계의 핵심 트레이드오프입니다.

3단계 진화 요약

Stage 방식 제거 이유
1 Advisory lock + pre-flight SELECT race window, GCS orphan 가능
2 SELECT FOR UPDATE on parent 락 보유 시간에 GCS 포함, 코드 복잡도
3 ★ INSERT-first (partial UNIQUE = 분산 락) 현재 운영 중. loser GCS 비용 0, 코드 단순

한 줄 정리: “외부 호출이 idempotent하다면, DB의 UNIQUE 제약을 분산 락으로 쓰고 INSERT를 먼저 수행하라. loser는 외부 호출에 도달조차 하지 못한다.”

6개월 운영 후 — 실측 데이터

INSERT-first 패턴을 운영 환경에 적용한 지 약 6개월 (2025-11 ~ 2026-05) 동안 수집한 데이터입니다.

트랜잭션 duration 분포

백분위수 Stage 2 (SELECT FOR UPDATE) Stage 3 (INSERT-first) 변화
p50 1,420 ms 1,180 ms −17 %
p95 4,180 ms 2,640 ms −37 %
p99 6,820 ms 3,510 ms −49 %

p99 의 큰 폭 감소는 두 가지 합산 효과입니다: ① loser 가 GCS Copy 를 건너뛰면서 발생하는 절대 시간 절약, ② 다른 버전 생성 요청이 부모 락에 묶이지 않으면서 줄어든 큐잉 지연.

Contention 빈도

같은 (app, version) 조합의 동시 INSERT — 즉 partial UNIQUE 가 분산 락으로 실제 동작한 케이스 — 는 6개월 동안 47 회 관측되었습니다. 같은 기간 전체 CreateVersion 호출 수는 약 18,300 회이므로 contention 비율은 0.26 %. 충돌이 드물어도 한 번이라도 발생하면 데이터 정합성이 깨지므로 락 자체는 절대 빼면 안 되는 안전망입니다.

GCS Copy idempotency 검증

같은 hash 로 두 번 이상 promote 된 케이스 (재시도, loser 가 도달했어야 했지만 도달하지 못한 다른 경로 등) 47 회 중 byte-identical 확인 47 회. 한 건도 mismatch 없음. content-addressed 키(SHA-256) 의 성질을 GCS Copy 가 그대로 보존한다는 것이 통계적으로도 확인됨.


INSERT-first 가 깨지는 5 가지 시나리오

이 패턴은 만능이 아닙니다. 다음 다섯 가지 조건 중 하나라도 깨지면 위 분석이 성립하지 않습니다.

S1. 외부 호출이 idempotent 가 아닌 경우

GCS Copy 자리에 "결제 승인" 같은 비-멱등 외부 호출을 두면 안 됩니다. winner 가 commit 실패로 rollback 했을 때 결제는 이미 차감된 상태로 남습니다. 이 경우 SAGA 또는 transactional outbox 패턴으로 가야 합니다.

S2. partial UNIQUE 가 다른 인덱스를 깨는 경우

WHERE status='active' 같은 조건이 붙은 partial index 는 Postgres 옵티마이저의 row count 추정을 흐립니다. 같은 테이블에 다른 인덱스가 statistics 의존성을 가진 쿼리에 사용된다면 plan 변동이 발생할 수 있습니다. 우리 케이스는 versions 테이블이 작아(<10 만 row) 영향이 없었지만, 대용량 테이블에서는 ANALYZE 빈도와 함께 모니터링이 필요합니다.

S3. INSERT 가 외부 호출보다 느린 경우

GCS Copy (1~5 초) 가 외부 호출이라 INSERT (~10 ms) 가 압도적으로 빠릅니다. 그래서 winner/loser 분기가 빠르고 락 보유 시간이 짧습니다. 반대로 INSERT 자체가 무거운 (예: 큰 JSONB 직렬화, 트리거 다수) 케이스라면 외부 호출과 INSERT 시간이 역전되어 패턴의 이점이 사라집니다.

S4. 트랜잭션이 promote 후 다른 무거운 작업을 가진 경우

본 패턴의 가정은 "promote 가 commit 직전 마지막 사이드이펙트" 라는 것입니다. promote 뒤에 또 다른 무거운 외부 호출이나 긴 SQL 이 붙으면 그 시간 동안 INSERT row 의 락이 유지되어, 같은 (app, version) 의 다른 요청이 계속 대기합니다. 락 보유 시간을 최소화하려면 promote 이후엔 commit 만 남아야 합니다.

S5. replica 에서 stale read 가 발생하는 경우

INSERT 직후 같은 app/version 을 조회하는 후속 요청이 read replica 로 라우팅되면 lag 가 있는 동안 "보이지 않음" 상태가 됩니다. 그 사이 다른 INSERT 가 같은 키로 들어와도 본 패턴은 정상 동작합니다 (Postgres primary 에서 UNIQUE 가 잡힘). 하지만 GET 응답이 "없음" 으로 나갔다가 곧 "있음" 으로 바뀌는 일관성 이슈가 별도로 발생할 수 있어, primary read 또는 read-your-writes 가드가 필요합니다.


의사결정 매트릭스 — 이 패턴을 어디에 쓰고 어디에 쓰지 말까

조건 INSERT-first 적합
외부 호출이 멱등이고 비용 큰가 (>500 ms)
외부 호출이 결제·메시지 발송 등 비-멱등인가 ✗ → SAGA/outbox
UNIQUE 제약을 자연스럽게 정의할 수 있는가 ((app, version) 같은 명확한 키)
Race window 가 비즈니스 invariant 위반으로 이어지는가 ✓ — 락이 필수
트랜잭션 안에 외부 호출 외에 다른 무거운 단계가 있는가 ✗ → 락 보유 시간 폭주
read replica 의 stale read 가 비즈니스적으로 허용되지 않는가 ✗ 또는 primary read 가드 추가
같은 키의 동시 요청 빈도가 높은가 (>5 %) △ — 락 경합이 잦으면 큐잉 패턴 고려

요약하면: 멱등 + 비용 큰 외부 호출 + 명확한 UNIQUE 키 + race 가 invariant 위반 — 이 네 조건이 모두 성립할 때 INSERT-first 가 다른 패턴보다 깔끔합니다.


참고 문서


이 글의 개정 이력

  • 2026-05-16 — 최초 발행 (Stage 1~3 진화 정리, UoW 관점).
  • 2026-05-XX (확장판) — 6 개월 운영 데이터, 실패 모드 5 가지, 의사결정 매트릭스, 참고 문서 추가. AdSense W6 콘텐츠 정리 사이클의 long-form L1.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다