[Spring Boot]게시글 조회수 증가, 중복방지 기능 만들기

모지리 개발자·2022년 10월 27일
5

spring

목록 보기
2/8

Intro


하고자하는 것은 위와 같이 게시글에 대하여 조회수를 증가시키는 로직을 구성하는 것입니다. 이글은 조회수 증가기능을 구현하면서 했던 고민들과 방법을 작성한 글입니다.

기존 조회수 +1 코드

기존에는 게시글을 조회할 때 +1 해주는 방식을 사용했었습니다. 코드는 아래와 같습니다.

BoardRepositoryImpl.java

    @Override
    public BoardResponseDTO getBoardWithTag(Long id) {

        // TODO 중복 조회를 어떻게 예방할 것인지 고민해야함
        queryFactory.update(board)
                .set(board.viewCount, board.viewCount.add(1))
                .where(board.id.eq(id))
                .execute();

        Board board1 = queryFactory.select(board)
                .from(board)
                .leftJoin(board.category, category).fetchJoin()
                .leftJoin(board.writer, member).fetchJoin()
                .where(board.id.eq(id))
                .fetchOne();

        if (board1 == null) {
            throw new NotFoundException("Could not found board id : " + id);
        }

        return BoardResponseMapper.INSTANCE.toDto(board1);

    }

이렇게 코드를 작성하게 된다면 게시글이 조회될 때마다 조회수 증가가 가능하지만 같은 사용자에 한해서도 무한정 조회수가 증가될 수 있게됩니다. 그래서 조회수 중복 count를 어떻게 방지할 수 있을지 고민해보았습니다.

정확한 기능 생각해보기

정확히 어떤 방식으로 조회수가 카운트 되는 것인지 생각해보았습니다.
예시로는 youtube의 조회수 카운트 방식이 있습니다.

  1. 유튜브 사용자가 의도적으로 동영상을 시작합니다.
  2. 유튜브 사용자는 적어도 30초 동안 영상을 시청해야합니다. (30초 이하의 영상일 시 전체 시청 할시 조회수 카운트)
  3. 유튜브의 동영상에 스팸 댓글을 남기는 사용자의 보기는 계산하지 않습니다.
  4. 웹 사이트에 포함된 자동 시작 동영상은 조회수로 집산되지 않습니다.
  5. 최대 반복수는 300번으로 예상되고 있습니다.
  6. 조회수가 증가하지 않는 상황
    • 많은 장치에 대해 하나의 IP 주소를 사용하여 동시에 동일한 영상을 시청하는 경우
    • 윈도우 또는 탭을 많이 실행시켜 영상을 동시에 보는 행위
    • 영상을 시청하는 30초마다 페이지 새로고침

물론...

게시글의 특성상, 그리고 다양한 레퍼런스들을 참고했을 때 유튜브처럼 조회수에 대해 복잡한 로직을 가지고 있지는 않았습니다. 하지만 간단한 조회수 증가 기능을 구현하더라도 다양한 것을 고려해야할 수 있다는 것을 말씀드리고 싶었습니다.

그래서 제가 생각한 제 게시글의 기능은 아래와 같습니다.

  • 회원, 비회원 모두 조회수 1 증가
  • 하루 뒤에 같은 게시글을 읽게 된다면 다시 조회수 1 증가

어떤 방법들이 있을까?

조회 수 중복 count를 방지하는 것에는 다양한 방법이 있을 것으로 예상됩니다.

  1. IP 또는 Mac Address
  2. 세션
  3. 쿠키

정도가 생각이 났습니다.

IP로 처리했을 때의 장단점

IP로 처리한다고는 작성했지만 사실 상 DB를 이용했을 때의 방법과 비슷합니다.

장점
1. 조작이 불가합니다.(해킹하지않는한)
단점
1. IP는 장소에 따라 유동적으로 변할 수 있는 문제점이 있습니다.
2. Mac Address는 같은 유저라도 다른 기기라면 다른 유저로 식별됩니다.
3. IP와 Mac Address는 값이 길기 때문에 수많은 유저와 수많은 게시글과 날짜를 함께 저장하기에는 문제점이 있습니다.

