IT·개발

Python BeautifulSoup으로 다음 검색 페이지 크롤링하기 — 6년 전 메모 재정리

5월 16, 2026 4 min read

2020년 4월에 기록해둔 짧은 Python 크롤링 메모를 6년 만에 다시 꺼냅니다. 당시 원문에는 “m.search.daum.net의 #연예인명단 이라는 해시태그 페이지를 이용하여 Crawling 했습니다”라는 한 줄 설명과 60여 줄의 코드가 전부였습니다. 코드를 다시 보니 BeautifulSoup의 기본 패턴이 잘 담겨 있어, 이번 기회에 4단계로 나누어 각 구간이 어떤 역할을 하는지 풀어 정리합니다. 본문 중 [분석] 인용 박스는 원문 코드에는 없는 해설·주의사항이니, 팩트(원문 그대로)와 분석(일반 가이드)을 구분해 읽어 주세요.

이 글은 Python 기초 문법은 알고 있지만 웹 크롤링이 처음인 분을 대상으로 합니다.


사전 준비 — 설치 및 import

실행 환경

이 코드는 Python 3.6 이상 환경을 기준으로 합니다. 외부 라이브러리 두 가지를 설치합니다. beautifulsoup4는 HTML 파싱, tqdm은 반복문 진행률 표시를 담당합니다. urllib.request를 비롯한 나머지 모듈은 Python 3 표준 라이브러리에 포함되어 있으므로 별도 설치가 필요하지 않습니다.

pip install beautifulsoup4 tqdm

설치 후 원문 코드의 import 구간은 다음과 같습니다.

from bs4 import BeautifulSoup as bs   # HTML 파싱
from tqdm import tqdm                 # 진행률 표시
import urllib.request                 # HTTP 요청 (표준 라이브러리)
import time, sys, codecs, os, random, csv

[분석] – 원문에는 라이브러리 버전 표기가 없습니다. 실행 전에 pip list 또는 pip show beautifulsoup4 tqdm으로 현재 환경의 버전을 확인하시기 바랍니다. – sys, codecs, os는 import 목록에 있지만 함수 본체에서 실제로 사용되지 않습니다. 사용하지 않는 import는 나중에 정리해 두는 편이 좋습니다.


코드 구조 한눈에 보기

본격적인 단계별 설명 전에, 전체 코드가 하나의 함수 celebrities_names(start, end) 안에 담겨 있다는 점을 먼저 짚겠습니다. 아래 흐름도를 한 번 보고 나면 이후 조각 코드가 어느 위치에 해당하는지 바로 파악할 수 있습니다.

celebrities_names(start, end)
  ① 헤더·URL 준비            ← 1단계
  ② start~end 10단위 루프    ← 2단계
     (각 페이지 요청·HTML 파싱)
  ③ CSS 셀렉터로 데이터 추출  ← 3단계
  ④ CSV 저장                 ← 4단계 (함수 내부)

if __name__ == "__main__":    ← 4단계 (함수 외부, 실행 진입점)

1단계 — 요청 헤더와 URL 구성

1.1 User-Agent 헤더 설정

웹 서버에 요청을 보낼 때는 브라우저처럼 보이는 User-Agent 헤더를 포함시킵니다. 원문에서는 'Mozilla/5.0'을 사용했습니다.

hdr = {'User-Agent': 'Mozilla/5.0'}

1.2 URL 구성 및 파라미터 인코딩

대상 URL은 m.search.daum.net의 연예인명단 해시태그 페이지이며, 끝에 sidx= 파라미터를 붙여 페이지 오프셋을 지정합니다.

url = "https://m.search.daum.net/kakao?w=smok&DA=AQJ&q=%EC%97%B0%EC%98%88%EC%9D%B8%EB%AA%85%EB%8B%A8&sidx="

[분석] URL의 q= 파라미터에 담긴 %EC%97%B0%EC%98%88%EC%9D%B8%EB%AA%85%EB%8B%A8는 한국어 “연예인명단”을 URL 인코딩한 값입니다. 이처럼 한국어를 URL에 포함할 때는 urllib.parse.quote()로 동적으로 인코딩할 수 있습니다.


2단계 — 페이지 반복과 랜덤 슬립

2.1 tqdm 루프 구성

