코드 저장소.

Redis Cache로 조회 성능 향상시키기. 본문

포폴/JPABlog

Redis Cache로 조회 성능 향상시키기.

slown 2024. 5. 31. 08:07

목차

1.캐시는 무엇인가?

2.왜 캐시를 사용했는가?

3.프로젝트 적용

 

1.캐시는 무엇인가?

캐시는 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다. 캐시의 접근 시간에 비해 원래 데이터를 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다. 데이터를 미리 복사를 해 놓으면 계산이나 접근 시간없이 더 빠른 속도로 데이터에 접근할 수 있다.

2.왜 캐시를 사용했는가?

프로젝트를 진행하다보니 메인페이지에서 있는 카테고리나 로그인 공지사항 글 등 빈번하게 접근하면서 데이터 갱신이 잘 되지 않은 부분이 있는데 매번 페이지로 이동을 할 때마다 반복적으로 쿼리가 작동이 되면 디비에 부담이 가기 때문에 반복적인 쿼리를 줄이는 방법으로 캐시를 선택하게 되었다. 

2-1.LocalCache vs GlobalCache

 로컬 캐싱 전략은 서버마다 각자 캐시를 저장하는 전략이다. 캐시 데이터 조회시 외부 캐시 서버와 통신할 필요가 없어 빠른 조회가 가능하다. 하지만 서버마다 중복된 데이터를 저장하게 될 수 있으므로 서버의 개수에 비례하여 저장하는 데이터도 늘어나기 쉬우며, 각 서버 캐시간의 정합성을 맞춰야 하는 경우에는 캐시 업데이트마다 다른 서버와 통신하는 과정이 필요하다.

 글로벌 캐싱 전략은 외부에 캐시 서버를 두고 각 서버들이 해당 캐시 서버에서 캐싱된 값들을 조회하는 전력이다. 각 서버가 동일한 캐시 서버를 참조하기 때문에 캐시 데이터를 서로 맞추어줄 필요가 없으며, 서버 확장으로 서버의 개수가 증가하더라도 저장되는 캐시 데이터의 양은 증가하지 않는다. 하지만 로컬 캐싱과 달리 서버 내부의 리소스에 데이터를 캐싱한 것이 아니기 때문에 외부에서 데이터를 가져오기 위해 캐시 서버와 통신 과정이 불가피하다.

JPABlog의 경우에는 현재 단일서버이지만 향후 확장성을 고려해서 글로벌 캐시를 선택을 하게 되었습니다.

캐시 스토리지로는 Redis를 선택하였다. 기존에 세션 저장소로 채택하여 이미 학습하였고, 캐시 저장소로 사용하는데에도 문제가 없으며 Spring Data Redis를 이용해 쉽게 사용이 가능하기 때문이다.

3.프로젝트 적용

우선 내가 만들고 있는 프로젝트에서 적용을 할 부분은 메인페이지에서 사용되는 카테고리와 게시글의 좋아요 그리고 게시글의 단일 조회이고 이유는 다음과 같다.

  • 카테고리
    • 페이지를 조회를 할 때마다 자주 조회를 한다.
    • 카테고리는 왠만하면 수정이 될 일이 거의 없다.
  • 게시글의 좋아요
    • 좋아요기능은 게시글을 조회할때 마다 update가 일어나야 데이터의 정합성을 맞출수가 있다.
  • 게시글의 조회
    • 게시글의 조회는 말그대로 조회가 빈번하게 일어나고 수정도 거의 없다.

그리고 내가 캐시를 적용할 패턴은 이러하다.

 

서버에서 데이터를 조회시 캐시에 저장된 내용이 있으면 내용을 반환을 하고 만약에 데이터가 없는 경우에는 디비에서 데이터를 조회후 캐시에 저장을 하는 방식이다.

 

그럼 직접 프로젝트에 적용된 내용을 보자.

 

우선은 redis를 사용하려면 gradle에 redis를 넣어야 한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

그 다음 redis에 관련된 설정클래스와 설정을 작성해야 한다.

@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
public class JpaboardpracticeApplication {

	public static void main(String[] args) {
		SpringApplication.run(JpaboardpracticeApplication.class, args);
	}

}
#redis
spring.cache.type=redis
spring.redis.port=6379
spring.redis.host=localhost

 

우선은 Redis를 어플리케이션에 적용을 하기 위해서 @EnableCaching 을 달아주면 Redis를 사용할 수 있고 properties에도 Redis를 사용할 수 있게끔 포트번호와 호스트를 설정 해놓으면 된다.

