Web
수정·

조회 수 그리고 동기화

by Dohoon Kim · 24년 12월 03일 17:13:05

조회 수 그리고 동기화

2023년 마지막 직장 퇴사 직전 진행했던 프로젝트는 숏폼 플랫폼 개발 프로젝트였다. 그 중 단순하지만 아주 생각할 것이 많았던 것이 "피드의 조회 수를 어떻게 업데이트 할 것인가?"에 대한 것이었다. 단순하게 그 피드를 시청했으면 조회 카운트 값을 1 증가 시키면 되는 것 아닌가? 하고 생각든다.

전혀 그렇지 않았다. 테스트 코드에서 100개의 스레드를 생성하여 서비스의 함수를 실행했더니 조회수는 100이 되는것이 아닌 98, 96 이런식으로 찍히곤 했다. 예상했던 문제였다. CPO에게 조회 수에 따라 크리에이터에게 돈을 지급할 계획에 관해 물어봤을 때 괜히 긁어 부스럼을 만든것이 아닌가 하는 생각이 들었지만, 뭐 제대로 서비스 만들려면 저런것도 있긴 해야하니...

PostgreSQL은 READ_COMMITED 방식을 기본으로 트랜잭션의 가시성을 관리한다.

Lost Update 문제는 이미 예상하고 있던 상황이었고 해결 방법도 여럿 생각해보았다. 이 글은 당시에 테스트 했던 점을 기억을 더듬어 정리하는 글이다.

요구 사항

CPO가 요구한 서버의 수용 스팩은 월 100만 MAU였다. 뭐 가능한 수치냐는 내가 판단할 문제가 아니었고 다음과 같은 가정으로 초당 Request를 계산하였다.

  1. 30%의 유저가 매일 서비스를 이용한다 가정한다 => DAU 30만
  2. 사용자 1인당 하루 최소 100개의 영상을 본다고 가정한다.
  3. DAU × 평균 조회수 = 300,000명 × 100 = 30,000,000 조회/일.
  4. 30,000,000 조회 ÷ 86,400초 ≈ 347 조회 요청/초 로 계산할 수 있다. 피크타임에는 아마도 1000회 이상은 넘지 않을까?

이 중 팔로워가 많은 크리에이터의 경우 분명 갱신 유실 문제가 발생할 수 있을 것으로 예상을 했었다.

정확한 조회 수 집계가 가능할 것

단순히 정확성을 확보하기 위한 방법은 많지만, 효율이 문제였다.

방안 1. 비관적 Lock

단순하게 JPA에서 피드 엔티티를 조회하고, 그 엔티티를 업데이트 하는 순간 Lock을 획득해서 처리한다면 그 자체로 병목이 발생할 수 있다. 하지만 요청이 빈번하지 않은 리소스에 대해서라면 충분히 적용할만한 전략이다. 요청이 초당 수천건이 넘어가는 서비스라면 고려 대상은 아닐것 같다.

아래는 예제코드이다.

@Entity
data class Post(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long,
    var viewCount: Long
)

@Repository
interface PostRepository : JpaRepository<Post, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Post p WHERE p.id = :postId")
    fun findByIdForUpdate(postId: Long): Post?
}

@Service
class PostService(private val postRepository: PostRepository) {

    @Transactional
    fun increaseViewCount(postId: Long) {
        val post = postRepository.findByIdForUpdate(postId)
            ?: throw RuntimeException("Post not found")

        post.viewCount += 1
        postRepository.save(post)
    }
}

@SpringBootTest
class PostServiceTest(@Autowired val postService: PostService, @Autowired val postRepository: PostRepository) {

    @BeforeEach
    fun setUp() {
        // 초기 데이터 설정
        val post = Post(id = 1L, viewCount = 0L)
        postRepository.save(post)
    }

    @Test
    fun `test increaseViewCount with database lock`() {
        val postId = 1L // 예시 게시물 ID
        val threads = mutableListOf<Thread>()

        for (i in 1..1000) {
            threads.add(Thread {
                postService.increaseViewCount(postId)
            })
        }

        threads.forEach { it.start() }
        threads.forEach { it.join() }

        val post = postRepository.findById(postId).orElseThrow { RuntimeException("Post not found") }
        assertEquals(1000, post.viewCount)
    }
}
  • 장점
    • 구현이 간단하다.
  • 단점
    • 성능 병목이 심각하다.
    • 단순 조회 작업임에도 컬럼 하나에 대한 쓰기 작업 하나로 조회 성능이 떨어진다.

방안 2. 낙관적 Lock

JPA에서는 각 엔티티에 version 필드를 두고, 커밋 전 후의 verison 필드의 변화를 감지해서 트랜잭션 충돌을 감지하는 방법이 존재한다.

꼭 JPA에서 제공하는 방식이 아니더라도, 직접 @Modifying 어노테이션과 @Query 어노테이션으로 다음과 같은 로직을 작성하여 충돌을 감지할 수 있다.

class Feed {
    ...
    var viewCount: Long = 0L
}

@Repository
class FeedRepository : JpaRepository<Feed, UUID> {

