Spring Boot + Redis 좋아요 캐시 시스템 만들기

목포·2022년 3월 9일
0

개발환경

Spring Framework 5.3.15
Redis 6.2.6v
MariaDB 10.6.5v

개발 소스

dependency 설정

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.mariadb.jdbc</groupId>
  <artifactId>mariadb-java-client</artifactId>
  <version>3.0.3</version>
</dependency>
<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-entitymanager</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

소스 구조

Redis 설정

spring.redis.lettuce.pool.max-active=10
spring.redis.lettuce.pool.max-idle=10
spring.redis.lettuce.pool.min-idle=2

spring.redis.host=127.0.0.1
spring.redis.port=6379

Lettuce(레터스)는 Java용 Redis Client이다. 레터스는 레디스 서버와 단일 커넥션으로 멀티 스레드 요청에 대해 처리가 가능하다. Redis와 상호 작용하기 위해 동기, 비동기 및 반응 API를 제공하며 스레드 안전하다. (Non-blocking + 비동기)

변수기본값설명
spring.redis.lettuce.pool.max-active8pool에 핼당될 수 있는 커넥션 최대수 (음수로 하면 무제한)
spring.redis.lettuce.pool.max-idle8pool의 "idle" 커넥션 최대수(음수로 하면 무제한)
spring.redis.lettuce.pool.min-idle0풀에서 관리하는 idle 커넥션의 최소 수 대상

RedisConfig.java

@Configuration
public class RedisConfig {

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

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(host);
        redisStandaloneConfiguration.setPort(Integer.parseInt(port));
        //redisStandaloneConfiguration.setPassword();

        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);

        return lettuceConnectionFactory;
    }


    @Bean
    @ConditionalOnMissingBean(name = "redisTemplate")
    public RedisTemplate<String, Object> redisTemplate() throws UnknownHostException {

        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);

        RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(redisConnectionFactory());
        template.setKeySerializer(jackson2JsonRedisSerializer);
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashKeySerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();

        return template;
    }


    @Bean
    @ConditionalOnMissingBean(StringRedisTemplate.class)
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;

    }
}

Java는 Redis Client로 Jedis와 Lettuce를 제공한다.

RedisConnectionFactory는 Redis 서버와의 통신을 위한 추상화를 제공한다. Jedis, Lettuce 등의 클라이언트 라이브러리가 있다. Jedis는 예전부터 Java의 표준 Redis 클라이언트로 사용되어 왔지만 Lettuce를 사용하는 것을 권장한다. (Lettuce와 Jedis의 성능 비교)

Redis Template
Spring boot parent 2.2 이상 버전에서는 자동 설정에 의해 redis template 빈이 4개가 생성된다. 밑에 redisTemplatestringRedisTemplate의 차이는 Serializer(직렬화)이다. redisTemplate의 key, value serializer는 JdkSerializationRedisSerializer이고 stringRedisTemplate의 serializer는 StringRedisSerializer이다. 직렬화 방식이 서로 다르기 때문에 혼용해 쓸 수 없다.

StringRedisTemplate 빈은 일반적인 String 값을 Key, Value로 쓸 때 사용하면되며, redisTemplate 빈은 Java Object를 Redis에 저장하는 경우에 사용하면 된다.

@Autowired RedisTemplate redisTemplate; 

@Autowired StringRedisTemplate stringRedisTemplate;

@Autowired ReactiveRedisTemplate reactiveRedisTemplate; 

@Autowired ReactiveStringRedisTemplate reactiveStringRedisTemplate;
  • JdkSerializationRedisSerializer
    설정된 serializer가 없으면 자동설정되는 defualt serializer이다. 기본적인 자바 직렬화 방식이다. 역직렬화 시 유형 정보를 제공할 필요가 없다는 장점이 있지만, 직렬화된 결과가 JSON 형식의 약 5배 정도로 매우 크다. Redis 서버에서 많은 메모리를 소비한다.

  • StringRedisSerializer
    StringRedisSerializer는 키 값을 직렬화할 때 String.getBytes()를 사용하므로 JDK 직렬화를 사용할 때 처럼 불필요한 타입정보가 붙지 않는다.

  • JacksonJsonRedisSerializer

  • Jackson2JsonRedisSerializer
    Jackson 라이브러리를 사용하여 객체를 JSON 문자열로 직렬화한다. 장점은 빠른 속도, 짧고 컴팩트한 직렬화가 가능하다는 것이다.

Redis Template 사용

RedisService.java

