커뮤니티 플랫폼 프로젝트

[Spring] 좋아요 기능(Redis,RabbitMQ활용)

gotopm 2025. 2. 28. 20:36
좋아요 값을 레디스에 저장하고 조회하고, DB에는 RabbitMQ를 통해 동기화 하는 방식

 

0.좋아요 엔티티

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
public class Likes extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "postId")
    Post post;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "userId")
    User user;
}

 

 

1.우선 레디스 연결 객체를 만든다.

 

@Bean
    @Qualifier("likes")
    public RedisConnectionFactory redisConnectionFactoryForLikes(){
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        -->먼저 레디스의 설정을 담당하는 RediuStandaloneConfiguration 객체 생성
        configuration.setHostName(host);
        configuration.setPort(port);
        configuration.setDatabase(1);
        -->설정객체에 레디스서버의 host,port 그리고 몇번 데이터베이스를 사용할 지 설정
        return new LettuceConnectionFactory(configuration);
        -->RedisConnectionFactory는 인터페이스. 이를 구현하는 LettuceConnetionFactory객체를
        리턴하는데, 우리가 만든 설정을 매개변수로 하여 주입.

    }

    @Bean
    @Qualifier("likes")
    public RedisTemplate<String,String> redisTemplateforLikes(@Qualifier("likes") RedisConnectionFactory redisConnectionFactoryForLikes){
        RedisTemplate<String,String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactoryForLikes);
        return redisTemplate;
    }
     -->LettuceConnetionFactory가 레디스와 물리적인 연결을 하는 객체인 반면, 아래
        RedisTemplate는 레디스의 데이터를 읽고/쓰는 기능을 제공하는 객체다.
        따라서 위에서 만든 매서드로 만들어지는 빈 객체를 매개변수로 받는 것.

 

 

2.RabbitMQ를 통해 RDB에 동기화 할 것 이기 때문에 RabbitMQ 연결 객체도 만든다(+RabbitMQ에 생성될 큐도)

@Configuration
public class RabbitmqConfig {

    @Value("${spring.rabbitmq.host}")
    private String host;
    @Value("${spring.rabbitmq.port}")
    private int port;
    @Value("${spring.rabbitmq.username}")
    private String username;
    @Value("${spring.rabbitmq.password}")
    private String password;
    @Value("${spring.rabbitmq.virtual-host}")
    private String virtualHost;
  -->  .yml에 세팅한 Rabbitmq 값들을 불러온다

    public static final String BACKUP_QUEUE_AL="backupAddLike";
    public static final String BACKUP_QUEUE_ML="backupMinusLike";
  --> RabbitMq에서 사용할 큐를 설정하는데 큐의 이름을 상수로 선언
  
    @Bean
    public Queue AddlikeQueue(){
        return new Queue(BACKUP_QUEUE_AL,true);
    }
    -->빈으로 등록된 AddlikeQueue()가 실행되면 backupAddLike라는 이름으로 래빗엠큐내에서 큐가 생성됨.
  이때 true설정은 RabbitMq가 꺼졋다가 다시 켜져도 큐가 유지되도록 하는 지속성에 대한 설정
    @Bean
    public Queue MinuslikeQueue(){
        return new Queue(BACKUP_QUEUE_ML,true);
  -->마찬가지로,MinuslikeQueue()가 실행되면 backupMinusLike라는 이름으로 
  래빗엠큐내에서 큐가 생성됨.



    @Bean
    public ConnectionFactory connectionFactory(){
        CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
        connectionFactory.setHost(host);
        connectionFactory.setPort(port);
        connectionFactory.setUsername(username);
        connectionFactory.setPassword(password);
        connectionFactory.setVirtualHost(virtualHost);
        return connectionFactory;
    }
-->레디스에서 물리적인 연결은 LettuceConnetionFactory객체가 한 것처럼, 
래빗엠큐에서는 CachingConnectionFactory객체가 담당한다.     
-->레빗엠큐의 설정값들을 바탕으로 물리적인 연결 객체를 만든다.

    @Bean 
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory){
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        rabbitTemplate.setMessageConverter(new Jackson2JsonMessageConverter());
        -->객체를 자동으로 JSON으로 직렬화,역직렬화 한다는 뜻
        return rabbitTemplate;
    }
-->물리적인 연결을 바탕으로 래빗엠큐를 통해 메세지를 주고 받는 RabbitTemplate객체를 만든다.
    }

 

 

3. 좋아요 및 좋아요 취소 이벤트를 비동기적으로 처리하는 서비스 클래스를 만든다

@Service
@Transactional
public class LikesRabbitmqService {
    private final RabbitTemplate template;
    private final PostRepository postRepository;
    private final UserRepository userRepository;
    private final LikesRepository likesRepository;

