들어가며
현재 성능 개선 작업을 진행하고 있는 협업 툴 서비스 Colla에서는 칸반 보드 페이지로 이동 시 해당 프로젝트 정보와 함께 프로젝트에 속한 테스크가 조회된다.
특정 프로젝트와 테스크를 조회하는 쿼리는 여러 엔티티들을 함께 조회해야 하므로 복잡하며, 발생하는 쿼리의 개수도 많다. 결국 칸반 보드 페이지로 이동할 때마다 이러한 복잡하고 많은 개수의 쿼리가 발생하게 된다는 것인데, 만약 테스크의 개수가 많고 동시에 여러 사용자가 칸반 보드 페이지에 접근한다면, 그 부하는 상당할 것이다.
따라서 이러한 문제점을 개선할 필요가 있으며, 여기서는 Redis를 활용한 캐싱을 통해 프로젝트와 테스크를 조회하는 작업을 개선해보고자 한다.
테스트 환경 구축
- 캐싱을 도입하기 전과 후를 비교하기 위해 부하 테스트 도구인 nGrinder를 사용한다.
- VUser는 100으로 설정하였고, 테스트 데이터로 프로젝트의 테스크 10000개를 넣어두었다.
서버 리소스의 제약으로 10000개 이상의 데이터는 부하 테스트로 감당하지 못하여 10000개로 설정하였다.😅
HikariCP Dead lock
앞서 구축한 테스트 환경에서 부하 테스트를 진행해보면, 다음과 같은 예외가 발생하여 테스트 시간을 다 채우지 못한 채 종료된다.


그렇다면 위와 같은 예외가 발생한 이유는 무엇일까?
현재 테스트하고 있는 API는 DB에서 프로젝트와 테스크를 조회하는 기능을 가지고 있다. 따라서 DB에 접근해야 하는데, DB에 접근하기 위해서는 Connection이 필요하다.
스프링 부트에서는 다음과 같이 HikariCP에 요청하여 Connection을 얻어올 수 있다.

참고로, HikariCP의 default maximumPoolSize는 10이다.
그런데 만약 HikariCP가 생성해둔 Connection을 다른 스레드가 다 가져갔다면 어떻게 될까?
다른 스레드들은 Connection이 반납될 때까지 기다려야할 것이다.
그럼 언제까지 기다릴까?
HikariCP의 default connection-timeout은 30000ms(30초)이다.
즉, 30초만큼 기다렸는데도 Connection을 얻지 못하면 예외가 발생하게 되고, 이로 인해 위 예외 코드에서 Connection is not available, request time out after 30000ms라는 메세지를 보게 되는 것이다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
HikariCP의 maximumPoolSize를 늘려주어 대기하고 있던 스레드에게 connection-timeout 이내에 Connection을 할당해줄 수 있도록 하면 된다. maximumPoolSize는 스프링 부트의 설정 파일로 쉽게 설정할 수 있다.

HikariCP maximumPoolSize를 적절하게 설정하는 방법은 HikariCP: About Pool Sizing을 참고하도록 하자.
여기서는 넉넉히 50으로 설정해주었다.
캐싱을 적용하기 전

캐싱을 적용하기 전 상태에서 부하 테스트를 진행해보면, TPS가 5.2로 측정된 것을 알 수 있다.
이제 Redis를 활용한 캐싱을 적용하여 성능을 개선시켜보도록 하자.
캐시란 무엇인가?
캐시란 자주 사용하는 데이터를 미리 복사해 놓는 임시 장소를 가리킨다.
이러한 캐시를 사용하면 매번 원래 데이터가 저장되어 있는 장소까지 가서 데이터를 가져오는 것이 아니라, 캐시에 저장되어 있는 데이터를 가져다 사용할 수 있기 때문에 더 빠른 속도로 데이터에 접근할 수 있게 된다.