import com.shinmj.like.redislike.domain.LikedCountDTO;
import com.shinmj.like.redislike.domain.UserLike;

import java.util.List;

public interface RedisService {

    /**
     * like. Status is 1
     * @param likedUserId
     * @param likedPostId
     */
    void saveLiked2Redis(String likedUserId, String likedPostId);

    /**
     * Cancel the like. Change state to 0
     * @param likedUserId
     * @param likedPostId
     */
    //void unlikeFromRedis(String likedUserId, String likedPostId);


    /**
     * Delete a like data from Redis
     * @param likedUserId
     * @param likedPostId
     */
    void deleteLikedFromRedis(String likedUserId, String likedPostId);

    /**
     * The user's likes plus 1
     * @param likedUserId
     */
    //void incrementLikedCount(String likedUserId);

    /**
     * The number of likes of the user minus 1
     * @param likedUserId
     */
    //void decrementLikedCount(String likedUserId);

    /**
     * Get all the like data stored in Redis
     * @return
     */
    List<UserLike> getLikedDataFromRedis();

    /**
     * Get the number of all likes stored in Redis
     * @return
     */
    //List<LikedCountDTO> getLikedCountFromRedis();
}

UserLike.java

@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLike {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @NonNull
    private String likedUserId;

    @NonNull
    private String likedPostId;

    @NonNull
    private Integer status = LikedStatusEnum.UNLIKE.getCode();
}

LikedStatusEnum.java

@Getter
public enum LikedStatusEnum {
  LIKE(1, "Like"),
  UNLIKE(0, "CancelLike/Not Like"),;

  private int code;
  private String msg;

  LikedStatusEnum(int code, String msg) {
      this.code = code;
      this.msg = msg;
  }
}

RedisKeyUtils.java

public class RedisKeyUtils {
  public static final String MAP_KEY_USER_LIKED = "MAP_USER_LIKED";

  public static final String MAP_KEY_USER_LIKED_COUNT = "MAP_USER_LIKED_COUNT";
  public static final String KEY_JOIN = "::";

  /**
   * 좋아한 사람의 아이디와 게시글 아이디를 Key로 연결한다.
   * @param likedUserId
   * @param likedPostId
   * @return
   */
  public static String getLikedKey(String likedUserId, String likedPostId) {
      StringBuilder stringBuilder = new StringBuilder();

      return stringBuilder.append(likedUserId)
              .append(KEY_JOIN)
              .append(likedPostId)
              .toString();
  }
}

기본적인 좋아요 기능을 위한 함수만 구현해놓았고, 추후에 싫어요 기능을 추가해볼 생각이라 아이디어만 작성해두었다.

RedisServiceImpl.java

@Service
@Slf4j
public class RedisServiceImpl implements RedisService {

    @Autowired
    RedisTemplate redisTemplate;

    @Override
    public void saveLiked2Redis(String likedUserId, String likedPostId) {
        String key = RedisKeyUtils.getLikedKey(likedUserId, likedPostId);
        //redisTemplate.opsForSet().add(likedPostId, likedUserId);
        redisTemplate.opsForHash().put(RedisKeyUtils.MAP_KEY_USER_LIKED, key, LikedStatusEnum.LIKE.getCode());
    }

    @Override
    public void unlikeFromRedis(String likedUserId, String likedPostId) {
        String key = RedisKeyUtils.getLikedKey(likedUserId, likedPostId);
        redisTemplate.opsForHash().put(RedisKeyUtils.MAP_KEY_USER_LIKED, key, LikedStatusEnum.UNLIKE.getCode());

    }

    @Override
    public void deleteLikedFromRedis(String likedUserId, String likedPostId) {
        String key = RedisKeyUtils.getLikedKey(likedUserId, likedPostId);
        redisTemplate.opsForHash().delete(RedisKeyUtils.MAP_KEY_USER_LIKED, key);
    }

    @Override
    public void incrementLikedCount(String likedUserId) {
        redisTemplate.opsForHash().increment(RedisKeyUtils.MAP_KEY_USER_LIKED_COUNT, likedUserId, 1);
    }

    @Override
    public void decrementLikedCount(String likedUserId) {
        redisTemplate.opsForHash().increment(RedisKeyUtils.MAP_KEY_USER_LIKED_COUNT, likedUserId, -1);
    }