    public LikesRabbitmqService(RabbitTemplate template, PostRepository postRepository, UserRepository userRepository, LikesRepository likesRepository) {
        this.template = template;
        this.postRepository = postRepository;
        this.userRepository = userRepository;
        this.likesRepository = likesRepository;
    }

    public void publishForAdding(BackupForLikesDto dto){
        template.convertAndSend(RabbitmqConfig.BACKUP_QUEUE_AL,dto);
    }
    -->BachupForLikesDto는 userId,PostId를 칼럼으로 가지는 dto로 따로 만들어놨다.
    -->converAndSend(큐이름,큐에 넣을 데이터(문자열or객체))는 
    래빗엠큐에 메시지를 보내는 역할을 하는 메서드
     -->RabitListner가 이 큐를 구독하고 있으면 메시지를 받아 처리한다
     -->좋아요 추가 요청을 보내는 큐!
    @RabbitListener(queues = RabbitmqConfig.BACKUP_QUEUE_AL)
    public void subscribeForAdding(Message message) throws JsonProcessingException {
        String messgeBody = new String(message.getBody());
        메세지의 내용을 가져온다.
        ObjectMapper objectMapper = new ObjectMapper();
        BackupForLikesDto dto = objectMapper.readValue(messgeBody, BackupForLikesDto.class);
       -->메세지에 저장된 JSON문자열을 역직렬화(->객체로 만들어준다) 
        Post post = postRepository.findById(dto.getPostId()).orElseThrow(()-> new EntityNotFoundException("게시글이 존재하지 않습니다"));
        User user = userRepository.findByLoginIdAndDelYN(dto.getUserId(), DelYN.N).orElseThrow(()->new EntityNotFoundException("유저가 존재하지않습니다"));

        Likes likes = Likes.builder()
                .user(user)
                .post(post)
                .build();
        likesRepository.save(likes);
      -->메세지에 저장된 postId와 userId를 통해 좋아요를 만들 수 있다.
        
    }
    -->backupAddLike이름의 큐를 구독하고 있는 메서드로 backupAddLike큐에 메시지가 
     있으면 실행된다
     -->큐에서 메세지를 받아 db에 좋아요를 저장하는 것!
    

    public void publishForMinus(BackupForLikesDto dto){
        template.convertAndSend(RabbitmqConfig.BACKUP_QUEUE_ML,dto);
    }
   -->좋아요 취소를 요청하는 큐!
    @RabbitListener(queues = RabbitmqConfig.BACKUP_QUEUE_ML)
    public void subscribeForMinus(Message message) throws JsonProcessingException {
        String messgeBody = new String(message.getBody());
        ObjectMapper objectMapper = new ObjectMapper();
        BackupForLikesDto dto = objectMapper.readValue(messgeBody, BackupForLikesDto.class);
        Post post = postRepository.findById(dto.getPostId()).orElseThrow(()-> new EntityNotFoundException("게시글이 존재하지 않습니다"));
        User user = userRepository.findByLoginIdAndDelYN(dto.getUserId(), DelYN.N).orElseThrow(()->new EntityNotFoundException("유저가 존재하지않습니다"));
        Likes cancelledLike = likesRepository.findByPost_IdAndUser_Id(post.getId(),user.getId()).orElseThrow(()->new EntityNotFoundException("해당 좋아요가 없습니다."));
        likesRepository.delete(cancelledLike);
    }
    -->큐에서 메시지를 받아 db의 좋아요를 삭제하는 것!

 

 

4.좋아요 컨트롤러 단

 

-좋아요의 특징은 한 사용자가 좋아요를 누르면 +1이 되고, 다시 누르면 -1이 된다는 것이다.

-따라서, 하나의 메서드로 좋아요가 +1이 되고 다시 누르면 -1이 되는 것을 구현한다.

//  1.좋아요 누르기(한번 누르면 좋아요, 다시 누르면 좋아요 해제)
    @PostMapping("/add/{id}")
    public ResponseEntity<?> addLike(@PathVariable Long id){
        Map<String,Object>likeStatus = likesService.toggleLike(id);
        return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(),"toggle succeed",likeStatus),HttpStatus.OK);

    }
    
  -->여기서 id는 사용자가 누른 게시글의 id이다.
  프론트에 Map형태로 리턴하는 것은 해당 포스트의 좋아요 갯수만 리턴하는 것이 아니라,
  이 사용자가 좋아요를 한 상태인지 좋아요를 해제한 상태인지에 대한 정보도 리턴해야하기 때문에
  이 2가지를 리턴하기 위해 Map형태로 리턴한다.
  
  *프론트는 이 사용자가 좋아요를 했는지 헤제했는지에 대한 boolean값을 받음으로써,
  화면에서 좋아요 아이콘에 변화를 줄 수 있다.
  (예를 들어, 한 사용자가 자신이 좋아요를 누른 포스트를 다시 조회하면 그 좋아요 아이콘이 색칠
  되어있다는 방식으로 이미 좋아요를 누른 게시물이라는 것을 알려줄 수 있다는 뜻)

 

