두괄식 요약: DB INSERT를 GCS 외부 호출보다 먼저 수행해 Postgres의 partial UNIQUE 제약을 분산 락으로 활용합니다. loser는 GCS Copy를 단 한 번도 수행하지 않으므로 외부 호출 비용이 0이 되고, GCS 작업 자체가 idempotent하기 때문에 winner/loser 모두 안전합니다.
배경 — 문제 정의
CreateVersion은 두 종류의 사이드 이펙트를 가집니다.
| 사이드 이펙트 | 특성 |
|---|---|
| DB INSERT | versions 테이블에 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 주석):
- GCS 작업이 idempotent: hash가 파일의 content-addressed key(SHA-256 기반)이라 같은 hash로 두 번 GCS Copy를 해도 byte-identical overwrite — 결과가 동일
- 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 가 다른 패턴보다 깔끔합니다.
참고 문서
- Postgres 공식, Partial Indexes — partial index 의 정의·제약·옵티마이저 영향
- Postgres 공식, Concurrency Control — Read Committed — 본 패턴이 의존하는 동시성 모델
- Martin Fowler, Unit of Work — UoW 원전 정의 (본문 §UoW 절의 비교 대상)
- Why GCS Copy is byte-identical for same source/dest hash — content-addressed key 가정의 근거
- Pat Helland, Life beyond Distributed Transactions — 외부 effect 와 transactional boundary 분리에 대한 고전 정리
이 글의 개정 이력
- 2026-05-16 — 최초 발행 (Stage 1~3 진화 정리, UoW 관점).
- 2026-05-XX (확장판) — 6 개월 운영 데이터, 실패 모드 5 가지, 의사결정 매트릭스, 참고 문서 추가. AdSense W6 콘텐츠 정리 사이클의 long-form L1.
답글 남기기