게이미피케이션 시스템 개선 2부 - 이벤트 아키텍처로 문제 해결하기
1편에서 드러난 설계 문제점들을 어떻게 해결했는지, 어떤 판단을 내렸는지 공유합니다.
- 회원 활동 점수 기록 로직과 도메인 로직의 강한 결합
- 동시성 문제로 인한 포인트 누락
- Enum으로 설정된 회원 활동 유형의 확장성 부족
- 단순한 뱃지 조건 시스템
개선된 아키텍처
기존: 비즈니스 로직 -> 직접 호출 -> 활동 기록
개선: 비즈니스 로직 -> 이벤트 발행 -> 이벤트 리스너 -> 활동 기록
gamification/
├── activitylog/ # 이벤트 기반 활동 로깅
│ ├── ActivityEventPublisher.java
│ ├── UserActivityEventListener.java
│ └── UserActivityLogRequestedEvent.java
├── constant/ # 활동 코드 상수 관리
│ └── ActivityCodes.java
├── domain/
│ ├── ActivityType.java # enum → 엔티티 변경
│ ├── UserPoints.java # 별도 엔티티로 분리
│ └── BadgeRule.java # 조건 시스템 확장
├── evaluator/ # 전략 패턴 기반 배지 평가
│ ├── BadgeRuleEvaluator.java
│ └── ActivityCountRuleEvaluator.java
└── service/
├── UserActivityService.java # 동시성 처리 강화
└── BadgeService.java
문제별 해결 방안
1. 강한 결합 문제 -> 이벤트 주도 아키텍처로 분리
기존 방식 코드
// 게시글 작성 시마다 직접 호출 필요
@PostMapping("/posts")
public ResponseEntity<PostResponse> createPost(@RequestBody CreatePostRequest request) {
Post post = postService.createPost(request);
// 이 코드를 빼먹으면 포인트도, 뱃지도 지급되지 않음
// 트랜잭션도 공유되어, 포인트를 부여하거나, 뱃지를 부여할 때 문제가 생기면 게시글도 등록이 되지 않음
userActivityService.recordActivity(userId, ActivityType.CREATE_POST, post.getId());
return ResponseEntity.ok(PostResponse.from(post));
}
해결: 이벤트 발행으로 느슨한 결합
EventPublisher
@Component
@RequiredArgsConstructor
public class ActivityEventPublisher {
private final ApplicationEventPublisher eventPublisher;
public void publish(String activityCode, Long userId, Long targetId) {
eventPublisher.publishEvent(new UserActivityLogRequestedEvent(userId, activityCode, targetId));
}
public void publishRevoke(String activityCode, Long userId, Long targetId) {
eventPublisher.publishEvent(new UserActivityRevokeRequestedEvent(userId, activityCode, targetId));
}
}
이제 게시글 서비스는 단순히 이벤트만 발행하면 됩니다.!
@PostMapping("/posts")
public ResponseEntity<PostResponse> createPost(@RequestBody CreatePostRequest request) {
Post post = postService.createPost(request);
// 이벤트 발행으로 활동 기록 분리
activityEventPublisher.publish(ActivityCodes.CREATE_POST, userId, post.getId());
return ResponseEntity.ok(PostResponse.from(post));
}
이벤트 리스너에서 비동기로 처리합니다.
@Component
@RequiredArgsConstructor
public class UserActivityEventListener {
private final UserActivityService userActivityService;
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handleUserActivity(UserActivityLogRequestedEvent event) {
try {
userActivityService.recordActivity(event.userId(), event.activityCode(), event.targetId());
} catch (Exception e) {
log.error("[활동 기록 실패] userId={}, activityCode={}, targetId={}",
event.userId(), event.activityCode(), event.targetId(), e);
}
}
}
핵심 개선점으로는 다음과 같습니다.
@TransactionalEventListener(phase = AFTER_COMMIT)
- 게시글이 저장되는 행위(트랜잭션) 커밋 후 실행하여 트랜잭션의 공유로 인한 예외를 방지할 수 있습니다.
@Async
- 비동기 처리로 메인 비즈니스 로직에 영향을 주지 않습니다. (회원 활동 기록, 뱃지 수여)
현재는 예외 발생 시 로깅만 하고 메인 로직은 정상 처리하였습니다.
2. 동시성 문제 > Optimistic Lock과 재시도 메커니즘
문제는 동시에 게시글 등록 활동이 발생하는 경우 입니다.
동시에 포인트를 부여하는 로직이 발생하게 되면 포인트 누락 현상이 발생할 수 있습니다.
1편에서 언급한 테스트를 살펴보면 동시에 두 활동이 발생하면 포인트 중 하나만 정상적으로 더해지는 것을 확인할 수 있습니다.
@Test
@DisplayName("동시에 두 활동이 발생하면 포인트 중 하나만 정상적으로 더해진다.")
void givenConcurrentActivities_whenRecordActivity_thenPointsAreNotDuplicated() {
// 두 개의 스레드가 동시에 포인트를 적립하면
// user.addPoints()가 스레드 세이프하지 않아 마지막 값으로 덮어씌워짐
}

