🚀 API 응답 캐싱의 필요성
Briefing 앱에서는 홈화면에서 타입(사회, 과학, ...)별로 브리핑 목록을 조회하고 있었습니다. 스크랩을 하거나 취소하였을 때, DB에 새로운 브리핑 목록이 추가되었을 때를 제외하면 동일한 응답을 계속 내려주고 있었습니다.
그래서 서버팀에서는 해당 API응답을 캐싱하기로 했으며, 아래와 같은 요구사항을 정의했습니다.
- 캐시를 도입하기 전/후로 API 응답 속도를 측정해보고 개선된 지표를 확인한다.
- 100명의 사용자가 10번 요청하는 것을 기준으로 속도를 측정한다.
- 운영서버와 스펙이 동일한 개발서버(겸 스테이징 서버)에서 속도를 측정한다.
- 캐시 이름은 메소드명과 동일하게 정하고, 키는 브리핑 타입으로 한다.
- 스크랩 개수의 정합성을 보장하기 위하여 스크랩 개수 변경시, 캐시를 비운다.
🌐 Artillery.io를 사용한 API 속도 측정
일반적으로 Artillery.io는 서버 부하테스트를 위해 많이 사용되지만 이번엔 응답 속도 측정이 주목적이었으므로 임계치까지 늘려보며 테스트하지는 않았고 정해두었던 상황에 대해서만 테스트를 해보았습니다. (또한 AWS를 사용하고 있어서 임계치까지 테스트하면 엄청난 비용이 발생할 수 있습니다..😂)
먼저, 정해둔대로 100명의 사용자가 10번 요청하는 것을 기준으로 측정해보았습니다.
artillery quick --count 100 -n 10 https://[개발서버주소]/v2/briefings?type=SOCIAL
측정 결과)
--------------------------------
Summary report @ 10:53:50(+0900)
--------------------------------
http.codes.200: ................................................................ 1000
http.downloaded_bytes: ......................................................... 1519000
http.request_rate: ............................................................. 287/sec
http.requests: ................................................................. 1000
http.response_time:
min: ......................................................................... 25
max: ......................................................................... 695
mean: ........................................................................ 247.5
median: ...................................................................... 262.5
p95: ......................................................................... 383.8
p99: ......................................................................... 620.3
http.responses: ................................................................ 1000
vusers.completed: .............................................................. 100
vusers.created: ................................................................ 100
vusers.created_by_name.0: ...................................................... 100
vusers.failed: ................................................................. 0
vusers.session_length:
min: ......................................................................... 1501.8
max: ......................................................................... 3044.7
mean: ........................................................................ 2521.9
median: ...................................................................... 2618.1
p95: ......................................................................... 2893.5
p99: ......................................................................... 2951.9
속도를 확인할 수 있는 지표인 http_response_time
의 필드들을 중심으로 살펴보겠습니다. (참고)
이중 가장 많이 지표로 사용되는 필드는 median
(중앙값)과 p95
(95 백분위 수 값)입니다.
median
- 262.5 ms
p95
- 383.8 ms
나름 준수한 편이지만, 속도 개선 → 사용자 전환율 ⬆️ & 이탈율 ⬇️ 이므로 활성 사용자가 늘었을 때를 대비하며 미리 개선해보겠습니다. (참고)
♻️ Redis를 사용한 응답 캐싱
먼저, CacheConfig
설정 파일을 작성합니다.
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// PolymorphicTypeValidator를 사용하여 JSON 직렬화/역직렬화 시 타입 안전성 보장
PolymorphicTypeValidator typeValidator =
BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build();
// ObjectMapper를 생성하고 Java 8 날짜/시간 모듈 등록
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// 비활성화된 기본 타이핑을 활성화하여 JSON 데이터의 클래스 타입 정보 유지
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
// Redis 캐시의 기본 설정 정의
RedisCacheConfiguration cacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // 캐시 항목의 유효 시간을 30분으로 설정
.disableCachingNullValues() // null 값 캐싱 비활성화
.prefixCacheNameWith("responseCache::") // 모든 캐시 키에 "responseCache::" 접두사 추가
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer())) // 키 직렬화에 StringRedisSerializer 사용
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper))); // 값 직렬화에 GenericJackson2JsonRedisSerializer 사용
// RedisConnectionFactory를 사용하여 RedisCacheManager를 구성하고, 위에서 정의한 기본 캐시 설정 적용
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
}
다음으로, 스프링 캐시 추상화를 활성화하여 캐싱 관련 어노테이션을 사용하기 위해 @EnableCaching
어노테이션을 추가해줍니다.
@SpringBootApplication
@EnableCaching
@EnableFeignClients
@EnableRedisRepositories
@ImportAutoConfiguration({FeignAutoConfiguration.class})
public class BriefingApplication {
public static void main(final String[] args) {
SpringApplication.run(BriefingApplication.class, args);
}
}
이제, 응답을 캐싱하기 위해 해당하는 컨트롤러 메소드에 @Cacheable
어노테이션을 추가해줍니다.
@GetMapping("/briefings")
@Operation(summary = "03-01Briefing \uD83D\uDCF0 브리핑 목록 조회 V2", description = "")
@Cacheable(value = "findBriefingsV2", key = "#params.getType()")
public CommonResponse<BriefingResponseDTO.BriefingPreviewListDTOV2> findBriefingsV2(
@ParameterObject @ModelAttribute BriefingRequestParam.BriefingPreviewListParam params) {
List<Briefing> briefingList = briefingQueryService.findBriefings(params, APIVersion.V2);
return CommonResponse.onSuccess(
BriefingConverter.toBriefingPreviewListDTOV2(params.getDate(), briefingList));
}
두번째 요구사항인 ‘캐시 이름은 메소드명과 동일하게 정하고, 키는 브리핑 타입으로 한다.’에 맞추어
value는 메소드명, key는 #params.getType()으로 설정해주었습니다.
이제, 응답이 잘 캐싱되는지 보겠습니다.
응답이 적절히 캐싱되며, 브리핑 타입별로 각각 잘 저장되는 것을 확인했습니다.
그러나, 여기까지만 설정하고 테스트를 수행했을 때는 예상대로 변경된 스크랩 개수가 응답에 반영되지 않는 문제가 있었습니다.
그래서 세번째 요구사항인 ‘스크랩 개수의 정합성을 보장하기 위하여 스크랩 개수 변경시, 캐시를 비운다.’를 위해 스크랩 컨트롤러의 해당하는 메소드들에 @CacheEvict
어노테이션을 추가했습니다.
@CacheEvict(value = "findBriefingsV2", allEntries = true)
@Operation(summary = "05-01 Scrap📁 스크랩하기 V2", description = "브리핑을 스크랩하는 API입니다.")
@PostMapping("/scraps/briefings")
public CommonResponse<ScrapResponse.CreateDTOV2> createV2(
@RequestBody final ScrapRequest.CreateDTO request) {
Scrap createdScrap = scrapCommandService.create(request);
Integer scrapCount = scrapQueryService.countByBriefingId(request.getBriefingId());
return CommonResponse.onSuccess(ScrapConverter.toCreateDTOV2(createdScrap, scrapCount));
}
@CacheEvict(value = "findBriefingsV2", allEntries = true)
@Operation(summary = "05-02 Scrap📁 스크랩 취소 V2", description = "스크랩을 취소하는 API입니다.")
@DeleteMapping("/scraps/briefings/{briefingId}/members/{memberId}")
public CommonResponse<ScrapResponse.DeleteDTOV2> deleteV2(
@PathVariable final Long briefingId, @PathVariable final Long memberId) {
Scrap deletedScrap = scrapCommandService.delete(briefingId, memberId);
Integer scrapCount = scrapQueryService.countByBriefingId(briefingId);
return CommonResponse.onSuccess(ScrapConverter.toDeleteDTOV2(deletedScrap, scrapCount));
}
초기 구현 당시, 스크랩 API 메소드 파라미터에는 브리핑 타입이 없어서 스프링의 @CacheEvict
어노테이션 key 파라미터로는 해결할 수 없었습니다.
따라서 allEntries = true
옵션을 주어 findBriefingsV2
캐시의 항목들을 모두 비우려고 했습니다.
그러나 이 방식에는 2가지 문제점이 존재했습니다.
- Briefing 팀에서는 ElasticCache를 사용하고 있었고, 실제 운영환경에서는 성능상의 문제로
allEntries=true
했을때 실행되는 Redis 커맨드가 사용하지 못하게 막혀있습니다. (참고) - 스크랩 개수가 변화한 캐시 항목 이외에도 모두 삭제되므로 캐시에 불필요한 삭제가 일어납니다.
이는 커스텀 삭제 로직을 구현하여 해결했습니다.
🔥 CacheEvictByBriefingId 어노테이션
문제를 해결하기 위해 커스텀 어노테이션을 구현했습니다. 사용자가 스크랩하거나 취소하려는 브리핑의 타입이 SOCIAL이면 캐시에서 SOCIAL을 키로 갖는 항목만 삭제하고 SCIENCE이면 SCIENCE만 삭제하는 것이 목적이었습니다.
먼저 어노테이션을 만들어줍니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheEvictByBriefingId {
String value(); // 캐시 이름
String briefingId(); // 캐시 키를 위한 briefingId
}
briefingId 표현식을 평가하여 나온 Long id을 통해 → 브리핑을 조회한 후 → 브리핑 타입을 가져와서 캐시키로 사용하여 → value의 이름을 갖는 캐시에서 삭제하는 로직을 구현했습니다.
@Aspect
@Component
@RequiredArgsConstructor
public class CacheEvictByBriefingIdAspect {
private final BriefingRepository briefingRepository;
private final CacheManager cacheManager;
private SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
private StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
@After(value = "@annotation(cacheEvictByBriefingId)")
public void evictCache(JoinPoint joinPoint, CacheEvictByBriefingId cacheEvictByBriefingId) {
String briefingIdExpression = cacheEvictByBriefingId.briefingId();
Long briefingId = evaluateExpression(joinPoint, briefingIdExpression, Long.class);
Optional<Briefing> optionalBriefing = briefingRepository.findById(briefingId);
if (optionalBriefing.isPresent()) {
Briefing briefing = optionalBriefing.get();
String cacheKey = briefing.getType().name();
Cache cache = cacheManager.getCache(cacheEvictByBriefingId.value());
System.out.println(cacheEvictByBriefingId.value());
System.out.println(cacheKey);
System.out.println(cache);
if (cache != null) {
cache.evict(cacheKey);
}
}
}
private <T> T evaluateExpression(
JoinPoint joinPoint, String expression, Class<T> desiredResultType) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Object[] args = joinPoint.getArgs();
String[] parameterNames = methodSignature.getParameterNames();
for (int i = 0; i < parameterNames.length; i++) {
evaluationContext.setVariable(parameterNames[i], args[i]);
}
Expression parsedExpression = spelExpressionParser.parseExpression(expression);
return parsedExpression.getValue(evaluationContext, desiredResultType);
}
}
코드가 복잡해보이지만, 실제 사용하는 것을 보면 간단합니다.
@CacheEvictByBriefingId(value = "findBriefingsV2", briefingId = "#request.getBriefingId()")
@Operation(summary = "05-01 Scrap📁 스크랩하기 V2", description = "브리핑을 스크랩하는 API입니다.")
@PostMapping("/scraps/briefings")
public CommonResponse<ScrapResponse.CreateDTOV2> createV2(
@RequestBody final ScrapRequest.CreateDTO request) {
Scrap createdScrap = scrapCommandService.create(request);
Integer scrapCount = scrapQueryService.countByBriefingId(request.getBriefingId());
return CommonResponse.onSuccess(ScrapConverter.toCreateDTOV2(createdScrap, scrapCount));
}
@CacheEvictByBriefingId(value = "findBriefingsV2", briefingId = "#briefingId")
@Operation(summary = "05-02 Scrap📁 스크랩 취소 V2", description = "스크랩을 취소하는 API입니다.")
@DeleteMapping("/scraps/briefings/{briefingId}/members/{memberId}")
public CommonResponse<ScrapResponse.DeleteDTOV2> deleteV2(
@PathVariable final Long briefingId, @PathVariable final Long memberId) {
Scrap deletedScrap = scrapCommandService.delete(briefingId, memberId);
Integer scrapCount = scrapQueryService.countByBriefingId(briefingId);
return CommonResponse.onSuccess(ScrapConverter.toDeleteDTOV2(deletedScrap, scrapCount));
}
briefingId에 SpEL 표현식을 넘겨주면 id에 해당하는 브리핑을 조회하여 타입을 꺼내고 해당 타입으로 캐싱되어있는 항목을 지우는 로직입니다.
이렇게 해서 세번째 요구사항 ‘스크랩 개수의 정합성을 보장하기 위하여 스크랩 개수 변경시, 캐시를 비운다.’까지 달성할 수 있었습니다.
전체코드는 아래 Github 리포지토리에서 확인하실 수 있습니다!
https://github.com/Team-Shaka/Briefing-Backend
💡 응답 캐싱 후 API 속도 측정
이전과 동일하게 100명의 사용자가 10번 요청하는 것을 기준으로 측정해보았습니다.
artillery quick --count 100 -n 10 https://[개발서버주소]/v2/briefings?type=SOCIAL
측정 결과)
--------------------------------
Summary report @ 17:06:43(+0900)
--------------------------------
http.codes.200: ................................................................ 1000
http.downloaded_bytes: ......................................................... 1519000
http.request_rate: ............................................................. 500/sec
http.requests: ................................................................. 1000
http.response_time:
min: ......................................................................... 13
max: ......................................................................... 123
mean: ........................................................................ 40.1
median: ...................................................................... 39.3
p95: ......................................................................... 74.4
p99: ......................................................................... 90.9
http.responses: ................................................................ 1000
vusers.completed: .............................................................. 100
vusers.created: ................................................................ 100
vusers.created_by_name.0: ...................................................... 100
vusers.failed: ................................................................. 0
vusers.session_length:
min: ......................................................................... 265.6
max: ......................................................................... 623.7
mean: ........................................................................ 450.1
median: ...................................................................... 450.4
p95: ......................................................................... 572.6
p99: ......................................................................... 620.3
median
- 39.3 ms
p95
- 74.4 ms
전후 비교)
median
- 262.5 ms → 39.3 ms
- 약 6.68배 속도 향상
p95
- 383.8 ms → 74.4 ms
- 약 5.16배 속도 향상
📚 마무리
생각보다 속도가 많이 개선되어서 캐시를 필요한 곳에는 적절히 활용해야겠다고 느꼈습니다. 다만, 현재는 하루에 스크랩이 4~5건 밖에 발생하지 않아서 브리핑 목록 조회 응답이 자주 변경되지 않지만, 스크랩이 잦다면 오히려 캐시를 사용하는 것이 성능에 부정적일 수 있습니다. 따라서, 구축해둔대로 방치하지 않고 스크랩 API 호출 횟수와 캐시 효율성을 잘 따져가면서 상황에 따라 대응해 나갈 예정입니다.🔥
'💻 Service > Briefing' 카테고리의 다른 글
[Briefing] nGrinder로 성능 테스트 해보기 (2) | 2024.02.07 |
---|---|
[Briefing] Facade로 계층 구조 개선하기 (2) | 2024.01.15 |
[Briefing] Spotless로 코드 포맷 유지하기 (0) | 2023.12.27 |