회원과 비회원을 고려한 복합 조회수 카운팅 정책

💡이 글은 현재 리팩토링 중인 동아리 기술 블로그 서비스의 조회수 카운팅 정책에 대한 내용입니다. 다양한 정책이 존재할 때, 장/단점을 따져보며 필요한 정책을 채택하는 고민이 담겨있습니다. 회원과 비회원에게 각각 다른 정책을 적용하는 방식을 선택하였습니다.

 

1. 조회수 카운팅 정책

 조회수를 어떻게 올릴까? 생각해보면 저는 단순히 조회마다 +1하는 방법이 가장 먼저 떠오릅니다. 이 경우 새로고침을 연타하여 조회수를 상승시켜 본 기억도 있습니다. 때에 따라 카운팅 정책을 알맞게 설정하여 예전의 저와 같은 행동으로 인해 발생하는 부정확한 조회수 문제를 방지할 수 있습니다. 아래에 소개하는 것처럼 다양한 방식으로 조회수 카운팅 정책을 설정할 수 있습니다.

 

1) 클릭 시 +1

 현재 조회 수 카운팅 정책으로, MVP 개발을 위해 채택한 구현에 가장 부담없는 정책입니다. 단순히 게시글 조회 시 +1을 해주며, 회원/비회원 모두 동일하게 적용됩니다.

 이 방식은 구현이 쉬우며, 어찌됐든 페이지의 총 조회수를 확인할 수 있다는 장점이 있습니다.

반면, 새로고침을 계속 하는 등 조회 수 어뷰징 문제가 발생할 수 있으며, 실제 방문자 수와는 거리가 멀어 과대 집계될 수 있다는 문제가 있습니다.

 

2) 쿠키 기반 제한

 브라우저에 쿠키를 저장하여 일정 기간 내 중복 조회 방지하는 방법입니다. 요청 시 쿠키가 없다면 조회수를 상승시키고 쿠키를 발급합니다. 발급된 쿠키는 저장소에 저장하여 추후 요청 시 사용합니다. 요청 시 쿠키가 있다면 조회수를 상승시키지 않습니다.

 따라서 쿠키가 저장되어 있는 동안에는 여러 번 조회하더라도 조회수가 상승하지 않습니다.

 이 방식은 회원 유저뿐만 아니라 비회원 유저의 중복 조회도 방지할 수 있으며, IP 기반 제한처럼 다른 사용자임에도 동일한 사용자로 인식되지 않기 때문에 정확하다는 장점이 있습니다.

 반면, 사용자가 브라우저 리셋을 하거나 쿠키를 삭제하는 등 제어할 수 없는 부분으로 인한 부정확한 카운팅이 존재할 수 있습니다.

 

3) IP 기반 제한

 사용자의 IP를 기준으로 일정 시간 내 한 번만 카운팅하는 방법입니다. 사용자의 IP를 통해 해당 게시글의 방문 여부를 확인합니다. 만약 방문한 적이 없다면 저장소에 저장하고, 조회수를 카운팅 합니다. 이전에 방문한 적이 있다면 카운팅하지 않습니다.

 이 방식은 회원과 비회원 모두 중복 조회 방지가 가능하며, 같은 IP를 사용하지 않는 경우 좋은 중복 조회 방지 방식입니다.

 하지만, 여러 사용자임에도 하나의 IP로 묶여 같은 취급을 받기 때문에, 조회수가 카운팅되지 않는 경우가 많이 발생할 수 있습니다. 특히, 교내 동아리 서비스인만큼 같은 IP를 사용하는 학교 내에서 다수의 사용자가 접근할 가능성이 있는데, 이러한 경우 사용이 적합하지 않을 수 있습니다.

 

4) 세션 기반 제한

 사용자의 세션 단위로 조회수를 카운팅합니다. 사용자가 처음 조회 시 세션 저장소에 세션 ID를 저장하고 조회수를 카운팅합니다. 이미 세션 저장소에 방문 기록이 있다면 카운팅하지 않습니다.

 이는 세션이 유지되는 동안 중복 조회를 막아주는 효과가 있습니다. 

 이 방식은 세션을 통해 회원과 비회원 모두 적용 가능하다는 장점이 있습니다. 

 

5) 사용자(로그인) 기반 제한

 로그인 된 사용자의 id를 기반으로 조회수를 카운팅하는 방법입니다. 위 방식들과 마찬가지로 사용자의 방문 기록을 저장소에 저장합니다. 저장 여부를 통해 조회수를 카운팅합니다.

 

