코딩관계론

파이썬 크롤링 작업 시간 단축하기 본문

TroubleShooting

파이썬 크롤링 작업 시간 단축하기

개발자_티모 2023. 8. 13. 02:04
반응형

소개

이번 글에서는 네이버 주식 테마와 관련된 정보를 크롤링하는 작업에서 발생한 초기 작업 시간이 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초

 
위의 코를 확인해보면 여러가지 문제가 있겠지만 크게 두 가지로 압축할 수 있다고 생각했습니다.

  1. BeautifulSoup(bs4)이 제공하는 함수를 사용하지 않고, string을 이용해 파싱했음으로 약간의 속도저하
  2. 동시성 개선

먼저 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:

  1. concurrent.futures 모듈에서 제공됩니다.
  2. 스레드 풀을 관리하고 작업을 실행하는 데 사용됩니다.
  3. 작업 큐에 작업들을 넣고 내부적으로 스레드를 관리하며 작업을 분배합니다.
  4. 주로 I/O 바운드 작업에 적합합니다. I/O 대기 시간 동안 스레드들이 다른 작업을 처리할 수 있어서 병렬 처리 효과를 가져올 수 있습니다.
  5. with 블록을 사용하여 자동으로 스레드 풀을 생성하고 종료합니다.
  6. GIL의 영향을 일부 회피할 수 있지만, CPU 바운드 작업에서는 GIL의 제약을 받을 수 있습니다.

threading.Thread:

  1. threading 모듈에서 제공됩니다.
  2. 개별적인 스레드를 생성하고 제어하는 데 사용됩니다.
  3. 개별 스레드를 생성하고 각 스레드에게 작업 함수를 할당하여 병렬로 실행할 수 있습니다.
  4. 주로 I/O 바운드 작업에 적합합니다. 그러나 GIL로 인해 CPU 바운드 작업에서 병렬 처리 효과를 기대하기 어렵습니다.
  5. 생성한 스레드를 직접 시작(start())하고 조작해야 합니다.
  6. 여러 개의 스레드를 생성하여 병렬 처리를 시도하더라도, 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

 

반응형