이 글은 누구를 위한 것인가
- 재방문율과 구매 빈도를 높이고 싶은 이커머스 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,
}
마무리
이커머스 게이미피케이션이 효과적이려면 진짜 가치가 있어야 한다. 스트릭을 유지하면 실제로 혜택이 커지고, 챌린지를 달성하면 의미 있는 보상이 있어야 한다. 형식적인 배지 수집은 오히려 역효과다.
시작은 단순하게: 스트릭 하나 + 레벨 시스템만으로도 충분하다. 데이터를 보면서 어떤 요소가 재방문과 구매에 실제로 연결되는지 측정하며 확장하라.