일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 셀러리
- 백준
- 알람 시스템
- prg 패턴
- 객체지향패러다임
- 수신자 대상 다르게
- spring event
- 검색어 추천
- AWS
- 쿠키
- 결제서비스
- jwt 표준
- 카카오
- piplining
- BFS
- docker
- 깊게 생각해보기
- 코드 계약
- branch 전략
- 완전탐색
- 이분탐색
- 구현
- 트랜잭샨
- 좋은 코드 나쁜 코드
- 숫자 블록
- 디버깅
- 프로그래머스
- 누적합
- gRPC
- 레디스 동시성
- Today
- Total
코딩관계론
Virtual Thread를 사용한 크롤링 성능 80% 향상 본문
최근에 네이버 금융 테마 페이지를 크롤링하는 작업을 진행했는데, 한 페이지를 크롤링하는 데 약 1분 정도가 소요되었습니다. 한두 페이지를 크롤링하는 것이라면 감내할 수 있는 시간이지만, 해당 작업은 주로 새벽 시간대에 실행되었기 때문에 사용자 트래픽이 적은 상황에서도 처리 시간이 길었습니다. 하지만 크롤링해야 할 페이지 수가 N개로 증가할수록, 전체 크롤링 시간은 선형적으로 증가하는 문제가 있었습니다.
이를 해결하기 위해 비동기 요청, 병렬 처리, 경량 스레드 등의 다양한 최적화 방법을 고민하게 되었고, 그 과정에서 얻게 된 경험과 성과를 공유하고자 합니다. 여담이지만, 개인적으로 작업이 완료되지 않으면 잠을 못 자는 성격이라, 성능을 최대한 단축하는 것이 필요하다고 판단했습니다.
첫 번째 시도: @Async와 TaskExecutor
처음에는 @Async와 TaskExecutor를 사용해 크롤링 시간을 줄이려고 했습니다. 그 결과, 기존 크롤링 시간 대비 약 37.5%의 성능 향상을 달성했습니다. 예를 들어, 8개의 페이지를 크롤링하는 데 8분이 걸리던 작업이, @Async를 적용한 후에는 약 5분으로 단축되었습니다.
성과와 한계
비록 시간 단축에는 성공했지만, 성과는 기대에 미치지 못했습니다. 그 이유는 @Async의 동작 방식에 있습니다. @Async를 호출하면 새로운 스레드가 생성되어 해당 스레드에서 비동기 작업이 처리됩니다. 하지만 작업 중 네트워크 요청이 발생하면, 스레드는 대기 상태로 전환되고, 이때 문맥 교환(Context Switching)이 발생하여 추가적인 시간이 소모됩니다. 또한, 스레드를 생성하고 운영 체제와의 상호작용을 위해 시스템 콜이 사용되기 때문에 스레드 생성과 관리 비용도 무시할 수 없습니다.
또한, 스레드 생성의 한계도 있었습니다. 자바의 플랫폼 스레드(Platform Thread)는 운영 체제의 OS 스레드와 1:1로 매칭되기 때문에, 스레드 수가 증가할수록 서버의 하드웨어 성능에 따라 작업 성능이 제약을 받게 됩니다. 결국, 스레드를 많이 생성하려고 해도 물리적 자원에 의해 제한되며, 이러한 한계는 서버 성능에 직접적인 영향을 미치게 됩니다.
Thread Pool 생성의 중요성
@Async의 성능을 최적화하려면 Thread Pool을 생성하는 것이 중요합니다. 단순히 @Async만 사용하면 새로운 스레드가 매번 생성되고 종료되면서, 스레드 생성과 관련된 비용이 발생하게 됩니다. 이를 해결하기 위해 TaskExecutor를 사용해 미리 Thread Pool을 생성하고, 크롤링 작업을 풀 내에서 관리하는 방식으로 성능을 더욱 최적화할 수 있습니다. 이를 통해 스레드 관리 비용을 줄이고, 비동기 처리의 효율성을 높일 수 있습니다.
@Async("taskExecutor")
public void crawlingThema(Integer page)
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(CORE_POOL_SIZE); // 기본적으로 유지할 스레드 수
taskExecutor.setMaxPoolSize(MAX_POOL_SIZE); // 최대 허용 스레드 수
taskExecutor.initialize(); // 스레드 풀 초기화
return taskExecutor;
}
해결 방안: 가상 스레드(Virtual Thread) 활용
많은 분들이 Go 언어의 고루틴(Goroutine)을 경량 스레드로 자주 언급하는데, 이는 기존의 스레드 모델보다 훨씬 작은 단위로 실행되어 컨텍스트 스위칭 비용과 Blocking 시간을 크게 줄여주는 특징을 가지고 있습니다.
앞서 언급한 문제는 여러 스레드가 네트워크 요청을 보낼 때 Blocking 상태로 전환되어 문맥 교환(Context Switching) 비용이 발생하면서 성능 저하로 이어지는 것이었습니다. 하지만 경량 스레드를 사용하면 이러한 문제를 효과적으로 해결할 수 있습니다. 특히 I/O 바운드 작업이 많은 크롤링 시스템에서는 경량 스레드가 매우 유리합니다.
이 방식을 적용한 결과, 여러 페이지를 크롤링하는 데 걸리는 시간이 약 2분으로 단축되는 것을 확인했습니다.
가상 스레드(Virtual Thread) 활용
가상 스레드(Virtual Thread)는 자바 21에서 도입된 경량 스레드로, 기존의 플랫폼 스레드(Platform Thread)와 달리 OS 스레드와 1:1로 매칭되지 않고 JVM에서 관리됩니다. 이를 통해 더 많은 동시 작업을 효율적으로 처리할 수 있으며, 특히 I/O 바운드 작업에 매우 적합합니다.
가상 스레드는 문맥 교환 시 플랫폼 스레드처럼 무거운 작업을 처리하지 않으므로, 스레드 전환 비용이 대폭 감소합니다. 특히 네트워크 요청 등 Blocking 작업이 발생할 때 물리적 스레드를 점유하지 않아, 효율성이 크게 증가합니다.
I/O 바운드 작업에서의 강점
크롤링과 같은 네트워크 I/O 작업이 많은 경우, 가상 스레드의 비동기 처리 특성이 성능을 극대화할 수 있습니다. 네트워크 요청 대기 시간 동안 물리적 자원이 블로킹되지 않으므로, 다른 작업을 즉시 처리할 수 있어 동시성 처리에서 탁월한 효율성을 발휘합니다.
기존의 플랫폼 스레드는 스레드마다 OS 자원을 할당받고 관리해야 하므로, 스레드 수가 많아질수록 서버의 물리적 자원에 큰 부담이 됩니다. 하지만 가상 스레드는 자원 소모가 적고 경량으로 동작하기 때문에, 수천에서 수백만 개의 스레드도 효율적으로 처리할 수 있습니다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { // 경량 쓰레드 전용 Executor 사용
for (int i = 1; i <= MAX_PAGE; i++) {
int page = i;
executor.submit(() -> {
try {
crawling.crawlingThema(page); // 크롤링 작업을 경량 쓰레드에서 실행
} catch (Exception e) {
log.error("Error while crawling page: " + page, e);
}
});
}
}
[참고자료]
https://techblog.woowahan.com/15398/
https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/
https://d2.naver.com/helloworld/1203723
'개발 > Hot-Stock' 카테고리의 다른 글
중복 뉴스 제거 (3) | 2024.12.09 |
---|---|
메시지 발행과 데이터베이스의 트랜잭션을 어떻게 원자적으로 처리할까? (Transactional Outbox Pattern) (0) | 2024.09.10 |
결제서비스 - 결제 승인 시스템 구조와 Retry 전략[#52] (0) | 2024.09.03 |
결제서비스 - Checkout 서비스 구현 [#50] (0) | 2024.09.03 |
결제 서비스 개발기 (0) | 2024.09.02 |