세션으로 처리했을 때의 장단점

우선 세션의 특징은 사용자 정보를 서버에서 관리하는 것입니다.

장점
1. 사용자정보를 서버에 둔다는 뜻은 쿠키보다 보안에는 좋다는 것을 의미합니다.
2. 저장데이터에 제한이 없습니다.(서버 성능이 무한히 좋다는 가정이 있을 시)
단점
1. 서버에 데이터를 저장하므로 서버의 리소스를 사용하기 때문에 세션양이 많아진다면 서버에 부하가 커집니다. -> 비용, 성능과 직결될 수 있는 문제가 발생할 수 있습니다.

쿠키로 처리했을 때의 장단점

쿠키는 웹사이트에 접속할 때 생성되는 정보를 담은 임시 파일입니다.
쿠키의 데이터 형태는 Key와 Value로 구성되고 String 형태로 이루어져있습니다.

장점
1. 서버의 공간을 절약할 수 있습니다.

단점
1. 개인의 정보가 기록된다면 사생활을 침해할 소지가 있습니다.
2. 서버가 가지고 있는 것이 아니라 사용자에게 저장되기 때문에, 임의로 고치거나 지울 수 있고, 가로채기도 쉬워 보안에 취약합니다. 이는 곧 조회수를 조작할 수 있다는 것을 의미합니다.

쿠키 너로 정했다.

쿠키로 조회수 증가 로직을 구현하기로 하였습니다.
이유는 아래와 같습니다.
1. 쿠키의 단점이었던 개인정보가 들어가있다면 보안에 취약하여 사생활 침해의 소지가 있다는 것 -> 제가 생성할 쿠키에는 개인정보가 들어가 있지 않았고 단순 조회수 증가로직에만 사용할 것이기 때문에 큰 문제가 되지 않을 것이라고 생각했습니다.
2. 조회 수 조작이 가능하다는 것 -> 우선 조작해도된다...라고 생각했습니다. 공부용 프로젝트 이기도 했고 session을 사용하거나 DB를 사용하려면 따로 서버를 생성해야하는데 비용이 부족한 상태이기도 했습니다.

코드로 확인하기

전체코드는 chu-chu github에서 확인하실 수 있습니다.
BoardController.java

    @GetMapping("/detail/{id}")
    public ResponseResult<BoardResponseDTO> getOne(@PathVariable(value = "id") Long id, HttpServletRequest req, HttpServletResponse res) {
        viewCountUp(id, req, res);
        return success(boardService.getBoardWithTag(id));
    }
    
   ...
   
    private void viewCountUp(Long id, HttpServletRequest req, HttpServletResponse res) {

        Cookie oldCookie = null;

        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("boardView")) {
                    oldCookie = cookie;
                }
            }
        }

        if (oldCookie != null) {
            if (!oldCookie.getValue().contains("[" + id.toString() + "]")) {
                boardService.viewCountUp(id);
                oldCookie.setValue(oldCookie.getValue() + "_[" + id + "]");
                oldCookie.setPath("/");
                oldCookie.setMaxAge(60 * 60 * 24);
                res.addCookie(oldCookie);
            }
        } else {
            boardService.viewCountUp(id);
            Cookie newCookie = new Cookie("boardView","[" + id + "]");
            newCookie.setPath("/");
            newCookie.setMaxAge(60 * 60 * 24);
            res.addCookie(newCookie);
        }
    }
  1. 클라이언트로부터 요청이 들어온다.
  2. 요청에 Cookie가 없고 글을 조회한다면 [게시글ID]의 값을 추가하여 Cookie생성 (기간은 하루로 설정)
  3. 요청에 Cookie가 있고 글을 조회한 기록이 있다면 pass 없다면 Cookie에 [게시글ID] 붙이기

[1]_[2] 처럼 값이 담기게 한 이유는 101번글과 10번글의 구분을 위해서 이렇게 작성하였습니다.

