본문 바로가기

로드스타

Spring 세션과 Redis를 활용한 조회수 증가 중복 방지

1. 서론

2. 여러 방법과 비교

3. 구현

4. 마무리

 


1. 서론

프로젝트를 진행하면서 게시글의 조회수를 카운트하도록 했습니다.

여러 서비스들을 살펴봤을 때, 새로고침을 할 때마다 조회수가 증가되는 서비스가 있었고, 새로고침하여도 조회수가 증가 되지 않도록 중복을 방지한 서비스도 있었습니다.

 

다른 서비스들은 조회수 증가의 중복을 방지하기 위해 어떤 방법을 사용하는지 알아봤는데, 그 중 대표적으로 유튜브는

  • 사용자가 의도적으로 동영상을 시작한다
  • 사용자는 적어도 30초동안 영상을 시청한다
  • 최대 반복수는 300번으로 예상된다
  • 최대 반복수를 넘었을 시 봇을 이용하는지 유효성 검사 단계가 수행되면서 더 이상 조회수가 증가하지 않는다
  • 조회수 증가하지 않는 상황
    • 많은 장치에 대해 하나의 IP 주소를 사용하여 동시에 동일한 영상을 시청할 때
    • 윈도우 또는 탭을 많이 실행시켜 영상을 동시에 보는 행위
    • 영상을 시청하는 30초마다 페이지 새로고침

위 방식을 사용한다고 합니다.

 

유튜브 개발자가 아니라서 정확한 알고리즘과 방법은 모르지만, 유튜브의 경우 조회수가 비즈니스에서 중요한 부분을 차지하기에 여러 조건들이 있는 것은 알았습니다.

 

제가 진행한 프로젝트는 단순 커뮤니티로 조회수가 중요한 부분을 차지하지는 않습니다. 그래서 간단하게 새로고침 했을 때 조회수 증가의 중복을 방지하고자 Redis와 세션을 활용했습니다.

 


 

2. 여러 방법과 비교

2 - 1. 쿠키 사용

쿠키는 key-value 쌍으로 저장되는데, value에 게시글의 인덱스를 저장하고 요청마다 확인하여 중복을 방지할 수 있습니다. 또한 scale-out하여 서버가 여러 개일 경우에도 정합성 문제가 발생하지 않아 문제 없이 중복 방지가 가능합니다.

 

하지만 쿠키는 변조가 가능하며 방문한 게시글이 많아질수록 HTTP 요청에서 네트워크 트래픽에 부담을 줄 수 있고, String형인 value를 선형탐색 하기 때문에 게시글의 인덱스를 포함하는지 확인하는데 O(N)의 시간복잡도가 필요합니다. 

 

2 - 2.  IP 사용

게시글의 인덱스와 사용자의 IP 관계를 DB에 저장하여 중복을 방지할 수 있다. 후에 제가 사용한 방법을 설명하겠지만 Redis에 저장하여 빠르게 방문한 게시글인지 확인할 수 있습니다.

 

하지만 IP 주소를 확인하는데 코드의 복잡성이 증가할 수 있습니다. HttpServeltRequest를 Controller 또는 Service 계층에 의존성을 추가하거나, AOP로 코드의 중복성을 낮출 순 있지만 이 또한 어느정도의 학습 과정과 복잡도가 증가한다고 생각합니다.

 

2 - 3 . 세션과 Redis 활용

제가 사용한 방법입니다. 

인가 방식으로 세션을 사용하고 세션 정보를 Redis에 저장하고 있습니다. 세션 정보와 함께 방문한 게시글의 인덱스를 함께 저장하여, 게시글을 조회할 때 방문한 게시글들을 확인합니다.

 

이렇게 했을 때 다른 방법과 비교해서 장점은 2가지 입니다.

  • Redis는 in-memory DB로 빠르게 세션 정보와 방문한 게시글 정보를 가져올 수 있습니다.
  • Redis는 자료구조 Set(Java HashSet)을 지원하기 때문에, 방문한 게시글인지 확인하는데 O(1)의 시간복잡도로 빠르게 확인할 수 있습니다.

하지만 단점 또한 있습니다.

