쿠키 + 인메모리 캐시 기반 게시글 조회수 로직

jkky98·2025년 2월 27일
0

ProjectSpring

목록 보기
14/20

기존 조회수 로직 문제점

기존의 조회수 로직은 게시글이 조회될 때마다 해당 게시글의 외래키를 갖는 조회 엔티티를 생성하여 데이터베이스에 INSERT하는 방식이다. 이 방식에는 다음과 같은 문제점들이 존재한다.

  1. 데이터베이스 부하 증가이다.
    게시글 조회가 발생할 때마다 INSERT 작업이 수행되므로, 트래픽이 많은 환경에서는 데이터베이스에 과도한 쓰기 부하가 발생하게 된다.

  2. 테이블 사이즈 급증이다.
    모든 조회마다 별도의 레코드가 생성되므로, 조회수 데이터가 누적되어 테이블의 건수가 빠르게 증가하게 되며, 이는 저장 공간 관리와 성능 유지에 어려움을 초래한다.

  3. 집계 작업의 비효율성이다.
    전체 조회수를 산출하기 위해 모든 레코드를 COUNT 하는 방식은 데이터가 많아질 경우 집계 쿼리의 성능을 저하시킬 수 있다.

  4. 동시성 및 경합 문제가 발생한다.
    높은 동시 접속 상황에서 다수의 INSERT 작업이 동시에 발생할 경우, 데이터베이스의 동시성 제어나 락 경합 문제가 발생하여 전체 시스템의 성능과 안정성에 영향을 미칠 수 있다.

  5. 운영 및 유지보수 비용 증가이다.
    대량의 조회수 데이터를 관리하고 분석하는 데 추가적인 리소스와 비용이 소요되며, 시스템 복잡성이 증가함에 따라 운영 및 유지보수가 어려워진다.

개선 방향

이를 개선하기 위해 게시글 테이블에 viewCount 필드로 하여금 조회수 필드를 활용하도록 하는 것이다.

레디스는 사용하지 않는 방향으로 개선해보도록 하자.

개선된 조회수 로직의 동시성 및 성능 측면 분석

  1. 엔티티 INSERT 대신 조회수 필드 업데이트
    기존에는 조회마다 별도의 조회 엔티티를 INSERT 하였으나, 게시글 엔티티에 조회수 필드를 추가하여 조회 시마다 해당 값을 증가시키는 방법을 채택하면,

    • 데이터베이스에 발생하는 쓰기 부하와 테이블 사이즈의 급증 문제를 효과적으로 해결할 수 있다.
    • 또한 전체 조회수를 산출할 때 단순히 게시글 엔티티의 조회수 필드 값을 읽으면 되므로 집계 쿼리 성능도 개선된다.
  2. 동시 접속 상황에서 UPDATE 작업의 성능 측면
    동시 접속 환경에서 INSERT 작업 대신 UPDATE 작업으로 전환하면,

    • 각 조회마다 새로운 레코드를 추가하는 INSERT 방식보다 동일한 엔티티의 필드 값을 변경하는 UPDATE 방식이 데이터베이스에 가하는 부하가 상대적으로 적다.
    • 이로 인해 전반적인 성능 향상을 기대할 수 있다.

    다만, 동시 다발적으로 동일 게시글의 조회수가 업데이트될 경우,

    • 락 경합 등의 동시성 이슈가 발생할 가능성이 있다.
    • 이 문제를 해결하기 위해, 데이터베이스의 락 메커니즘(예: 비관적 락 또는 낙관적 락)을 적용할 수 있다.

결론적으로, 동시 접속 상황에서 INSERT 작업 대신 UPDATE 작업을 사용하는 방식은 전반적으로 성능 향상에 기여한다.

다만, 동일 엔티티에 대한 빈번한 업데이트로 인한 동시성 문제를 적절히 관리하기 위한 추가 전략이 필요하다.

낙관적 락 적용 결정 프로세스

프로젝트에서 게시글의 조회수를 관리하는 로직을 기존의 INSERT 방식에서 게시글 엔티티의 조회수 필드를 UPDATE 하는 방식으로 전환하였다.
이 방식은 데이터베이스 부하와 테이블 사이즈 증가 문제를 해결하고, 집계 작업을 단순화할 수 있다는 장점이 있다.

