Codeit/프로젝트

[모두의 플리] watcherCount를 Redis로 리팩토링

leejunkim 2025. 12. 23. 13:48

상황

프로젝트에서 실시간 콘텐츠 시청 세션 기능을 구현했다. 사용자가 영상 페이지에 들어오면 시청자로 집계되고, 나가면 집계에서 빠져야 한다. 이 숫자는 웹소켓을 통해 모든 사용자에게 실시간으로 브로드캐스팅되어야 했다.

문제점

프론트에서 콘텐츠를 불러올 때, 요구하는 dto에 watcherCount가 들어있다.

그래서 기존 코드는 콘텐츠 엔티티에 watcherCount 필드를 사용했다. 그래서 WatchingSessionService에서 watchingSession이 업데이트 될때마다 이 콘텐츠의 필드도 업데이트 하는 방식으로 했다:

contentRepository.incrementWatcherCount(content.getId());

contentRepository.decrementWatcherCount(content.getId());

 

public interface ContentRepository extends JpaRepository<Content, UUID>, ContentRepositoryCustom {
  @Modifying
  @Query("UPDATE Content c SET c.watcherCount = c.watcherCount + 1 WHERE c.id = :contentId")
  void incrementWatcherCount(@Param("contentId") UUID contentId);

  @Modifying
  @Query("UPDATE Content c SET c.watcherCount = c.watcherCount - 1 " +
      "WHERE c.id = :contentId AND c.watcherCount > 0")
  void decrementWatcherCount(@Param("contentId") UUID contentId);
}

 

여기서 생각해보면, 사용자 한명한명 들어올 때마다 매번 디스크 기반의 DB write 작업을 수행해야 한다.

  • 현재 @Transactional을 메서드에 붙히긴 했지만, 트랜잭션의 원자성(성공 아니면 롤백)을 보장할 뿐, 여러 스레드가 동시에 같은 데이터에 접근하는 동시성 문제까지는 해결해주지 못한다.
  • 만약에 시청자 A, B가 0.0001초 차이로 동시에 입장했으면 lost update가 생길 수 있다.
    • Transaction A: DB에서 현재 인원 조회 -> 10명
    • Transaction B: DB에서 현재 인원 조회 -> 10명 (A가 아직 저장을 안 했으므로)
    • Transaction A: 10 + 1 = 11명으로 저장 (UPDATE)
    • Transaction B: 10 + 1 = 11명으로 저장 (UPDATE)
    • 결과적으로 두 명이 들어왔으니 12명이 되어야 하는데, DB에는 11명만 남는다. A의 업데이트 내역이 B에 의해 덮어씌워져 사라진 것이다 (Lost Update)
  • 이를 막으려면 비관적 락을 걸어야하는데, A가 끝날때까지 B는 대기하게 하는 것이다. 그러면 데이터는 정확해지지만 만약에 100명이 들어오게 되면 100명이 줄을 서게 되어서 성능이 급격하게 저하된다.

해결 - Redis

Redis는 구조적으로 이 문제를 완벽하게 회피한다.

  • Single Thread: Redis는 한 번에 하나의 명령어만 처리한다. 아무리 많은 요청이 동시에 몰려도 내부적으로는 줄을 세워 하나씩 순서대로 처리한다.
  • Atomic Operation: Redis의 INCR(증가) 명령어는 "값을 읽고 -> 더하고 -> 저장하는" 과정이 분리되지 않고 한 방에 일어난다.
  • 이제 100명이 동시에 INCR을 날려도 1부터 100까지 순서대로 빠르게 처리해서 정확히 +100을 만들어낸다.

바뀐 것들

  •  이제 Content 테이블의 watcher_count 컬럼은 제거되고 실시간 용도로 쓰지 않는다.
  • Redis는 실시간 조회수를 관리한다.
private static final String COUNT_KEY_PREFIX = "watching:count:";

public Long increaseWatcherCount(UUID contentId) {
    String key = COUNT_KEY_PREFIX + contentId;
    // DB 조회 없이 메모리에서 즉시 +1
    return redisTemplate.opsForValue().increment(key);
}

public Long decreaseWatcherCount(UUID contentId) {
    String key = COUNT_KEY_PREFIX + contentId;
    // 메모리에서 즉시 -1
    return redisTemplate.opsForValue().decrement(key);
}

 

결과

  • DB 부하 감소: 단순 조회수 갱신을 위한 UPDATE 쿼리가 완전히 사라졌다. DB는 이제 중요한 비즈니스 데이터(결제, 유저 정보 등) 처리에만 집중할 수 있게 되었다.
  • 그 위워별도의 락(Lock) 구현 없이도 Redis의 원자적 연산 덕분에 100명이 동시에 들어와도 정확하게 +100이 카운팅된다.