커뮤니티 포인트/레벨 시스템 구축기
커뮤니 포인트/레벨 시스템 구축기
왜 만들었나
제가 운영하는 커뮤니티(fullstackfamily.com)에는 글도 올라오고, 댓글도 달리고, Q&A도 돌아갑니다. 그런데 한 가지 아쉬운 점이 있었습니다.
"열심히 활동해도 티가 안 난다."
매일 좋은 글을 써주는 분과, 오늘 처음 가입한 분이 화면에서 동일하게 보입니다. 닉네임 하나만 있을 뿐.
활발한 기여자 입장에서는 "나 여기서 꽤 오래 활동했는데..." 하는 아쉬움이 있을 수밖에 없습니다.
그래서 경험치와 레벨 시스템을 붙이기로 했습니다.
어떤 구조로 만들었나
큰 그림은 이렇습니다.
사용자 활동 → 경험치 적립 → 레벨 계산 → 뱃지 표시
글을 쓰면 +50 EXP, 댓글 달면 +10 EXP, 출석하면 +20 EXP. 이런 식으로 경험치가 쌓이고, 일정량이 모이면 레벨이 올라갑니다. 레벨은 1부터 99까지.
닉네임 옆에 작은 뱃지가 붙는데, 레벨 구간에 따라 색이 달라집니다.
[Lv.3] 코딩초보 ← 회색 뱃지 [Lv.15] 개발자지망생 ← 초록 뱃지 [Lv.35] 자바마스터 ← 파란 뱃지 [Lv.55] 풀스택장인 ← 보라빛 글로우 [Lv.75] 아키텍트 ← 주황빛 글로우 [Lv.99] 전설의개발자 ← 금빛 그라데이션
글만 봐도 "아, 이 분은 오래 활동하신 분이구나" 하는 게 바로 보입니다.
경험치 공식: 초반엔 빠르게, 후반엔 도전적으로
레벨 시스템에서 가장 중요한 건 성장 곡선입니다.
처음 가입한 사람이 글 하나 쓰고 바로 레벨 2가 되면 "오, 뭔가 올랐다!" 하는 재미를 느낍니다. 반면 레벨 70인 사람이 글 하나 쓴다고 레벨이 오르면 의미가 없겠죠.
그래서 이런 곡선을 적용했습니다.
다음 레벨까지 필요 경험치 = 50 × 현재레벨^1.8
실제로 계산하면 이렇게 됩니다.
레벨 1→2 : 50 EXP (글 1개면 끝) 레벨 5→6 : 550 EXP (하루 열심히 하면) 레벨 10→11: 1,580 EXP (1주일 정도) 레벨 20→21: 5,490 EXP (한 달) 레벨 50→51: 27,400 EXP (반년~1년) 레벨 99(만렙): 누적 약 290만 EXP
초반에는 활동 몇 번이면 쑥쑥 오르지만, 뒤로 갈수록 정말 꾸준한 사람만 도달할 수 있습니다. RPG 게임의 레벨 시스템과 비슷한 감각입니다.
레벨 계산은 어떻게?
경험치가 쌓일 때마다 "이 경험치면 레벨 몇이지?"를 계산해야 하는데, 매번 1레벨부터 반복하면 비효율적입니다.
서버 시작 시점에 각 레벨의 누적 경험치 테이블을 미리 만들어 두고, 이진 탐색으로 레벨을 결정합니다.
누적 경험치 테이블 (서버 시작 시 1회 계산) ┌──────┬────────────────┐ │ 레벨 │ 누적 필요 EXP │ ├──────┼────────────────┤ │ 1 │ 0 │ │ 2 │ 50 │ │ 3 │ 220 │ │ 4 │ 540 │ │ 5 │ 1,020 │ │ ... │ ... │ │ 99 │ 2,905,000 │ └──────┴────────────────┘ 경험치 12,500이면? → 이진 탐색 → 레벨 15에 해당 → O(log 99) ≈ 7번의 비교로 끝
반복문 99번 vs 이진 탐색 7번. 횟수 자체는 둘 다 빠르지만, 이 계산이 글 쓸 때마다, 댓글 달 때마다, 추천 누를 때마다 호출됩니다. 습관적으로라도 효율적인 쪽을 선택하는 게 맞습니다.
경험치를 어떤 활동에 줄 것인가
이 부분이 기술적인 구현보다 오히려 더 고민이 많았습니다.
┌─────────────────────────────────────────────┐ │ 경험치 테이블 │ ├──────────────────┬──────────┬───────────────┤ │ 활동 │ 경험치 │ 일일 한도 │ ├──────────────────┼──────────┼───────────────┤ │ 글쓰기 │ +50 │ 5회 │ │ 댓글 작성 │ +10 │ 20회 │ │ 출석 체크 │ +20 │ 1회 │ │ 연속 출석 보너스 │ +5×일수 │ 최대 +35 │ │ 내 글에 추천 받기 │ +5 │ 무제한 │ │ 추천 누르기 │ +1 │ 30회 │ │ Q&A 답변 채택됨 │ +100 │ 무제한 │ │ 첫 글쓰기 보너스 │ +100 │ 1회성 │ │ 첫 댓글 보너스 │ +30 │ 1회성 │ └──────────────────┴──────────┴───────────────┘
설계할 때 고려한 점들:
글쓰기 > 댓글 > 추천. 콘텐츠를 생산하는 행위에 가장 높은 보상을 줍니다. 추천은 상대적으로 쉬운 행위이니 적게.
답변 채택 +100은 의도적으로 높게 잡았습니다. Q&A 게시판에서 질문에 답변해주고, 질문자가 "이 답변이 도움 됐어요" 하고 채택하면 +100. 커뮤니티에서 가장 가치 있는 행동이라고 생각합니다.
일일 한도는 어뷰징 방지의 핵심입니다. 한도 없으면 봇으로 글 100개 도배해서 레벨 올리는 사람이 반드시 나타납니다. 글쓰기 5회, 댓글 20회, 추천 30회. 이 정도면 정상적인 활동에는 지장 없으면서 도배를 막을 수 있습니다.
동기 vs 비동기: 경험치 지급의 두 가지 길
여기서부터 기술적인 이야기입니다.
경험치를 지급하는 상황이 크게 두 가지로 나뉩니다.
[직접 API] 글쓰기, 댓글, 출석, 답변 채택 → 내가 직접 한 행동 → 그 즉시 "결과"를 보고 싶다 → "+50 EXP!" 토스트가 바로 떠야 한다 [간접 이벤트] 누군가 내 글에 추천을 눌렀다 → 내가 한 행동이 아니다 → 나는 그 순간 다른 페이지를 보고 있을 수도 있다 → 굳이 실시간으로 알릴 필요 없다
그래서 하이브리드 방식을 택했습니다.
직접 API → 동기 처리 + 즉시 피드백
글을 쓰면 같은 트랜잭션 안에서 경험치를 바로 적립하고, API 응답에 경험치 정보를 함께 실어 보냅니다.
사용자: "글 작성" 클릭 │ ▼ ┌─────────────────────────────┐ │ 하나의 트랜잭션 안에서: │ │ │ │ 1. 글 저장 (DB INSERT) │ │ 2. 일일 한도 확인 │ │ 3. 경험치 +50 적립 │ │ 4. 레벨 재계산 │ │ │ │ → 응답: { postId: 42, │ │ expGrantInfo: { │ │ expGained: 50, │ │ levelUp: true, │ │ newLevel: 6 }} │ └─────────────────────────────┘ │ ▼ 프론트엔드: 1. 에메랄드색 토스트 "+50 EXP!" (화면 상단, 2.5초 후 자동 사라짐) 2. 레벨업 시 0.8초 후 축하 모달 "레벨 6 달성!"
API 응답에 expGrantInfo가 같이 오기 때문에, 프론트엔드는 별도의 폴링이나 웹소켓 없이 받자마자 토스트와 모달을 띄울 수 있습니다. 글쓰기, 댓글, 출석 체크, Q&A 답변 발행 모두 같은 패턴입니다.
처음에는 백엔드에서 경험치를 적립하면서도 프론트엔드에 결과를 전달하지 않았습니다. 경험치는 쌓이는데 사용자 입장에서는 아무런 피드백이 없었던 거죠. 코드리뷰에서 이 문제가 지적되어, 응답 DTO에 expGrantInfo 필드를 추가하고 프론트엔드의 알림 컨텍스트를 실제 활동 경로에 연결하는 작업을 추가로 진행했습니다.
간접 이벤트 → 비동기 처리 (전용 스레드 풀)
누군가 내 글에 추천을 누르면? 추천 API의 응답은 추천을 누른 사람에게 돌아가지, 글 작성자에게 돌아가지 않습니다. 그래서 비동기로 처리합니다.
추천을 누른 사람의 API 요청 │ ▼ ┌─────────────────────────────────┐ │ 추천 트랜잭션 (먼저 완료) │ │ 1. 추천 저장 │ │ 2. 트랜잭션 커밋 │ │ 3. 이벤트 2개 발행 ──┐ │ └───────────────────────┼──────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ [level-exp 스레드 A] [level-exp 스레드 B] 글 작성자에게 +5 EXP 추천 누른 사람에게 +1 EXP
여기서 주목할 부분이 두 가지 있습니다.
첫째, 이벤트를 2개로 분리했습니다. 하나의 이벤트에서 두 사용자(글 작성자 + 추천자)의 경험치를 동시에 건드리면 DB 교착(deadlock)이 발생할 수 있습니다. "한 이벤트 = 한 사용자만 건드린다"는 원칙을 세우니 교착 걱정이 깔끔하게 사라졌습니다.
둘째, 비동기 처리에 **전용 스레드 풀(levelExpExecutor)**을 사용합니다. 처음에는 Spring의 기본 스레드 풀을 쓰고 있었는데, 성능 리뷰에서 지적이 들어왔습니다. 추천이 몰리는 시간대에 경험치 처리가 다른 비동기 작업들과 같은 풀에서 경쟁하면 큐가 밀리거나 지연이 전파될 수 있다는 것이었습니다. 전용 풀(core 4, max 16, queue 1000)로 분리하고, 큐가 가득 차면 호출 스레드에서 직접 실행하도록(CallerRunsPolicy) 설정해 유실 없이 처리되도록 했습니다.
어뷰징 방지: 믿으면 안 되는 건 사용자 입력
레벨 시스템에서 간과하기 쉬운 게 어뷰징입니다. "설마 그런 사람이 있겠어?" 하는 순간 반드시 나타납니다.
┌──────────────────────────────────────────────────────┐ │ 어뷰징 방지 체크리스트 │ ├──────────────────────────┬───────────────────────────┤ │ 공격 시나리오 │ 방어 │ ├──────────────────────────┼───────────────────────────┤ │ 글 100개 도배 │ 일일 5회 한도 │ │ 의미없는 "ㅋ" 댓글 반복 │ 공백 제거 후 5자 미만 제외 │ │ 자기 글에 자기가 추천 │ 작성자 == 추천자 체크 │ │ 같은 글에 추천 취소/재추천 │ 동일 대상 하루 1회 제한 │ │ 첫 글 보너스 2번 받기 │ DB 유니크 제약 (grant_key) │ │ 동시에 2탭에서 출석 클릭 │ DB 유니크 제약 (날짜) │ │ 경험치 조작으로 음수 만들기 │ 총 경험치 최소값 0 고정 │ └──────────────────────────┴───────────────────────────┘
특히 1회성 보너스 중복 방지가 흥미로운 부분입니다. "첫 글쓰기 +100 EXP"는 평생 한 번만 받아야 하는데, 두 탭에서 동시에 첫 글을 쓰면 어떻게 될까요?
코드에서 if (이미받았으면) return; 하면 될 것 같지만, 두 요청이 거의 동시에 도착하면 둘 다 "아직 안 받았네?" 하고 지급해버릴 수 있습니다.
그래서 DB에 맡겼습니다.
경험치 이력 테이블: UNIQUE(user_id, grant_key) 첫 글쓰기 보너스: grant_key = "FIRST_POST" 동시에 2개 요청이 오면: 요청 A: INSERT → 성공 요청 B: INSERT → 유니크 키 충돌 → 무시
DB의 유니크 제약이 동시성 문제를 원천 차단합니다. 애플리케이션에서 복잡한 락을 걸 필요가 없습니다.
일일 반복 활동도 같은 패턴입니다. 글쓰기 경험치의 grant_key는 "POST_CREATE:2026-02-07:3" 같은 형태라서, 같은 날 같은 순번은 절대 중복 지급되지 않습니다. 무제한 타입(추천 받기 등)은 타임스탬프 기반 키를 사용해 중복을 방지합니다.
연속 출석: 작지만 강력한 습관 루프
출석 체크는 단순하지만 효과가 큽니다.
DAY 1: +20 EXP (출석) + 5 EXP (1일차 보너스) = 25 EXP DAY 2: +20 EXP + 10 EXP (2일차) = 30 EXP DAY 3: +20 EXP + 15 EXP (3일차) = 35 EXP ... DAY 7: +20 EXP + 35 EXP (7일차, 최대) = 55 EXP DAY 8: +20 EXP + 35 EXP (7일 이후는 유지) = 55 EXP 하루라도 빼먹으면? → 다시 DAY 1부터. (25 EXP)
7일 연속 출석하면 첫날 대비 2배 넘는 경험치를 받습니다. 7일째에 끊기면 아까우니 8일차도 찍게 되고, 그러다 보면 습관이 됩니다.
출석 중복 처리도 신경 썼습니다. 실수로 버튼을 두 번 누르면? "이미 출석했어요" 하고 끝. 에러가 나지도 않고, 경험치도 중복 지급되지 않습니다. DB에 UNIQUE(user_id, 날짜) 제약을 걸어두면 됩니다.
레벨 뱃지: 서버는 숫자만, 색은 프론트가
뱃지 디자인에서 한 가지 결정을 내렸는데, 서버는 레벨 숫자만 보내고 색상이나 효과는 전부 프론트엔드에서 계산하도록 했습니다.
서버 응답: { "authorLevel": 55 } 프론트엔드: 55 → "PURPLE_GLOW" 구간 → 배경: #8b5cf6 (보라) → 테두리: #c4b5fd → 글로우 효과: blur 1.5px → SVG 뱃지 렌더링
이렇게 하면 "뱃지 색을 바꾸고 싶다"거나 "새 구간을 추가하고 싶다" 할 때 서버 배포 없이 프론트엔드만 수정하면 됩니다. 디자인은 자주 바뀌니까요.
7개 구간을 정했는데, 각 구간 안에서도 레벨이 올라갈수록 색이 점점 밝아집니다.
회색 구간 (Lv.1~10): Lv.1 ████████ #4a4a4a (어두운 회색) Lv.5 ████████ #5a5a5a Lv.10 ████████ #6a6a6a (밝은 회색) 파란 구간 (Lv.21~40): Lv.21 ████████ #2563eb (진한 파랑) Lv.30 ████████ #4080f0 Lv.40 ████████ #60a5fa (밝은 파랑) 전설 구간 (Lv.81~99): Lv.81 ████████ 빨강 그라데이션 Lv.90 ████████ 빨강→금 그라데이션 Lv.99 ████████ 금빛 4색 그라데이션 + 글로우
Lv.99 만렙 뱃지는 금색 그라데이션에 글로우 효과를 넣었습니다. 여기까지 도달하려면 정말 오랜 기간 꾸준히 활동해야 하니, 그 정도 시각적 보상은 해줘야 한다고 생각했습니다.
뱃지 컴포넌트는 React.memo로 감싸고, SVG 내부 ID는 React의 useId()로 생성하도록 했습니다. 게시글 목록처럼 한 화면에 뱃지가 수십 개 나올 수 있는데, 매 렌더마다 랜덤 ID를 생성하면 DOM이 불필요하게 다시 그려지거든요.
리더보드: 보이는 경쟁이 동기를 만든다
리더보드를 주간/월간/전체 세 가지로 나눈 것도 의도가 있습니다.
┌──────────────────────────────────────────┐ │ 주간 랭킹 │ 월간 │ 전체 │ ├──────────────────────────────────────────┤ │ 1위 풀스택지망생 Lv.35 +850 EXP │ │ 2위 자바코딩중 Lv.28 +720 EXP │ │ 3위 리액트러버 Lv.22 +680 EXP │ │ 4위 백엔드초보 Lv.15 +520 EXP │ │ 5위 ... │ │ │ │ 내 순위: 15위 / 234명 참여 │ └──────────────────────────────────────────┘
전체 랭킹은 오래 활동한 고레벨 유저가 항상 상위에 있게 됩니다. 신규 유저 입장에서는 "저걸 어떻게 따라잡지..." 하고 의욕이 꺾일 수 있습니다.
주간/월간 랭킹은 이번 주에 얼마나 활동했느냐로 순위가 매겨지니, 오늘 가입한 사람도 이번 주 1위를 할 수 있습니다. 그게 전체 순위를 올리는 동기가 되고요.
N+1 방지: 뱃지 때문에 느려지면 안 되니까
게시글 목록에 레벨 뱃지를 보여주려면 각 글의 작성자 레벨을 알아야 합니다. 글이 20개면 작성자 레벨 조회도 20번?
당연히 그러면 안 됩니다.
안 좋은 방법 (N+1): 글 목록 조회 (1번) → 글 20개 반환 글1의 작성자 레벨 조회 (1번) 글2의 작성자 레벨 조회 (1번) ... 글20의 작성자 레벨 조회 (1번) = 총 21번의 DB 쿼리 배치 조회 (1+1): 글 목록 조회 (1번) → 글 20개 반환 → 작성자 ID 수집: {1, 5, 12, 23, ...} → 한 번에 조회 (1번) → {1: Lv.15, 5: Lv.8, ...} → 매핑 = 총 2번의 DB 쿼리
authorId를 모아서 findAllById()로 한 번에 조회하고, Map으로 매핑합니다. 간단하지만 빼먹기 쉬운 부분입니다.
성능 리뷰 후 개선한 것들
초기 구현 후 성능/안정성 코드리뷰를 받았는데, 의미 있는 개선이 몇 가지 있었습니다.
불필요한 COUNT 쿼리 제거
경험치를 지급할 때마다 "오늘 이 유형으로 몇 번 받았나?" 하는 일일 한도 체크를 합니다. 그런데 추천 받기(POST_VOTE_RECEIVED)처럼 일일 한도가 없는 타입도 매번 COUNT 쿼리를 날리고 있었습니다.
변경 전: 모든 타입에 COUNT 쿼리 실행 POST_VOTE_RECEIVED (무제한) → COUNT 실행 → "무제한이니 통과" COMMENT_VOTE_RECEIVED (무제한) → COUNT 실행 → "무제한이니 통과" 변경 후: 무제한/1회성 타입은 COUNT 스킵 POST_VOTE_RECEIVED (무제한) → COUNT 생략 → 바로 지급 FIRST_POST (1회성) → COUNT 생략 → grant_key 유니크 제약으로 중복 방지
추천은 고빈도 이벤트입니다. 글 하나에 추천이 100개 달리면 200번의 불필요한 COUNT가 사라지는 셈입니다.
복합 인덱스 추가
리더보드와 관리자 페이지의 쿼리는 WHERE status = 'ACTIVE' ORDER BY total_exp DESC 패턴이 대부분입니다. 그런데 인덱스가 total_exp 단일 컬럼으로만 걸려 있어서, status 필터링 시 인덱스를 효율적으로 사용하지 못했습니다.
-- 추가한 복합 인덱스 CREATE INDEX idx_users_status_total_exp ON users(status, total_exp DESC, id DESC); CREATE INDEX idx_users_status_level ON users(status, level DESC, id DESC);
현재 사용자 수에서는 체감 차이가 크지 않지만, 사용자가 늘어나도 쿼리 성능이 유지되도록 선제적으로 적용했습니다.
레벨 분포 통계 안정성 수정
관리자 페이지의 레벨 분포 차트를 그리기 위해 구간별 사용자 수를 DB에서 집계하는 쿼리가 있는데, 반환값의 타입 캐스팅이 불안정했습니다. JPA의 반환 타입에 따라 런타임 예외가 발생할 수 있는 구조였는데, List<Object[]>로 반환 타입을 명확히 하고 null/empty 방어 로직을 추가해서 안정성을 확보했습니다.
만렙 이후는?
레벨은 99로 고정이지만 경험치는 계속 쌓입니다.
만렙 유저끼리도 리더보드에서 경쟁할 수 있게 하기 위해서입니다. 같은 Lv.99라도 누적 300만 EXP와 500만 EXP는 다른 무게감이니까요.
향후에는 업적 배지("1000번째 댓글", "연속 30일 출석" 같은)나 레벨별 특전(닉네임 색상 변경 등)도 붙일 계획입니다. 레벨 시스템이 깔려 있으니 그 위에 무엇이든 쌓아올릴 수 있습니다.
마무리
이 시스템을 만들면서 느낀 건, 기술적 난이도보다 밸런스 설계가 훨씬 어렵다는 점입니다.
"글쓰기에 +50을 줄까 +100을 줄까", "일일 한도를 5회로 할까 10회로 할까", "레벨 공식의 지수를 1.5로 할까 2.0으로 할까". 이런 숫자 하나하나가 사용자 경험에 직접 영향을 줍니다.
너무 쉬우면 의미가 없고, 너무 어려우면 포기합니다. 결국 운영하면서 데이터를 보고 조정해 나가야 하는 부분인데, 그래서 관리자가 경험치를 수동 조정할 수 있는 기능도 넣어뒀습니다.
초기 구현 이후의 성능 리뷰도 유익했습니다. 불필요한 쿼리 제거, 스레드 풀 격리, 인덱스 최적화 같은 것들은 기능상 눈에 보이는 변화가 아니지만, 시스템이 커졌을 때 문제가 터지는 대신 조용히 잘 돌아가게 해주는 장치들입니다.
핵심은 결국 "활동하면 보상받고, 보상이 보이면 더 활동하게 된다"는 루프를 만드는 것이었습니다.
활동 → 경험치 → 레벨업 → 뱃지가 바뀜 → 뿌듯함 → 더 활동 ↑ │ └──────────────────────────────┘
이 루프가 잘 돌아가는지는, 이제부터 지켜봐야 합니다.

댓글
댓글을 작성하려면 이 필요합니다.