사이드 프로젝트에서 사용자 참여를 유도하기 위해 게이미피케이션 시스템을 설계해보았습니다.
포인트와 뱃지라는 보상 구조를 중심으로 설게했지만, 실제로 구현하고 나니 여러 비효율이 드러났습니다.
이 글에서는 제가 처음에 설계한 구조와, 그것이 왜 문제였는지를 공유해 보려고 합니다.
1. 왜 게이미피케이션을 도입했나
사용자가 자발적으로 활동에 참여하게 만들고 싶었습니다.
단순한 로그인이나 댓글 활동 같은 기본 행동에 의미를 부여하고, 이를 시각적으로 보상하면 더 높은 참여율을 기대할 수 있다고 생각했습니다.
2. 초기 설계 구조
시스템은 다음과 같은 구성으로 시작했습니다.

구성요약
- USER_ACTIVITY: 사용자의 활동을 기록
- ACTIVITY_TYPE: 활동 유형과 해당 점수
- BADGES: 뱃지 메타데이터 정의
- BADGE_RULES: 어떤 활동이 몇 번 일어나면 뱃지를 주는지 정의
- USER_BADGES: 실제 수여된 뱃지 기록
설계 당시엔 “단순하지만 확장 가능한 구조”를 목표로 했습니다.
시스템의 동작 흐름 요약
사용자 행동이 포인트 적립과 뱃지 발급으로 이어지는 전체 흐름은 다음과 같습니다:
- 사용자가 게시글을 작성하거나 댓글을 다는 등 어떤 활동을 수행하면
- UserActivityService 에서 해당 활동을 UserActivity 로 기록하고, 포인트를 부여합니다.
- 이후 BadgeService 가 동작하여,
- 해당 활동 유형(ActivityType)과 뱃지 조건(BadgeRule)을 조회하고
- 기준을 만족할 경우 UserBadge 테이블에 뱃지를 발급합니다.
이 과정을 통해 사용자 행동이 정량화되고 시각적으로 보상되는 흐름을 완성했습니다.
도메인 객체 간 관계 정리
| 엔티티 | 설명 | 관계 |
| UserActivity | 유저의 활동 로그 | User(N:1), ActivityType(N:1) |
| ActivityType | 활동 유형 정의(예: 게시글 작성, 댓글 등) | UserActivity(1:N), BadgeRule(1:N) |
| Badge | 발급 가능한 뱃지 정의 (이름, 설명 등) | BadgeRule(N:1), UserBadge(1:N) |
| BadgeRule | 뱃지 조건 정의(활동 횟수 기준) | Badge(N:1), ActivityType(N:1) |
| UserBadge | 실제 유저에게 발급된 뱃지 | User(N:1), Badge(N:1) |
즉 유저의 행동 기록 -> 포인트 적립 -> 조건 충족 -> 뱃지 발급 이라는 하나의 흐름으로 설계되었습니다.
3. 겉보기엔 괜찮았지만...
설계 초반에는 비교적 명확한 흐름을 갖고 있었기 때문에 큰 문제가 없다고 생각했습니다.
하지만 실제로 활동 데이터를 쌓고, 다양한 기능에서 이 시스템을 활용하려 하면서 몇 가지 구조적인 한계가 드러났습니다.
1) 활동 로직을 모든 기능에서 직접 호출해야 함
게시글 작성, 댓글 작성, 모임 참여 등 사용자의 활동이 일어날 때마다, 다음과 같은 코드를 명시적으로 넣어줘야 했습니다.
userActivityService.recordActivity(userId, ActivityType.CREATE_POST, postId);
이걸 빼먹으면 포인트 적립도 안 되고, 뱃지도 지급되지 않습니다.
즉, 도메인 로직과 활동 기록 로직이 강하게 결합되어 있어 누락 위험이 큽니다.
2) 동시에 활동이 발생하면 포인트 적립이 정확히 반영되지 않는 문제
활동은 유니크 제약조건(user_id, activity_type, target_id 조합)으로 저장됩니다.
하지만 동시에 활동이 발생하면, 포인트 누락이 발생할 수 있습니다.
예: 사용자가 거의 동시에 두 게시글을 작성하면 각각 10점씩, 총 20점이 적립돼야 합니다.
하지만 실제 테스트에서 확인한 결과는 아래와 같았습니다:

