이 글은 누구를 위한 것인가
- 포인트 만료 배치가 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를 마비시킬 수 있으므로, 반드시 작은 배치로 나눠서 처리해야 한다. 포인트 = 이연 수익이라는 회계 원칙을 처음부터 반영하면 감사 대비도 된다.