🎯 무엇을 개선하지?
현재 진행중인 프로젝트에서 모든 집 게시글을 조회하는 기능 이 있습니다. 아래 화면에서 사용되고 있는 기능인데 현재는 DB 에 조회해 현재 판매중인 집 게시글을 모두 조회해 사용 중입니다. 이 두개의 화면은 사용자가 가장 많이 접속해 사용하는 기능이라 판단해 추가적인 성능 개선이 필요하다 판단했습니다.
🎯 선택한 캐싱 전략과 이유
이런 조회 기능을 개선할때 자주 사용되는 기술중 하나는 캐싱(Caching) 기능 입니다.
캐싱이란 자주 DB 에서 조회되는 데이터를 임시 저장소에 저장한 뒤, 동일한 데이터 조회 요청이 들어오면 DB 에서 조회하는 것이 아닌, 임시 저장소에서 데이터를 조회 하는 기술입니다. 즉, DB 접근을 최소화해 부하를 줄일 수 있고 사용자가 같은 데이터를 많이 조회할수록 효율적으로 동작될 수 있습니다.
기본적으로 이러한 캐싱을 적용할때 다양한 전략이 있는데, 저는 Look Aside(읽기) Write-Through(쓰기)을 선택했습니다. 이유는 다음과 같습니다.
(1) 캐시에서 찾는 데이터가 없을때만 DB 에 접근하기 때문에 성능 개선에 효과적이다.
(2) 집 게시글 정보는 즉시 DB 에 저장되어야 한다.
Write Around 을 사용하면 성능에서는 이점이 있을 수 있습니다. 하지만 집 게시글 데이터는 DB 에 즉각적으로 반영되어야 합니다. 그래서 채팅,거래 기능이 문제없이 실행될 수 있습니다.
🎯 개선 과정
✅ RedisCacheConfig
/**
* 캐시 설정 클래스
*/
@Configuration
@RequiredArgsConstructor
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(10L));
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
CacheManager 는 애플리케이션 전반의 걸쳐 캐싱을 구성하는 인터페이스 입니다.
serializeKeysWith 은 캐싱 Key 를 직렬화하기 위한 객체를 등록합니다.
serializeValuesWith 는 캐싱된 데이터를 직렬화 하는 객체를 등록합니다. (캐싱 값들은 Json 형태로 직렬화 된다.)
entryTtl 은 캐싱된 데이터의 유효 시간을 설정합니다.
✅ RedisConfig
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
✅ 집 게시글 조회 메서드
/**
* 모든 집 게시글 조회
*/
@Cacheable(value = "homeOverviewCache", key = "'allHomes'")
public List<HomeOverviewResponse> findAllHomes() {
List<HomeOverviewResponse> response = new ArrayList<>();
List<Home> homes = homeRepository.findAllSellHome();
homes.forEach(home -> {
User user = OptionalUtil.getOrElseThrow(userRepository.findById(home.getUserIdx()), NOT_EXIT_USER_ID);
response.add(homeMapper.toSimpleHomeDto(home, user));
});
return response;
}
@Cacheable 애노테이션은 캐시 생성 및 전달을 담당합니다. 캐시에 데이터가 없을 경우에는 기존의 로직을 실행 후 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 캐시의 데이터를 반환합니다. value = "homeOverviewCache" 는 캐시의 이름을 지정하는 옵션입니다. Redis 는 Key-Value 타입으로 저장됩니다. allHomes 가 key 가 되고, 메서드 반환값이 value 가 됩니다.
✅ 캐시 무효화 적용
캐시 데이터를 사용할때 가장 조심해야 하는 것이 바로 데이터 정합성 문제 입니다. 즉, DB 에 저장된 데이터와 Redis 캐시 메모리에 저장된 데이터가 달라질 수 있는 상황을 조심해야 합니다.
그래서 집 게시글의 추가,수정,삭제등의 기능이 동작할때 캐시를 무효화하는 로직을 꼭 넣어줘야 합니다.
하지만 여기서 의문점이 들었습니다. "꼭 데이터가 바뀔때 무효화를 해줘야할까?" 이게 뭔 소린가 싶을겁니다. 하지만 저는 캐시에 저장될 데이터(DTO)는 집 주소, 대표 이미지, 위도, 경도 등의 데이터 입니다. Redis 데이터를 무효화 하고 다시 저장하는 과정이 너무 많이 반복되면 Redis 메모리에 많은 부하를 줄 수 있을거라 생각했습니다.
그래서 꼭 필요한 데이터 변경시점에만 캐시 무효화를 적용하려 합니다. 예를 들어 집 게시글을 수정할때는 집 이미지, 옵션, 설명 등을 수정할 겁니다. 즉 지도와 집 리스트 화면에서는 크게 중요하지 않은 데이터 입니다. 때문에 집 게시글 생성, 삭제 메서드에서 캐시 무효화 기능을 넣으려 합니다. 이러면 Redis 에 대한 부담을 최소화함과 동시에 조회 성능을 효과적으로 개선할 수 있을거라 판단했습니다.
집 게시글 삭제 && 생성 기능
/**
* 집 게시글 삭제
* 캐시 무효화: 집 게시글이 삭제되면 전체 집 목록 캐시를 삭제
*/
@CacheEvict(value = "homeOverviewCache", key = "'allHomes'", allEntries = true)
public void delete(Long homeId) {
Home home = OptionalUtil.getOrElseThrow(homeRepository.findById(homeId), NOT_EXIST_HOME);
homeRepository.delete(home);
}
/**
* 집 게시글 등록
*/
@CacheEvict(value = "homeOverviewCache", key = "'allHomes'", allEntries = true)
public Long save(HomeGeneratorRequest homeCreateDto, List<MultipartFile> files, LatLng latLng) {
Home home = homeMapper.toEntity(homeCreateDto, getLoggedInUserId());
if (!files.isEmpty() && !files.get(0).getOriginalFilename().isEmpty()) {
home.setImages(generateHomeImages(home, files));
}
home.setLatLng(latLng.getLat(), latLng.getLng());
return homeRepository.save(home).getId();
}
🎯 성능 비교 With K6
총 100 개의 Home 데이터를 DB 에 넣고 K6 를 통한 부하테스트 결과 입니다.
✅ 캐싱 적용전 응답 시간
✅ 캐싱 적용 후 응답 시간
✅ 결과 비교
총 요청 횟수 | 80번 (7.72 req/s) | 90번 (8.40 req/s) | +10 요청, 초당 처리 속도 증가 |
평균 응답 시간 (http_req_duration) | 291.08ms | 187.27ms | 두 번째 테스트에서 평균 응답 시간 단축 |
최대 응답 시간 | 547.81ms | 1.15s | 두 번째 테스트에서 최대 응답 시간 증가 |
응답 대기 시간 (http_req_waiting) | 283.76ms | 178.63ms | 두 번째 테스트에서 응답 대기 시간 단축 |
받은 데이터량 | 2.6MB (248kB/s) | 2.9MB (270kB/s) | 두 번째 테스트에서 더 많은 데이터 수신 |
보낸 데이터량 | 8.1kB (780B/s) | 9.1kB (849B/s) | 두 번째 테스트에서 더 많은 데이터 전송 |
요청 처리 속도 (http_reqs) | 80 요청 (7.72 req/s) | 90 요청 (8.40 req/s) | 두 번째 테스트에서 처리 속도 증가 |
캐시를 적용하기 전, 80번의 요청을 보냈고 확실한 비교값을 측정하기 위해 캐싱을 적용한 기능에서는 90번의 요청을 보냈습니다. 요청 횟수가 증가했음에도 평균 응답 시간과 대시 시간이 단축 되었음을 확인할 수 있습니다.
그런데, 최대 응답시간은 오히려 두번째 요청에서 증가했음을 확인할 수 있습니다. 이는 캐싱을 적용하면서 발생할 수 있는 저장 시간일 가능성이라 생각합니다. 캐시를 적용할때는 보통 첫번째 요청이 캐시를 저장하고, 이후 다음 요청들이 빠르게 캐시에 데이터를 가져오는 방식으로 성능이 향상됩니다.
때문에 첫 번째 요청에서 캐시 저장 과정이 포함되어, 그로 인해 응답 시간이 늘어난 것으로 추측됩니다. 현재는 100개의 데이터를 기준으로 테스트했지만, DB 에 데이터가 클 수록 조회 성능 이 비약적으로 향상될 것으로 보입니다.
'Trouble Shooting && 성능 개선' 카테고리의 다른 글
[Trouble Shooting] EC2 다운 문제, CPU 사용률 100% 해결 (0) | 2025.01.19 |
---|---|
[Trouble Shooting] Spring Boot Redis List 역직렬화 에러 (0) | 2025.01.15 |
Spring과 WebSocket으로 실시간 채팅에서 '읽음/읽지 않음' 상태 관리하기 (0) | 2024.12.26 |
[Trouble Shooting] 멀티모듈 프로젝트에서 중복되는 given 줄이기 (1) | 2024.09.17 |
[Trouble Shooting] 멀티모듈에서 @SpringBootTest 사용시 주의할 점 (0) | 2024.09.07 |