티스토리 뷰

이번 포스팅에서는 조회수가 무한 증가되는 것을 방지하는 방법들을 설명하겠다. 먼저 조회수가 무한 증가가 되는 것이 어떤 말인지 알아보자. 모든 코드는 깃허브에 있습니다. 

 

조회수 무한 증가

조회수가 무한으로 증가된다는 뜻이 무슨 말일까? 원래라면 한 게시글은 하루에 단 하나의 조회수만 올라가야 한다. 내가 2번 봤다고 해서 2번 올라가는 것이 아닌, 사용자마다 하루에 단 한 번만 올라가야 된다. 만약 한 사용자가 한 게시글을 볼 때마다 조회수가 올라간다면 조회수라는 의미가 없어진다. 따라서 게시글을 볼 때마다 무작정 조회수가 올라가는 것을 조회수 무한 증가라고 한다.

 

아래 코드를 보자.

public QuestionResponseDto getQuestion(Long id) {
        Question question = questionFacade.getQuestionById(id);
        question.addViewCount();
        return QuestionResponseDto.builder()
                .question(question)
                .build();

 

게시글을 불러오고 아무런 조치없이 조회수를 올린 다음 반환한다. 조회수가 무한으로 증가되는 것을 막기 위해 쿠키를 사용한다.

 

쿠키를 사용해 조회수 중복 방지

아마 가장 흔한 방식일 것이다. 쿠키를 사용해 어떻게 방지를 할까? 

 

  1. 사용자가 A 게시글을 조회함
  2. 요청으로 온 쿠키의 값에 A 게시글의 id가 있는지 확인
  3. 없다면 쿠키를 생성해 id를 넣은 후 조회수 증가

id를 넣는 이유는 게시글의 고유한 컬럼이기 때문이다. 그럼 한번 구현해보자. 아래는 기존의 게시글 단건 조회 로직이다.

public QuestionResponseDto getQuestion(Long id) {
        Question question = questionFacade.getQuestionById(id);
        question.addViewCount();
        return QuestionResponseDto.builder()
                .question(question)
                .build();

 

이제 여기서 무작정 올려주지 말고, 먼저 요청으로 온 쿠키가 있는지 확인한다. 

    @GetMapping("/{id}")
    public QuestionResponseDto getQuestion(@PathVariable("id") Long id,
                                           HttpServletRequest request,
                                           HttpServletResponse response) {
        return questionService.getQuestion(id, request, response);
    }

 

HttpServletRequest로 요청 값들을 확인할 수 있다. 대표적으로 요청 쿠키와 헤더를 받을 수 있는 메서드를 제공한다. 

 

 

HttpServletResponse는 쿠키를 사용자 브라우저에 자동으로 적용시킨다. 브라우저에 쿠키가 있기 때문에 요청에서 자동으로 쿠키가 포함된다. 자세한 내용은 여기를 참고하자. 

 

서비스 로직에서는 다음과 같다. 진짜 간단하게 요청으로 온 쿠키가 없다면 생성한 후 브라우저에 적용시키고 조회수를 증가해주는 코드다. 

public QuestionResponseDto getQuestion(Long id,
                                           HttpServletRequest request,
                                           HttpServletResponse response) {
        Question question = questionFacade.getQuestionById(id);
        Cookie[] cookies = request.getCookies();
        if(cookies == null) {
        	Cookie cookie = new Cookie("visit", String.valueOf(question.getId()));
            response.addCookie(cookie);
            question.addViewCount();
        }
        return QuestionResponseDto.builder()
                .question(question)
                .build();

 

당연히 이렇게만 짜면 안된다. 또한 비즈니스 로직만 있어야 하기 때문에 코드를 고쳐보도록 하겠다.

public QuestionResponseDto getQuestion(Long id,
                                           HttpServletRequest request,
                                           HttpServletResponse response) {
        Question question = questionFacade.getQuestionById(id);
        if(questionFacade.canAddViewCount(request, id)) {
            Cookie cookie = questionFacade.createCookie(id);
            response.addCookie(cookie);
            question.addViewCount();
        }
        return QuestionResponseDto.builder()
                .question(question)
                .build();

 

요청으로 온 쿠키를 받고, 만약 조회수를 증가할 수 있다면 쿠키를 생성 후 브라우저에 적용시키고 조회수를 올려준다. 그럼 어떻게 조회수 증가할 수 있는지 확인하는 코드를 보자. 

public boolean canAddViewCount(HttpServletRequest request, Long id) {
        Cookie[] cookies = request.getCookies();
        if(cookies == null) {
            return true;
        }
        return Arrays.stream(cookies)
                .noneMatch(cookie -> cookie.getName().equals(VISIT_COOKIE_NAME) &&
                        cookie.getValue().contains(toString(id)));
    }

    public Cookie createCookie(Long id) {
        Cookie cookie = new Cookie(VISIT_COOKIE_NAME, toString(id));
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        cookie.setMaxAge(60 * 60 * 24);
        return cookie;
    }

    private String toString(Long id) {
        return String.valueOf(id);
    }

 

먼저 요청으로 온 쿠키가 없다면 true를 반환함으로써 쿠키를 생성하고 조회수를 증가시켜야 한다.

        Cookie[] cookies = request.getCookies();
        if(cookies == null) {
            return true;
        }

 

이제 이 코드가 실행되지 않았다는 것은 쿠키가 있다는 말이기 때문에, 쿠키들을 돌면서 이름이 visit이고 값으로 해당 게시글이 있는지 확인한다. noneMatch는 인자 안 로직이 전부 일치하지 않다면 true를, 하나라도 일치한다면 false를 반환한다. 

return Arrays.stream(cookies)
                .noneMatch(cookie -> cookie.getName().equals(VISIT_COOKIE_NAME) &&
                        cookie.getValue().contains(toString(id)));

 

즉, 쿠키 값이 조회한 게시글의 id가 있다면 조회수를 증가시켜주면 안되고, 모든 쿠키가 조회한 게시글의 id라는 값을 가지고 있지 않다면 id를 담은 쿠키를 생성하고 브라우저에 적용시키고 조회수를 증가시킨다. 이제 쿠키가 존재하지 않거나, 조회한 게시글의 id가 담긴 쿠키가 없다면 새로 생성해주어야 한다. 생성하는 코드는 아래와 같다. 

    public Cookie createCookie(Long id) {
        Cookie cookie = new Cookie(VISIT_COOKIE_NAME, toString(id));
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        cookie.setMaxAge(60 * 60 * 24);
        return cookie;
    }

 

key에는 visit라는 문자를 넣고 value로 조회한 게시글의 id를 넣어준다. 여기서 중요한 점은 하루가 지나면 게시글을 조회할 때 조회수가 증가해야 되기 때문에 만료시간을 하루로 설정했다. 나머지 코드의 의미가 궁금하다면 여기를 참고하자. 

 

이제 사용자는 조회한 게시글에 대해 하루에 단 한번의 조회수만 올려지게 된다. 하지만 쿠키를 이용한 방법에는 성능이 저하된다는 문제점이 있다. 예를 들어서, 만약에 내가 하루에 1000개의 게시글을 조회했다면 해당 사용자의 브라우저에는 1000개의 쿠키가 저장되고, 요청 시 1000개의 쿠키가 받아져서 게시글을 조회했는지 안 했는지 확인하기 위해서는 1000개의 쿠키를 하나하나 확인해야 한다. 단순히 게시글을 보는 것뿐인데 몇 초 이상이 걸린다면.. 심각하다. 여기서 조금 더 성능을 개선하는 방법이 있다. 

 

하나의 쿠키를 주고받고, 쿠키의 값에 id를 문자로 더해서 관리한다. 만약 2번 게시글을 조회했다면 2/가 저장되고 3번 게시글을 조회했다면 3이 포함되는지 확인 후 2/3/ 으로 값을 덮는다. 이렇게 하나의 쿠키로만 관리할 수 있어 성능이 개선된다. 하지만 이 방법에서도 문제는 있다.

 

쿠키의 용량은 4KB로 매우 작다. 약 4000자를 치면 4000바이트다. 

 

 

하지만 만약 슬래시 까지 포함하여 계산한다면 더욱 줄어들어 약 2000자 밖에 저장하지 못한다. 즉 id를 한 문자로 연결하여 관리하는 방법은 사용자가 하루에 2000 게시글을 안본다는 가정하에 사용할 수 있는 방법이다. 그럼 어떻게 하면 사용자가 많은 게시글을 보더라도 속도가 저하되지 않을까? 바로 redis를 사용하면 된다. 

 

redis를 사용해 조회수 중복 방지

redis는 쿠키와 다르게 자료구조를 지원하고 있다. 따라서 한 value에다가 이어붙이는 것이 아닌, List를 사용하여 요소를 추가한다. 전체적인 흐름으로 보면 아래와 같다. 

 

  1. 사용자는 2번 게시글을 조회한다.
  2. visit이라는 키를 가진 List에 2번 게시글의 id가 있는지 확인한다.
  3. 없다면 List에 2를 넣고 조회수를 증가시킨다.
  4. 또 2번 게시글을 조회한다.
  5. visit이라는 키를 가진 List에 2번 게시글의 id가 있는지 확인한다. 
  6. 있으니까 조회수를 올리지 말고 응답만 한다.

이제 구현해보도록 하겠다. 먼저 docker에서 redis를 설치한다. 만약 docker가 설치되어있지 않다면 설치글을 먼저 보는 것을 추천한다.

docker pull redis

 

그런 다음 깔린 redis 이미지를 컨테이너로 돌려보자.

docker run --name test-redis -p 6397:6397 -d redis

 

각 옵션에 대한 설명은 아래와 같다. 

 

  • --name : 컨테이너 이름 지정
  • -p : 포트 설정 (운영 환경에서는 따로 설정해주는 것이 좋다.)
  • -d : 백그라운드에서 실행

이제 application.yml에서 redis를 설정해준다.

spring:
    redis:
        host: 레디스컨테이너이름
        port: 6379

 

그리고 Bean으로 등록해줘야 한다.

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(port);
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

 

Spring boot에서 redis를 사용하는 2가지 방법이 있는데, 여기서는 RedisTemplate를 사용하겠다. RedisTemplate말고 StringRedisTemplate를 써도 상관없다.

    @Bean
    public StringRedisTemplate stringRedisTemplate() {
        StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();
        stringRedisTemplate.setKeySerializer(new StringRedisSerializer());
        stringRedisTemplate.setValueSerializer(new StringRedisSerializer());
        stringRedisTemplate.setConnectionFactory(redisConnectionFactory());
        return stringRedisTemplate;
    }

 

그런 다음 미리 redis에 값을 추가하고 가져오는 로직을 구현해보자.

@Service
@RequiredArgsConstructor
public class RedisService {
    private static final String KEY = "visit";
    private final RedisTemplate visitQuestionIds;

    public void addQuestionId(String questionId) {
        ListOperations<String, String> alreadyVisitQuestionIds = visitQuestionIds.opsForList();
        alreadyVisitQuestionIds.rightPush(KEY, questionId);
    }

    public List<String> getQuestionIds() {
        ListOperations<String, String> alreadyVisitQuestionIds = visitQuestionIds.opsForList();
        int questionIdsSize = getQuestionIdsSize();
        return alreadyVisitQuestionIds.range(KEY, 0, questionIdsSize);
    }

    @Scheduled(cron = "0 0 0 * * *")
    void removeQuestionIds() {
        ListOperations<String, String> alreadyVisitQuestionIds = visitQuestionIds.opsForList();
        int questionIdsSize = getQuestionIdsSize();
        for(int i=0; i<questionIdsSize; i++) {
            alreadyVisitQuestionIds.leftPop(KEY);
        }
    }

    private int getQuestionIdsSize() {
        ListOperations<String, String> alreadyVisitQuestionIds = visitQuestionIds.opsForList();
        return Math.toIntExact(alreadyVisitQuestionIds.size(KEY));
    }
}

 

redis역시 쿠키와 마찬가지로 하루가 지나면 안의 데이터가 사라져야 하기 때문에 시간을 설정해야 한다. 하지만 key value에만 아래와 같이 시간을 설정할 수 있었다. 그래서 스케쥴링으로 직접 하나하나 없애주기로 했다. 

redisTemplate.expire(key, time, TimeUnit.SECONDS);

 

아래는 게시글을 조회하는 로직이다. 

@Transactional
    public QuestionResponseDto getQuestion(Long id) {
        Question question = questionFacade.getQuestionById(id);
        questionFacade.addViewCountByVisit(question);
        return QuestionResponseDto.builder()
                .question(question)
                .build();
    }

 

QuestionFacade.java

public void addViewCountByVisit(Question question) {
        String questionId = String.valueOf(question.getId());
        List<String> questionIds = redisService.getQuestionIds();
        if(canAddViewCount(questionIds, questionId)) {
            redisService.addQuestionId(questionId);
            question.addViewCount();
        }
    }

    private boolean canAddViewCount(List<String> questionIds, String questionId) {
        return questionIds.isEmpty() || !questionIds.contains(questionId);
    }

 

코드는 간단하다. 먼저 List를 가져온 후 만약 요소가 없거나 조회하려는 게시글의 id가 포함하지 않는다면 List에 id를 넣고 조회수를 증가시킨다. 

List<String> questionIds = redisService.getQuestionIds();
        if(canAddViewCount(questionIds, questionId)) {
            redisService.addQuestionId(questionId);
            question.addViewCount();
        }

 

 

이렇게 redis로도 조회수 어뷰징을 막을 수 있다. 오히려 쿠키보다 성능이점이 있다. 하지만 오직 나만의 방식대로 짰기 때문에 중복된 코드들이 있고, 확실하게 어뷰징을 막을 수 있는지는 모르겠다. 그리고 redis의 이점인 시간이 지나면 데이터가 사라진다는 특징을 살리지 못했다. 나중에 한꺼번에 없애는 방법이나 Redis Repository를 사용해서 스케쥴링을 짜지 않아도 자동으로 삭제되도록 만들어봐야겠다. 그래도 이런 식으로 최소한의 조치라도 한다면 사용자들이 조금 더 깨끗하고 기분좋게 사용하지 않을까 생각한다. 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday