커뮤니티 플랫폼 프로젝트

[Spring]조회수 기능(Redis,Spring Schedular활용)

gotopm 2025. 3. 14. 20:13
조회수를 레디스에 저장,조회하고 RDB에 batch를 활용하여 동기화 하는 방식

 

1. RedisConfig 클래스 -우선 레디스 연결 객체를 만든다.

public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;

//yml에 기입해둔 Redis 호스트명과 port번호를 Value어노테이션을 통해 가지고와 속성으로 선언해둔다. 
//왜냐 레디스의 설정을 담당하는 RedusConnectionFactory객체를 만드는데 우리가 연결할
//레디스 서버의 host명과 port번호가 필요하니까

   //조회수 관련
    @Bean
    @Qualifier("viewCount")
    public RedisConnectionFactory redisConnectionFactoryForClickCount(){ 
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(host); 
        configuration.setPort(port); 
        configuration.setDatabase(4); //레디스에 사용할 데이터베이스 인덱스
        return new LettuceConnectionFactory(configuration); //위에서 설정한 configuration을 기반으로 레디스에 연결할 객체
    }
 //먼저, 레디스의 설정을 담당하는 RedisStandaloneConfiguration 객체를 생성하고,
 //설정객체에 연결될 Redis의 호스트명, 포트번호, 그리고 데이터베이스 번호까지 설정해준다.
 //이 설정객체를 배개변수로 하여 LettuceConnentionFactory라는 레디스와 물리적으로 연결하는 객체를 생성한다. 

    @Bean
    @Qualifier("viewCount")
    public RedisTemplate<String,String> redisTemplateForClickCount(@Qualifier("viewCount") RedisConnectionFactory redisConnectionFactoryForClickCount){
        RedisTemplate<String,String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactoryForClickCount);
        return redisTemplate;
    }
//RedisConnectionFactory가 레디스의 물리적인 연결을 하는 객체라면, RedisTemplate는 물리적인 
//연결을 기반으로 레디스 내의 데이터를 읽고/쓰는 기능을 제공하는 객체다. 그래서 매개변수로 위에서 설정한 물리적 연결객체가 필요한 것!
//그리고 레디스는 기본적으로 데이터를 바이너리 데이터를 저장하고 꺼내는데,Key와 Value를
//사람이 읽을 수 있는 String 형태로 변환해주는 설정이다.
   }

 

2.RedisServiceForViewCount클래스  -  조회수 기능과 관련한 메서드를 구현한다(조회수 증가, 조회수 조회, RDB 동기화)
-여기서는 어뷰징 방지를 막기위해 한 사람이 계속 조회하여 조회수를 증가시키는 것을 막는다.
-그래서 redis에 TTL설정을 통해 어뷰징 방지용 로직을 조회수 증가 메서드에 걸어둔다.

@Service
@Transactional
public class RedisServiceForViewCount {
    //"post:viewCount"를 아래 코드를 보면  중복으로 사용하는 곳이 많아, 상수로 선언하여 편하게 사용(나중에 키값이 바뀌더라도 한번에 수정하기 용이해서 상수선언하였음)
    private static final String VIEW_COUNT_PREFIX = "post-viewCount-"; // 레디스에 저장 될 조회수 키
    private static final String VIEW_LOCK_PREFIX = "post-viewLock-"; // 레디스에 저장될 어뷰징 방지용 키
 
    @Qualifier("viewCount")
    private final RedisTemplate<String,String> redisTemplate;

    public RedisServiceForViewCount(@Qualifier("viewCount") RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    //RedisTemplate의존성 주입


    //레디스에서 조회수 증가시키는 함수
    public void increaseViewCount(Long postId,String userId){
        String key = VIEW_COUNT_PREFIX + postId;
        String lockKey = VIEW_LOCK_PREFIX + postId +"-" + userId;

        if(Boolean.TRUE.equals(redisTemplate.hasKey(lockKey))){
            return;
        } //유저아이디와 아이디가 본 게시물id를 조합한 키값이 이미 있다면 그 유저는 그 게시물을 1분이내에 봐서 조회수를 증가시켰다는 의미로 아래 로직을 타지않고 리턴시킴
         //그런데 redisTemplate.hasKey(lockKey).equals(true)를 하지 않는 이유는 앞에서 null값이면 에러가 터지니까  반대로 적용하였음

        redisTemplate.opsForValue().increment(key); //레디스에 해당 해당 게시물id키값에 대해 조회수를 1증가시킴
        redisTemplate.opsForValue().set(lockKey,"1", Duration.ofMinutes(1)); // 유저아이디와 아이디가 본 게시물id를 조합한 키값에 대한 밸류를 1로 설정하고 1분뒤 삭제
    }

    //레디스에서 조회수 가지고 오는 함수
    public int getViewCount(Long postId){
        String key = VIEW_COUNT_PREFIX + postId;
        String countStr = redisTemplate.opsForValue().get(key); //레디스에 해당 키가 없으면 countStr = null이 됨
        return countStr == null ? 0 : Integer.parseInt(countStr);
    }

    //레디스에 저장되어 있는 데이터 rdb반영하기 위한 작업
    //rdb에 어떤 포스트가 몇개의 조회수가 있는지를 동기화 해야하니까, postId와 해당 조회수가 필요하다.
    //그래서 postID:조회수로, 키/밸류 형태로으로 값을 저장하기 편하니 Map클래스를 사용한다.
    public Map<Long,Integer> getAllViewCountForRdb(){
        Set<String>keys = redisTemplate.keys(VIEW_COUNT_PREFIX+"*"); //.keys는 특정패턴을 가진 모든 키 목록을 가지고 오는 것.우리는 post-viewCount-로 시작되는 모든 키를 가지고 온다.
        Map<Long,Integer> viewCounts = new HashMap<>(); //게시글의 ID와 조회수가 들어갈 맵

        if(keys !=null){
            for(String key :keys){ //현재 키는 prefix가 붙어있는 post-viewCount+게시글ID형태로 되어있다.
                Long postId = Long.parseLong(key.replace(VIEW_COUNT_PREFIX,""));//그래서 키값에서 prefix(접두사)인 post-viewCount-를 없애면 id값만 남는다.
                String countStr = redisTemplate.opsForValue().get(key) //조회수를 가지고 오고
                Integer count = (countStr == null) ? 0: Integer.parseInt(countStr) // 만약에 조회수가 null이면 0 조회수가 있다면 정수화
                               viewCounts.put(postId, count == null);
            }
        }
        return viewCounts;
    }
}

 

3. PostService클래스 - 게시물 상세조회 메서드 로직

-게시글 보기가 일어날때 조회수가 1증가해야하니까 여기서 위에서 구현한 조회수 증가메서드를 사용한다.
이때 post를 컨트롤러 단에 반환할 때 해당 게시글의 조회수가 담겨서 반환하도록 DTO를 구현해놓았기 때문에 조회수 조회메서드도 사용되었음.

