포인트 만료·회계 정합성: 충성도 포인트 시스템의 숨겨진 복잡성

이커머스

포인트 시스템충성도 프로그램회계 처리이연 수익배치 처리

이 글은 누구를 위한 것인가

  • 포인트 만료 배치가 DB를 죽이는 경험을 한 개발자
  • 포인트를 회계 장부에 어떻게 기재해야 하는지 모르는 팀
  • 포인트 차감 순서(만료 임박 순)를 구현해야 하는 엔지니어

들어가며

포인트는 단순해 보이지만 회계적으로는 "이연 수익(Deferred Revenue)"이다. 고객에게 10,000포인트를 줬다면, 이 포인트가 사용될 때까지 부채로 인식된다. 만료되면 수익으로 전환된다. 이걸 놓치면 재무제표가 틀린다.

이 글은 bluefoxdev.kr의 이커머스 포인트 설계 가이드 를 참고하여 작성했습니다.


1. 포인트 원장 설계

-- 포인트 원장 (이벤트 소싱 방식)
CREATE TABLE point_ledger (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id),
    transaction_type VARCHAR(30) NOT NULL,
    -- 'earned', 'used', 'expired', 'refunded', 'adjusted'
    amount INT NOT NULL,          -- 양수: 적립, 음수: 차감
    balance_after INT NOT NULL,   -- 트랜잭션 후 잔액
    reference_id VARCHAR(100),    -- 주문 ID, 이벤트 ID 등
    earned_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ,       -- 적립 시 만료일
    batch_id UUID,                -- 만료 배치 ID
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 포인트 버킷 (만료일별 잔액 추적)
CREATE TABLE point_buckets (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    earned_amount INT NOT NULL,
    remaining_amount INT NOT NULL,  -- 남은 포인트
    earned_at TIMESTAMPTZ DEFAULT NOW(),
    expires_at TIMESTAMPTZ NOT NULL,
    source VARCHAR(50),             -- 'purchase', 'event', 'manual'
    status VARCHAR(20) DEFAULT 'active'  -- active, used, expired
);

2. FIFO 포인트 차감

async def use_points(user_id: str, amount: int, order_id: str) -> bool:
    """
    포인트 사용: 만료일 임박 순서로 차감 (FIFO)
    """
    
    async with db.transaction():
        # 사용 가능한 포인트 버킷 (만료일 순)
        buckets = await db.fetch("""
            SELECT id, remaining_amount, expires_at
            FROM point_buckets
            WHERE user_id = $1
              AND status = 'active'
              AND remaining_amount > 0
              AND (expires_at IS NULL OR expires_at > NOW())
            ORDER BY expires_at ASC NULLS LAST  -- 만료 임박 순
            FOR UPDATE SKIP LOCKED
        """, user_id)
        
        total_available = sum(b["remaining_amount"] for b in buckets)
        
        if total_available < amount:
            return False
        
        remaining_to_deduct = amount
        
        for bucket in buckets:
            if remaining_to_deduct <= 0:
                break
            
            deduct_from_bucket = min(bucket["remaining_amount"], remaining_to_deduct)
            
            # 버킷에서 차감
            await db.execute("""
                UPDATE point_buckets
                SET remaining_amount = remaining_amount - $1,
                    status = CASE WHEN remaining_amount - $1 = 0 THEN 'used' ELSE 'active' END
                WHERE id = $2
            """, deduct_from_bucket, bucket["id"])
            
            remaining_to_deduct -= deduct_from_bucket
        
        # 원장에 차감 기록
        new_balance = await get_point_balance(user_id) - amount
        await db.execute("""
            INSERT INTO point_ledger (user_id, transaction_type, amount, balance_after, reference_id)
            VALUES ($1, 'used', $2, $3, $4)
        """, user_id, -amount, new_balance, order_id)
    
    return True

3. 만료 배치 처리

import asyncio
from datetime import datetime

async def expire_points_batch(batch_size: int = 1000):
    """
    매일 자정 실행: 만료된 포인트 처리
    대규모 배치를 작은 단위로 분할
    """
    
    total_expired = 0
    offset = 0
    
    while True:
        # 배치 단위로 처리 (DB 과부하 방지)
        expired_buckets = await db.fetch("""
            SELECT id, user_id, remaining_amount
            FROM point_buckets
            WHERE status = 'active'
              AND remaining_amount > 0
              AND expires_at < NOW()
            ORDER BY expires_at
            LIMIT $1 OFFSET $2
        """, batch_size, offset)
        
        if not expired_buckets:
            break
        
        for bucket in expired_buckets:
            async with db.transaction():
                # 버킷 만료 처리
                await db.execute("""
                    UPDATE point_buckets
                    SET status = 'expired', remaining_amount = 0
                    WHERE id = $1
                """, bucket["id"])
                
                # 원장 기록 (회계용)
                new_balance = await get_point_balance(bucket["user_id"])
                await db.execute("""
                    INSERT INTO point_ledger 
                    (user_id, transaction_type, amount, balance_after, reference_id)
                    VALUES ($1, 'expired', $2, $3, $4)
                """, bucket["user_id"], -bucket["remaining_amount"], 
                    new_balance - bucket["remaining_amount"], bucket["id"])
                
                total_expired += bucket["remaining_amount"]
        
        offset += batch_size
        await asyncio.sleep(0.1)  # DB 부하 완화
    
    # 회계 팀 리포트
    await save_expiration_report({
        "date": datetime.now().date().isoformat(),
        "total_expired_points": total_expired,
        "revenue_recognized": total_expired * 1.0  # 1포인트 = 1원 기준
    })
    
    print(f"만료 처리 완료: {total_expired:,} 포인트")

마무리

포인트 시스템은 작은 것 같지만 FIFO 차감, 버킷 관리, 회계 처리가 모두 맞아야 한다. 특히 만료 배치는 사용자 수가 많아지면 DB를 마비시킬 수 있으므로, 반드시 작은 배치로 나눠서 처리해야 한다. 포인트 = 이연 수익이라는 회계 원칙을 처음부터 반영하면 감사 대비도 된다.