이번 포스트에서는 DB가 "원래 데이터가 저장되어 있는 장소"이고, Redis가 "캐시"이다.
결국 Redis를 활용하여 도달하고자 하는 목표는 매번 DB까지 가서 데이터를 가져오는 것이 아니라 Redis에 데이터가 존재하는 지 확인한 후,
- 존재한다면(Cache Hit) 해당 데이터를 제공하고,
- 존재하지 않는다면(Cache Miss) DB에서 데이터를 가져온 뒤, Redis에 저장하고 해당 데이터를 제공하는 것이다.
Redis 캐시 저장소 등록
스프링에서 Redis를 활용한 캐싱을 도입하기 위해서는 캐싱 기능 활성화와 Redis를 캐시 저장소로 등록해주는 작업이 필요하다.
Redis를 캐싱 저장소로 등록하기 위해서는 먼저 Redis와의 연결을 위한 설정을 다음과 같이 해주어야 한다.
RedisConnectionFactory는 Redis Connection을 생성하는 역할을 하며, RedisTemplate은 객체와 Redis의 바이너리 데이터 간에 자동 직렬화/역직렬화를 수행해주는 역할을 한다.
RedisTemplate은 default로 JdkSerializationRedisSerializer를 이용하여 직렬화/역직렬화를 수행한다.
@Configuration
public class RedisConfig {
private final String host;
private final int port;
public RedisConfig(
@Value("${redis.host}") String host,
@Value("${redis.port}") int port
) {
this.host = host;
this.port = port;
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
다음으로 @EnableCaching 어노테이션으로 캐싱 기능을 활성화하고, 앞서 Redis와의 연결을 위해 설정한 RedisConnectionFactory와 함께 Redis를 캐시 저장소로 등록해주도록 한다.
여기서 캐싱 기능을 활성화한다는 것은 @Cacheble, @CacheEvict 등과 같은 캐싱 관련 어노테이션을 활성화한다는 의미이다.
It is important to note that even though declaring the cache annotations does not automatically triggers their actions - like many things in Spring, the feature has to be declaratively enabled (which means if you ever suspect caching is to blame, you can disable it by removing only one configuration line rather then all the annotations in your code).
To enable caching annotations add the annotation @EnableCaching to one of your @Configuration classes. - Cache Abstraction
@EnableCaching
@Configuration
public class CachingConfig {
private final RedisConnectionFactory redisConnectionFactory;
public CachingConfig(RedisConnectionFactory redisConnectionFactory) {
this.redisConnectionFactory = redisConnectionFactory;
}
@Bean
public CacheManager redisCacheManager() {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(60))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer<>(ProjectResponse.class)));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
그리고 RedisCacheConfiguration 설정을 통해 캐시에 대한 추가적인 설정을 진행할 수 있다. 위 예제에서는 TTL(Time-to-Live)를 60분으로 설정하고, key와 value에 대한 직렬화 전략을 설정해주었다.
key는 문자열에 대한 직렬화 전략을, value는 지정한 ProjectResponse 객체와 JSON 간에 직렬화 전략을 설정해주었다.
직렬화 전략은 RedisSerializer를 참고하도록 하자.
Redis를 활용한 캐싱 도입
앞서 @EnableCaching 어노테이션을 통해 캐싱 기능을 활성화해주었기 때문에, 스프링에서 제공하는 @Cacheable, @CacheEvict, @CachePut, @Caching과 같은 캐싱 관련 어노테이션의 기능을 활용할 수 있게 되었다.
각 어노테이션의 기능은 다음과 같다.
- @Cacheable - Triggers cache population
- @CacheEvict - Triggers cache eviction
- @CachePut - Updates the cache without interfering with the method execution
- @Caching - regroups multiple cache operations to be applied on a method
더 자세한 정보는 Cache Abstraction을 참고하도록 하자.
캐싱을 적용하기 위해서는 캐싱하고 싶은 데이터를 조회하는 메서드에 @Cacheable 어노테이션을 붙여주어 캐싱에 대한 AOP가 적용되도록 해야한다.
@Cacheable(
value = "Project",
key = "#projectId",
condition = "#projectId != null",
unless = "#result == null"
)
public ProjectResponse getProjectWithTasks(Long projectId) {
Project project = findProjectById(projectId); // DB에서 조회
...
}
@Cacheable 어노테이션에 적용할 수 있는 속성들로는 key, value, condition, unless 등이 있는데, 각 속성들은 다음과 같은 용도로 사용될 수 있다.
- value - 캐시 저장소에 저장될 때의 namespace를 설정한다.
- key - 캐시 저장소에 저장될 때의 key값을 설정한다.
- condition - 메서드의 캐싱 조건을 설정한다.
- unless - 메서드 호출 결과에 따라 캐싱 여부를 설정한다.
key, condition, unless 속성에는 SpEL을 사용할 수 있다.
위 예제에서는 Project namespace에 projectId를 key값으로 하여 캐싱하도록 하였고, 캐싱 조건은 매개변수로 넘어온 projectId가 null이 아닐 때와 메서드의 호출 결과가 null이 아닐 때 캐싱하도록 하였다.
그런데 위에서 @Cacheable 어노테이션을 붙여주어 캐싱에 대한 AOP가 적용되도록 한다라고 하였는데, 이것이 무슨 의미일까?

캐싱 관련 어노테이션을 사용하면 스프링은 AOP를 통해 캐싱을 처리한다. 따라서 Service 클래스의 메서드에 @Cacheable 어노테이션을 사용하면 매번 Target 클래스에게 작업을 위임하는 것이 아니라, Proxy 단에서 캐시 저장소를 먼저 조회해본 다음, 캐싱된 데이터가 없을 경우에만 위임하게 된다.
이제 실제로 캐싱이 이루어지는지 확인해보기 위해 API를 호출해보면, 다음과 같이 데이터가 캐시 저장소에 저장되는 것을 확인할 수 있다.

여기서 한 가지 더 생각해보아야 할 부분이 캐싱하고 있는 데이터에 변경이 일어날 때이다.
이 경우 캐싱하고 있는 데이터도 갱신해주어야 하는데, 이때는 다음과 같이 @CacheEvict를 사용해서 기존 캐시 데이터를 날려주어 다음 조회 작업이 일어날 때 새로 캐싱되도록 해야 한다.
@CacheEvict(value = "Project", key = "#createTaskRequest.projectId")
public Long createTask(CreateTaskRequest createTaskRequest) {
...
}
여기까지 Redis를 활용하여 캐싱을 도입해보았다. 매번 원래 데이터가 저장되어 있는 장소까지 가서 데이터를 가져오는 것이 아니라, 캐시에 저장되어 있는 데이터를 가져다 사용할 수 있기 때문에 더 빠른 속도로 데이터에 접근할 수 있게 되었다.
그렇다면 이제 부하 테스트를 진행하여, 앞서 캐싱을 적용하기 전에 측정한 성능과 비교해보도록 하자!
캐싱을 적용한 후

캐싱을 적용한 상태에서 부하 테스트를 진행해보았더니, TPS가 67.7로 측정되었다. 캐싱을 적용하기 전 TPS인 5.2에 비해 대략 13배 정도 개선된 것을 알 수 있다.
마치며
Redis를 활용한 캐싱을 도입해보면서, 캐싱에 대해 좀 더 깊게 알아가는 시간을 가질 수 있었던 것 같다.
그리고 이론적으로만 접했던 캐싱의 개념을 직접 적용해보니, 그 강력함을 느낄 수 있어 흥미로웠다:)
물론 이번 포스트에서는 캐싱을 도입하는 것을 목적으로 하다보니 미흡한 부분이 많이 존재하지만, 이번 경험을 바탕으로 더 학습하여 보완해나갈 생각이다.
Reference
Transaction, Caching and AOP: understanding proxy usage in Spring
'Backend > Spring' 카테고리의 다른 글
Spring REST Docs 적용 (1) | 2022.06.13 |
---|---|
테스트 격리하기 (0) | 2022.05.04 |
트랜잭션 전파 알아보기 (0) | 2022.04.26 |
AOP와 빈 후처리기를 이용한 부가 기능 분리 (0) | 2022.03.26 |
다이내믹 프록시를 활용한 JPA QueryCounter 구현기 (0) | 2022.03.20 |