0. 들어가기 앞서
평소 백엔드 성능 개선에 관심을 가지고 해당 프로젝트의 성능을 향상시키기 위해 다양한 방법을 시도하고 있다. 이번 글에서는 데이터 처리 속도를 개선하기 위해 몇가지 여정을 지나 CompletableFuture을 적용한 사례를 살펴보려고 한다.
사이드 프로젝트를 진행하고 있다.
주요 서비스는 내 아티클(링크,글귀,이미지)을 태그를 통해 분류하고 관리하는 것이다. 또한 공개여부를 통해 내 아티클을 공유할 수 있고, 등록된 나의 태그를 기반으로 다른 사용자들이 올려놓은 아티클들을 볼 수 있다.
여기서 특징적인 기능이 있다.
링크 타입의 아티클을 등록 및 조회가 될 경우에는 사용자가 링크 주소만을 보여주는 것이 어색하다고 판단했다.
보통 우리가 링크를 공유하거나 했을 땐 이렇게 썸네일, 해당 링크의 제목, 설명등이 간략하게 나와서 사용자가 한눈에 알아볼 수 있다.
나는 해당 기능을 찾아보니 페이스북에서 개발한 OpenGraph 태그를 이용하면 된다는 것을 보았다.
그래서 간략하게, 링크 타입의 코드상 플로우를 보자면
- 사용자가 링크 타입을 선택 후 링크의 유효성 검사를 한다.
- 해당 링크가 유효하다면 아래 처럼 해당 링크의 메타 데이터를 사용자에게 보여준다.
3. 이 과정에서 Java OpenGraphParser를 이용하여 해당 링크의 메타 데이터를 불러오게 된다.
4. 이 때 Jsoup을 이용하게 된다.
해당 부분을 코드로 보자면
@Service
@RequiredArgsConstructor
public class OpenGraphServiceImpl implements OpenGraphService {
private final MotticleOgMetaElementHtmlParser motticleOgMetaElementHtmlParser;
@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);
}
private Optional<OpenGraphResponse> buildOpenGraphResponse(OpenGraph openGraph) {
return Optional.of(
OpenGraphResponse.builder()
.image(getValueSafely(openGraph, "image"))
.title(getValueSafely(openGraph, "title"))
.url(getValueSafely(openGraph, "url"))
.description(getValueSafely(openGraph, "description"))
.build()
);
}
private String getValueSafely(OpenGraph openGraph, String property) {
OpenGraph.Content content = openGraph.getContentOf(property);
return content != null ? content.getValue() : null;
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class MotticleOgMetaElementHtmlParser implements OgMetaElementHtmlParser {
@Override
public List<OgMetaElement> getOgMetaElementsFrom(String url) {
try {
final Document document = Jsoup.connect(url)
.timeout(2000)
.get();
final Elements metaElements = document.select("meta");
List<OgMetaElement> ogMetaElements = metaElements.stream()
.filter(m -> m.attr("property").startsWith("og:"))
.map(m -> {
final String property = m.attr("property").substring(3).trim();
final String content = m.attr("content");
return new OgMetaElement(property, content);
})
.collect(Collectors.toList());
addDescriptionIfNotExists(ogMetaElements, document);
addTitleIfNotExists(ogMetaElements, document);
return ogMetaElements;
} catch (IndexOutOfBoundsException | IllegalArgumentException | IOException e) {
log.warn("Failed to parse OpenGraph Metadata. url:{}", url, e);
return Collections.emptyList();
}
}
private void addDescriptionIfNotExists(List<OgMetaElement> ogMetaElements, Document document) {
Optional<OgMetaElement> descriptionOptional = ogMetaElements.stream()
.filter(it -> "description".equals(it.getProperty()))
.findFirst();
if (descriptionOptional.isEmpty() || !StringUtils.hasLength(descriptionOptional.get().getContent())) {
Elements metaDescription = document.select("meta[name=description]");
if (!metaDescription.isEmpty()) {
ogMetaElements.add(
new OgMetaElement("description", metaDescription.get(0).attr("content"))
);
}
}
}
private void addTitleIfNotExists(List<OgMetaElement> ogMetaElements, Document document) {
Optional<OgMetaElement> titleOptional = ogMetaElements.stream()
.filter(it -> "title".equals(it.getProperty()))
.findFirst();
if (titleOptional.isEmpty() || !StringUtils.hasLength(titleOptional.get().getContent())) {
ogMetaElements.add(new OgMetaElement("title", document.title()));
}
}
}
메타 데이터를 OpenGraphParser를 통해 받아오는 과정에서 해당 인터페이스를 따로 구현체를 만들어 살짝 커스텀 하여서 사용하고 있었다.
서론이 너무 길었던 것 같으니 본내용으로 가보자.
해당 과정에서 생긴 문제들은
사용자 링크 타입을 등록하면
1. OpenGraphParser 를 이용해 jsoup 을 통해 해당 링크의 정보를 얻어온다.
2. 메타 데이터를 파싱한다.
3. 파싱한 데이터를 사용자에게 넘겨준다.
위 작업을 수행하는 과정에서 시간이 너~무 오래 걸렸다.
그래서 이 점을 어떻게 하면 개선할 수 있을까 하는 고민과 그 여정을 단계별로 기록하려고 한다.
사용했던 방법은 크게 두가지 정도가 되었다.
- Stream(parallelStream)을 이용한 데이터 처리
- CompletableFuture을 이용한 비동기적 데이터 처리
* DB에서의 문제는 아니라고 파악을 하여, 데이터의 갯수는 크게 중요하지 않았고 사용자가 보는 화면에서 10개씩 데이터를 주며, 그 이상은 데이터를 무한 스크롤로 보여주고 있다. 이것을 기준으로 파악하면 좋을 듯 하다.
1. Stream을 이용한 데이터 처리
기존에는 큰 고민 없이 Stream을 이용해서 데이터를 처리해주면 되겠다 라고 생각하였다.
코드는 단순하다.
- DB 에서 넘어온 아티클들을 무한 스크롤 할 수 있게 가공하여 해당 클래스로 넘겨준다.
- Content 필드에 들어가는 Link들의 해당 사이트의 메타 데이터를 파싱한다.
- articleOpenGraphMap에 key는 ID로 Value로는 파싱된 해당 OpenGraph 객체를 넣어준다.
- 메타 데이터가 포함된 OpenGraph를 DTO로 만들어 넘겨준다.
이정도 플로우 이다.
public ArticlesOgRes generateArticlesOgResWithOpenGraph(Slice<Article> articles) {
final Map<Long, OpenGraphResponse> articleOpenGraphMap;
articleOpenGraphMap = articles.getContent()
.stream()
.collect(
Collectors.toMap(
Article::getId,
article -> getOpenGraphResponse(article.getType(), article.getContent())
)
);
List<ArticleOgRes> articleOgResList = articles.stream()
.peek(article -> article.setFilePath(article.getContent()))
.map(article -> ArticleOgRes.of(article, articleOpenGraphMap.get(article.getId())))
.toList();
return ArticlesOgRes.of(articleOgResList, articles);
간단히 화면에서 해당 API를 사용해보니 8초가 걸리는 것을 볼 수 있다..
OpenGraphParser 및 Jsoup을 처음 써봐서 예상하지 못하게 Latency가 오래 걸렸다.그래서 코드를 자세하게 살펴보니 Stream이 보였다.
Stream은 순차적으로 요소를 처리하는 특징을 가지고 있는데, 이 방식으로 데이터를 처리할 때에는 각 요소가 하나씩 순차적으로 처리되기 때문에 대용량 데이터의 경우에는 처리 시간이 길어지는 문제가 있었다.
보는바와 같이 10개의 데이터를 처리하는 데에는 약 8.06초가 소요되었다.
2. ParallelStream을 이용한 병렬적 데이터 처리
이에 대한 대안으로 ParallelStream을 사용하여 병렬적으로 데이터를 처리하였다.
ParallelStream은 각 요소를 병렬적으로 처리하여 처리 속도를 향상시킬 수 있는 특징을 가지고 있다.
public ArticlesOgRes generateArticlesOgResWithOpenGraph(Slice<Article> articles) {
final Map<Long, OpenGraphResponse> articleOpenGraphMap;
articleOpenGraphMap = articles.getContent()
.parallelStream() // 변경
.collect(
Collectors.toMap(
Article::getId,
article -> getOpenGraphResponse(article.getType(), article.getContent())
)
);
List<ArticleOgRes> articleOgResList = articles.stream()
.peek(article -> article.setFilePath(article.getContent()))
.map(article -> ArticleOgRes.of(article, articleOpenGraphMap.get(article.getId())))
.toList();
return ArticlesOgRes.of(articleOgResList, articles);
Stream(8.06s)에 비해 5.45초 빨라졌다 (67.5% 개선)
하지만 이 역시 나는 처리 시간이 만족스럽지 않았고, (내 기준) Latency가 여전히 길어지는 문제가 있었다.
사용자에게 자주 사용되는 읽어오는 기능이 매번 2초 이상씩 걸린다면 내 기준 매력적이지 않은 서비스라고 느낄 것 같다.
3. CompletableFuture 도입
그러다 모던 자바 인 액션 서적에서 보았던 스레드를 사용하는 CompletableFuture에 대해서 떠오르게 되었다.
CompletableFuture를 사용하는 가장 큰 이유는 비동기적인 작업 처리이다. 데이터를 처리할 때 비동기적으로 작업을 수행함으로써 블로킹을 최소화할 수 있기에 시스템의 처리량을 향상시킬 수 있다는 장점이 있다. 나는 이점을 이용하여 시스템의 응답성을 향상시킬 수 있지 않을까? 하는 생각이 들었다.
(자세한 CompletableFuture의 정보는 꼭 검색해서 알아보도록 하자!)
우선 나는 스레드 풀을 커스텀하여 사용하기로 했다.
@Configuration
public class ExecutorConfig {
//todo 적절한 스레드풀 사이즈 고려
@Bean
public ThreadPoolTaskExecutor threadPoolExecutor() {
return new TaskExecutorBuilder()
.corePoolSize(10)
.maxPoolSize(10)
.queueCapacity(200)
.awaitTermination(true)
.awaitTerminationPeriod(Duration.ofSeconds(10))
.threadNamePrefix("task-executor-")
.build();
}
}
커스텀한 스레드풀을 하나씩 간단하게 알아보면
- corePoolSize() : 스레드 풀의 기본 크기
- 여기서 나는 10개로 해준 이유는 보통 한 페이지에 10개의 데이터를 보여주기 때문에 10개로 설정하였다.
- maxPoolSize() : 스레드 풀의 최대 크기를 설정
- queueCapacity() : 대기열의 용량을 설정한다. 스레드 풀이 작업을 처리하지 못할 경우 대기열에 작업을 저장
- awaitTermination: 스레드 풀이 종료될 때 대기 중인 작업을 처리할지 여부를 설정
- awaitTerminationPeriod: 스레드 풀이 종료될 때 대기 중인 작업을 처리하는 기간을 설정
- threadNamePrefix: 생성되는 스레드의 이름 접두사를 설정
이를 적용 시킨 본 코드를 보면
@RequiredArgsConstructor
@Slf4j
@Service
public class OpenGraphProcessor {
@Qualifier("threadPoolExecutor")
private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
private final OpenGraphService openGraphService;
public ArticlesOgRes generateArticlesOgResWithOpenGraph(Slice<Article> articles) {
final Map<Long, OpenGraphResponse> articleOgMap = new ConcurrentHashMap<>();
final List<CompletableFuture<Void>> completableFutures =
articles.stream()
.map(
article -> CompletableFuture.runAsync(
() -> articleOgMap.put(
article.getId(),
getOpenGraphResponse(article.getType(), article.getContent())
),
threadPoolTaskExecutor
)
).toList();
// 비동기 작업 끝날때까지 대기
completableFutures.forEach(CompletableFuture::join);
List<ArticleOgRes> articleOgResList = articles.stream()
.peek(article -> article.setFilePath(article.getContent()))
.map(article -> ArticleOgRes.of(article, articleOgMap.get(article.getId())))
.toList();
return ArticlesOgRes.of(articleOgResList, articles);
}
// 이하 DTO로 변경해주는 메서드들 ...
}
-> ParallelStream(2.61s)에 비해 1.36초 빨라졌다 (52.87% 개선)
-> Stream(8.06s)에 비해 6.81초 빨라졌다 (84.58% 개선)
CompletableFuture를 사용했을 때에 엄청나게 개선이 된 점을 알 수 있다!
하지만 CompletableFuture를 사용할 때에도 여러 고려 사항이 있다.
아무래도 커스텀 스레드 풀을 설정해줘야 하다보니, 적절하고 이상적인 스레드 풀의 설정값을 파악해야 하는 점이 가장 중요할 것 같다.
이 점은 배포를 해본 후 EC2 스펙에 맞는 것으로 또 적절하게 바꾸어 주려고 한다.
마치며..
'CompletableFuture까지가 끝인가..?' 하는 고민들을 안은채, 다른 기능들을 개발하던 중에 번뜩! 의문점이 들었다.
한 유저가 A,B,C의 서로 다른 링크를 등록하고 그걸 조회한다고 치면, 조회를 할 때마다 새롭게 메타 데이터 정보를 파싱해서 넘겨주는건가? 그래서 응답 시간이 1초 이상씩 계속 걸리는 것인가?
그러고 Jsoup을 사용해서 파싱을 하는 과정을 확인해보니, 내가 생각한 점이 맞았다.
그러면 이 점을 또 어떻게 잘하면.. 개선할 수 있지 않을까 하는 고민이 되었다!! 다음 글에서는 캐싱을 이용하여 성능을 올려보려고 한다!
참고
https://luna-archive.tistory.com/32
https://dkswnkk.tistory.com/733