tqdm(range(start, end, 10))으로 start부터 end까지 10단위로 페이지 인덱스를 늘려가며 각 페이지를 요청합니다. sidx= 파라미터에 현재 인덱스를 이어 붙여 URL을 완성한 뒤, urllib.request.urlopen()으로 HTML을 가져옵니다.

randomsl = random.uniform(1, 3)

namesList = []
for j in tqdm(range(start, end, 10)) :
    page = j
    req = urllib.request.Request(url + str(page), headers=hdr)
    html = urllib.request.urlopen(req).read()
    soup = bs(html, 'html.parser')
    time.sleep(randomsl)

2.2 랜덤 슬립 주의사항

[분석] randomsl = random.uniform(1, 3)함수 시작 시 단 1회만 계산됩니다. 이후 time.sleep(randomsl)이 매 요청마다 호출되지만, 슬립 간격은 처음 계산된 고정값으로 모든 요청에 동일하게 적용됩니다. 루프마다 서로 다른 랜덤 간격을 주고 싶다면 randomsl = random.uniform(1, 3) 호출을 루프 안으로 옮겨야 합니다. 1~3초 슬립은 서버에 과도한 부하를 주지 않기 위한 기본 매너에 해당합니다.


3단계 — BeautifulSoup CSS 셀렉터

3.1 select()와 select_one() 차이

BeautifulSoup에서 select()는 조건에 맞는 모든 요소를 리스트로 반환하고, select_one()첫 번째 요소 하나만 반환합니다. 이 코드에서는 먼저 select('.wrap_cont')로 페이지 안의 카드 목록 전체를 받아온 뒤, 각 카드 안에서 필드를 하나씩 꺼낼 때 select_one()을 씁니다.

3.2 필드 추출 코드

.wrap_cont 컨테이너 안에서 이름·직업·프로필 링크·생년월일 4개 필드를 추출합니다. 아래 표에 각 셀렉터를 정리했습니다.

필드셀렉터
이름.f_link_tit
직업.cont.f_eb
프로필 링크.wrap_cont 첫 번째 <a>[href] (도메인 결합)
생년월일.cont.f_eb.ff_hel

프로필 링크는 CSS 클래스 셀렉터가 없으므로 i.find('a')['href']로 href를 꺼낸 뒤 앞에 https://m.search.daum.net/kakao를 붙여 완전한 URL을 만듭니다.

wrap_cont = soup.select('.wrap_cont')

for i in wrap_cont :
    temp = []
    try :
        temp.append(i.select_one('.f_link_tit').text)   # name
        temp.append(i.select_one('.cont.f_eb').text)    # job
        temp.append('https://m.search.daum.net/kakao' + i.find('a')['href'])    # profile link
        temp.append(i.select_one('.cont.f_eb.ff_hel').text)     # birth
    except :
        temp.append('NaN')  # birth가 없는사람도 있어서 try 처리
    namesList.append(temp)

4단계 — CSV 저장과 실행 진입점

4.1 CSV 저장 (함수 내부)

수집한 데이터를 CSV 파일로 저장합니다. 파일명은 'celebritiesname' + str(end) + '.csv' 형식이며, 첫 줄에 ['name', 'job', 'link', 'birth'] 헤더를 씁니다. 아래 코드는 celebrities_names 함수 안의 마지막 구간입니다.

    with open('celebritiesname' + str(end) + '.csv', 'w', encoding='utf-8', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['name', 'job', 'link', 'birth'])
        writer.writerows(namesList)

4.2 실행 진입점 (함수 외부)

if <strong>name</strong> == "<strong>main</strong>": 블록은 함수 밖에 위치합니다. 시작 페이지와 종료값을 input()으로 받아 함수를 호출합니다.

if __name__ == "__main__":
    start = int(input('Start page 10x input :'))
    end = int(input('How many people will you search : '))
    celebrities_names(start, end)

예를 들어 start=0, end=50을 입력하면 range(0, 50, 10) → 인덱스 0·10·20·30·40, 총 5개 페이지를 순회합니다. 터미널에서 python celebrities_names.py로 실행하면 tqdm이 아래와 같은 진행률을 표시합니다.

$ python celebrities_names.py
Start page 10x input :0
How many people will you search : 50
100%|████████████████████| 5/5 [00:12<00:00, 2.53s/it]