이 방식은 로그인 한 사용자에 한해 정확도 높은 조회수를 측정할 수 있지만, 비회원의 조회수 카운팅 제한할 수 없다는 문제가 있습니다.

 해당 서비스는 동아리 부원만 회원가입하여 로그인 할 수 있기 때문에 조회의 경우 비회원 사용자가 훨씬 많이 존재합니다. 이러한 경우, 비회원의 조회에 더 신경을 써야 하므로 로그인 유저 기반 방식은 다소 부족함이 많습니다.

 

6) 복합(혼합) 정책

 여러 정책을 복합적으로 사용하는 방식입니다. 동아리 기술 블로그 서비스는 부원들만 회원 가입이 가능합니다. 비회원은 게시글 조회가 가능하기 때문에 회원과 비회원을 나누어 관리할 수 있습니다. 회원의 경우 ID로 방문 기록을 확인하고, 비회원의 경우 쿠키나 IP 등 앞에서 설명한 방식으로 조회수 중복 상승을 방지할 수 있습니다.

 이 방식은 상황에 맞게 조정할 수 있다는 점으로 인해 원하는 조회수 카운팅 방식을 만들기 좋습니다. 하지만, 따져 볼 것이 많기 때문에 구현에 불편함이 다소 존재할 수 있습니다.

 각각의 사용자 유형(회원/비회원)에 대해 최대한 정확한 집계가 가능하기 때문에 회원에 대한 통계를 바탕으로 동아리 컨텐츠로 사용할 수 있으며, 비회원 역시 다양한 형태로 정보를 사용할 수 있습니다.

 

2. 선택

 현재 프로젝트에서 글 작성은 회원만 가능하고, 조회는 회원과 비회원 모두 가능합니다. 이미 게시글 상세 조회 시에 회원과 비회원을 나누어 작업하고 있었고, 분리해서 처리한다면 더욱 정확한 조회수 카운팅 할 수 있을 것입니다. 따라서 회원은 ID, 비회원은 쿠키를 사용한 복합 정책을 사용하기로 했습니다. TTL은 2시간으로 설정해놓아 2시간 후 재방문하면 다시 조회수가 카운팅되도록 했습니다.

 

 처음에는 회원 - ID 사용 / 비회원 - IP + 쿠키 사용 방식으로 계획했습니다. 쿠키와 IP를 함께 사용하면 IP 사용 방식의 단점인 같은 IP에 속한 다른 사용자의 조회를 무시한다는 점을 쿠키로 해결할 수 있었습니다. 하지만, 쿠키를 단일로 사용할 때의 문제인 사용자가 쿠키를 제어할 수 있다는 문제는 해결되지 않았습니다. 이렇게 되면 IP를 함께 검사하더라도 쿠키 여부에 따라 조회수 카운팅 여부가 결정되었습니다.

  • 쿠키 o + IP o => 카운팅 x
  • 쿠키 o + IP x => 카운팅 x
  • 쿠키 x + IP o => +1 카운팅 
  • 쿠키 x + IP x => +1 카운팅

 위와 같이 네 가지 경우로 나누어 따져볼 수 있는데, 사용자가 쿠키를 삭제할 수 있는 상황에서 IP를 검사하여 얻을 수 있는 것이 없습니다.

 

 따라서 회원은 ID 사용, 비회원은 쿠키 사용 방식으로 조회수 카운팅 정책을 결정했습니다. 사용자가 쿠키를 삭제할 수 있다는 문제는 해결되지 않지만, 아직 조회수 집계로 생산하는 컨텐츠가 없기 때문에 비용을 줄이는 방안으로 선택했습니다. 조회수 수집 방식이 조금 더 면밀해져야 한다면 view용 조회수와 분석용 조회수를 나누어 수집하는 것도 좋을 것 같습니다.

 

 회원의 경우 ID를 사용하는 것이 가장 효과적이며, 회원은 동아리 부원에 한정되기 때문에 저장소 공간 차지가 적습니다. 따라서 결정에 어려움이 없었지만, 비회원은  IP와 쿠키, 세션 중 선택해야 했습니다. 먼저, IP는 동일한 IP에서 접근하는 모든 조회를 카운팅하지 않기 때문에 학교라는 특징이 있는 서비스 특성 상 무시되는 카운팅이 많아질 것이라고 생각했습니다. 또한, IP와 세션은 별도 저장소를 필요로 하기 때문에 관리하는 데 사용되는 자원이 상대적으로 클 것입니다. 트래픽에 따라 달라지겠지만 회원과 다르게 비회원은 들어오는 요청이 매우 많이 늘어날 수 있기 때문입니다.

 

