카테고리 없음

Redis HyperLogLog + 스케쥴링을 통한 조회수 카운트

후후후하하하 2024. 7. 11. 20:49

조회수를 구현할 때는

 

1. 중복 카운팅 2. 동시성 3. 속도 4. 락

위 부분을 고려해야 합니다.

 

먼저 조회수를 구현하는 방법에는 여러 방법이 있습니다.

1. IP or Mac Address

IP는 같은 IP에서 여러 번 조회하는 경우에 조회수 중복 문제가 방지된다는 장점이 있지만, 여러 사용자가 같은 IP에서 조회한다면 조회수가 1만 올라간다는 단점이 있습니다. 또한 Mac Address는 같은 유저라도 다른 기기라면 다른 유저로 인식한다는 단점 또한 존재하죠.

 

2. 쿠키

브라우저 의존성 : 쿠키는 브라우저에 저장됩니다. 그렇다면 크롬으로 조회하고, edge로 조회했을 때 같은 사용자임에도 조회수가 중복 카운팅 된다는 단점이 있습니다. 또한 정확한 카운팅을 위해 쿠키에 사용자가 방문한 페이지들을 저장한다고 하면 쿠키는 저장할 수 있는 데이터 용량이 작으므로 크기 제한에 도달할 수 있습니다. 

 

3. 세션

만약 세션이 만료된다면 재접속시에 새로운 조회 카운트가 증가한다는 점과,

세션은 서버에 저장되는 값으로써, 트래픽이 증가한다면 서버의 부하가 증가할 수 있기 때문에 적합하지 않습니다.

또한 현재 프로젝트에서는 JWT를 통한 인증/인가를 진행하고 있기 때문에 조회수를 위해 세션을 도입하는 것은 좋지 않다고 판단하였습니다.

 

4. Redis

Redis는 고성능 메모리 기반 데이터 저장소로 강력한 속도를 제공합니다.

 만약 조회수를 매번 RDB에 저장한다면 Disk I/O가 일어나기에 속도 저하가 필연적으로 발생하고, update시에 락이 걸리기 때문에 성능 저하도 발생할 수 있습니다. 하지만 Redis를 사용하여 메모리에서 카운팅을 진행한다면 매우 빠르게 처리할 수 있습니다. 또한 레디스는 Single Thread로 동작하기에 동시성 문제도 발생하지 않습니다.

 

또한 조회수 구현 시에는 동시성 문제를 고려해야 합니다.

이를 해결하기 위한 방법으로는 4가지가 있습니다.

  1. Optimistic Locking (낙관적 락):
    1. 낙관적 락은 데이터베이스 레벨에서 강제적인 락을 사용하지 않고, 데이터에 버전 정보를 추가하여, 업데이트 시점에 데이터가 수정되지 않았는지 확인하는 방식입니다. 일반적으로 @Version 어노테이션을 엔티티 필드에 추가하여 버전 관리 필드를 사용합니다.
    2. 장점: 데이터베이스 락을 사용하지 않으므로 성능에 유리하며, 대부분의 경우 데이터 충돌이 없다는 가정하에 동작합니다.
    3. 단점: 동시에 많은 트랜잭션이 발생하고, 데이터 충돌이 자주 일어나면 실패가 빈번할 수 있습니다.
  2. Pessimistic Locking (비관적 락):
    1. 비관적 락은 데이터베이스에서 조회하는 동안 해당 데이터를 잠금 상태로 두어, 다른 트랜잭션이 해당 데이터에 접근하지 못하게 하는 방법입니다. @Lock 어노테이션을 사용해 비관적 락을 구현할 수 있습니다.
    2. 장점: 동시성 문제가 발생할 가능성이 낮습니다. 데이터 충돌이 빈번한 경우에도 안전하게 데이터를 관리할 수 있습니다.
    3. 단점: 트랜잭션이 길어질 수 있고, 데이터베이스 락이 발생하여 성능 저하를 초래할 수 있습니다.
  3. Atomic Operations (원자적 연산)
    1. 특정 필드의 값을 원자적으로 증가시키는 방법입니다. 데이터베이스에서 제공하는 increment 또는 decrement 함수를 사용하여 조회수를 증가시킬 수 있습니다.
    2. 장점: 매우 간단하고 빠릅니다. 데이터베이스 레벨에서 동시성을 보장하기 때문에 별도의 락이 필요 없습니다.
    3. 단점: 데이터베이스에서 지원하지 않는 경우에는 사용할 수 없습니다.
  4. Redis와 같은 캐시 사용
    1. 조회수를 즉각적으로 데이터베이스에 반영하지 않고, Redis와 같은 인메모리 데이터베이스에 캐싱하여 일정 주기마다 반영하는 방법입니다.
    2. 장점: 데이터베이스 부하를 줄이고, 성능을 극대화할 수 있습니다.
    3. 단점: 캐시된 데이터와 실제 데이터베이스 간의 일관성 문제가 발생할 수 있습니다.
    4. 사용법: 조회수를 캐시에 저장하고, 주기적으로 또는 트래픽이 적은 시간에 데이터베이스에 동기화합니다.

 

