[Spring Boot] 게시글 조회수 (+중복 방지) 구현

임원재·2024년 11월 14일
0

SpringBoot

목록 보기
8/19
post-thumbnail

게시글 조회수 구현

  • 게시글의 조회수를 구현하고자 고민을 하였다.
  • 게시글을 불러올 때마다 조회수를 증가시킨다면 무분별한 새로고침으로 조회수 조작이 가능해진다.
  • 이를 막기 위해 하루에 한 번 게시글을 조회할 때 조회수를 증가시키는 방향을 고려하였다.
  • 게시글 조회수 기능 요구사항은 대략 아래와 같다.

요구사항

  1. 게시글을 조회하면 해당 게시글을 마지막으로 조회한 시간과 비교하여 조회수를 올릴지 판별
  2. 즉 게시글정보에 마지막 조회 시간정보가 같이 붙어있어야 함

방식

이를 구현하기 위해 다음과 같은 방식이 떠올랐다.

1. 세션

  • value 값을 {memberId}:{게시글Id}의 형태로 세션에 저장하는 방식을 생각했다.
  • 세션의 TTL(Time To Live)기능으로 시간정보를 저장할 필요가 없다는 장점이 있다.
  • 하지만 N명의 사용자가 M개의 게시글을 조회한다고 하면 총 N * M개의 세션 데이터를 서버 메모리에서 관리한다.
  • 또한 이렇게 되면 게시글을 조회할 때마다 브라우저가 sessionId를 담아 보내야하는 쿠키의 개수도 증가하게된다.

2. Redis

  • 메모리 기반의 데이터 저장소인 Redis도 매력적인 선택지였다.
  • {memberId}:{게시글Id}로 설정하여 게시글을 조회할 때 해당 키가 있는지 확인하여 조회수 중복 방지를 구현할 수 있다.
  • 이 또한 TTL기능이 있어 시간정보를 담을 필요가 없다.
  • 하지만 이또한 메모리에서 관리되는 인프라이므로 데이터 양이 많아질수록 서버에 부하가 올 수 있다.

3. 쿠키

  • {logId}:{yyyy-MM-dd HH:mm:ss}식의 게시글 조회 정보를 하나의 쿠키에 담아 사용하는 방식을 생각해 보았다.
  • {logId1}:{시간1}/{logId2}:{시간2}/ ... 이런 식의 키값으로 사용하는 것이다.
  • 쿠키는 4KB의 길이 한계가 있지만 부족하면 쿠키를 하나 더 만드는 식으로 늘려나가는 로직도 충분히 가능하다고 생각한다.
  • 이는 아무리 많은 사용자가 많은 게시글을 조회해도 서버에 부하가 가지 않는 그나마 나은 선택지라고 판단했다. 시간정보를 담아 데이터의 길이가 길어지는게 너무 아쉬웠다.
  • 이에 {yyyy-MM-dd HH:mm:ss}이러한 형식을 {yyyy-MM-dd} 이렇게 줄여 정확히 24시간 후가 아닌 하루 단위로 일괄적으로 초기화되는 방식을 택하는것도 괜찮다고 생각했다. (11:59에 조회 후 12:00에 조회해도 조회수는 둘 다 증가함)

레디스를 사용하는게 간단하게 구현될 것이라 생각했지만 서버의 부담을 줄이고 쿠키만으로도 충분히 구현할 수 있을거라고 생각하였기에 세 가지의 방법 중 쿠키를 사용하여 구현하기로 결정하였다.


쿠키 방식 구현

  • postView라는 이름의 쿠키에 {게시글Id}:{날짜정보}의 Set을 담아 해당 게시글을 조회해도 되는지 안되는지 판단하는 로직을 작성하였다. 플로우차트는 아래와 같다.

  • 쿠키를 읽어들여 해당 게시글의 조회수를 증가시킬지 판단하는 로직을 Service레이어에서 처리하지 않고 별도의 ViewCountValidator라는 클래스를 만들었다.

  • ControllerService가 아닌ViewCountValidator에 조회수 증가 판별 로직 책임을 지움으로써 코드 가독성을 고려하였다.

  • ViewCountValidator는 이미 조회를 했으면(조회수 유지) true를 반환, 조회를 안했으면(조회수 +1) false를 반환한다.

  • 아래의 코드에 게시글을 Log라고 이름을 지어서 Log == 게시글이라고 보면 될 것 같다. (logId = 게시글의 Id)

