이커머스 게이미피케이션: 스트릭·챌린지로 충성도를 높이는 설계 전략

이커머스

게이미피케이션충성도 프로그램이커머스 UX리텐션사용자 참여

이 글은 누구를 위한 것인가

  • 재방문율과 구매 빈도를 높이고 싶은 이커머스 PM
  • 포인트 적립 외에 새로운 충성도 메커니즘을 찾는 팀
  • 게이미피케이션 시스템의 DB 설계와 API 구현이 필요한 개발자

들어가며

포인트 적립만으로는 사용자의 습관을 만들기 어렵다. 듀오링고는 스트릭(연속 학습)으로 수억 명의 일일 재방문을 만들었다. 스타벅스는 별 적립 챌린지로 구매 빈도를 높였다. 이커머스에도 같은 원리를 적용할 수 있다.

게이미피케이션은 단순한 "게임 요소 추가"가 아니다. 사용자가 성취감을 느끼고, 진행 상황을 추적하며, 목표를 향해 나아가는 경험을 설계하는 것이다.

이 글은 bluefoxdev.kr의 이커머스 리텐션 전략 가이드 를 참고하고, 게이미피케이션 실전 설계 관점에서 확장하여 작성했습니다.


1. 게이미피케이션 메커니즘 선택

[이커머스 게이미피케이션 요소]

진행도 기반:
  - 스트릭: 연속 구매/방문 (ex: 7일 연속 방문 보상)
  - 레벨: 총 구매금액 기반 등급 (Bronze → Diamond)
  - 미션 진행바: 이번 달 목표까지 N원 남음

완료 기반:
  - 챌린지: 특정 기간 내 목표 달성 (30일 챌린지)
  - 배지/업적: 첫 구매, 리뷰 작성, 특정 카테고리 구매
  - 컬렉션: 시리즈 상품 모두 구매

경쟁 기반:
  - 리더보드: 이번 달 구매 순위 (1~100위)
  - 친구 랭킹: 지인 대비 순위

사회적:
  - 공유 보상: 리뷰/후기 작성 시 포인트
  - 추천: 친구 초대 시 양방향 보상

2. DB 스키마 설계

-- 레벨 시스템
CREATE TABLE loyalty_tiers (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,    -- Bronze, Silver, Gold, Platinum, Diamond
    min_amount DECIMAL(15,2) NOT NULL,  -- 최소 누적 구매금액
    max_amount DECIMAL(15,2),
    benefits JSONB NOT NULL,
    -- {"point_multiplier": 1.5, "free_shipping": true, "early_access": false}
    badge_image_url VARCHAR(500),
    sort_order INT DEFAULT 0
);

INSERT INTO loyalty_tiers (name, min_amount, max_amount, benefits, sort_order) VALUES
('브론즈',  0,       99999,   '{"point_multiplier": 1.0, "free_shipping_threshold": 50000}', 1),
('실버',   100000,  299999,   '{"point_multiplier": 1.2, "free_shipping_threshold": 30000}', 2),
('골드',   300000,  699999,   '{"point_multiplier": 1.5, "free_shipping_threshold": 0}',     3),
('플래티넘', 700000, 1499999, '{"point_multiplier": 2.0, "free_shipping_threshold": 0, "birthday_bonus": 5000}', 4),
('다이아몬드', 1500000, NULL,  '{"point_multiplier": 3.0, "free_shipping_threshold": 0, "vip_support": true}', 5);

