0. 들어가기 앞서
이전글에서 링크의 HTML 메타 데이터 파싱시에 Stream부터 ParallelStream, CompletableFuture까지 성능을 개선시켜 보았다.
이번엔 간단하게 Caffeine Cache를 도입하여 성능을 개선시킨 후기를 적어볼까 한다.
이전글을 꼭 읽어보고 오는걸 추천한다!
Caffeine Cache 도입
CompletableFuture까지 적용시켜 성능을 개선했지만, 그냥 단순하게 생각해보니 캐싱 전략 같은건 우선 제외하고 링크의 주소들을 캐싱해두고 불러오면 일단 Latency를 줄어들지 않을까? 하는 생각이 들었다.
또한 보통 사용자가 아티클을 등록/수정을 하게되면 당연히 DB에 영향이 가게 된다.
하지만 내가 개선 시키려는 Latency는 DB와는 큰 연관이 없고, 캐싱하려는 부분도 DB와 큰 연관이 없다.
단순히 사용자가 요청한 해당 '링크의 HTML 메타 데이터'가 로컬에 캐싱되어 있는지만 확인하면 된다.
그리고 그 링크에 해당하는 게시물은 원본 작성자가 내용을 빈번하게 수정될 부분이 없다. 라고 판단하여 원본 HTML의 내부 메타 데이터도 크게 바뀌지 않을 것 같았다.
로컬 캐시 vs 글로벌 캐시
로컬 캐시는 해당 로컬에서만 사용되는 캐시다. 말 그대로 로컬에서 돌아가기 때문에 네트워크를 탈 필요가 없어 속도가 빠르지만, 분산 시스템에서 데이터 정합성이 깨질 수 있다.
캐시는 반복해서 조회해도 동일한 결과를 가져오는 상황에서 유용하게 쓰일 수 있다. 매번 데이터가 달라진다면 오히려 캐시에 저장하거나 캐시를 확인하는 작업 때문에 성능이 좋아지지 않을 수도 있다.
그래서 로컬 캐시와 글로벌 캐시(ex:Redis)둘 중 고민을 하다가, 로컬 캐시를 사용하기로 했고 간편하게 큰 러닝 커브없이 사용할 수 있는 Caffeine Cache를 도입해 보고자 했다.
실제로 HTML 문서를 파싱하여 메타 데이터를 추출하는 MotticleOgMetaElementHtmlParser 클래스의 getOgMetaElementsFrom 메서드에 캐싱을 적용하지 않고, getMetaData 메서드에 캐싱을 적용할 것이다.
이유는 위에서 말한 것처럼 해당 '링크'의 메타 데이터가 캐싱되어 있는지만 확인해서 반환해주면 되기 때문이다.
여기서 getMetadata 메서드는 이미 파싱된 HTML 문서나 메타 데이터를 가져오는 역할을 한다.
즉, 이전에 이미 파싱한 적이 있는 URL에 대해서는 다시 파싱할 필요 없이 저장된 메타 데이터를 반환하면 된다.
* 약간의 캐싱 전략
잠깐 캐싱을 적용하기 전에, 숨을 돌리며 우리 서비스에 적절한 캐싱 전략을 무엇인지 간단한 것만 적용해보려고 한다.
짧게 요약을 해보자면
- 모든 사용자는 링크 타입의 아티클만 등록하는 것이 아닌, 링크/이미지/글귀(텍스트) 중 한 가지를 선택할 수 있다. (1/3의 확률)
- 해당 원본(유튜브,벨로그,티스토리 등등) 링크의 작성자는 빈번하게 글을 수정할 것 인가?
결론은 캐싱된 내역이 자주 업데이트등이 되지 않을 것 같다는게 가장 큰 나의 생각이었다.
쉽게 말해 안전빵으로 넉넉하게 기간과 사이즈를 둬도 될 것이다 라고 판단했다. 이 추론을 토대로 캐싱 전략을 세운다면
@Getter
@RequiredArgsConstructor
public enum CacheType {
META_DATA("metaData", 1, 10000);
private final String cacheName;
private final int expiredAfterWrite;
private final int maximumSize;
}
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
List<CaffeineCache> caches = Arrays.stream(CacheType.values())
.map(cache -> new CaffeineCache(cache.getCacheName(), Caffeine.newBuilder().recordStats()
.expireAfterWrite(cache.getExpiredAfterWrite(), TimeUnit.HOURS)
.maximumSize(cache.getMaximumSize())
.build()
)
)
.toList();
SimpleCacheManager simpleCacheManager = new SimpleCacheManager();
simpleCacheManager.setCaches(caches);
return simpleCacheManager;
}
}
CacheType은 ENUM으로 관리하였고, 해당 Enum의 값들로 Cache 설정을 해주는 것이다.
여기서 눈여겨 볼 것은 expiredAfterWrite 필드인데, 이 것은 항목이 생성된 후 또는 해당 값을 가장 최근에 바뀐 후 특정 기간이 지나면 각 항목이 캐시에서 자동으로 제거되도록 지정한다. 현재 이부분을 현재 1시간으로 지정해둔 상태다.
반대로 말하면 한시간 동안은 캐싱을 유지해서 같은 데이터를 줘도 된다! 라고 판단했다.
이유는 위에서 말한 것처럼 원본 링크의 주인이 빈번하게 내용을 바꾸지 않을 것이라고 생각했기 때문에!
하지만 이부분은 실제 서비스를 해보았을 때 캐싱 데이터와 바뀐 데이터간 정합성이 맞지 않는 부분을 유저들에게 제보(?)를 받으면서 수정해 나가야 할 것 같다!
@Service
@RequiredArgsConstructor
public class OpenGraphServiceImpl implements OpenGraphService {
private final MotticleOgMetaElementHtmlParser motticleOgMetaElementHtmlParser;
@Cacheable("metaData")
@Override
public Optional<OpenGraphVO> getMetadata(String url) {
OgParser ogParser = new OgParser(motticleOgMetaElementHtmlParser);
OpenGraph openGraph = ogParser.getOpenGraphOf(url);
if (openGraph.getAllProperties().isEmpty()) {
return Optional.empty();
}
return buildOpenGraphResponse(openGraph);
}
Jsoup을 이용하여 파싱을 진행하는 관련 클래스의 메서드를 호출하고, 불러온 정보를 가공하여 실제 Service, Controller로 넘겨주기 위해 DTO로 변환해주는 OpenGraphServiceImpl의 getMetaData 메서드에 캐싱을 적용하였다.
Stream을 사용하는 초기(8.06s)에 비해 6.81초 빨라졌다 (95.08% 개선)
Caffeine Cache를 사용하여 Latency를 어마어마 하게 향상 시켰다!
캐싱을 사용하여 이전에 처리된 결과를 재사용함으로써 중복된 작업을 방지하고, 시간 및 자원을 절약할 수 있었던 것 같다.
저정도 Latency가 나오니 서비스를 사용하는데 큰 불편함도 없고 쾌적함(?)도 느껴졌다!
마치며..
하지만 고민들이 있다. 캐싱을 사용해서 이전에 처리된 데이터의 결과를 재사용함으로써 중복된 작업을 방지하여 단순히 Latency를 높이긴 했다.
그런데 캐싱을 사용하면 메모리에 결과를 저장하기에, 메모리 부하가 발생할 수 있는 문제점도 있고, 캐시 일관성을 유지하는 추가적인 관리(캐싱 전략)가 필요할 것 같다.
그래서 다음 글은 성능 테스트 툴 (Artillery, Ngrinder, Jmeter)등을 사용하여 실제 가상의 유저들과 그 시나리오를 짜서 어느 정도의 성능이 나오는지, 또 개선할 점은 없는지 고민해보고 관련된 글을 작성해보고자 한다.!