@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
	
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;
		
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }
		
		//redisTempelete를 사용한 방법
		//redis에서 제공하는 다양한 자료구조가 있는데 그 자료구조에 맞게끔 메소드를 
		//만들어서 작성하면 된다. 
    @Bean
    public RedisTemplate<?,?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
				//redis 연결
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        return redisTemplate;
    }
		
		//Cache어노테이션을 사용하기 위한 설정.
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory){

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
                .defaultCacheConfig()
                .disableCachingNullValues()//null value는 캐시를 안함.
                .entryTtl(Duration.ofSeconds(CacheKey.DEFAULT_EXPIRE_SEC))//캐시의 기본 유효기간 설정
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));//redis 캐시 설정 방식을 StringRedisSerializer으로 설정

        //캐시키별  default 유효기간 설정
        Map<String,RedisCacheConfiguration> cacheConfigurations = new HashMap<>();

        cacheConfigurations.put(CacheKey.USER, RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofSeconds(CacheKey.USER_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.BOARD,RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofSeconds(CacheKey.BOARD_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.LIKE,RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofSeconds(CacheKey.LIKE_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.CATEGORY,RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofSeconds(CacheKey.CATEGORY_EXPIRE_SEC)));

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .withInitialCacheConfigurations(cacheConfigurations).build();
    }

}

 

설정 클래스에서는 properties에서 설정한 포트번호와 호스트의 값을 받고 자바의 Redis Client 라이브러리는 Jedis와 Lettuce가 있는데, Lettuce가 성능이 더 좋기 때문에 Lettuce로 RedisConnectionFactory를 통해 redis와 연결을 한다.

 

설정이 끝났으면 각 로직(카테고리,회원유저,게시글 단일조회,좋아요)에 적용을 한다.

@Log4j2
@Service
@AllArgsConstructor
public class CategoryService {
    private final CategoryRepository categoryRepository;
    private StringRedisTemplate stringRedisTemplate;
    private static final ObjectMapper objectMapper = new ObjectMapper();

    //카테고리 목록
    public List<CategoryDto> categoryList() {
        List<CategoryDto> list = categoryRepository.categoryList();

        String cachedCategories = stringRedisTemplate.opsForValue().get(CacheKey.CATEGORY);

        if(cachedCategories!=null){
            log.info("목록::"+list);
            list = deserializeCategories(cachedCategories);
        }else{
            stringRedisTemplate.opsForValue().set(CacheKey.CATEGORY,serializeCategories(list));
            log.info("목록::"+list+"null임??");
        }
        return list;
    }

    //카테고리 등록
    @Transactional
    @Cacheable(value=CacheKey.CATEGORY,key = "#req.parentId",unless = "#result == null")
    public void create(CategoryCreateRequest req) {
        Category parent = Optional.ofNullable(req.getParentId())
                .map(id -> categoryRepository.findById(id)
                        .orElseThrow(()->new CustomExceptionHandler(ErrorCode.CATEGORY_NOT_FOUND)))
                .orElse(null);

        categoryRepository.save(new Category(req.getName(),parent));
    }

    //카테고리 삭제
    @Transactional
    @CacheEvict(value = CacheKey.CATEGORY,key = "#id")
    public void delete(Integer id) {
        Category category = categoryRepository
                .findById(id)
                .orElseThrow(()->new CustomExceptionHandler(ErrorCode.CATEGORY_NOT_FOUND));

        if(category!=null){
            categoryRepository.deleteById(id);
        }
    }
    
    //cache 직렬화 설정
    private String serializeCategories(List<CategoryDto> categories) {
        try {
            return objectMapper.writeValueAsString(categories);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Error serializing categories", e);
        }
    }
    
    //cache 역직렬화 설정
    private List<CategoryDto> deserializeCategories(String cachedCategories) {
        try {
            TypeReference<List<CategoryDto>> typeReference = new TypeReference<List<CategoryDto>>(){};
            return objectMapper.readValue(cachedCategories, typeReference);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Error deserializing categories", e);
        }
    }
}
@Log4j2
@Service
@AllArgsConstructor
public class LikeService {
    private final LikeRepository repository;
    private final MemberRepository memberRepository;
    private final String likeMessage ="좋아요 처리 완료";
    private final String likeCancelMessage ="좋아요 취소 처리 완료";

    /*
    * 좋아요+1
    * @param Board
    * @param Member
    * 게시글조회에서 좋아요 +1기능
    */
    @Transactional
    @Cacheable(value = CacheKey.LIKE,key = "#boardId",unless = "#result == null")
    public String createLikeBoard(Board board){
        Member member =getMember();
        //좋아요 증가
        board.increaseLikeCount();
        Like like = new Like(board,member);
        repository.save(like);
        return likeMessage;
    }

