들어가며
캐시에 저장을 했지만 자주 히트율이 떨어지는 데이터의 경우
캐시에서 데이터를 찾는 작업, 디비에서 데이터를 조회하는 작업이 이중으로 일어나며
메모리 자원을 차지하기 때문에 캐시는 상황에 맞게 설정을 해야합니다.
제가 개발한 서비스의 메인 페이지에 위치하여 자주 조회되고, 수정이 거의 일어나지 않아 히트율이 높은
해시태그 데이터와 네일샵 데이터를 캐시에 저장하여 활용하기로 결정했습니다.
Global Cache vs Local Cache
캐싱처리를 하면서 크게 local cache ,global cache 2가지가 있다는 것을 알게 되었습니다.
local cache 의 경우
- 로컬 캐시는 애플리케이션 내부에서만 유효하며, 동일한 애플리케이션 내의 여러 모듈이나 서비스 간에는 공유되지 않습니다.
- 또한, 메모리 내에 데이터를 저장하므로 매우 빠른 읽기 및 쓰기 성능을 제공합니다.
- 로컬 캐시는 애플리케이션의 JVM 내부 또는 로컬 서버에 저장되며, 외부에서 접근할 수 없습니다.
- EhCache, Guava Cache, Caffeine Cache 등이 있습니다.
global cache 의 경우
- 글로벌 캐시는 여러 서버 또는 애플리케이션 간에 데이터를 공유할 수 있습니다.
- 글로벌 캐시는 주로 네트워크를 통해 데이터에 접근하므로 로컬 캐시에 비해 상대적으로 느린 읽기 및 쓰기 성능을 가질 수 있습니다.
- 글로벌 캐시는 주로 네트워크를 통해 외부 서버에 데이터를 저장하므로 여러 애플리케이션이 공유할 수 있습니다.
- Redis, Memcached 등이 있습니다.
저의 경우 캐싱처리를 global cache인 Redis를 활용하였습니다.
- 릴리즈 이후에 Scale-Out 할 가능성이 있었기 때문에 Scale-out에 취약한 Local cache보다는 global cache를 선택하였습니다.
- 캐싱 처리가 필요한 부분은 메인 페이지에 보이는 부분이였기 때문에 서비스 신뢰랑 직결되고, 캐시 데이터의 정합성이 중요하다고 생각했기 때문입니다.
Docker이용해 redis 설치
Docker, Docker-compose 설치
아래 링크를 참고해 Mac에 docker를 설치하고,
인스턴스에 docker, docker-compose를 설치합니다.
Docker 활용해 Redis 설치 ( in 로컬 )
로컬 환경에서 redis를 사용하고 배포 전 테스트를 하기 위해 docker를 이용해 Reids를 설치합니다.
docker pull redis # 가장 최근 버전의 iamge
docker images -a # image 확인
가장 최근 버전의 image를 pull 받습니다.
# docker run --name {컨테이너명} -p {로컬포트}:{도커포트} --network {네트워크명} -it -d {이미지명}
docker run --name redis -p 6379:6379 -it -d redis
redis 서버를 실행합니다.
# docker exec -it {컨테이너명} redis-cli
docker exec -it redis redis-cli
docker 컨테이너 내부에 접속하여, redis-cli 를 실행하여 접속합니다.
잘 접속되는 것을 확인할 수 있습니다.
Docker-compose 활용해 Redis 설치 ( in 인스턴스)
# docker-compose.yml
version : "3"
services:
application:
image: sumnail
environment:
SPRING_DATASOURCE_URL: {db url}
SPRING_DATASOURCE_USERNAME: {db username}
SPRING_DATASOURCE_PASSWORD: {db password}
restart: always
container_name: sumnail
ports:
- "80:8080"
depends_on:
- redis
redis:
hostname: redis
container_name: redis
image: redis
ports:
- "6379:6379"
- image : redis 이미지를 내려받습니다. 최근버전 redis를 이미지로 내려받습니다.
- container_name: 다운받은 redis image를 run 시키면 redis에 대한 container가 실행이 되는데 해당 container에 대한 이름을 설정하는 것입니다.
- ports: 앞 6379는 사용자가 redis에 접근할 포트번호이고 뒤 6379는 redis 포트번호입니다.
- hostname: 컨테이너가 네트워크 상에서 식별되는 이름입니다.
application 서비스는 redis 서비스에 의존하도록 설정되어 있어
아래 명령어가 실행될 때 application 서비스가 먼저 시작되고, 이후 redis 서비스가 시작됩니다
docker-compose -p sumnail up -d
Docker Compose 파일에 이미지 이름이 포함되어 있으면
docker-compose up 명령어를 실행할 때 해당 이미지가 인스턴스에 존재하지 않는 경우
자동으로 Docker Hub에서 해당 이미지를 가져옵니다.
따라서 docker pull redis 를 하지 않아도 됩니다!
Springboot에 redis 연동
1. 의존성 추가
//build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
2. application.yml 설정
# src/main/resources/application.yml
spring:
data:
redis:
host: redis # 로컬에서 돌릴 땐 127.0.0.1
port: 6379
redis 컨테이너 연결 정보 설정을 해준다.
docker-compose.yml 파일에서 redis의 hostname을 "redis"로 설정해주었으므로
application.yml 파일의 spring.data.redis.host 를 똑같이 "redis"로 설정해야
어플리케이션은 docker compose에서 실행 중인 redis 컨테이너를 찾아 해당 호스트와 포트로 연결할 수 있습니다.
3. Redis 설정 파일 작성
// src/main/java/backend/sumnail/global/config/RedisConfig.java
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
/**
* 내장 혹은 외부의 Redis를 연결
*/
@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory(host, port);
}
/**
* RedisConnection에서 넘겨준 byte 값 객체 직렬화
*/
@Bean
public RedisTemplate<?,?> redisTemplate(){
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return redisTemplate;
}
}
redis를 springboot에서 사용하기 위해서는 아래의 설정 파일을 생성 해주어야합니다.
해당 설정 파일은 redis 저장소와 연결하는 과정입니다.
- redisConnectionFactory() : Redis 구현체로는 Jedis와 Lettuce가 있는데, Lettuce가 전 분야에서 Jedis보다 뛰어나므로 Lettuce를 사용하였습니다. Lettuce에 port와 host 정보를 담아서 redis와 연결을 해줍니다. 이미 존재하는 connection이 있다면 이미 있는 것을 리턴하고 그렇지 않으면 새로 만든 것을 리턴해줍니다.
- redisTemplate() : Redis data access code를 간소화하기 위해 제공되는 클래스입니다. 주어진 객체들을 자동으로 직렬화/역직렬화 하며 binary 데이터를 redis에 저장합니다.기본 설정은 JdkSerializationRedisSerializer입니다.
- StringRedisSerializer : binary 데이터로 저장되기 때문에 이를 String으로 변환시켜줍니다 (반대로도 가능)
- GenericJackson2JsonRedisSerializer : 객체를 json 타입으로 직렬화/역직렬화를 수행합니다.
4. Redis Cache 적용을 위한 CacheManager 설정
// src/main/java/backend/sumnail/global/config/RedisCacheConfig.java
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 리소스 유형에 따라 만료 시간을 다르게 지정
Map<String, RedisCacheConfiguration> redisCacheConfigMap = new HashMap<>();
redisCacheConfigMap.put("NAILSHOP", defaultConfig.entryTtl(Duration.ofMinutes(30)));
redisCacheConfigMap.put("HASHTAG", defaultConfig.entryTtl(Duration.ofHours(1)));
return RedisCacheManager.builder(cf)
.withInitialCacheConfigurations(redisCacheConfigMap)
.build();
}
}
reidsCacheManager 라는 메서드 안에 Map을 이용하여 캐시 키를 등록해주었고 entryTtl은 각각의 키 별로 설정해주었습니다.
NAILSHOP 키에 30분 설정을 해두었고, HASHTAG 키에 1시간 설정을 해두었습니다.
@Cacheable 적용
//src/main/java/backend/sumnail/domain/hashtag/service/HashtagService.java
@Cacheable(cacheNames = "HASHTAG", cacheManager = "cacheManager")
public HashtagFindAllResponse findAllHashtag() {
List<Hashtag> hashtags = hashtagRepository.findAll();
return HashtagFindAllResponse.from(hashtags);
}
캐싱을 적용하고 싶은 메서드에 @Cacheable 을 선언하여
해당 메서드에 요청이 오면 데이터를 캐시할 수 있도록 명시해주었습니다.
우선 해시태그 전체 조회 기능에 캐싱을 적용하였습니다.
@Cacheable(cacheNames = "NAILSHOP", key = "#pageable.pageNumber.toString()", condition = "#pageable.pageNumber <= 50", cacheManager = "cacheManager")
public NailShopFindAllResponse findAllShop(Pageable pageable) {
Page<NailShop> nailShopPage = nailShopRepository.findAll(pageable);
List<NailShop> nailShops = nailShopPage.getContent();
return toNailShopFindAllResponse(nailShops);
}
네일샵 전체 조회 기능에도 캐싱을 적용하였습니다.
pageNumber를 기반으로 캐시 key를 생성하여 1page에 대한 캐시인지 2page에 대한 캐시인지 어떻게 구분할 수 있습니다.
메인 페이지의 경우 보통 최신 네일샵 데이터를 주로 봅니다.
메모리는 한정적이라 모든 데이터를 저장 하는 것은 비효율이기 때문에
condition 속성을 활용하여 캐시가 적용되기 위한 추가적인 조건을 지정해주었습니다
condition에 지정된 조건이 true인 경우에만 캐시가 적용됩니다.
여기서는 네일샵 페이징 조회시 pageNumber가 50 이하인 경우에만 캐싱처리 됩니다.
Trouble Shooting: 역직렬화 에러
직렬화해서 redis에 데이터를 저장하는 부분까진 잘 되었는데, 다시 역직렬화 하려고 하니 에러가 발생하였습니다.
아래의 블로그를 보고 List를 감싸는 Wrapper 클래스를 만들어주어 해결하였습니다.
Spring Boot 에서 Redis Cache 사용 시 List 역직렬화 에러 (GenericJackson2JsonRedisSerializer)
전체 코드는 아래 PR에서 확인할 수 있습니다.
https://github.com/Sum-nail/sum-nail-server/pull/62/files
Ngrinder로 성능 테스트
Ngrinder 란 ?
네이버에서 성능 측정 목적으로 개발 된 오픈소스 프로젝트입니다.
Jython, Groovy 두 가지 중 하나를 선택하여 스크립트를 작성합니다.
또한 Junit 기반으로 되어 있어 IDE에서 먼저 확인해보고 디버깅할 수 있습니다.
스크립트를 수정할 수 있어 세밀한 성능 테스트가 가능합니다.
웹 애플리케이션 Controller와 자바 애플리케이션 Agent로 구성 되어있습니다.
해시태그 전체 조회 성능 테스트
1. Script 생성
script 탭을 클릭하고 “create” 버튼을 클릭합니다.
script name을 지어주고 테스트 하고자하는 url을 입력합니다.
주의!! localhost(x) → 127.0.0.1
validate가 200 OK 가 되면 SAVE 버튼을 눌러 저장합니다.
2. Performance Test 생성
1분간 Vuser를 10명으로 설정하여 동일한 조건에서 캐싱 전과 후를 비교하였습니다.
vUser 10은 가상 사용자(Virtual User)가 10명을 나타냅니다.
이것은 부하 테스트 시나리오에서 10명의 가상 사용자가 동시에 시스템 또는 애플리케이션에서 동작하고 있다는 것을 의미합니다.
script는 아까 위에서 만든 스크립트를 선택합니다.
3. 캐싱 전 후 비교
TPS( 평균 TPS ) : 270.2 → 7156.5
Peek TPS ( 최고 TPS ) : 321.0 → 7952.0
Mean Test Time (평균 테스트 시간): 36.63 ms → 1.30 ms
해당 테스트를 통해 tps 수치와 응답시간을 변화를 체크하였고
확인 결과 각각 tps 수치는 26.5배 개선, 응답시간은 28.2배 단축되었음을 확인했습니다.
네일샵 전체 조회 성능 테스트
1. Script 생성
위에서 한 것과 같이 script를 생성합니다.
다만 랜덤한 페이지를 호출하도록 아래와 같이 test() 메소드를 수정해줍니다.
...
@Test
public void test() {
// 랜덤한 페이지 값 생성 (1 이상, 100 이하)
def randomPage = new Random().nextInt(100) + 1
// API 호출 URL 조합
def apiUrl = "http://127.0.0.1:8080/v1/nail-shops?page=${randomPage}"
// API 호출 및 응답 획득
HTTPResponse response = request.GET(apiUrl, params)
if (response.statusCode == 301 || response.statusCode == 302) {
grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
} else {
assertThat(response.statusCode, is(200))
}
}
...
2. Performance Test 생성
위와 같은 조건으로 1분간 Vuser를 10명으로 설정하여 동일한 조건에서 캐싱 전과 후를 비교하였습니다.
3. 캐싱 전 후 비교
TPS ( 평균 TPS ) : 72.2 → 144.4
Peek TPS ( 최고 TPS ) : 81.5 → 162.5
Mean Test Time ( 평균 테스트 시간 ) : 138.23 ms → 68.68 ms
해당 테스트를 통해 tps 수치와 응답시간을 변화를 체크하였고
확인 결과 각각 tps 수치는 2배 개선, 응답시간은 2배 단축되었음을 확인했습니다.