즉, user.addPoints() 가 스레드 세이프하지 않아서 마지막 값으로 덮어씌워졌습니다.
재현을 위해 작성한 테스트는 다음과 같습니다:
@SpringBootTest
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class UserActivityConcurrencyTest {
@Autowired
private UserRepository userRepository;
@Autowired
private UserActivityService userActivityService;
private User testUser;
@BeforeEach
void setUp() {
// 기존 데이터 정리
userRepository.deleteAll();
testUser = userRepository.save(User.builder()
.email("test@example.com")
.password("password1234")
.nickname("testuser")
.role(Role.USER)
.provider(Provider.LOCAL)
.points(0)
.build());
}
@Test
@DisplayName("동시에 두 활동이 발생하면 포인트 중 하나만 정상적으로 더해진다.")
void givenConcurrentActivities_whenRecordActivity_thenPointsAreNotDuplicated() throws InterruptedException {
// given
Long userId = testUser.getId();
Long targetId1 = 100L;
Long targetId2 = 101L;
ExecutorService executor = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
// when: 두 개의 활동을 동시에 수행
executor.submit(() -> {
try {
userActivityService.recordActivity(userId, ActivityType.CREATE_POST, targetId1);
} finally {
latch.countDown();
}
});
executor.submit(() -> {
try {
userActivityService.recordActivity(userId, ActivityType.CREATE_POST, targetId2);
} finally {
latch.countDown();
}
});
latch.await();
executor.shutdown();
// then
User updatedUser = userRepository.findById(userId).orElseThrow();
int expected = ActivityType.CREATE_POST.getPoints() * 2;
// assertThat(updatedUser.getPoints()).isNotEqualTo(expected);
assertThat(updatedUser.getPoints()).isEqualTo(expected);
}
}
이 테스트를 통해 실제 환경에서의 Race Condition 가능성을 확인했고,
다음 글에서는 이를 어떻게 해결할 수 있었는지 공유드릴 예정입니다.
3) 활동 유형이 enum으로 고정되어 확장 불가능
사용자 활동 유형은 ActivityType이라는 Java enum으로 정의되어 있었습니다.
처음에는 활동 유형이 몇 개 되지 않았고, 컴파일 타임 안전성과 자동완성 편의를 고려해 enum을 사용했습니다.
하지만 확장 단계에서는 제약으로 작용했습니다.
public enum ActivityType {
CREATE_POST(10, "게시글 작성"),
CREATE_COMMENT(5, "댓글 작성"),
LIKE_POST(1, "게시글 좋아요")
}
- 새로운 활동 추가: enum 항목 추가 → 컴파일 → 테스트 → 배포 필요
- 포인트 조정: enum 내부 값 수정 → 배포 필요
- 뱃지 조건: BadgeRule도 enum에 의존 → enum에 없는 활동은 조건 추가 불가
운영 중 유연한 변경이 사실상 불가능했습니다.
| 항목 | 정의 위치 | 변경 방식 |
| 활동 이름 | ActivityType enum | 코드 수정 |
| 포인트 값 | enum 내부 필드 | 코드 수정 |
| 뱃지 조건 | BadgeRule(activityType) | enum 기반, 코드에 종속 |
4) 뱃지 발급 조건이 너무 단순
현재 뱃지 조건은 BadgeRule이라는 테이블에 다음과 같이 정의되어 있습니다:
badge_id | activity_type | threshold
즉, "특정 활동을 몇 번 했을 때"만 조건으로 줄 수 있습니다.
하지만 실제로는 아래와 같은 조건이 필요했습니다:
- 총 포인트가 100점 이상일 때
- 같은 주에 3번 이상 모임에 참여했을 때
- 첫 활동을 완료했을 때 등
이런 복합적이고 시나리오 중심의 조건은 기존 구조로 표현하기 어려웠습니다.
5) 활동 정의가 enum과 DB 양쪽에서 중복 관리됨
처음에는 활동 유형을 Java enum으로 정의하고, 뱃지 조건을 badge_rules 테이블에 따로 저장했습니다.
하지만 이 구조에서는 같은 활동을 두 군데에서 따로 정의해야 했습니다.
예를 들어:
- 활동 정의가 enum과 DB 양쪽에서 중복 관리됨
- 동시에 DB의 badge_rules에도 같은 활동을 다시 명시해야 함
코드와 DB가 분리되어 있어 활동 정의가 이중 관리되고 있었습니다.
요약하면...
당시의 설계는 “단순하고 직관적인 구조”라는 장점이 있었지만,
- 기능 확장성 부족
- 유연하지 못한 조건 구조
- 로직 누락 위험
- 동시성에 대한 고려 부족
이라는 문제들을 포함하고 있었습니다.
이런 문제들을 하나씩 정리하면서,
다음 글에서는 이 구조를 어떻게 개선했는지, 그리고 그 과정에서 어떤 판단을 내렸는지 공유해 보려고 합니다.
'사이드 프로젝트 > 이벤트 있다' 카테고리의 다른 글
| 두 개의 외부 API 통합하기: 서로 다른 특성을 가진 데이터를 하나의 시스템으로 (0) | 2025.08.12 |
|---|---|
| @EventListener는 비동기일까? 직접 실험해봤습니다 (4) | 2025.07.26 |
| Spring 6.1에서 RestClient 도입기: 선언형 HTTP 클라이언트의 장점 정리 (0) | 2025.06.01 |
| N+1 문제를 마주한 경험 (0) | 2025.06.01 |
| Spring Boot JWT 인증 구현기: Refresh Token 보안을 중심으로 (0) | 2025.05.28 |