구현

 조회수는 게시글 상세 조회 API를 기준으로 카운팅합니다. 현재 게시글 상세 조회 시 회원/비회원의 응답 값이 다르고, 이를 전략 패턴으로 구현하고 있습니다. Factory에서 회원/비회원인지 판별 후 그에 맞는 Service를 반환하고, 게시글 상세 조회 응답을 전달합니다.

 

 각자에게 맞는 서비스에서 게시글 상세 응답을 구성합니다. 게시글 상세 조회와 함께 조회수를 카운팅하는데, 알맞은 정책에 따라 업데이트 여부를 결정합니다.

 

 비회원

[ AnonymousPostDetailServiceImpl.java ]

@Transactional
public SuccessResponse<AnonymousPostDetailRes> getPostDetail(PostDetailParams postDetailParams) {
    Post post = postRepository.findById(postDetailParams.getPostId())
            .orElseThrow(() -> new ResourceNotFoundException());

    String cookieName = POST_COOKIE_NAME_PREFIX + postDetailParams.getPostId();
    Cookie existingCookie = WebUtils.getCookie(postDetailParams.getRequest(), cookieName);
    if (existingCookie == null) {
        post.incrementViewCount();
        Cookie newCookie = new Cookie(cookieName, POST_COOKIE_VALUE);
        newCookie.setPath(COOKIE_PATH);
        newCookie.setMaxAge(COOKIE_AGE);
        newCookie.setHttpOnly(true);
        postDetailParams.getResponse().addCookie(newCookie);
    }

    List<String> tagNameList = taggingRepository.findByPost(post).stream()
            .map(tagging -> tagging.getTag().getName())
            .collect(Collectors.toList());

    Member writer = post.getMember();
    AnonymousPostDetailRes anonymousPostDetailRes = AnonymousPostDetailRes.of(post, writer, tagNameList);
    return SuccessResponse.of(anonymousPostDetailRes);
}

 앞에서 살펴본 것처럼 요청에 쿠키가 없다면 조회수 +1 후 쿠키를 생성하여 함께 응답합니다. 쿠키와 함께 요청을 한다면 게시글 정보만을 응답합니다.

 

 회원

[ MemberPostDetailService.java ]

    @Transactional
    public SuccessResponse<MemberPostDetailRes> getPostDetail(PostDetailParams postDetailParams) {
        Member member = postDetailParams.getUserDetails().getMember();
        Post post = postRepository.findByIdAndStage(postDetailParams.getPostId(), Stage.PUBLISHED)
                .orElseThrow(() -> new ResourceNotFoundException());

        String key = REDIS_KEY_MEMBER_PREFIX + member.getId() + REDIS_KEY_POST_PREFIX + post.getId();
        if (!redisUtil.hasKey(key)) {
            post.incrementViewCount();
            redisUtil.setDataExpire(key, POST_REDIS_VALUE, REDIS_DURATION);
        }

        List<String> tagNameList = taggingRepository.findByPost(post).stream()
                .map(tagging -> tagging.getTag().getName())
                .collect(Collectors.toList());

        Member writer = post.getMember();
        boolean sameUser = member.equals(writer);
        boolean liked = likesRepository.existsByMemberAndPost(member, post);
        boolean scraped = scrapRepository.existsByMemberAndPost(member, post);


        MemberPostDetailRes memberPostDetailRes = MemberPostDetailRes.of(post, writer, tagNameList, sameUser, liked, scraped);
        return SuccessResponse.of(memberPostDetailRes);
    }

 마찬가지로 저장소에서 방문 여부를 확인한 후, 조회수 카운팅 여부를 결정합니다. 메일 인증과 JWT 토큰을 위해 Redis를 사용하고 있었으며, 다중 서버 환경에서도 문제 없이 확인할 수 있도록 하기 위해 외부 저장소로 Redis를 사용했습니다. 

 

 

 개발 당시 해당 주제에 대해 PM과 이야기 한 기억이 있습니다. 정책을 추가하면 좋겠지만, MVP로 빠르게 개발을 완료해야 했기 때문에 잠시 접어두고 단순 +1 방식으로 하기로 결정했었습니다. 남겨두었던 것을 다시 자세히 알아보며 따져보아야 할 것들을 생각해보는 유익한 시간이었습니다.