    /*
     * 좋아요-1
     * @param Board
     * @param Member
     * 게시글 조회에서 좋아요 -1
     */
    @Transactional
    @CacheEvict(value = CacheKey.LIKE,key = "#boardId")
    public String removeLikeBoard(Board board){
        Member member =getMember();
        Like likeBoard = repository.findByMemberAndBoard(member,board).orElseThrow(()->{throw new CustomExceptionHandler(ErrorCode.LIKE_NOT_FOUND);});
        //좋아요 감소기능
        board.decreaseLikeCount();
        repository.delete(likeBoard);
        return likeCancelMessage;
    }

    /*
    * 좋아요 중복처리기능
    * @param Board
    * @param Member
    *
    */
    @Transactional
    public boolean hasLikeBoard(Board board){
        Member member =getMember();
        return repository.findByMemberAndBoard(member,board).isPresent();
    }

    /*
     * 좋아요 갯수
     */
    @Transactional
    public Integer LikeCount(Integer boardId){
        return repository.findByBoardId(boardId);
    }

    /*
     * 회원 인증
     */
    private Member getMember(){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = (String)authentication.getName().toString();
        log.info(username);
        Member member = memberRepository.findByUsername(username).orElseThrow(()->new CustomExceptionHandler(ErrorCode.NOT_FOUND));
        return member;
    }
}
/*
	 * 게시글 삭제 (파일 삭제 포함)
	 * @Param boardId 게시물 번호
	 * @Param Member 회원 객체
	 * @Exception : 회원글이 존재하지 않은 경우 NOT_BOARD_DETAIL
	 * @Exception : 글작성자와 로그인한 유저의 아이디가 일치하지 않으면 NOT_USER
	*/
	@Transactional
	@CacheEvict(value = CacheKey.BOARD,key = "#boardId")
	public void deleteBoard(Integer boardId)throws Exception{

		Member member = getMember();

		Board board = validateMember(boardId,member);

		List<AttachDto>list = fileService.filelist(boardId);

		for(int i = 0; i<list.size();i++){

			String filePath = list.get(i).getFilePath();
			File file = new File(filePath);

			if(file.exists()){
				file.delete();
			}
		}
		//게시글 삭제
		repos.deleteById(board.getId());
	}

  /*
    * 글 목록 단일 조회
    * @Param boardId
    * @Exception :게시글이 존재하지 않음.(NOT_BOARD_DETAIL)
   */
	@Transactional
	@Cacheable(value = CacheKey.BOARD,key = "#boardId",unless = "#result == null")
	public BoardResponseDto getBoard(Integer boardId){
		//글 조회
		Optional<Board>articlelist = repos.findByboardId(boardId);
		if(articlelist.isPresent()){
			updateReadCount(boardId);
		}

		return BoardDto.BoardResponseDto
			   .builder()
			   .board(articlelist.get())
			   .build();
	}
@Override
	//캐시 설정
	@Cacheable(key ="#userPk",value = CacheKey.USER,unless = "#result == null")
	public UserDetails loadUserByUsername(String userPk) throws UsernameNotFoundException {
		log.info("userDetailService");
		Optional<Member> member = Optional
				.ofNullable(repository
						.findByUsername(userPk)
						.orElseThrow(()-> new UsernameNotFoundException("조회된 아이디가 없습니다.")));

		if(member.isPresent()){
			CustomUserDetails customUserDetails = new CustomUserDetails(member.get());
			return customUserDetails;
		}
		return null;
	}

 

마지막으로 캐시를 적용했을 경우의 성능을 확인하겠습니다. 

 

위의 사진은 캐싱이 적용되기전의 카테고리를 측정한 것입니다.  속도는 Postman에 보여주듯이 1451ms이고 아직 캐싱이 적용이 안되어서 로그인 로직과 카테고리 목록 쿼리가 보여지는 것을 알 수 가 있습니다. 

 

그리고 Redis에 데이터가 없으면 데이터를 키와 벨류의 형식으로 데이터를 저장을 합니다. 

위의 사진은 캐싱이 적용이 된 사진입니다. 보시는 바와 같이 속도가 대폭 개선이 된 점을 볼수 있습니다. (1451ms → 27ms) 또한 디비에서 데이터를 가져오는 것이 아닌 redis에서 데이터를 가져오기 때문에 콘솔에서는 쿼리가 나오지 않는 것을 알 수가 있습니다.