    @Modifying
    @Transactional
    @Query("UPDATE Feed f SET f.viewCount + :delta WHERE f.id = :id AND f.viewCount = :currentViewCount")
    fun increaseViewCountIfMatch(
        @Param("id") id: UUID, 
        @Param("delta") delta: Long, 
        @Param ("currentViewCount") currentViewCount: Long): Int
}
  • 장점
    • 불필요한 필드인 Version 필드를 따로 두지 않아도 된다.
    • 런타임에 충돌 발생 시 0이 반환되기 때문에 try catch로 인한 성능 하락을 줄일 수 있다.
  • 단점
    • 쿼리를 직접 작성해야하고 그에 따른 JPA의 타입 확인 등의 기능을 이용할 수 없게됨.

방안 3. Redis Lock

DB Lock이 데이터 베이스의 테이블의 레코드 자체에 거는 락이라고 하면, Redis Lock은 Critical Section에 해당하는 코드 영역의 접근을 위해 얻어야 하는 락 정도로 이해할 수 있다. 데이터베이스의 락에 비해 락을 얻거나 해제하는 속도가 빠른 편이다.

@Service
class PostService(private val postRepository: PostRepository, private val redisTemplate: RedisTemplate<String, String>) {

    fun increaseViewCount(postId: Long) {
        val lockKey = "post:$postId:lock"
        val value = UUID.randomUUID().toString()

        // Redis에서 Lock을 얻기
        val isLockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, value, 10, TimeUnit.SECONDS)

        if (isLockAcquired == true) {
            try {
                val post = postRepository.findById(postId).orElseThrow { RuntimeException("Post not found") }
                post.viewCount += 1
                postRepository.save(post)
            } finally {
                // Lock 해제
                redisTemplate.delete(lockKey)
            }
        } else {
            // Lock이 없으면 재시도하거나 기다림
            Thread.sleep(100)
            increaseViewCount(postId)
        }
    }
}
  • 장점
    • 데이터베이스 락에 비해 상대적으로 가볍다.
  • 단점
    • 이를 위해 별도의 인프라 레이어 하나가 추가되어야 한다.

방안 4. 캐싱 및 주기적 동기화

멀티 인스턴스 환경에서 서비스 인스턴스의 메모리에 캐싱해두고 주기적으로 이를 데이터베이스에 반영하는 방법. 나쁘지 않다. EHCache 등을 이용해도 되고, 이런 것들도 값 증가 시 한 인스턴스 내에서 원자성을 보장하는 방법이 있다. 다만 이걸 동기화 할 때는 갱신 유실이 발생하지 않도록 구성해야 한다.

@Service
class PostService(private val postRepository: PostRepository, private val cache: Cache<Long, Long>) {

    @Scheduled(fixedRate = 60000) // 1분마다 캐시된 조회수를 DB에 동기화
    fun syncCacheToDatabase() {
        cache.asMap().forEach { (postId, cachedViewCount) ->
            val post = postRepository.findById(postId).orElseThrow { RuntimeException("Post not found") }
            post.viewCount += cachedViewCount
            postRepository.save(post)
            cache.invalidate(postId)
        }
    }

    fun increaseViewCount(postId: Long) {
        cache.put(postId, cache.get(postId, { 0L }) + 1)
    }
}

또한 인메모리 데이터베이스인 Redis에는 INCR 이라는 명령어가 있다. 해당 값을 스레드 세이프하게 1 증가시키는 연산으로, 이곳에 값을 써놓고 주기적으로 데이터베이스에 반영시키는 방법이 있다.

  • 장점
    • 읽기 작업이 쓰기 작업에 의해 방해 받는 것을 줄일 수 있다.
  • 단점
    • 주기적 플러시 작업이 추가된다.
    • 로컬 캐시를 사용하는 경우 재배포 시 데이터 유실이 발생할 수 있다. Graceful 종료 로직이 필요하다.
    • Redis 를 이용하여 유실 조회수를 관리하게 되면 이를 실제 데이터에 반영시킬 때, 중복처리를 방지할 서비스 코드가 필요하다. 또한 다중화 구성도 필요하다.

방안 5. 조회 이벤트를 별도로 발행하여 저장해두고 정확한 집계용 데이터를 별도로 구성

피드 조회 시 피드 조회 이벤트를 데이터베이스에 따로 저장해두고, 이것을 매일 특정 시간에 배치처리하여 정산을 위한 조회수 테이블을 별도로 구성하여 업데이트하여 사용하는 방식이 있다. 각 메시지가 500bytes 정도이고, 하루에 6000만건의 조회가 발생한다고 가정해보자. 대략 하루 3Gb 가량의 데이터가 쌓이는 셈이다. 하루 한번 처리한 뒤 삭제하는 방식등을 고려할 수 있다.

  • 장점
    • 매일 조회수 추이 변화 등을 제공할 수 있다.
    • 매월 조회 수 등을 관리하려할 때 좋은 선택지
  • 단점
    • 배치 처리를 위한 코드 추가

실무에서는 나중에 게시물의 조회 수가 영상 조회 시가 아닌 영상을 20초이상 시청한 뒤에 클라이언트에서 업데이트 요청을 보내도록 정책을 만들게 되어 다음과 같이 구성하였다.

  1. Modifying + Query 어노테이션을 통한 업데이트 적용
  2. Affected Row 가 0으로 반환된다면 충돌이 발생한 것이기 때문에 인메모리에 저장(ConcurrentHashmap)
  3. 주기적으로 ConcurrentHashMap을 Flush하여 데이터 베이스에 반영하도록 코드 작성
  4. ConcurrentHashMap을 관리하는 클래스에 @PreDestroy를 적용하여 어플리케이션 종료 전 데이터 베이스 반영 코드 작성