Controller

    @Operation(summary = "id로 로그 조회")
    @GetMapping("/{log_id}")
    public ApiResponse<LogDetailResponseDto> readLog(HttpServletRequest request, HttpServletResponse response, @PathVariable(name = "log_id") String logId) {
        boolean hasViewed = viewCountValidator.hasViewedInCoookie(request, response, logId);
        return ApiResponse.onSuccess(logService.readLog(logId, hasViewed));
    }
  • ViewCountValidator에서 게시글을 조회해도 되는지의 여부를 판단하여 Service단으로 넘기는 것을 확인할 수 있다.

Service

    public LogDetailResponseDto readLog(String logId, boolean hasViewed) {
        // 게시글 조회가 유효하지 않다면 조회수 증가
        if(!hasViewed) logRepository.increaseViewCountByLogId(logId);
        Log findLog = logRepository.findLogAndMemberById(logId).orElseThrow(() ->
                new LogException(ErrorCode.LOG_NOT_EXIST));
        return LogDetailResponseDto.of(findLog);
  • 조회를 하지 않았다면 (hasViewed == false) logRepository.increaseViewCountByLodId(logId)를 통해 조회수를 증가시킨다.
  • 그 후 게시글을 repository에서 찾아 반환한다.

ViewCountValidator

  • postViewed라는 이름의 쿠키가 있는지 판단한다.

  • 쿠키가 존재하지 않으면, postViewed : logId:yyyyMMdd라는 쿠키를 생성한다.

  • 쿠키가 존재하면, 쿠키 값 안에 게시글의 Id가 있는지도 판단해야한다.

  • 게시글의 Id가 없으면, 기존의 쿠키 값에 조회 정보를 추가한다.
    postViewed : {기존 값}/logId:yyyyMMdd
    이때 {게시글Id}:{날짜정보}간의 구분자는 /로 한다.

  • 게시글의 Id가 존재하면, 기간이 지났는지를 체크한다.

  • 게시글의 Id가 존재하지만 기간이 지났다면 index 0부터 해당 게시글Id까지 지운다. (날짜가 오래된 순서대로 쌓이기 때문에 해당 게시글Id의 기간이 지났다면 앞의 게시글Id 보도 기간이 지난 것) + 새로 날짜정보를 업데이트하여 값을 추가한다.
    { } / { } / {게시글 Id : 날짜정보 } / { }
    { } / { } / {게시글 Id : 날짜정보 } / { } / {게시글 Id : 새로운 날짜 정보}

  • 게시글 Id의 기간이 지나지 않았다면 해당 게시글은 조회수를 늘려도 되므로 true를 반환하게 된다.

  • 이러한 로직을 구현하면 아래와 같다.

@Component
public class ViewCountValidator {

    private static final String COOKIE_NAME = "postViewed";
    private static final int COOKIE_MAX_AGE = 24 * 60 * 60; // 1 day in seconds
    private static final String DATE_FORMAT = "yyyyMMdd-HHmm";

    private final SimpleDateFormat dateFormatter = new SimpleDateFormat(DATE_FORMAT);

    // 해당 게시글을 조회한게 유효한지 체크 (조회한 날짜 내에서만 유효)
    public boolean hasViewedInCoookie(HttpServletRequest request, HttpServletResponse response, String logId) {

        Cookie viewedCookie = getCookie(request);

        if (viewedCookie != null) {
            if(isLogIdInCookie(viewedCookie, logId)) {
                return checkAndUpdateCookie(viewedCookie, response, logId);
            } else {
                updateCookieWithNewLogId(viewedCookie, response, logId);
                return false;
            }
        } else {
            createNewCookie(response, logId);
            return false;
        }
    }
    
    // getCookies()에서 특정 이름의 쿠키를 찾음
    private Cookie getCookie(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        return (cookies == null) ? null : Arrays.stream(cookies)
                        .filter(cookie -> cookie.getName().equals(COOKIE_NAME))
                        .findFirst()
                        .orElse(null);
    }

    // cookie에 특정 logId가 포함되어있는지 확인
    private boolean isLogIdInCookie(Cookie cookie, String logId) {
        return Arrays.stream(cookie.getValue().split("/"))
                .anyMatch(entry -> entry.startsWith(logId + ":"));
    }

    // 쿠키 날짜를 확인하고 필요시 업데이트하여 조회수를 막는 로직
    private boolean checkAndUpdateCookie(Cookie cookie, HttpServletResponse response, String logId) {
        String today = getCurrentDate();
        String[] entries = cookie.getValue().split("/");

        for (String entry : entries) {
            String[] parts = entry.split(":");
            if(parts[0].equals(logId)) {
                if(parts[1].equals(today)) return true;
                else {
                    updateCookieWithLogId(cookie, response, logId);
                    return false;
                }
            }
        }
        return false;
    }

    // logId:yyyyMMdd-HHmm/
    // 만료된 logId를 없애고 해당 logId 새로 추가하는 로직 (시간 순서대로 정렬되어있으므로 조회하지 않는 logId도 한꺼번에 정리 가능)
    private void updateCookieWithLogId(Cookie cookie, HttpServletResponse response, String logId) {
        System.out.println(getCurrentDate());
        String deletedExpireLogIdInValue = cookie.getValue().split(logId)[1].substring(DATE_FORMAT.length() + 1);
        String updateValue = deletedExpireLogIdInValue + "/" + logId + ":" + getCurrentDate();
        addCookie(response, updateValue);
    }
    
    //새로 logId를 추가하여 쿠키 추가
    private void updateCookieWithNewLogId(Cookie cookie, HttpServletResponse response, String logId) {
        String updateValue = cookie.getValue() + "/" + logId + ":" + getCurrentDate();
        addCookie(response, updateValue);
    }
    
    // 새로운 쿠키 생성하는 로직 (logId 처음으로 정의)
    private void createNewCookie(HttpServletResponse response, String logId) {
        addCookie(response, logId + ":" + getCurrentDate());
    }

    private void addCookie(HttpServletResponse response, String value) {
        Cookie cookie = new Cookie(COOKIE_NAME, value);
        cookie.setPath("/");
        cookie.setMaxAge(COOKIE_MAX_AGE);
        response.addCookie(cookie);
        response.setCharacterEncoding("UTF-8");
    }

    // 현재 날짜(yyyyMMdd) 반환
    private String getCurrentDate() {
        return dateFormatter.format(new Date());
    }
}
  • 나름 가독성을 고려하여 깔끔하게 작성하려 노력했지만 잘 읽히는 코드는 아닌거같았다. 더 많은 코드 작성이 필요해보인다.
  • 조회수 유효기간을 24시간 단위가 아닌 하루 단위를 사용하였다.
    즉 23:59에 조회하여 조회수가 증가하였으면 00:00에도 조회수가 증가한다.
  • 이렇게 하면 시간 단위를 yyyyMMdd-HH:mm:ss가 아닌 yyyyMMdd까지 사용 가능하여 쿠키의 용량을 조금이라도 줄일 수 있다.
  • SimpleDateFormat을 사용하여 시간 단위를 관리하였다.
  • 최대 유효기간이 24시간이므로 쿠키 자체의 유효시간은 24시간으로 설정하였다.

테스트

  • 원래는 게시글의 조회 유효시간을 하루 단위로 설정해야하나 테스트하기 위해 분단위로 설정하였다. (yyyyMMdd-HHmm)
  • 게시글Id가 c33a7968~로 시작하는 게시글의 viewCount는 0이다.
  • 해당 게시글을 조회하면 쿠키가 생성되어야 한다.

  • 위 사진과 같이 {게시글Id}:20241114-1739로 게시글에 대한 시간정보가 쿠키값으로 들어감을 확인할 수 있다.
  • db를 refresh해보자

  • 다음과 같이 조회수가 증가하였다.
  • 1분이 지나기전에 서둘러 다시 한번 더 조회해보자

  • 현재 시각과 시간정보가 같으므로 조회수는 증가하지 않고 쿠키값도 변경되지 않는다.

  • 여러 게시글을 조회해봤더니 구분자 /를 기준으로 조회정보가 잘 들어갔음을 확인할 수 있었다.

  • 위 사진과 같이 기간이 지난 게시글을 조회했다면 해당 게시글 밑으로는 전부 기간이 지났으므로 다 지워지고 새로운 정보로 업데이트된다.

정리

  • 이로써 쿠키로 게시글의 조회수를 관리하는 로직을 구현해보았다.
  • 아쉬운 점은 쿠키는 조작하여 조회수 조작이 가능하다는 것과, 구현과정에서 쿠키의 크기를 고려하지 않았다는 것이다.
  • 조금 더 보완할 점은 쿠키값을 암호화하여 전송하는 로직이다.
  • 또한 쿠키값은 4KB이므로 계산해봤을 때 대략 80여개의 게시글 조회 정보가 하나의 쿠키에 담긴다는 것이다. 쿠키의 용량이 4KB를 넘어서면 새로운 쿠키를 생성하여 여러 개의 쿠키를 사용하는 것도 충분히 고려할 수 있다고 생각한다.

0개의 댓글