사용자가 브라우저를 종료하면 세션 ID가 초기화 되기 때문에 재접속 후 조회 시에는 조회수가 증가하기 때문에, 완전한 조회수 증가의 중복 방지는 할 수 없습니다. 또한 in-memory DB이기 때문에 데이터의 양에 주의해야 합니다.

 


 

3. 구현

@Configuration
@EnableRedisHttpSession
public class RedisConfig {

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

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

    @Bean(name = "sessionRedisConnectionFactory")
    public RedisConnectionFactory sessionRedisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
    }

    @Bean(name = "sessionRedisTemplate")
    public RedisTemplate<String, Object> sessionRedisTemplate(@Qualifier("sessionRedisConnectionFactory") RedisConnectionFactory cf) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(cf);

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());

        Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        redisTemplate.setValueSerializer(jsonSerializer);
        redisTemplate.setHashValueSerializer(jsonSerializer);

        return redisTemplate;
    }


}

@EnableRedisHttpSession과 @Configuration으로 Redis를 세션 저장소로 사용합니다.

 

    @Transactional(readOnly = false)
    public void increaseViewIfNotViewedBefore(Board board, User user) {
        HttpSession httpSession = getCurrentSession();

        HashSet<Long> ids = (HashSet<Long>) httpSession.getAttribute("boards");

        if ((!ids.contains(board.getId())) && (!board.getUser().getId().equals(user.getId()))) {
            //이미 조회한 게시글이 아니고 작성자도 아니어야해.
            board.addView();
            ids.add(board.getId());
            httpSession.setAttribute("boards", ids);
        }
    }
    
    private HttpSession getCurrentSession() {
        return ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
                .getRequest()
                .getSession(false);
    }

Service 계층에서, RequestContextHolder의 현재 session 정보를 가져오도록 했습니다.

게시글 인덱스들을 저장해둔 HashSet을 가져오고, 현재 조회한 게시글이 포함되어 있지 않으며 작성자가 아니면 게시글의 조회수가 1 증가되도록 구현했습니다.

 

 

테스트 해보기 위해 중간중간 log를 찍도록 했습니다.

3 - 1. 작성자가 조회했을 때

작성자가 조회했을 때 log

user1이 게시글을 작성하고(boardId = 1) 자신의 게시글을 조회했을 때입니다.

조회한 게시글은 없으며, 자신의 게시글이기 때문에 조회수는 0으로 증가하지 않았습니다.

 

3 - 2. 작성자가 아닌 사용자가 조회했을 때

작성자가 아닌 사용자가 조회했을 때 log

user1이 작성한 게시글(boardId = 1)을 user2가 조회했을 때입니다.

user2는 이전에 아무 게시글을 조회하지 않았고, 그렇기 때문에 게시글의 조회수는 1 증가했습니다.

 

3 - 3. 조회한 게시글을 다시 조회했을 때

조회한 게시글을 다시 조회했을 때 log

user2가 이전에 조회했던 boardId = 1인 게시글을 다시 조회했을 때입니다.

세션정보와 함께 가져온 HashSet에는 1이 저장되어 있고, 이로 인해 조회수는 증가하지 않아 그대로 1입니다.

 


 

4. 마무리

세션 저장소로 Redis를 사용하면서, 이를 활용하여 조회수 증가의 중복을 방지했습니다.

저의 경우엔 인가 방법으로 세션 방식을 사용하고 세션 저장소로 Redis를 사용하기 때문에 이 방법으로 구현했습니다.

 

장점으로는 in-memory DB인 Redis를 사용하고 Set으로 빠른 처리 속도가 있지만, 세션 방식을 사용하지 않을 때는 다른 방법을 사용해야 합니다. 또한 메모리에 데이터가 저장되기 때문에 사용량에 주의해야 합니다.

 

번외

조회수는 다수의 사용자가 동시에 요청할 때, 동시성 문제가 발생할 수 있습니다. 이를 위해 적절한 Lock을 사용하여 문제를 해결해야 합니다.

https://prefercoding.tistory.com/64

 

(1) 스프링 동시성 문제

1. 서론 나의 프로젝트들 중에는 '조회수'와 '북마크 수'를 카운트하는 기능이 있다. 조회수가 증가하는 로직은 중복을 방지하여 사용자가 게시글을 조회하면 조회수에 + 1 갱신 북마크 수는 DB 테

prefercoding.tistory.com