해결1: 포인트를 별도 엔티티로 분리하고 Optimistic Lock(낙관적 락)을 적용하였습니다.
@Entity
@Table(name = "user_points")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserPoints {
@Id
private Long userId;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "user_id")
private User user;
@Version
@Column(nullable = false)
private Long version;
@Column(nullable = false)
private int points;
public void addPoints(int amount) {
this.points += amount;
}
public void subtractPoints(int amount) {
this.points = Math.max(0, this.points - amount);
}
}
해결2: 재시도 메커니즘으로 충돌 해결
@Service
@RequiredArgsConstructor
public class UserActivityService {
private static final int MAX_RETRIES = 3;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordActivity(Long userId, String activityCode, Long targetId) {
int attempt = 0;
while (true) {
try {
performRecordActivity(userId, activityCode, targetId);
return;
} catch (ObjectOptimisticLockingFailureException e) {
if (++attempt >= MAX_RETRIES) {
throw e;
}
}
}
}
private void performRecordActivity(Long userId, String activityCode, Long targetId) {
if (userActivityRepository.existsByUserIdAndActivityType_IdAndTargetId(
userId, activityType.getId(), targetId)) {
return;
}
UserPoints userPoints = getOrCreateUserPoints(user);
// Optimistic Lock이 적용된 포인트 적립
userPoints.addPoints(activityType.getDefaultPoint());
userPointsRepository.save(userPoints); // Version 증가
// 활동 기록
userActivityRepository.save(new UserActivity(user, activityType, targetId));
badgeService.checkAndAwardBadges(user, userPoints);
}
}
개선된 동시성 테스트:
전체 코드 ↓
@Test
@DisplayName("같은 targetId로 중복 활동을 시도하면 한 번만 기록된다")
void givenDuplicateActivity_whenRecordActivity_thenRecordedOnce() throws InterruptedException {
// given
Long targetId = 100L;
int threadCount = 5;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
// when
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
startLatch.await();
userActivityService.recordActivity(testUserId, ActivityCodes.CREATE_POST, targetId);
successCount.incrementAndGet();
} catch (Exception e) {
log.warn("동시성 테스트 중 예외 발생: {}", e.getMessage(), e);
} finally {
endLatch.countDown();
}
});
}
startLatch.countDown();
boolean finished = endLatch.await(10, TimeUnit.SECONDS);
executor.shutdown();
assertThat(finished).isTrue();
await().atMost(Duration.ofSeconds(2))
.until(() -> userPointsRepository.findByUserId(testUserId).isPresent() &&
userActivityRepository.countByUserId(testUserId) == 1L);
// then
assertThat(successCount.get()).isEqualTo(threadCount);
UserPoints userPoints = userPointsRepository.findByUserId(testUserId)
.orElseThrow(() -> new AssertionError("UserPoints not found"));
assertThat(userPoints.getPoints()).isEqualTo(activityType.getDefaultPoint());
long activityCount = userActivityRepository.countByUserId(testUserId);
assertThat(activityCount).isEqualTo(1L);
}
테스트 코드 설명:
1. 동시성 설정
int threadCount = 5;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(threadCount);
ExecutorService, CountDownLatch 설명 ↓
ExecutorService
- 스레드 풀을 관리하는 인터페이스
- 여러 작업을 동시에 실행
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
- newFixedThreadPool(5): 5개의 고정된 스레드를 가진 풀 생성
- submit(): 작업을 스레드 풀에 제출하여 비동기 실행
- shutdown(): 새로운 작업 수락 중단, 기존 작업은 완료까지 대기
- https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html
ExecutorService (Java Platform SE 8 )
An Executor that provides methods to manage termination and methods that can produce a Future for tracking progress of one or more asynchronous tasks. An ExecutorService can be shut down, which will cause it to reject new tasks. Two different methods are p
docs.oracle.com
CountDownLatch
- 지정된 횟수만큼 카운트다운되면 대기 중인 스레드들을 해제하는 동기화 도구
CountDownLatch startLatch = new CountDownLatch(1); // 1번 카운트다운
CountDownLatch endLatch = new CountDownLatch(threadCount); // threadCount번 카운트다운
사용 방법
startLatch (동시 시작용)
startLatch.await(); // 다른 스레드가 countDown() 호출할 때까지 대기
startLatch.countDown(); // 카운트를 1 감소 (1 → 0), 대기 중인 모든 스레드 해제
endLatch (완료 대기용)
endLatch.countDown(); // 각 스레드가 작업 완료 시 호출 (5 → 4 → 3 → 2 → 1 → 0)
endLatch.await(10, TimeUnit.SECONDS); // 모든 스레드 완료까지 최대 10초 대기
2. 동일한 게시글을 같은 시간 대에 여러 개 등록
Long targetId = 100L; // 모든 스레드가 같은 게시글 유니크 키 사용
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
startLatch.await(); // 모든 스레드가 동시에 시작되도록 대기
userActivityService.recordActivity(testUserId, ActivityCodes.CREATE_POST, targetId);
});
}
3. Awaitility로 비동기 결과 대기
await().atMost(Duration.ofSeconds(2))
.until(() -> userPointsRepository.findByUserId(testUserId).isPresent() &&
userActivityRepository.countByUserId(testUserId) == 1L);
4. 성공 횟수 검증
assertThat(successCount.get()).isEqualTo(threadCount);
- 5개의 스레드 모두 예외 없이 recordActivity() 메서드를 성공적으로 호출했는지 확인
- 유의 깊게 봐야 할 점: 메서드 호출은 5회로 성공했지만, 실제 데이터베이스 저장은 1번만 되어야 함
5. 포인트 중복 적립 방지 검증
assertThat(userPoints.getPoints()).isEqualTo(activityType.getDefaultPoint());
- 5번 시도했지만 포인트는 1번만 적립되었는지 확인
- 예상 결과: activityType.getDefaultPoint() (10포인트) 딱 1번만 적립
- 만약 중복 방지가 실패했다면: 50포인트 (10 x 5) 가 적립될 것
6. 활동 기록 중복 저장 방지 검증
assertThat(activityCount).isEqualTo(1L);
- 데이터베이스에 유저 활동 기록이 1건만 저장되었는지 확인
- 중복을 방지하기 위해 user_id, activity_type, target_id 복합 키 설정을 추가
7. 테스트 성공
- 사용자가 같은 게시글을 모바일과 웹으로 같은 시간에 등록한 경우 같은 게시글 번호를 가지기 때문에 충돌이 일어날 수 있습니다.
- 유저 활동을 저장할 때 동일한 건이 2건의 되어 한 건의 게시글이 최종적으로는 등록된 것이지만 10 + 10 -> 2건의 게시글을 등록한 점수를 받을 수 있습니다.
- 유저 활동을 저장할 때 유니크 키 중복 예외가 발생하게 되므로 한 건의 유저 활동만 저장되고 나머지 건들을 실패하게 됩니다.