-- 배지/업적
CREATE TABLE badges (
    id SERIAL PRIMARY KEY,
    code VARCHAR(100) UNIQUE NOT NULL,
    name VARCHAR(200) NOT NULL,
    description TEXT,
    icon_url VARCHAR(500),
    condition_type VARCHAR(50),  -- first_purchase, review_count, streak, category, amount
    condition_value JSONB,       -- {"count": 5} 또는 {"category": "shoes", "min_amount": 100000}
    points_reward INT DEFAULT 0,
    is_hidden BOOLEAN DEFAULT false,  -- 달성 전 숨김 배지
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 사용자 배지 획득
CREATE TABLE user_badges (
    id SERIAL PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(id),
    badge_id INT NOT NULL REFERENCES badges(id),
    earned_at TIMESTAMPTZ DEFAULT NOW(),
    order_id UUID REFERENCES orders(id),
    UNIQUE(user_id, badge_id)
);

-- 스트릭 추적
CREATE TABLE user_streaks (
    id SERIAL PRIMARY KEY,
    user_id UUID UNIQUE NOT NULL REFERENCES users(id),
    streak_type VARCHAR(50) NOT NULL,  -- daily_visit, purchase
    current_count INT DEFAULT 0,
    max_count INT DEFAULT 0,
    last_action_date DATE NOT NULL,
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 챌린지 정의
CREATE TABLE challenges (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    challenge_type VARCHAR(50),  -- purchase_count, purchase_amount, category, streak
    target_value JSONB NOT NULL, -- {"count": 5, "days": 30}
    reward JSONB NOT NULL,       -- {"points": 5000, "badge_code": "30day_warrior"}
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    is_active BOOLEAN DEFAULT true
);

-- 사용자 챌린지 참여 및 진행
CREATE TABLE user_challenges (
    id SERIAL PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(id),
    challenge_id INT NOT NULL REFERENCES challenges(id),
    current_value JSONB NOT NULL DEFAULT '{}', -- {"count": 3}
    completed_at TIMESTAMPTZ,
    reward_claimed_at TIMESTAMPTZ,
    joined_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(user_id, challenge_id)
);

3. 스트릭 처리 로직

from datetime import date, timedelta
from sqlalchemy.orm import Session

class StreakService:
    def __init__(self, db: Session):
        self.db = db
    
    def update_streak(self, user_id: str, action_date: date = None) -> dict:
        """방문 또는 구매 시 스트릭 업데이트"""
        today = action_date or date.today()
        
        streak = self.db.query(UserStreak).filter_by(
            user_id=user_id, streak_type='daily_visit'
        ).first()
        
        if not streak:
            # 첫 기록
            streak = UserStreak(
                user_id=user_id,
                streak_type='daily_visit',
                current_count=1,
                max_count=1,
                last_action_date=today,
            )
            self.db.add(streak)
            return {'streak': 1, 'is_new_record': True}
        
        days_diff = (today - streak.last_action_date).days
        
        if days_diff == 0:
            # 오늘 이미 기록됨
            return {'streak': streak.current_count, 'already_recorded': True}
        
        elif days_diff == 1:
            # 연속! 스트릭 증가
            streak.current_count += 1
            streak.last_action_date = today
            streak.max_count = max(streak.max_count, streak.current_count)
            
            # 스트릭 마일스톤 보상
            rewards = self._check_streak_milestone(user_id, streak.current_count)
            
            self.db.commit()
            return {
                'streak': streak.current_count,
                'is_record': streak.current_count == streak.max_count,
                'rewards': rewards,
            }
        
        else:
            # 스트릭 끊김
            old_streak = streak.current_count
            streak.current_count = 1
            streak.last_action_date = today
            
            self.db.commit()
            return {
                'streak': 1,
                'streak_broken': True,
                'previous_streak': old_streak,
            }
    
    def _check_streak_milestone(self, user_id: str, current_streak: int) -> list:
        """스트릭 마일스톤 달성 시 보상"""
        milestones = {7: 500, 30: 3000, 100: 10000}  # 스트릭: 포인트
        rewards = []
        
        if current_streak in milestones:
            points = milestones[current_streak]
            award_points(user_id, points, reason=f'{current_streak}일 스트릭')
            rewards.append({
                'type': 'points',
                'amount': points,
                'reason': f'{current_streak}일 연속 방문 달성!'
            })
        
        return rewards

4. 챌린지 API

from fastapi import APIRouter, Depends

router = APIRouter(prefix="/challenges")

@router.get("/active")
async def get_active_challenges(user_id: str = Depends(get_current_user)):
    """현재 참여 가능한 챌린지 목록"""
    today = date.today()
    
    challenges = db.query(Challenge).filter(
        Challenge.is_active == True,
        Challenge.start_date <= today,
        Challenge.end_date >= today,
    ).all()
    
    result = []
    for ch in challenges:
        user_ch = db.query(UserChallenge).filter_by(
            user_id=user_id, challenge_id=ch.id
        ).first()
        
        result.append({
            'id': ch.id,
            'title': ch.title,
            'description': ch.description,
            'end_date': ch.end_date,
            'target': ch.target_value,
            'reward': ch.reward,
            'progress': user_ch.current_value if user_ch else None,
            'completed': user_ch.completed_at is not None if user_ch else False,
            'joined': user_ch is not None,
        })
    
    return result

@router.post("/{challenge_id}/join")
async def join_challenge(challenge_id: int, user_id: str = Depends(get_current_user)):
    """챌린지 참여"""
    existing = db.query(UserChallenge).filter_by(
        user_id=user_id, challenge_id=challenge_id
    ).first()
    
    if existing:
        raise HTTPException(status_code=400, detail="이미 참여 중인 챌린지입니다")
    
    user_challenge = UserChallenge(
        user_id=user_id,
        challenge_id=challenge_id,
        current_value={'count': 0, 'amount': 0},
    )
    db.add(user_challenge)
    db.commit()
    
    return {'status': 'joined'}

5. 사용자 대시보드 데이터

def get_gamification_dashboard(user_id: str) -> dict:
    """게이미피케이션 현황 종합 조회"""
    
    # 현재 등급
    user = get_user_with_tier(user_id)
    
    # 다음 등급까지
    next_tier = get_next_tier(user.total_amount)
    remaining_to_next = max(0, next_tier.min_amount - user.total_amount) if next_tier else 0
    
    # 스트릭
    streak = get_streak(user_id)
    
    # 활성 챌린지 진행률
    active_challenges = get_user_active_challenges(user_id)
    
    # 최근 획득 배지
    recent_badges = get_recent_badges(user_id, limit=5)
    
    return {
        'tier': {
            'current': user.tier.name,
            'total_amount': user.total_amount,
            'next_tier': next_tier.name if next_tier else None,
            'remaining_to_next': remaining_to_next,
            'progress_pct': calculate_tier_progress(user.total_amount, user.tier, next_tier),
        },
        'streak': {
            'current': streak.current_count,
            'max': streak.max_count,
            'next_milestone': get_next_milestone(streak.current_count),
        },
        'challenges': [
            {
                'title': ch.challenge.title,
                'progress': ch.current_value.get('count', 0),
                'target': ch.challenge.target_value.get('count', 0),
                'days_left': (ch.challenge.end_date - date.today()).days,
            }
            for ch in active_challenges
        ],
        'badges': recent_badges,
    }

마무리

이커머스 게이미피케이션이 효과적이려면 진짜 가치가 있어야 한다. 스트릭을 유지하면 실제로 혜택이 커지고, 챌린지를 달성하면 의미 있는 보상이 있어야 한다. 형식적인 배지 수집은 오히려 역효과다.

시작은 단순하게: 스트릭 하나 + 레벨 시스템만으로도 충분하다. 데이터를 보면서 어떤 요소가 재방문과 구매에 실제로 연결되는지 측정하며 확장하라.