파이썬 크롤링 작업 시간 단축하기
소개
이번 글에서는 네이버 주식 테마와 관련된 정보를 크롤링하는 작업에서 발생한 초기 작업 시간이 1분 30초로 길었던 문제를 개선하여 30초로 단축하는 방법에 대해 소개하겠습니다.
맨 처음에 문제라고 생각했던 부분은 request 부분이었습니다. requsts가 느려 뒤에 있는 작업도 느려진다고 생각해 time 함수를 통해서 검증을 시도했습니다.
문제 검증
처음에는 request의 속도가 느려 뒷 작업이 밀리는 것으로 인지하고 있었습니다. 따라서 time 함수를 이용해 어떤 부분이 실제로 느려지는 확인이 필요해졌고, 아래 코드와 같이 time함수를 사용해 느려지는 부분을 체크했습니다.
now = time.time()
page_source = self.web.get_page("https://finance.naver.com/sise/theme.nhn?&page=" + str(i))
soup = BeautifulSoup(page_source, "html.parser")
print("요청시간", time.time() - now())
하지만 인터넷 요청부분에서는 1초 정도의 시간만 소요될 뿐이었고, 1분30초나 느려지게 되는 주요 원인은 아니었습니다.
따라서 다음 실행의 시작과 끝을 체크했는데 해당 구문이 12초 정도의 시간을 소요하고 있었습니다.
now = time.time()
for i in soup.find_all('a'):
cand = str(i)
if "/sise/sise_group_detail" in cand:
start = 0
end = 0
for index in range(1, len(cand)): #형식이 <>target<이런 식임
if cand[index] == '>':
start = index + 1
if cand[index] == '<':
end = index
break
name_list = self.get_include_names("https://finance.naver.com" + i['href'])
thema_in_stock[cand[start:end]] = name_list
for name in name_list:
if name in stock_to_thema.keys():
stock_to_thema[name] = stock_to_thema[name] + cand[start:end] + "\n"
else:
stock_to_thema[name] = cand[start:end] + "\n"
print("요청시간", time.time() - now()) #12초
위의 코를 확인해보면 여러가지 문제가 있겠지만 크게 두 가지로 압축할 수 있다고 생각했습니다.
- BeautifulSoup(bs4)이 제공하는 함수를 사용하지 않고, string을 이용해 파싱했음으로 약간의 속도저하
- 동시성 개선
먼저 bs4 함수를 사용하기 위해서 다음과 위의 코드를 다음과 같이 변경했습니다. 개선 효과로는 약 1초 정도 빨라지긴 했지만 확실한 성능 체감은 되지 않았습니다.
#변경 후
for i in soup.find_all('a'): #a속성 모두 찾기
link = i['href']
if "/sise/sise_group_detail" in link:
name_list = self.get_include_names("https://finance.naver.com" + i['href'])
self.thema_in_stock[i.get_text()] = name_list
for name in name_list:
if name in self.stock_to_thema.keys():
self.stock_to_thema[name] = self.stock_to_thema[name] + i.get_text() + "\n"
else:
self.stock_to_thema[name] = i.get_text() + "\n"
#변경 전
for i in soup.find_all('a'):
cand = str(i)
if "/sise/sise_group_detail" in cand:
start = 0
end = 0
for index in range(1, len(cand)): #형식이 <>target<이런 식임
if cand[index] == '>':
start = index + 1
if cand[index] == '<':
end = index
break
name_list = self.get_include_names("https://finance.naver.com" + i['href'])
thema_in_stock[cand[start:end]] = name_list
for name in name_list:
if name in stock_to_thema.keys():
stock_to_thema[name] = stock_to_thema[name] + cand[start:end] + "\n"
else:
stock_to_thema[name] = cand[start:end] + "\n"
동시성 개선
동시성을 향상시키기 위해 다음 두 가지 방법을 고려했습니다.
1. 비동기 함수
비동기 함수는 I/O 작업을 효율적으로 처리할 수 있도록 설계된 방식입니다. 이 방식은 작업이 I/O 바운드일 때 유용하며, 여러 작업을 동시에 수행할 수 있습니다. 비동기 함수를 사용하면 작업이 블로킹되지 않고 이벤트 루프를 통해 비동기적으로 실행됩니다. 이를 통해 병렬 작업을 수행하면서 CPU 자원을 효율적으로 활용할 수 있습니다.
2. 스레드
스레드는 프로세스 내에서 실행되는 작은 실행 단위로, 병렬 작업을 수행하는데 사용됩니다. 스레드는 CPU 연산 작업을 분산하여 처리하거나, 여러 작업을 동시에 처리할 때 유용합니다. 하지만 스레드 간의 동기화와 관련된 문제들을 다루는 것이 복잡할 수 있으며, GIL(Global Interpreter Lock)로 인해 파이썬의 경우 CPU 연산 작업에서는 병렬성 향상이 제한될 수 있습니다.
이 두 가지 방법은 각자의 장단점을 가지고 있으며, 작업의 특성에 따라 선택되어야 합니다. I/O 작업은 비동기 함수를 사용하여 병렬성을 높일 수 있고, CPU 연산 작업은 스레드를 사용하여 효율적으로 처리할 수 있습니다. 선택한 방법에 따라 작업의 성격과 요구사항에 맞는 최적의 동시성 처리 방식을 적용할 수 있습니다.
저의 경우에는 I/O 작업에서 프로그램의 속도가 느려지는 것이 아니기 때문에 스레드를 택하여 사용했습니다. 따라서 코드를 아래와 같이 변경했습니다. 하지만 작업이 병렬적으로 실행되지 않고, 순차적으로 실행되고 있었습니다.(그 이유는 다음 페이지에서 확인할 수 있습니다.)
def get_stock_in_thema(self):
import concurrent.futures, time
self.thema_in_stock = {}
self.stock_to_thema = {}
for i in range(1, 7):
t = threading.Thread(target=self.__get_stock_in_thema, args=(i,)
t.start()
return self.thema_in_stock, self.stock_to_thema
def __get_stock_in_thema(self, i):
"""네이버의 테마 리스트 있는 정보를 크롤링한다
Returns:
"""
따라서 threading.Thread가 아닌 ThreadPoolExecutor를 사용하게 됐습니다. 두 쓰레드의 차이점은 다음과 같습니다.
ThreadPoolExecutor:
- concurrent.futures 모듈에서 제공됩니다.
- 스레드 풀을 관리하고 작업을 실행하는 데 사용됩니다.
- 작업 큐에 작업들을 넣고 내부적으로 스레드를 관리하며 작업을 분배합니다.
- 주로 I/O 바운드 작업에 적합합니다. I/O 대기 시간 동안 스레드들이 다른 작업을 처리할 수 있어서 병렬 처리 효과를 가져올 수 있습니다.
- with 블록을 사용하여 자동으로 스레드 풀을 생성하고 종료합니다.
- GIL의 영향을 일부 회피할 수 있지만, CPU 바운드 작업에서는 GIL의 제약을 받을 수 있습니다.
threading.Thread:
- threading 모듈에서 제공됩니다.
- 개별적인 스레드를 생성하고 제어하는 데 사용됩니다.
- 개별 스레드를 생성하고 각 스레드에게 작업 함수를 할당하여 병렬로 실행할 수 있습니다.
- 주로 I/O 바운드 작업에 적합합니다. 그러나 GIL로 인해 CPU 바운드 작업에서 병렬 처리 효과를 기대하기 어렵습니다.
- 생성한 스레드를 직접 시작(start())하고 조작해야 합니다.
- 여러 개의 스레드를 생성하여 병렬 처리를 시도하더라도, GIL로 인해 하나의 스레드만 파이썬 코드를 실행하는 시점이 발생할 수 있습니다.
요약하면, ThreadPoolExecutor는 주로 I/O 바운드 작업에 사용되며, 스레드 풀을 관리하여 병렬 처리를 도와줍니다. 따라서 특정 테마주 페이지를 요청하고 나서, 다른 쓰레드에 cpu 제어 권한을 넘길 수 있도록 하는 것이 ThreadPoolExecutor이고, GIL의 영향으로 하나의 thread만 처리하는 것이 threading 모듈입니다.
따라서 최종적인 코드는 아래와 같습니다.
def get_stock_in_thema(self):
import concurrent.futures
self.thema_in_stock = {}
self.stock_to_thema = {}
with concurrent.futures.ThreadPoolExecutor() as executor:
executor.map(self.__get_stock_in_thema, range(1,7))
return self.thema_in_stock, self.stock_to_thema
def __get_stock_in_thema(self, i):
"""네이버의 테마 리스트 있는 정보를 크롤링한다
Returns:
thema_in_stock: 테마를 입력하면 테마에 속해있는 종목을 알 수 있다.
stock_to_thema: 종목을 입력하면 종목이 속한 테마를 알려준다
"""
# thema_in_stock = {}
# stock_to_thema = {}
page_source = self.web.get_page("https://finance.naver.com/sise/theme.nhn?&page=" + str(i))
page = BeautifulSoup(page_source, "html.parser")
print(i, "실행")
for i in page.find_all('a'): #a속성 모두 찾기
link = i['href']
if "/sise/sise_group_detail" in link:
name_list = self.get_include_names("https://finance.naver.com" + i['href'])
self.thema_in_stock[i.get_text()] = name_list
for name in name_list:
if name in self.stock_to_thema.keys():
self.stock_to_thema[name] = self.stock_to_thema[name] + i.get_text() + "\n"
else:
self.stock_to_thema[name] = i.get_text() + "\n"
def get_include_names(self, address):
soup = BeautifulSoup(requests.get(address).text, "html.parser")
name = []
for i in soup.find_all('a'):
link = i['href']
if "code" in link:
if i.string:
name.append(i.string)
return name