올바른 쓰기: Redis, Lua 및 Go를 사용한 원자성 및 멱등성
Source: Dev.to
소개
세상이 단일 함수들—한 번 실행되고, 실패하면 다시 시도할 수 있는 간단한 함수들—로 가득했다면 인생은 쉬웠을 것입니다.
하지만 우리는 분산 시스템을 구축하고, 인생은 그렇게 쉽지 않습니다(하지만 재미있습니다).
전형적인 시나리오: 사용자가 “지금 결제” 버튼을 클릭합니다. 백엔드는 다음을 수행해야 합니다:
- 사용자 지갑에서 잔액 차감 (Postgres).
- 이벤트 발행을 통해 확인 이메일을 보내고 창고에 알림을 전송 (Redis/Kafka).
데이터베이스 커밋은 되었지만 네트워크 오류로 이벤트가 발행되지 않으면, 사용자는 차감되지만 이메일은 전송되지 않고 창고는 물품을 출고하지 못합니다. 프로세스를 되돌리면 반대 문제가 발생합니다. 이것이 **이중 쓰기 문제(Dual Write Problem)**이며, 마이크로서비스에서 데이터 무결성을 조용히 파괴하는 원인입니다.
이를 해결하기 위해 두 가지 아키텍처 기둥이 필요합니다:
- 원자성(Atomicity) – 데이터베이스 쓰기와 이벤트 발행이 함께 일어나도록 보장.
- 멱등성(Idempotency) – 반복 클릭이 하나의 청구만 발생하도록 보장.
우리는 Go, Redis, Postgres를 사용해 Transactional Outbox Pattern을 원자성에 적용하고, Redis를 이용해 멱등성을 구현하는 견고한 백엔드를 구축합니다.
전체 실행 가능한 소스 코드는 GitHub에 있습니다:
HERE
Transactional Outbox Pattern
요청 처리 중에 데이터베이스와 메시지 브로커를 별개의 엔티티로 취급하는 것을 멈춰야 합니다. Redis는 Postgres 트랜잭션에 참여할 수 없으므로, 메시지 큐를 데이터베이스 쪽으로 가져옵니다.
Transactional Outbox Pattern: Redis에 직접 발행하는 대신, 비즈니스 데이터를 수정하는 동일한 트랜잭션 안에서 로컬 SQL 테이블(outbox)에 메시지를 삽입합니다. 이후 백그라운드 워커가 이 메시지를 Redis에 전달합니다.
아키텍처 개요