BoardService.java

    @Transactional
    public void viewCountUp(Long boardId) {
        Board board = findById(boardId);
        board.viewCountUp(board);
    }

Board.java

    public void viewCountUp(Board board) {
        board.viewCount++;
    }

결과 확인해보기


우선 위와 같이 게시글이 2개가 있다고 가정해보겠습니다.

1번과 2번글을 조회했을 때

위와 같이 Cookie가 생성된 것을 볼 수 있습니다. 이제는 1번과 2번글을 조회하더라도 조회수가 count되지 않습니다.

문제점

  1. 1번 글을 읽는다. -> 쿠키 상태 : [1] (age 하루인 상태)
  2. 하루가 지나기전에 다른 글을 읽는다. -> [1]_[2] (다시 age가 하루로 바뀜)

위와같은 상황이 반복된다면 하루 이틀 일주일이 지나더라도 1번 글을 조회할 때 조회수가 count 되지 않는 문제가 발생하게 됩니다.
이 문제를 해결하기 위해서는 위에서 작성했던 코드처럼 작성하는 것이 아니라 게시글 각각 cookie를 생성하는 방법을 생각해볼 수 있었습니다.

하지만 이것도 한가지 문제가 있었습니다. 클라이언트에는 쿠키의 저장용량, 저장개수가 한정되어있다는 것입니다.

보통 아래에 적혀있는 것이 표준안 이라고 합니다.

  • 총 300개
  • 하나의 도메인당 20개
  • 하나의 쿠키당 4kb(=4096byte)

게시글마다 cookie를 생성하게 된다면 21개 이상의 글을 조회하는 순간 쿠키가 생성되지 않게됩니다.

그래서 우선은 제가 작성한 방법대로 cookie를 사용하기로 하고 더 효율적인 방법이 생각나거나 떠오른다면 수정해보도록하겠습니다.

결론

cookie를 사용하여 조회수 증가 로직, 중복 count를 방지해보았습니다. 더 좋은 방법이 생기거나 수정하게 된다면 글을 업데이트 하도록 하겠습니다. 감사합니다!

제가 잘못이해하고 있거나 잘못 작성한 부분이 있다면 지적, 비판, 피드백 뭐든 해주시면 감사하겠습니다!

참고블로그
https://guiyomi.tistory.com/92
https://ssdragon.tistory.com/118
https://audiencegain.net/%EC%9C%A0%ED%8A%9C%EB%B8%8C-%EC%A1%B0%ED%9A%8C%EC%88%98%EB%A5%BC-%EC%B9%B4%EC%9A%B4%ED%8A%B8%ED%95%9C-%EB%B0%A9%EB%B2%95/
https://velog.io/@juwonlee920/Spring-%EC%A1%B0%ED%9A%8C%EC%88%98-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84-%EC%A1%B0%ED%9A%8C%EC%88%98-%EC%A4%91%EB%B3%B5-%EB%B0%A9%EC%A7%80

profile
항상 부족하다 생각하며 발전하겠습니다.

2개의 댓글

comment-user-thumbnail
2023년 4월 17일

사실 큰 서비스에서는 자본에 상관없이 DB에 log를 남겨 처리하는 곳도 있는것 같지만,
간단하게는 Redis라는 캐싱을 사용하여도 좋을 것 같아요. 만료기한을 주는 것이죠

답글 달기
comment-user-thumbnail
2023년 8월 9일

board.viewCount++; 가 불안한 부분이네용
위 코드는 ARU에서 실제적으로 board.viewCount = board.viewCount + 1 이 되요

즉, 3개의 operation이 발생하는데

  1. board.viewCount 를 읽는다.
  2. board.viewCount + 1 을 계산한다.
  3. board.viewCount 를 저장한다.

이 3개의 동작이 atomic하게 동작할 무언가가 필요하다고 생각해요.
그렇지 않다면 요청수가 매우 많을 경우 write skew 와 같은 상황이 발생할거 같아요

답글 달기