    @Override
    public List<UserLike> getLikedDataFromRedis() {
        Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.opsForHash().scan(RedisKeyUtils.MAP_KEY_USER_LIKED, ScanOptions.NONE);

        List<UserLike> list = new ArrayList<>();

        while(cursor.hasNext()) {
            Map.Entry<Object, Object> entry = cursor.next();

            String key[] = ((String)entry.getKey()).split(RedisKeyUtils.KEY_JOIN);

            list.add(UserLike.builder()
                    .likedUserId(key[0])
                    .likedPostId(key[1])
                    .status((Integer)entry.getValue())
                    .build()
            );
        }
        return list;
    }

    @Override
    public List<LikedCountDTO> getLikedCountFromRedis() {
        return null;
    }
}

작성해둔 RedisService 인터페이스를 implement하여 redis에 할 작업들을 작성해주었다. 아까 만들어둔 redisTemplate Bean을 활용하여 1. 좋아요를 저장, 2. 좋아요를 삭제, 3. 좋아요 정보를 Redis에서 가져오는 부분을 작성하였다.

처음에는 Set을 이용해 저장하려고 했으나 추후에 싫어요 기능을 추가할 것을 생각하여 단순히 Key, Value(Set)의 형태보다는 좋아요/싫어요의 상태를 나누어 Json 형태로 저장하는 것이 좋을 것 같다고 생각했다.

