이전 포스트에서 Redis를 설치하고 Spring boot 프로젝트와 연동하는 방법을 알아보았습니다. 이번 포스트에서는 실제로 Redis를 적용하는 방법과 성능의 차이를 비교해보도록 하겠습니다.
Redis는 key:value형식으로 데이터를 다룹니다. value로 입력되는 값은 직렬화된 형태로 저장됩니다. Redis Config 파일에 직렬화/역직렬화를 위한 cacheManager 설정 부분을 추가하도록 하겠습니다.
@Bean
public CacheManager cacheManager() {
RedisCacheManager.RedisCacheManagerBuilder builder =
RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
RedisCacheConfiguration configuration =
RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues()
.entryTtl(Duration.ofMinutes(30L));
builder.cacheDefaults(configuration);
return builder.build();
}
Java에서는 직렬화/역직렬화를 위해 다양한 직렬화 규칙을 적용 할 수 있습니다. (StringRedisSerializer, Jackson2JsonRedisSerializer, GenericJackson2JsonRedisSerializer 등)
저는 이중 GenericJackson2JsonRedisSerializer를 사용해보겠습니다.
GenericJackson2JsonRedisSerializer의 이점은 다른 직/역직렬화 규칙과는 다르게 @class정보를 value값과 함께 등록하여 Object mapping하여 사용할 수 있다는 점입니다.
Cache적용에 앞서 캐싱하기 위한 데이터의 형태와 변경이 발생하였을 경우 대응 방안 등 캐싱 전략을 우선적으로 수립하여야합니다.(캐싱 전략에 대하여 개발자 KimJunHee님의 블로그 글)
이 포스팅은 캐싱으로 인한 조회 성능 개선 비교를 중심으로 작성하기 때문에 캐싱 전략에 대한 설명은 생략하겠습니다. Spring framework을 사용하신다면 org.springframework.cache.annotation에서 제공하는 @Cacheable, @CachePut, @CacheEvict annotation을 사용하여 간단하게 데이터를 저장하거나 수정, 조회할 수 있습니다.
메서드의 반환값을 캐시 저장소에 저장하고 조회를 위해 사용합니다. @Cacheable이 붙어있는 메소드가 실행되면 데이터를 DB에서 조회하는 것이 아닌 우선 Cache 저장소(이 경우 Redis)에 명시된 key값으로 데이터 존재 유무를 확인합니다.
만약 데이터가 존재할 경우 DB조회 단계는 생략되고 Cache 저장소의 내용을 반환하게됩니다.
데이터가 존재하지 않을 경우 DB조회 로직을 수행하게되고 반환하는 결과값에 대하여 Cache 저장소에 저장 후 결과값을 반환하게됩니다.
캐싱되어있지 않은 데이터를 조회할 경우 최초 요청에 대해서는 Redis 저장소에 데이터를 등록하는 추가적인 작업이 필요하기 때문에 단순 조회에 비해 성능에서 희생을 필요로하게되지만, 이후 요청에 대해서는 단순 조회보다 월등히 뛰어난 성능을 보여주게 됩니다.
다음은 체육관 전체 목록을 조회하는 로직입니다.
@Cacheable(cacheNames = "gyms", key = "'all'")
public Gyms findAllGymsWithCache(){
List<Gym> gyms = gymRepository.findAll();
return new Gyms(gyms);
}
체육관의 전체 목록에 대한 조회 요청이 있을 경우 우선 Redis 저장소의 gyms::all으로 저장되어있는 데이터를 조회합니다.
만약 데이터가 존재하지 않을 경우 gymRepository.findAll();
이 실행되고 결과값에 대하여 Redis 저장소에 등록, 데이터 반환 과정이 차례대로 진행됩니다.
Test 코드를 통해 위 로직을 실행하고 최초 등록 시 응답에 걸리는 시간과 Redis 저장소에 등록 내용을 확인해보겠습니다.
@Nested
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class 체육관_조회_테스트{
@Test
@Rollback(value = false)
@DisplayName("00_벌크 데이터 등록")
public void test_a(){
int gymCount = 30000;
for (int i = 1; i <= gymCount; i++) {
Gym gym = new Gym("test gym" + i);
repository.save(gym);
for(int j = 0; j < 2; j++){
Member member = new Member("test member", gym.getName(), TestValue.IPSUM);
memberRepository.save(member);
}
}
}
@Test
@DisplayName("01_체육관 정보 조회")
public void test_b(){
long before = System.currentTimeMillis();
List<Gym> gyms = service.findAllGyms();
long after = System.currentTimeMillis();
long diff = after - before;
log.debug("전체 데이터 크기: {}", gyms.size());
log.debug("전체 조회 실행 시간: {}", diff);
}
@Test
@DisplayName("02_체육관 정보 조회 캐시적용")
public void test_c(){
long before = System.currentTimeMillis();
Gyms gyms = service.findAllGymsWithCache();
long after = System.currentTimeMillis();
long diff = after - before;
log.debug("전체 데이터 수: {}", gyms.getGyms().size());
log.debug("전체 조회 캐시 최초 실행 시간: {}", diff);
}
@Test
@DisplayName("03_체육관 전체 정보 캐시조회")
public void test_d(){
long before = System.currentTimeMillis();
Gyms gyms = service.findAllGymsWithCache();
long after = System.currentTimeMillis();
long diff = after - before;
log.debug("전체 데이터 수: {}", gyms.getGyms().size());
log.debug("전체 조회 캐시 적용 실행 시간: {}", diff);
}
@Test
@DisplayName("04_체육관 전체 정보 캐시조회")
public void test_e(){
long before = System.currentTimeMillis();
Gyms gyms = service.findAllGymsWithCache();
long after = System.currentTimeMillis();
long diff = after - before;
log.debug("전체 데이터 수: {}", gyms.getGyms().size());
log.debug("전체 조회 캐시 적용 실행 시간: {}", diff);
}
}
30,000건의 bulk data를 등록 후 일반 조회 -> 캐시 조회 (최초) -> 캐시 조회 2회
순서로 테스트 코드를 실행하고 각 단계별로 조회에 소요된 시간을 기록하여 비교해보도록 하겠습니다.
redis cache가 적용되지 않은 일반 조회 로직을 실행하였을 때, 30,000개의 데이터를 조회하는데 311ms가 소요되었습니다.
다음으로 redis cache가 적용된 로직을 실행한 경우 중 최초 실행하였을때입니다. 실행 시간은 493ms로 앞서 일반 조회 성능보다 180ms이상 성능이 악화되었습니다.
실행된 hibernate 로그를 확인해보면 데이터 조회하는 SQL이 실행된 것을 볼 수 있습니다.
2025-01-02T13:26:57.107+09:00 DEBUG 43420 --- [board-for-workers] [ main] org.hibernate.SQL : select g1_0.gym_id,g1_0.address,g1_0.close_time,g1_0.is_open,g1_0.location,g1_0.name,g1_0.open_time,g1_0.phone_number from gym g1_0
Hibernate: select g1_0.gym_id,g1_0.address,g1_0.close_time,g1_0.is_open,g1_0.location,g1_0.name,g1_0.open_time,g1_0.phone_number from gym g1_0
cache가 적용된 로직의 경우 최초 실행 시 명시된 cache 저장소에서 요청된 key::value 형태로 저장되어있는 값이 존재하는지 우선적으로 확인하게됩니다. 만약 존재하지않다면 SQL을 실행하여 데이터베이스 조회를 실행하고 사용자에게 응답 전 cache 저장소에 해당 내용을 기록하게됩니다. 최초 실행 시 일반 실행보다 더 많은 시간이 소요되는 것은 이 cache 저장소에 최초로 기록하는 작업이 발생하기 때문입니다.
다시 말해 cache는 최초 요청자에 대한 응답을 희생하여 이후 동일한 요청이 있을 때 성능 향상을 기대하는 것입니다.
앞서 이야기한대로라면 이번 조회 실행부터는 일반 조회 혹은 cache 최초 실행에 비해 더 빠른 응답 시간을 기대할 수 있습니다.
위와같이 cache 저장소에 값이 입력되어있을 경우 일반 조회에 비하여 절반 정도의 성능이 향상된 것을 확인할 수 있습니다. 실제로 데이터 조회를 위해 SQL을 실행하지 않고 Redis cache 저장소의 값을 return하기때문에 hibernate 로그도 찍히지 않았습니다.
실제 테스트를 진행한 환경은 단순 30,000건의 데이터를 조회하는 테스트기때문에 폭발적인 성능의 향상까지는 확인할 수 없었습니다. 하지만 실제로 join연산이 동반되거나 수십만건의 데이터를 조회할 경우 cache의 적용만으로도 엄청난 성능 향상이 되는 것을 확인할 수 있습니다.
하지만 이런 cache 저장소의 적용에 앞서 반드시 주의해야할 내용이 한가지 있습니다. 바로 cache저장소에 저장된 내용에 대하여는 SQL을 실행하지 않기 때문에 발생하는 문제입니다.
아래의 경우를 생각해보겠습니다.
Gym 전체 목록 조회 요청 (cache/최초) -> SQL 실행 -> 데이터 cache 저장소 등록 -> 사용자에게 응답 -> Gym 목록에서 2개의 데이터 삭제
기존 데이터에 수정이나 삭제 등 변경이 있을 경우 cache 저장소의 데이터에 별도 처리가 없을 경우, 사용자가 동일한 요청을 보냈을 때 SQL이 실행되지 않고 cache 저장소의 값을 응답하기 때문에 사용자는 수정 이전에 데이터를 조회하는 문제가 발생합니다.
때문에 cache 저장소의 적용에 앞서 데이터 동기화 처리를 위한 cache 설계 전략을 수립해야합니다. (예: 데이터 등록/삭제/수정 시 관련 cache 데이터 clear)
이번 포스팅은 이것으로 마무리를 하고 다음 포스트에서는 실제 실무에서 사용하였을 때 어느정도의 성능 차이가 있는지 확인해보도록 하겠습니다.