제 생각에는 Redis가 가장 단점이 적은 방법이고 학습도 진행할 겸 Redis로 진행하게 되었습니다. 하지만 이 방법 또한 단점이 없지는 않았습니다. 우선 메모리 비용입니다. 조회수 데이터는 사실 매우 많은 데이터고 업데이트도 매우 자주 되는 데이터입니다. 하지만 메모리 용량은 한계가 있고, 데이터량이 많아질수록 많은 비용이 들 수 있습니다. 또 하나는 유실 가능성입니다. 물론 RDB(Snapshot), AOF(Append Only File)같은 방법들로 영구적으로 데이터를 저장할 수 있지만,  RDB 같은 경우는 스냅샷을 생성하는 사이에 유실 가능성이 있고, AOP 같은 경우는 유실되었을 시 다시 복구하는데 비용이 많이 든다는 단점이 있었습니다.

 

그래서 메모리 효율을 높일 수 있는 방법을 찾아보다 Redis가 지원하는 HyperLogLog(HLL)라는 알고리즘을 발견했습니다.HLL은 대규모 시스템에서 고유한 사용자 수나 방문자 수를 셀 때 메모리를 적게 사용하여 빠르게 결과값을 추정할 수 있는 알고리즘입니다. HLL의 장점은 메모리를 일반적으로 단 12KB 사용함으로써 수십억개의 데이터 처리가 가능하단 점입니다. 이는 일반 Redis 사용의 첫 번째 단점이었던 데이터가 많아질수록 메모리를 많이 사용할 수 있다는 단점을 해결합니다. 또한 중복 카운팅을 막을 수 있는 장점이 있습니다. HLL은 해싱을 통해 고유 항목의 개수를 추정함으로써 중복을 막을 수 있습니다. 즉, USER1이 세 번 요청해도 USER1의 해싱값은 하나이므로 마치 SET 자료구조와 같이 중복제거 역할을 할 수 있는 것입니다. 

하지만 역시 장점만 가진 무언가는 존재하지 않습니다.(사람도 마찬가지죠) HLL은 100% 정확한 수치를 계산하지는 못합니다. 보통 +-0.81% 정도의 오차가 있다고 합니다. 
그래서 사실 정확한 데이터가 필요하다면 HLL은 그리 좋은 방법은 아닙니다. 하지만 현재 프로젝트 특성상 조회수가 그렇게 정확해야 하는 데이터가 아니며, 사실상 0.8%대의 오차 정도라면 조회수에 한하여 큰 문제가 되지 않겠다는 판단하에 도입하게 되었습니다.

 

두 번째인 유실 가능성 문제를 해결하기 위해서는 스프링이 제공하는 @Scheduled 어노테이션을 통해 기존 MariaDB에 배치를 통해 데이터를 저장하였습니다.

 앞서 언급했듯이 Redis는 RDB(Snapshot), AOF라는 데이터 영구적 저장 기능이 있습니다. 

 하지만 RDB(Snapshot) 는 SNAPSHOT이 찍히기 전, 중간 과정에서 데이터 유실 가능성이 있고, AOF는 데이터 유실 발생 시 백업을 위해 모든 데이터를 다시 복구해야하는 단점이 있습니다.

또한 조회수를 이용한 정렬 기능을 수행해야 했기에, 데이터를 RDB(MariaDB)에 넣는 건 필수불가결한 작업이였습니다.

 물론 Redis에서 바로 조회수를 가져와서 정렬을 수행하는 방법도 있었지만, 앞서 언급했던 데이터 유실 가능성으로 인해 안전성이 좀 떨어진다는 단점이 있었습니다. 그래서

1. 데이터의 영구성 및 안전성

2. 쿼리 사용

3. 추후 모니터링 과정에서 관리 수월

 

이러한 이유로 RDB(MariaDB)에 데이터를 보관해야 했습니다.

하지만 이는 실제 Redis의 데이터를 RDB로 UPDATE 쿼리가 나가는 것이기에 너무 자주 진행한다면 지금까지 메모리와 속도를 향상시키기 위한 노력이 의미가 없습니다. 그래서 6시간 단위로 스케쥴러를 만들어서 UPDATE 를 진행하였습니다.

 

이러한 과정들을 통해 메모리는 일반적으로 12KB 이내로 사용하면서, 동시성 문제, 데이터 유실 걱정도 없는 조회수 카운팅 기능을 완성하였습니다. (99.2%의 정확도)

 

# Spring을 이용한 HLL 방법은 많이 공유되어 있지 않은 듯하여 HLL 설정 부분 간단하게 코드 첨부하겠습니다.

@Service
public class ViewService {
    public static final String HYPERLOGLOG_KEY_PREFIX = "profile:views:";

    private final RedisTemplate<String, String> redisTemplate;

    public ViewService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void addProfileView(Long memberId, String viewerId) {
        String key = HYPERLOGLOG_KEY_PREFIX + memberId;
        redisTemplate.opsForHyperLogLog().add(key, viewerId);
    }

    public long getProfileViewCount(Long memberId) {
        String key = HYPERLOGLOG_KEY_PREFIX + memberId;
        return redisTemplate.opsForHyperLogLog().size(key);
    }
}