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

발행: (2025년 11월 30일 오후 03:53 GMT+9)
11 분 소요
원문: 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

관련 글

더 보기 »

Day 1276 : 커리어 클라이밍

토요일 역으로 가기 전에, 현재 진행 중인 사이드 프로젝트에서 코딩을 했어요. 꽤 좋은 진전을 이루었고, 이제 나갈 시간이었어요. Made i...

Stateless AI 애플리케이션의 아키텍처

프로젝트는 위험해 보이는 결정으로 시작되었습니다: 백엔드 데이터베이스를 사용하지 않는 것이었습니다. 당시에는 사용자 데이터를 영구 저장할 필요가 없었으며—사용자의 응답을 얻는 것이...

JWT 토큰 검증기 챌린지

개요 2019년에 Django의 세션 관리 프레임워크에 미묘하지만 치명적인 취약점 CVE‑2019‑11358이 존재했습니다. 프레임워크는 적절하게 inv...