5.좋아요 서비스 단

 

-좋아요 요청이 들어오면 레디스에 저장되는데,

이때 3종류의 key-value값을 저장한다.(예를 들어 id=2인 유저가 id=1인 포스트에 대한 좋아요가 들어오면,)

  1. 키가 post-1-likeUsers, value는 1번 게시글에 좋아요를 누른 사람들이다.
  2. 키가 post-1-likeCount, value는 1번 게시글에 대한 좋아요 개수
  3. 키가 user-2-myLikeList, value는 2번 유저가 좋아요한 게시글들이다.

2번 키는 해당 게시물에 대한 좋아요 개수에 1을 더하거나 빼야하므로 당연히 있어야할 키.

1번 키는 해당 게시물에 좋아요한 사람목록에서 요청을 한 유저가 있다면 이미 좋아요를 눌렀다는 걸 알 수 있으므로 -1을 처리하게 하고, 목록에서 없는 유저라면 좋아요+1을 처리하게 하기 위해 존재하는 키다.

이때 value는 중복이 불가능한 set으로 설정함으로써 한 사용자가 중복해서 좋아요+1 하는 것을 막는다

3번 키는 나중에 유저가 좋아요한 목록을 볼 수 있게 하기 위해 만든 키다.

public Map<String, Object> toggleLike(Long postId){
        String loginId = SecurityContextHolder.getContext().getAuthentication().getName();
        User loginUser = userRepository.findByLoginIdAndDelYN(loginId, DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 사용자입니다"));
        Post post = postRepository.findById(postId).orElseThrow(()-> new EntityNotFoundException("없는 게시글입니다"));
       
       -->Authentication 객체를 통해 현재 로그인한 user를 불러온다.매개변수로 받은 post도 불러온다
       어떤 유저가 어떤 포스트에 대해 좋아요한건지 확인 또는 저장해야하니까.
       
        BackupForLikesDto dto = BackupForLikesDto.builder().PostId(postId).UserId(loginId).build();//레디스 mq에 들어갈 백업용dto
        -->RabbitMQ를 통해 db에 백업을 하기 위해 만드는 dto객체
        
        String likeUserKey = "post-" + postId +"-likeUsers"; //레디스에 해당 포스트에 좋아요 누른 목록사람에 대해 저장될 키값(밸류는 셋 자료구조를 이용한 좋아요 누른 사람 목록) ex.post-1-likeusers : 5 이렇게 저장될 수 있도록
        String likeCountKey = "post-" + postId + "-likeCount"; //레디스에 해당 포스트의 좋아요 개수와 관련한 키값(밸류는 좋아요 개수)
        String likeMyListKey = "user-"+ loginId + "-myLikeList"; //레디스에 해당 유저가 좋아요한 포스트id를 저장.그에 대한 키값

        if(redisTemplate.opsForSet().isMember(likeUserKey,loginUser.getLoginId())){ //해당 포스트 좋아요 누른 유저 목록에 지금 유저 아이디가 있다면, 즉 이미 좋아요를 누른사람이라면
            redisTemplate.opsForSet().remove(likeUserKey,loginUser.getLoginId()); //해당 목록에서 현재 유저를 제거
            redisTemplate.opsForValue().decrement(likeCountKey);//그리고 좋아요 개수에서 1을 뺀다
            redisTemplate.opsForSet().remove(likeMyListKey,String.valueOf(post.getId()));//내가 좋아요 한 글 목록에 해당 포스트 아이디를 삭제한다.

            likesRabbitmqService.publishForMinus(dto);//rbd에서 좋아요 삭제
        } else{
            redisTemplate.opsForSet().add(likeUserKey,loginUser.getLoginId());//해당 포스트 좋아요 누른 유저목록에 현재 유저를 추가
            redisTemplate.opsForValue().increment(likeCountKey);//그리고 좋아요 개수를 1증가시킨다
            redisTemplate.opsForSet().add(likeMyListKey,String.valueOf(post.getId()));//내가 좋아요 한 글 목록에 해당 포스트 아이디를 저장시킨다.

            likesRabbitmqService.publishForAdding(dto); //rdb에서 좋아요 추가
          
        }
        boolean liked = redisTemplate.opsForSet().isMember(likeUserKey,loginUser.getLoginId()); // 해당 유저가 좋아요 눌렀는지 아닌 지 불린값
        Map<String,Object> likeStatus = new HashMap<>();
        likeStatus.put("likesCount",Long.parseLong(redisTemplate.opsForValue().get(likeCountKey)));
        likeStatus.put("liked",liked);

        return likeStatus;
    }