그러나 동시 접속 상황에서 동일 게시글의 조회수 필드를 업데이트할 때 발생할 수 있는 동시성 문제를 해결하기 위한 추가 전략이 필요하였다.
이를 위해 여러 락(Lock) 기술을 검토한 결과, 낙관적 락(Optimistic Lock)을 적용하는 것이 가장 합리적이라고 판단하였다.

결정 프로세스

  1. 기존 문제 인식

    • INSERT 방식의 문제점: 조회마다 별도의 레코드를 INSERT 하여 데이터베이스 부하와 테이블 사이즈 급증이 발생함.
    • UPDATE 방식의 이점: 게시글 엔티티의 조회수 필드를 단순히 증가시키는 UPDATE 작업으로 전환하여, 집계 및 성능 측면에서 개선됨.
  2. 동시성 문제 발생 가능성

    • 여러 사용자가 동시에 같은 게시글을 조회하는 경우, 동일한 엔티티의 조회수 필드를 UPDATE 할 때 동시성 문제(예: 락 경합)가 발생할 수 있음.
    • 빈번한 UPDATE 작업은 데이터베이스의 동시성 관리 측면에서 추가적인 고려가 필요함.
  3. 락 기술 검토

    • 비관적 락(Pessimistic Lock): 데이터에 대한 접근 시마다 강제적인 락을 걸어 동시성을 제어하는 방식이나, 높은 트래픽 환경에서 성능 저하 및 병목 현상이 발생할 수 있음.
    • 낙관적 락(Optimistic Lock): 업데이트 시 버전 정보를 활용하여 동시성 충돌을 감지하는 방식으로, 대부분의 경우 충돌이 발생하지 않을 것으로 가정하고 처리하는 방식이다.
      • 버전 정보(예: @Version 필드)를 통해 충돌이 발생하면 재시도를 할 수 있어, 락에 의한 성능 저하를 최소화할 수 있음.
  4. 낙관적 락 선택 이유

    • 조회수 업데이트는 대부분 충돌 없이 진행될 것으로 예상되며, 충돌이 발생하는 경우에만 재시도하면 되므로 전체적인 성능에 미치는 영향이 적다.
    • 비관적 락보다 락 경합을 최소화할 수 있어, 높은 동시 접속 상황에서도 안정적인 성능을 보장할 수 있다.
    • Spring Data JPA 등 ORM 프레임워크에서 기본적으로 지원하므로, 구현 및 유지보수가 용이하다.

따라서, 동시 접속 환경에서 발생할 수 있는 동시성 문제를 적절히 관리하기 위해
낙관적 락을 적용하는 것이 가장 합리적인 선택이다.
이 접근법은 조회수 업데이트의 성능 향상과 안정성을 동시에 확보할 수 있는 효과적인 전략이다.

낙관적 락 적용기

// Post 엔티티 필드 추가
    @Version
    private Integer version;

    private int viewCount;

@Version 필드로 하여금 낙관적 락을 구현하자.

@Version 어노테이션이 적용된 version 필드는 데이터베이스 레코드의 버전을 관리하는 역할을 한다.
레코드가 DB 수준에서 변경되면 해당 레코드의 버전 값이 자동으로 증가된다.

두 쓰레드가 동시에 게시글의 조회수를 증가시키려 할 경우, 낙관적 락은 다음과 같이 동작한다.

  1. 쓰레드1이 조회수 증가를 위해 Post 엔티티를 조회한다.
  2. 쓰레드2도 조회수 증가를 위해 동일한 Post 엔티티를 조회한다.
  3. 쓰레드1은 조회수 필드의 기존 값(예: 0)에 1을 더한다.
  4. 쓰레드2 역시 조회수 필드의 기존 값(0)에 1을 더한다.
  5. 쓰레드1이 DB에 flush를 수행하면, version 값이 증가하고 조회수 증가가 반영된다.
  6. 쓰레드2가 flush를 시도할 때, 초기 조회 시의 version과 DB에 반영된 version이 달라져 OptimisticLockException이 발생한다.
  7. 이 예외를 처리하여 재시도(Retry) 로직을 구성해야 한다.

조회수 중복 및 어뷰징 문제

위의 내용까지는 게시글 조회시 항상 Post엔티티의 viewCount++를 수행한다. 이를 막고 조회수는 하루에 한 번까지만 가능하게 해야한다.

다양한 해결책이 있지만 쿠키와 함께, 인메모리 캐시인 ehcache를 활용하여 조회수 중복 문제를 해결해보려고 한다.

최종 결과물