 public PostDetailDto findById(Long id){
        Post post = postRepository.findByIdAndDelYN(id,DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 게시글입니다"));
        String userId = SecurityContextHolder.getContext().getAuthentication().getName();
        redisServiceForViewCount.increaseViewCount(post.getId(),userId); //해당 포스트에 대해 조회수 1증가시킴
        return post.toDetailDto(redisTemplate, redisServiceForViewCount.getViewCount(id));

    }

 

4.Batch클래스- 일정한 주기로 레디스에 있는 조회수 값을 RDB에 동기화

@Service
@Transactional
public class ViewCountBatchService {
    private final RedisServiceForViewCount redisServiceForViewCount;
    private final PostRepository postRepository;

    public ViewCountBatchService(RedisServiceForViewCount redisServiceForViewCount, PostRepository postRepository) {
        this.redisServiceForViewCount = redisServiceForViewCount;
        this.postRepository = postRepository;
    }
    //게시글 DB에 조회수 데이터를 넣어줘야 하니까 위의 2개의 의존성 주입

    @Scheduled(fixedRate = 600000) //10분마다 실행
    public void syncViewCounts(){
        Map<Long,Integer> viewCounts = redisServiceForViewCount.getAllViewCountForRdb();

//        내 방식 => 더티체킹을 활용하는 것.쉬운코드.
//            for(Long key :viewCounts.keySet()){ -->keySet은 map자료에서 키 목록만 뽑아오는 것
//            Post post = postRepository.findById(key).orElseThrow(()->new EntityNotFoundException("없는 게시물입니다"));
//            post.updateViewCounts(viewCounts.get(key));
//        }

//        gpt방식 => 대량의 데이터 업데이트에 유리(네이티브 쿼리 사용하여 성능에 주안점을 둔 커뮤니티 사이트에 맞는 방식이라고 함)
        for(Map.Entry<Long, Integer> entry : viewCounts.entrySet()){
            Long postId = entry.getKey();
            Integer views = entry.getValue();
            postRepository.increaseViewCountByValue(postId,views);
//     지금 increseViewCountByValue메서드는 레디스에 있는 조회수로 업데이트 하는 걸로 했는데 원래라면 레디스에 있는 조회수 만큼 증가시키는 걸로 바꾸고
//     여기에 레디스에 있는 조회수값을 삭제하는 코드가 추가되었어야함
//     그런데 우리 프로젝트에선 게시물의 조회수를 db에서 갖고 오는게 아니라 레디스에서 계속 가지고 오는 걸로 세팅했기 때문에 이렇게 코드를 짬.(실제 커뮤니티에서 조회수는 그냥 db에서 가지고 오는게 나을듯)
        }

 

5.PostRepository 클래스 - 네이티브 쿼리를 이용하여 어떤 게시글의 조회수를 변경하여 저장함.

@Repository
public interface PostRepository extends JpaRepository<Post, Long>{

    @Modifying // 업데이트,딜리트 쿼리를 실행하기 위한 JPA어노테이션
    @Query("Update Post p SET p.viewCount = :newview WHERE p.id = :postId") //매개변수로 준 게시물 id에 해당하는 게시물의 조회수칼럼을 매개변수로 준 newview로 업데이트한다는 말
    @Transactional
//    여기서 Param은 쿼리 내의 :postId와 :increment와 매핑되기 위해 사용한다.
    public void increaseViewCountByValue(@Param("postId")Long postId, @Param("newview")Integer newview);

}