[분석] 위 출력 형태는 tqdm의 터미널 표시 예시입니다. 2026년 현재 다음 모바일 검색 페이지의 HTML 구조가 변경되어 실제 데이터 수집은 동작하지 않을 수 있으나, tqdm 진행률 표시 자체는 동일하게 작동합니다.


코드 주의사항 & 참고사항

실행 전에 세 가지 사항을 확인하세요.

[분석] 1. try 블록 컬럼 수 불일치

원문 코드는 try 안에서 이름·직업·링크·생년월일을 차례로 append합니다. 생년월일(birth) 추출에서 예외가 발생하면 앞서 추가된 컬럼만 남은 채 'NaN' 하나만 더 붙는 구조입니다. 실패한 행은 컬럼 수가 1~4개로 들쭉날쭉할 수 있습니다. 원문 코드는 그대로 보존하지만, 실무에서는 빈 문자열로 채워 컬럼 수를 일정하게 유지하는 편이 안전합니다.

[분석] 2. 2026년 기준 셀렉터 동작 여부

원문은 2020년 4월 기준으로 작성되었습니다. 현재(2026년 기준)에는 다음(Daum) 모바일 검색 페이지의 HTML 구조가 변경되어 셀렉터가 동작하지 않을 수 있습니다. 코드 자체는 BeautifulSoup 기본 사용 패턴 학습용으로 참고하세요.

[분석] 3. robots.txt·이용약관 확인

특정 사이트를 크롤링하기 전에는 해당 사이트의 robots.txt와 이용약관을 반드시 확인하고 진행하시기 바랍니다. 공개된 프로필 정보라도 사이트별 수집 정책이 다를 수 있습니다. 이 글은 법적 판단을 제공하지 않습니다.


전체 코드

전체 코드는 다음과 같습니다.

from bs4 import BeautifulSoup as bs
from tqdm import tqdm
import urllib.request
import time, sys, codecs, os, random, csv

def celebrities_names(start,end):
    randomsl = random.uniform(1,3)

    hdr = {'User-Agent': 'Mozilla/5.0'}
    url = "https://m.search.daum.net/kakao?w=smok&DA=AQJ&q=%EC%97%B0%EC%98%88%EC%9D%B8%EB%AA%85%EB%8B%A8&sidx="

    namesList = []
    for j in tqdm(range(start, end, 10)) :
        page = j
        req = urllib.request.Request(url + str(page), headers=hdr)
        html = urllib.request.urlopen(req).read()
        soup = bs(html, 'html.parser')
        time.sleep(randomsl)

        wrap_cont = soup.select('.wrap_cont')

        for i in wrap_cont :
            temp = []
            try :
                temp.append(i.select_one('.f_link_tit').text)   # name
                temp.append(i.select_one('.cont.f_eb').text)    # job
                temp.append('https://m.search.daum.net/kakao' + i.find('a')['href'])    # profile link
                temp.append(i.select_one('.cont.f_eb.ff_hel').text)     # birth
            except :
                temp.append('NaN')  # birth가 없는사람도 있어서 try 처리
            namesList.append(temp)

    with open('celebritiesname' + str(end) + '.csv', 'w', encoding='utf-8', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['name', 'job', 'link', 'birth'])
        writer.writerows(namesList)

if __name__ == "__main__":
    start = int(input('Start page 10x input :'))
    end = int(input('How many people will you search : '))
    celebrities_names(start, end)

마무리

요청 → HTML 파싱 → CSS 셀렉터 → CSV 저장이라는 BeautifulSoup 기본 패턴을 한 흐름으로 짚어보는 데 의의가 있습니다. 셀렉터나 대상 페이지가 달라지더라도 이 뼈대는 다른 크롤링 작업에도 그대로 응용할 수 있습니다.

6년 전에는 동작만 하면 됐다 싶어 넘겼던 randomsl 위치 문제나 try 블록의 컬럼 수 불일치가, 지금 다시 보니 가장 먼저 손보고 싶어집니다. 코드를 오래 두면 자신의 성장이 보인다는 게 이런 맛인 것 같습니다.


이 글은 2020-04-23 작성된 원본 코드(티스토리 blog-7)를 기반으로 재정리되었습니다.