데이터베이스 스키마
-- Business Table: stores the actual state
CREATE TABLE orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
amount INT NOT NULL,
status VARCHAR(50) DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW()
);
-- Outbox Table: stores the intent to publish
CREATE TABLE outbox (
id UUID PRIMARY KEY,
event_type VARCHAR(255) NOT NULL, -- e.g., "order.created"
payload JSONB NOT NULL, -- data to publish
status VARCHAR(50) DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW()
);
Go 구현 – CreateOrder
type Order struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Amount int `json:"amount"`
}
func CreateOrder(ctx context.Context, db *sql.DB, order Order) error {
// 1. Start the transaction (atomicity boundary)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// 2. Insert the business record
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, user_id, amount) VALUES ($1, $2, $3)`,
order.ID, order.UserID, order.Amount)
if err != nil {
return fmt.Errorf("failed to insert order: %w", err)
}
// 3. Insert the outbox record
payload, err := json.Marshal(order)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox (id, event_type, payload) VALUES ($1, $2, $3)`,
uuid.New(), "order.created", payload)
if err != nil {
return fmt.Errorf("failed to insert outbox event: %w", err)
}
// 4. Commit the transaction
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
이 함수는 오직 Postgres만을 건드립니다. 주문과 outbox 레코드가 전부 혹은 전무(Atomicity)로 함께 영속됩니다.
가드: 멱등성 구현
분산 시스템에서는 네트워크 장애로 클라이언트가 요청을 재시도하게 되며, 이때 중복 처리가 발생할 위험이 있습니다. 우리는 멱등성 키(예: Idempotency-Key 헤더에 담긴 UUID)를 사용하고, 빠른 원자 연산을 제공하는 Redis에 저장합니다.
멱등성 키의 세 가지 상태
| 상태 | 의미 |
|---|---|
| Null (New) | 아직 보지 못한 키 – 잠금을 걸고 진행합니다. |
| PENDING | 다른 요청이 이 키를 처리 중 – 409 Conflict 응답을 반환합니다. |
| JSON Payload | 요청이 완료됨 – 캐시된 응답을 반환합니다. |
왜 Lua인가?
단순한 체크‑후‑셋 방식은 레이스 컨디션을 일으킬 수 있습니다:
// BAD CODE: DO NOT USE
if redis.Get(key) == "" {
redis.Set(key, "PENDING")
// ... process ...
}
두 개의 동시 요청이 모두 키가 비어 있다고 판단하고 진행하면 중복 청구가 발생합니다. Redis Lua 스크립트는 원자적으로 실행돼 이러한 레이스를 방지합니다.
Lua 스크립트 (script.lua)
-- script.lua
local key = KEYS[1]
local pending_status = ARGV[1] -- "PENDING"
local ttl = ARGV[2] -- expiration in seconds
-- 1. Check if key exists
local value = redis.call("GET", key)
-- 2. If it exists, return the value (could be "PENDING" or final JSON)
if value then
return value
end
-- 3. If not, lock it with "PENDING" and a TTL
redis.call("SET", key, pending_status, "EX", ttl)
return nil
TTL은 “데드맨 스위치” 역할을 합니다: 서버가 PENDING 상태에서 크래시가 나면 키가 자동으로 만료돼 클라이언트가 다시 시도할 수 있습니다.
멱등성 가드가 포함된 HTTP 핸들러
func HandleCreateOrder(w http.ResponseWriter, r *http.Request, db *sql.DB, rdb *redis.Client) {
// 1. Get Idempotency Key
idempotencyKey := r.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
http.Error(w, "Missing Idempotency-Key", http.StatusBadRequest)
return
}
// 2. Execute Lua guard
ctx := r.Context()
val, err := rdb.Eval(ctx, luaScript, []string{idempotencyKey}, "PENDING", 60).Result()
if err != nil && err != redis.Nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// CASE A: Another request is processing
if val == "PENDING" {
http.Error(w, "Request is processing, please retry shortly", http.StatusConflict)
return
}
// CASE B: Request already completed
if val != nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(fmt.Sprintf("%v", val)))
return
}
// CASE C: New request – lock acquired
orderID := uuid.New()
order := Order{ID: orderID, UserID: uuid.New(), Amount: 1000}
// Call the atomic transaction
if err := CreateOrder(ctx, db, order); err != nil {
// Remove lock on failure
rdb.Del(ctx, idempotencyKey)
http.Error(w, "Transaction failed", http.StatusInternalServerError)
return
}
// 3. Store final result in Redis (cached for 24h)
response := fmt.Sprintf(`{"status":"success","order_id":"%s"}`, orderID)
rdb.Set(ctx, idempotencyKey, response, 24*time.Hour)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}
이제 우리는 다음을 확보했습니다:
- 데이터베이스는 SQL 트랜잭션으로 보호됩니다(원자성).
- API는 Redis Lua 스크립트로 보호됩니다(멱등성).
백그라운드 워커
CreateOrder 함수는 outbox 테이블에 이벤트를 남깁니다. 백그라운드 워커(“Courier”)가 이 테이블을 폴링해 Redis에 메시지를 발행합니다.
워커 확장성
여러 서비스 복제본이 실행될 경우, 모두 같은 대기 중인 이벤트를 가져올 수 있습니다. 중복 처리를 방지하기 위해 PostgreSQL의 FOR UPDATE SKIP LOCKED 절을 사용해 워커마다 행을 잠그고, 이미 다른 워커가 잠근 행은 건너뛰게 합니다.
워커 구현
func StartOutboxWorker(ctx context.Context, db *sql.DB, rdb *redis.Client) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
processBatch(ctx, db, rdb)
}
}
}
func processBatch(ctx context.Context, db *sql.DB, rdb *redis.Client) {
// 1. Start a transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Printf("Worker failed to begin tx: %v", err)
return
}
defer tx.Rollback()
// 2. Fetch pending events with SKIP LOCKED
rows, err := tx.QueryContext(ctx, `
SELECT id, event_type, payload
FROM outbox
WHERE status = 'PENDING'
ORDER BY created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED
`)
if err != nil {
log.Printf("Worker query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var eventType string
var payload []byte
if err := rows.Scan(&id, &eventType, &payload); err != nil {
continue
}
// 3. Publish to Redis
if err := rdb.Publish(ctx, eventType, payload).Err(); err != nil {
log.Printf("Failed to publish event %s: %v", id, err)
return // transaction will roll back, event stays PENDING
}
// 4. Mark as processed
_, err = tx.ExecContext(ctx,
"UPDATE outbox SET status = 'PROCESSED' WHERE id = $1", id)
if err != nil {
return
}
}
// 5. Commit the batch
if err := tx.Commit(); err != nil {
log.Printf("Worker failed to commit: %v", err)
}
}
견고성 보장
- At‑Least‑Once Delivery – 발행에 실패하면 트랜잭션이 롤백돼 이벤트가
PENDING상태로 남아 나중에 재시도됩니다. - 동시성 안전성 –
SKIP LOCKED덕분에 여러 워커가 동시에 실행돼도 같은 이벤트를 두 번 처리하지 않습니다.
결론
Transactional Outbox Pattern과 멱등성(Redis Lua 스크립트 활용)을 결합함으로써, 분산 환경에서도 단일 함수의 단순함을 되찾을 수 있습니다.
- 데이터베이스 쓰기와 이벤트 발행이 원자적으로 이루어집니다.
- 클라이언트의 반복 요청이 안전하게 중복 제거됩니다.
- 백그라운드 워커가 outbox 이벤트를 신뢰성 있게 전달하며, 필요에 따라 손쉽게 확장됩니다.
신뢰성은 “문제가 안 일어나길 바라는 것”이 아니라, “문제가 발생해도 시스템이 우아하게 대처하도록 설계하는 것”입니다.