2025-08-14T15:15:13.415+09:00 WARN 58096 --- [pool-2-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 23505, SQLState: 23505
2025-08-14T15:15:13.415+09:00 ERROR 58096 --- [pool-2-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_A ON PUBLIC.USER_ACTIVITIES(USER_ID NULLS FIRST, ACTIVITY_TYPE_ID NULLS FIRST, TARGET_ID NULLS FIRST) VALUES ( /* key:4 */ CAST(1 AS BIGINT), CAST(1 AS BIGINT), CAST(100 AS BIGINT))"; SQL statement:
insert into user_activities (activity_type_id,created_at,points_earned,target_id,user_id,id) values (?,?,?,?,?,default) [23505-232]
2025-08-14T15:15:13.415+09:00 WARN 58096 --- [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 23505, SQLState: 23505
2025-08-14T15:15:13.415+09:00 ERROR 58096 --- [pool-2-thread-3] o.h.engine.jdbc.spi.SqlExceptionHelper : Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_A ON PUBLIC.USER_ACTIVITIES(USER_ID NULLS FIRST, ACTIVITY_TYPE_ID NULLS FIRST, TARGET_ID NULLS FIRST) VALUES ( /* key:4 */ CAST(1 AS BIGINT), CAST(1 AS BIGINT), CAST(100 AS BIGINT))"; SQL statement:
insert into user_activities (activity_type_id,created_at,points_earned,target_id,user_id,id) values (?,?,?,?,?,default) [23505-232]
2025-08-14T15:15:13.415+09:00 WARN 58096 --- [pool-2-thread-4] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 23505, SQLState: 23505
2025-08-14T15:15:13.415+09:00 ERROR 58096 --- [pool-2-thread-4] o.h.engine.jdbc.spi.SqlExceptionHelper : Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_A ON PUBLIC.USER_ACTIVITIES(USER_ID NULLS FIRST, ACTIVITY_TYPE_ID NULLS FIRST, TARGET_ID NULLS FIRST) VALUES ( /* key:4 */ CAST(1 AS BIGINT), CAST(1 AS BIGINT), CAST(100 AS BIGINT))"; SQL statement:
insert into user_activities (activity_type_id,created_at,points_earned,target_id,user_id,id) values (?,?,?,?,?,default) [23505-232]
2025-08-14T15:15:13.415+09:00 WARN 58096 --- [pool-2-thread-5] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 23505, SQLState: 23505
2025-08-14T15:15:13.416+09:00 ERROR 58096 --- [pool-2-thread-5] o.h.engine.jdbc.spi.SqlExceptionHelper : Unique index or primary key violation: "PUBLIC.CONSTRAINT_INDEX_A ON PUBLIC.USER_ACTIVITIES(USER_ID NULLS FIRST, ACTIVITY_TYPE_ID NULLS FIRST, TARGET_ID NULLS FIRST) VALUES ( /* key:4 */ CAST(1 AS BIGINT), CAST(1 AS BIGINT), CAST(100 AS BIGINT))"; SQL statement:
insert into user_activities (activity_type_id,created_at,points_earned,target_id,user_id,id) values (?,?,?,?,?,default) [23505-232]
해결3: 유저 활동 타입 확장성 -> enum에서 DB 엔티티로 변경
기존: 하드코딩된 enum
public enum ActivityType {
CREATE_POST(10, "게시글 작성"),
CREATE_COMMENT(5, "댓글 작성"),
LIKE_POST(1, "게시글 좋아요")
// 새 활동 추가 시 코드 수정 + 배포 필요
}
개선: DB 기반 동적 관리
@Entity
@Table(name = "activity_types")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ActivityType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String code; // "CREATE_COMMENT"
@Column(nullable = false)
private String name; // "댓글 작성"
@Column(nullable = false)
private int defaultPoint;
@Builder
public ActivityType(String code, String name, int defaultPoint) {
this.code = code;
this.name = name;
this.defaultPoint = defaultPoint;
}
}
- 관리자 기능은 구현되어 있지 않지만, 새로운 활동 타입을 인서트 만으로 추가 가능합니다.
- 포인트 조정을 관리자가 수정할 수 있습니다.
- 단점으로는 마찬가지로 해당 코드에 로직을 넣는 코드가 필요합니다. 새로운 타입을 DB를 통해 추가하였더라도, 해당하는 비즈니스 로직 코드에 이벤트 리스너 호출이 필요합니다.
해결4: 뱃지 부여 시스템 -> 전략 패턴으로 확장 가능하게 수정
기존: 단순한 활동 횟수만 지원
- BadgeRule: activity_type(활동 유형) + threshold(포인트 부여 조건 횟수)
- "게시글을 10번 작성하면" 같은 단순한 조건만 표현 가능
개선: 전략 패턴으로 다양한 조건 지원
public interface BadgeRuleEvaluator {
boolean supports(BadgeRule rule);
boolean isSatisfied(User user, BadgeRule rule);
}
@Component
public class ActivityCountRuleEvaluator implements BadgeRuleEvaluator {
private final UserActivityRepository userActivityRepository;
@Override
public boolean supports(BadgeRule rule) {
return rule.getActivityType() != null; // 활동 기반 조건
}
@Override
public boolean isSatisfied(User user, BadgeRule rule) {
long count = userActivityRepository.countByUserIdAndActivityType_Id(
user.getId(), rule.getActivityType().getId()
);
return count >= rule.getThreshold();
}
}
뱃지 서비스에서의 전략 활용
@Service
@RequiredArgsConstructor
public class BadgeService {
private final List<BadgeRuleEvaluator> evaluators; // DI로 자동 주입
public void checkAndAwardBadges(User user, UserPoints userPoints) {
List<BadgeRule> enabledRules = badgeRuleRepository.findByEnabledTrue();
for (BadgeRule rule : enabledRules) {
if (isAlreadyAwarded(user, rule.getBadge())) {
continue;
}
// 뱃지 부여 조건 순회
for (BadgeRuleEvaluator evaluator : evaluators) {
if (evaluator.supports(rule) && evaluator.isSatisfied(user, rule)) {
awardBadge(user, rule.getBadge());
break;
}
}
}
}
}
확장 예시로는 다음과 같습니다. ↓
@Component
public class PointsRuleEvaluator implements BadgeRuleEvaluator {
@Override
public boolean supports(BadgeRule rule) {
return rule.getConditionType() == ConditionType.TOTAL_POINTS;
}
@Override
public boolean isSatisfied(User user, BadgeRule rule) {
UserPoints userPoints = userPointsRepository.findById(user.getId()).orElse(null);
return userPoints != null && userPoints.getPoints() >= rule.getThreshold();
}
}
- 새로운 조건 타입을 추가할 때도 기존 코드 수정 없이 Evaluator 구현체만 추가하면 됩니다.
개선 전후 비교 표를 만들어보았습니다.
| 개선 전 | 개선 후 | |
| 결합도 | 강한 결합(직접 메서드 호출) | 느슨한 결합(이벤트) |
| 동시성 | 경합 상황 발생 | Optimistic Lock + Unique 제약조건 |
| 확장성 | enum 수정 | DB에서 동적 관리 |
| 뱃지 조건 | 단순 횟수만 가능 | 전략 패턴으로 확장성 증가 |
| 예외 처리 | 메인 로직에 영향 | 이벤트 리스너에서 격리 |
구현할 때 고려사항
- 이벤트 기반 처리의 복잡성
- 디버깅 시 이벤트 흐름을 추적해야 합니다. 코드 분석이 필요합니다.
- 비동기 처리 유의할 점
- 활동 기록 실패 시 재처리 로직이 필요합니다.
- 현재는 로깅만 처리되어 있습니다.
- Optimistic Lock의 한계
- 높은 동시성에서는 재시도에 대한 시도가 많아져 애플리케이션의 성능이 저하될 수 있습니다.
- 비관적 락도 고려해볼만 하다고 생각합니다.
향후 개선 방향
장애 복구 및 재처리 로직 리팩토링
현재 로깅만 하고 소실된 포인트나 활동에 대해서 처리를 하고 있지 않습니다.
슬랙이나 외부 메일을 통해서 유저 활동이 제대로 기록되지 않았을 경우 반영하기 위한 처리가 필요합니다.
외부 메시지 큐 도입
현재는 Spring의 ApplicationEvent을 사용하지만, 확장성을 위해
RabbitMQ 를 고려해볼만 하다고 생각합니다.
옳은 구조인지는 아직도 잘 모르겠습니다.
중요한 것은 문제가 드러났을 때 적절히 대응할 수 있는 확장 가능한 구조를 만드는 것이라고 생각하면서 리팩토링을 수행했습니다.