@Transactional
    public void increaseViewCountV2(Long postId, HttpServletRequest request, HttpServletResponse response) {
        // 쿠키 파싱: postId와 조회 시점을 Map으로 가져옴
        Map<Long, LocalDateTime> viewedPostsMap = getViewedPostsCookieEntries(request);
        String clientIdentifier = getClientIdentifier(request);
        String cacheKey = clientIdentifier + "-" + postId;

        // 쿠키에 해당 postId가 있고, 조회 시점이 6시간 이내라면 조회수 증가 스킵
        if (viewedPostsMap.containsKey(postId)) {
            LocalDateTime lastViewTime = viewedPostsMap.get(postId);
            long hoursSinceLastView = Duration.between(lastViewTime, LocalDateTime.now()).toHours();
            if (hoursSinceLastView < 6) {
                log.info("조회수 증가 스킵 - 최근 {}시간 이내에 조회됨 (postId: {})", hoursSinceLastView, postId);
                return;
            }
        }

        // 쿠키에 기록이 없거나, 6시간이 지난 경우 캐시 확인 (캐시에는 key:String, value:true 형식으로 저장)
        Cache cache = cacheManager.getCache("viewLogCache");
        if (cache != null && cache.get(cacheKey) == null) {
            boolean success = false;
            int retryCount = 0;
            int maxRetry = 3;
            Post post = postService.findById(postId);

            while (!success && retryCount < maxRetry) {
                try {
                    // 게시글의 조회수 증가
                    post.increaseViewCount();
                    entityManager.flush();
                    entityManager.clear();
                    log.info("조회수 증가 성공 - postId: {}, 새로운 viewCount: {}", postId, post.getViewCount());

                    // 캐시에 새로운 조회 기록 저장
                    cache.put(cacheKey, true);
                    log.info("📥 캐시에 새로운 조회 기록 저장 - cacheKey: {}", cacheKey);

                    // 쿠키 업데이트: 현재 시간으로 해당 postId의 조회 기록을 갱신
                    viewedPostsMap.put(postId, LocalDateTime.now());
                    String newCookieValue = buildViewedPostsCookieValue(viewedPostsMap);
                    setViewedPostsCookie(response, newCookieValue);
                    log.info("쿠키 업데이트 완료 - postId: {}, cookieValue: {}", postId, newCookieValue);

                    success = true;
                } catch (OptimisticLockException e) {
                    retryCount++;
                    log.warn("OptimisticLockException 발생 - 재시도 {}/{} (postId: {})", retryCount, maxRetry, postId);
                    if (retryCount >= maxRetry) {
                        log.error("조회수 업데이트 실패 (최대 재시도 초과) - postId: {}", postId);
                        throw new RuntimeException("조회수 업데이트 실패 (최대 재시도 초과)", e);
                    }
                    // 최신 데이터를 위해 영속성 컨텍스트 초기화 후 재조회
                    entityManager.clear();
                    post = postService.findById(postId);
                }
            }
            return;
        }

        // 캐시에 이미 기록이 있는 경우 (중복 조회로 간주)
        log.info("조회수 증가 스킵 - 캐시에 중복 조회 기록 존재 (cacheKey: {})", cacheKey);
    }

1차적으로 쿠키를 조회한다. 쿠키에는 클라이언트가 접속한 게시글의 id와 조회시 시간(timestamp)이 담기게 된다.

이를 통해 쿠키에 postId는 존재하나 6시간이 지나지 않았는 지 확인한다.
만약 지나지 않았다면 return하여 조회수 증가 로직 함수를 전체 패스 시킨다.

그렇다면 필터링된 경우는 다음과 같다.
1. 쿠키에 postId는 존재하지만 6시간이 지난 경우
2. 쿠키에 해당 postId자체가 존재하지 않는 경우
3. 쿠키자체가 없는 경우

이 세 경우는 모두 그 다음 과정인 캐시 확인이 필요하다.

캐시 확인을 하는 이유는 의도적인 쿠키 삭제를 통한 조회수 어뷰징을 막기 위해서이다.

조회수 증가 로직에서 캐시는 IP+UserAgent를 key로 하는 캐시를 확인한다.
캐시는 TTL이 6시간으로 적용되었다.

만약 캐시 조회도 실패한다면 조회수를 증가시킨다.

이때 조회수 증가는 낙관적 락이 적용되므로 OptimisticLockException 예외를 잡아 3번의 retry를 허용하도록 한다.

조회수 증가에 성공했다면, 쿠키를 재발급(postId:현재시간)하고, 캐시를 갱신한다.

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보