redisTemplate.opsForHash().put(RedisKeyUtils.MAP_KEY_USER_LIKED, key, LikedStatusEnum.LIKE.getCode()

그래서 위와 같이 MAP_KEY_USER_LIKED를 HashKey로 잡고 key값(userId, postId)과 현 좋아요 상태코드를 json으로 저장하였다.

Redis에서 좋아요 정보를 가져올 때는 getLikedDataFromRedis()는 Redis keys명령 보다는 scan을 써서 처리했다.

Keys vs Scan

Redis의 명령 중 keys를 이용하면 모든 키 값을 가져올 수 있다. 하지만, 이 경우 이 명령을 실행하는 동안 다른 명령을 실행하지 못한다. 따라서 성능에 영향을 줄 수 있어 Redis에서는 scan, hscan을 권장한다.

scan 명령어는 한 번에 모든 레디스 키를 읽어오는 것이 아닌 count 값을 정하여 그 값만큼 여러 번 레디스의 모든 키를 읽어온다. (default 10)

따라서 count의 개수를 낮게 잡으면 키를 읽어오는 시간은 적게걸리고 모든 데이터를 처리하는데 시간은 오래걸리겠지만, 그 사이사이에 다른 요청들을 처리할 수 있는 상태가 된다. Redis의 Scan 퍼포먼스

Redis to DB

캐시 서버의 역할을 하는 Redis에 데이터를 적재했다면 데이터의 영속성을 위해 DB에 저장을 해주어야할 것이다. 데이터를 옮길 때에는 JPA를 사용했다.

알다시피 JPA는 함수 naming convention을 통해 쿼리를 간소화할 수 있다. JPA method naming convention

LikedService.java

@Service
public interface LikedService {

    /**
     * Save like record
     * @param userLike
     * @return
     */
    UserLike save(UserLike userLike);


    /**
     * Batch save or modify
     * @param list
     * @return
     */
    List<UserLike> saveAll(List<UserLike> list);

    /**
     *
     * @param likedUserId the id of the liked person
     * @param pageable
     * @return
     */
    Page<UserLike> getLikedListByLikedUserId(String likedUserId, Pageable pageable);

    /**
     * 좋아요를 누른 사람의 아이디를 기준으로 좋아요 목록 조회
     * @param likedPostId
     * @param pageable
     * @return
     */
    Page<UserLike> getLikedListByLikedPostId(String likedPostId, Pageable pageable);

    /**
     * like, like person id로 같은 기록이 있는지 조회
     * @param likedUserId
     * @param likedPostId
     * @return
     */
    UserLike getByLikedUserIdAndLikedPostId(String likedUserId, String likedPostId);

    /**
     * Store the like data in Redis into the database
     */
    void transLikedFromRedis2DB();

    /**
     * Store the number of likes in Redis into the database
     */
    void transLikedCountFromRedis2DB();
}

LikedServiceImpl.java

@Service
public class LikedServiceImpl implements LikedService {

    @Autowired
    UserLikeRepository userLikeRepository;

    @Autowired
    RedisService redisService;

    @Override
    public UserLike save(UserLike userLike) {
        return userLikeRepository.save(userLike);
    }

    @Override
    public List<UserLike> saveAll(List<UserLike> list) {
        return userLikeRepository.saveAll(list);
    }

    @Override
    public Page<UserLike> getLikedListByLikedUserId(String likedUserId, Pageable pageable) {
        return userLikeRepository.findByLikedUserIdAndStatus(likedUserId, LikedStatusEnum.LIKE.getCode(), pageable);
    }

    @Override
    public Page<UserLike> getLikedListByLikedPostId(String likedPostId, Pageable pageable) {
        return userLikeRepository.findByLikedPostIdAndStatus(likedPostId, LikedStatusEnum.LIKE.getCode(), pageable);
    }

    @Override
    public UserLike getByLikedUserIdAndLikedPostId(String likedUserId, String likedPostId) {
        return userLikeRepository.findByLikedUserIdAndLikedPostId(likedUserId, likedPostId);
    }

    @Override
    public void transLikedFromRedis2DB() {
        List<UserLike> list = redisService.getLikedDataFromRedis();

        for (UserLike ul : list) {
            UserLike checked = getByLikedUserIdAndLikedPostId(ul.getLikedUserId(), ul.getLikedPostId());

            if(checked != null) {
                checked.setStatus(ul.getStatus());
                save(checked);
            }else {
                save(ul);
            }
        }
    }

    @Override
    public void transLikedCountFromRedis2DB() {

    }
}

Controller

LikeController.java

@RestController
@RequestMapping(value = LikeController.LIKE)
public class LikeController {

    public static final String LIKE = "/like";
    public static final String TRANS_DB = "/trans";
    public static final String USER_ID = "/{userId}";
    public static final String POST_ID = "/{postId}";


    @Autowired
    private RedisService redisService;


    @Autowired
    private LikedService likedService;

    /**
     * 좋아요 추가 (게시물 + 좋아요한 사용자 ID)
     * @param postId
     * @param userId
     * @return
     */
    @RequestMapping(
            value = POST_ID + USER_ID,
            method = RequestMethod.POST,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<?> createLike(
        @PathVariable String postId,
        @PathVariable String userId
    ) {
        redisService.saveLiked2Redis(userId, postId);
        return new ResponseEntity<>(HttpStatus.CREATED);
    }

    /**
     * 좋아요 취소 (게시물 + 취소한 사용자 ID)
     * @param postId
     * @param userId
     * @return
     */
    @RequestMapping(
            value = POST_ID + USER_ID,
            method = RequestMethod.DELETE,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<?> cancelLike(
            @PathVariable String postId,
            @PathVariable String userId
    ) {
       redisService.deleteLikedFromRedis(userId, postId);
       return new ResponseEntity<>(HttpStatus.CREATED);
    }

    /**
     * 좋아요 목록 가져오기
     * @return
     * @throws JsonProcessingException
     */
    @RequestMapping(
            method = RequestMethod.GET,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<?> getLikedData() throws JsonProcessingException {

        return ResponseEntity.ok().body(new ObjectMapper().writeValueAsString(redisService.getLikedDataFromRedis()));
    }


    /**
     * 좋아요 정보 Redis -> DB 전송
     * @return
     */
    @RequestMapping(
            value = TRANS_DB,
            method = RequestMethod.POST,
            produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<?> transLikedFromRedis2DB() {
        likedService.transLikedFromRedis2DB();
        return new ResponseEntity<>(HttpStatus.CREATED);
    }
}

Scheduling

서비스 시 일정 주기로 Redis 데이터를 DB로 Transport 하기 위해 Quarzt를 이용한 스케쥴기능을 추가했다.

QuartzConfig.java

@Configuration
public class QuartzConfig {
    private static final String LIKE_TASK_IDENTITY = "LikeTaskQuartz";

    @Bean
    public JobDetail quartzDetail() {
        return JobBuilder.newJob(LikeTask.class)
                .withIdentity(LIKE_TASK_IDENTITY)
                .storeDurably()
                .build();
    }

    @Bean
    public Trigger quartzTrigger() {
        SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder
                .simpleSchedule()
                .withIntervalInMinutes(30)
                .repeatForever();

        return TriggerBuilder.newTrigger().forJob(quartzDetail())
                .withIdentity(LIKE_TASK_IDENTITY)
                .withSchedule(scheduleBuilder)
                .build();
    }
}

LikeTask.java

@Slf4j
public class LikeTask extends QuartzJobBean {

    @Autowired
    LikedService likedService;

    private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        log.info("Like Task Started : {}", sdf.format(new Date()));

        likedService.transLikedFromRedis2DB();
    }
}
profile
mokpo devlog

0개의 댓글