올바른 쓰기: Redis, Lua 및 Go를 사용한 원자성 및 멱등성

발행: (2025년 11월 30일 오후 03:53 GMT+9)
11 min read
원문: Dev.to

Source: Dev.to

소개

세상이 단일 함수들—한 번 실행되고, 실패하면 다시 시도할 수 있는 간단한 함수들—로 가득했다면 인생은 쉬웠을 것입니다.
하지만 우리는 분산 시스템을 구축하고, 인생은 그렇게 쉽지 않습니다(하지만 재미있습니다).

전형적인 시나리오: 사용자가 “지금 결제” 버튼을 클릭합니다. 백엔드는 다음을 수행해야 합니다:

  1. 사용자 지갑에서 잔액 차감 (Postgres).
  2. 이벤트 발행을 통해 확인 이메일을 보내고 창고에 알림을 전송 (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에 전달합니다.

아키텍처 개요

HLD

데이터베이스 스키마

-- 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 이벤트를 신뢰성 있게 전달하며, 필요에 따라 손쉽게 확장됩니다.

신뢰성은 “문제가 안 일어나길 바라는 것”이 아니라, “문제가 발생해도 시스템이 우아하게 대처하도록 설계하는 것”입니다.

Back to Blog

관련 글

더 보기 »