사이드 프로젝트/이벤트 있다

게이미피케이션 시스템 개선 2부 - 이벤트 아키텍처로 문제 해결하기

솜사탕코튼 2025. 8. 12. 13:19

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);

 

 

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에서 동적 관리
뱃지 조건 단순 횟수만 가능 전략 패턴으로 확장성 증가
예외 처리 메인 로직에 영향 이벤트 리스너에서 격리

 

구현할 때 고려사항

  1. 이벤트 기반 처리의 복잡성
    • 디버깅 시 이벤트 흐름을 추적해야 합니다. 코드 분석이 필요합니다.
  2. 비동기 처리 유의할 점
    • 활동 기록 실패 시 재처리 로직이 필요합니다.
    • 현재는 로깅만 처리되어 있습니다.
  3. Optimistic Lock의 한계
    • 높은 동시성에서는 재시도에 대한 시도가 많아져 애플리케이션의 성능이 저하될 수 있습니다.
    • 비관적 락도 고려해볼만 하다고 생각합니다.

 

향후 개선 방향

장애 복구 및 재처리 로직 리팩토링

현재 로깅만 하고 소실된 포인트나 활동에 대해서 처리를 하고 있지 않습니다.

슬랙이나 외부 메일을 통해서 유저 활동이 제대로 기록되지 않았을 경우 반영하기 위한 처리가 필요합니다.

 

외부 메시지 큐 도입

현재는 Spring의 ApplicationEvent을 사용하지만, 확장성을 위해

RabbitMQ 를 고려해볼만 하다고 생각합니다. 

 

옳은 구조인지는 아직도 잘 모르겠습니다.

중요한 것은 문제가 드러났을 때 적절히 대응할 수 있는 확장 가능한 구조를 만드는 것이라고 생각하면서 리팩토링을 수행했습니다.