그로스마케터 부트캠프

260609

lightnsolt 2026. 6. 10. 15:34

한 줄 요약: Selenium·BeautifulSoup으로 네이버·구글 이미지를 로컬에 다운로드하고, POST 방식 페이지네이션 사이트에서 공공데이터를 DataFrame으로 수집하는 전 과정을 다룬다.


목차

  1. 이미지 저장 폴더 준비와 네이버 이미지 크롤링
    • 저장 폴더 안전 생성
    • BeautifulSoup vs Selenium 이미지 요소 추출
    • placeholder 필터링 & urlretrieve 다운로드
    • 스크롤 포함 전체 코드
    • 수집 누락이 발생하는 주요 원인
  2. 구글 이미지 크롤링과 봇 탐지 우회
    • undetected_chromedriver 도입
    • 선택자 정밀화 & GIF 필터링
    • Jupyter에서 이미지 미리보기
  3. POST 방식 페이지네이션 수집
    • GET vs POST 구분 방법
    • requests.post()로 단일 페이지 수집
    • 1~5페이지 루프 수집 & DataFrame 구성
  4. 핵심 요약

Part 1. 이미지 저장 폴더 준비와 네이버 이미지 크롤링

저장 폴더 안전 생성

이미지 수집은 파일 저장이 전제이므로, 코드 실행 전 반드시 저장 디렉터리를 확인하고 없으면 생성한다. os.path.isdir()로 폴더 존재 여부를 확인한 뒤, 없을 때만 os.mkdir()를 호출하면 중복 생성 오류 없이 재실행할 수 있다.

# 필요한 라이브러리 임포트
from selenium import webdriver as wb
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from bs4 import BeautifulSoup as bs
from tqdm import tqdm
import time, os
from urllib.request import urlretrieve

# 저장 폴더 안전 생성
if not os.path.isdir("이미지"):
    os.mkdir("이미지")
    print("폴더를 생성합니다.")
else:
    print("폴더가 이미 존재합니다.")
💡 실무 팁
os.makedirs("경로", exist_ok=True)를 사용하면 중간 디렉터리까지 한 번에 생성할 수 있어 프로젝트 규모가 커질 때 더 유용하다.

BeautifulSoup vs Selenium 이미지 요소 추출

네이버 이미지 검색 결과 URL을 브라우저로 열면 이미지 요소들이 로드된다. 이미지 요소 선택에는 두 가지 방법을 모두 활용할 수 있다.

구분BeautifulSoupSelenium
요소 선택soup.select("CSS선택자")driver.find_elements(By.CSS_SELECTOR, "...")
속성 접근element["src"] (딕셔너리 문법)element.get_attribute("src")
HTML 소스driver.page_source를 파싱해 사용드라이버에서 직접 접근
적합한 상황정적 HTML, 파싱 속도가 빠를 때스크롤·클릭 등 동적 상호작용 후 수집
# BeautifulSoup 방법
soup = bs(driver.page_source, "lxml")
imgs_bs = soup.select("._fe_image_tab_content_thumbnail_image")
imgs_bs[0]["src"]  # 딕셔너리 문법으로 속성 접근

# Selenium 방법
imgs_sel = driver.find_elements(By.CSS_SELECTOR, "._fe_image_tab_content_thumbnail_image")
imgs_sel[0].get_attribute("src")  # get_attribute()로 속성 접근
★ 핵심
BeautifulSoup은 driver.page_source를 받아 파싱하는 방식이므로, 페이지 소스를 가져오는 시점의 DOM 상태만 반영된다. 스크롤 후 동적으로 추가된 요소는 스크롤 완료 후 다시 page_source를 받아야 한다.

placeholder 필터링 & urlretrieve 다운로드

이미지 페이지에서는 모든 이미지를 즉시 로딩하지 않는다. 화면 바깥의 이미지는 data:image/gif;base64,... 형태의 투명 placeholder로 대체되어 있으며, 이를 그대로 다운로드하면 깨진 이미지가 저장된다. 따라서 https로 시작하는 URL만 필터링하는 처리가 필수다.

# 리스트 내포로 src 속성 일괄 추출
imgs_src_list = [i.get_attribute("src") for i in imgs_sel]

# https로 시작하는 유효 URL만 필터링 (placeholder 제외)
imgs_src_valid = [i for i in imgs_src_list if i[:5] == "https"]

print(f"전체: {len(imgs_src_list)}개 → 유효: {len(imgs_src_valid)}개")
# urlretrieve로 이미지 파일 저장
# enumerate: 인덱스와 값을 동시에 반환 → 파일명 중복 방지
for idx, img in tqdm(enumerate(imgs_src_valid)):
    urlretrieve(img, "이미지/" + str(idx + 1) + ".jpg")
    time.sleep(0.5)  # 서버 부하 방지 및 안정성 향상
💡 실무 팁
enumerate()를 활용하면 별도의 카운터 변수(cnt = 0; cnt += 1) 없이 인덱스와 값을 동시에 관리할 수 있다. 파일명에 인덱스를 넣으면 덮어쓰기 없이 순차 저장이 가능하다.

스크롤 포함 전체 통합 코드

네이버 이미지는 스크롤에 따라 이미지가 지연 로딩되므로, Keys.END를 반복 전송해 더 많은 이미지를 로드한 뒤 수집한다. 반복 횟수가 명확할 때는 for _ in range(3)처럼 언더바(_) 변수를 사용해 "루프 카운터를 사용하지 않는다"는 의도를 명시하는 것이 관례다.

# 전체 통합 코드 (스크롤 3회 포함)
driver = wb.Chrome()
driver.maximize_window()
driver.get("https://search.naver.com/search.naver?where=image&query=원하는검색어")
body = driver.find_element(By.TAG_NAME, "body")
time.sleep(2)

# 스크롤 3회: _ 는 루프 변수 미사용 의도 표현
for _ in range(3):
    body.send_keys(Keys.END)
    time.sleep(2)

# 이미지 요소 선택 및 src 추출
imgs = driver.find_elements(By.CSS_SELECTOR, "._fe_image_tab_content_thumbnail_image")
imgs_list = [i.get_attribute("src") for i in imgs]
imgs_valid = [i for i in imgs_list if i[:5] == "https"]

# 다운로드
for idx, img in tqdm(enumerate(imgs_valid)):
    urlretrieve(img, "이미지/" + str(idx) + ".jpg")
    time.sleep(0.5)

print("크롤링 완료")
driver.quit()

수집 누락이 발생하는 주요 원인

  1. 웹 페이지의 실시간 변동성: 수집 도중 대상 콘텐츠가 삭제·수정되는 경우
  2. 반응형 구조 변화: 화면 크기나 상태에 따라 DOM 구조가 달라지는 경우
  3. 충분한 대기 시간 미확보: time.sleep() 없이 실행하면 이미지가 아직 로딩되지 않은 상태에서 코드가 진행됨
  4. 검색어 자체의 데이터 부족: 희귀한 검색어는 업로드된 이미지 수가 적어 결과물이 적게 수집될 수 있음

Part 2. 구글 이미지 크롤링과 봇 탐지 우회

undetected_chromedriver 도입

구글은 네이버보다 자동화 감지 로직이 강해 일반 webdriver.Chrome()으로 접근하면 CAPTCHA가 발생할 수 있다. undetected_chromedriver는 브라우저가 "자동화된 소프트웨어로 제어 중"이라는 신호를 숨겨, 사람이 직접 접근하는 것처럼 동작하게 만들어 준다.

# 설치
!pip install undetected_chromedriver

import undetected_chromedriver as uc

driver = uc.Chrome()
driver.maximize_window()
⚠️ 주의
SessionNotCreatedException이 발생하면 크롬 브라우저 버전과 드라이버 버전이 맞지 않는 경우다. uc.Chrome(version_main=148)처럼 현재 설치된 크롬 메이저 버전을 명시하거나, 크롬을 최신으로 업데이트하면 해결된다.

선택자 정밀화 & GIF 필터링

구글 이미지는 단순히 img 태그만 잡으면 광고·아이콘 등 불필요한 요소가 섞인다. 상위 컨테이너 div 클래스까지 포함한 하위 선택자로 범위를 좁혀 정확도를 높인다. 또한 구글에서도 미로딩 요소는 data:image/gif로 표현되므로 이를 추가로 제외해야 한다.

# 구글 이미지 크롤링 전체 코드
driver = uc.Chrome()
driver.maximize_window()
driver.get("https://www.google.com/search?q=검색어&udm=2")
time.sleep(2)

body = driver.find_element(By.TAG_NAME, "body")
for _ in range(3):
    body.send_keys(Keys.END)
    time.sleep(1.5)

# 상위 div까지 포함한 정밀 선택자
imgs = driver.find_elements(By.CSS_SELECTOR, ".H8Rx8c img.YQ4gaf")
imgs_list = [i.get_attribute("src") for i in imgs]

# data:image/gif 필터링 (https 필터와 병행 적용)
imgs_valid = [i for i in imgs_list if i[:14] != "data:image/gif"]

for idx, img in tqdm(enumerate(imgs_valid)):
    urlretrieve(img, "이미지2/" + str(idx + 1) + ".jpg")
    time.sleep(0.5)

print("크롤링 완료!")
문제 유형징후대응 방법
봇 탐지 (CAPTCHA)자동 접근 감지 팝업 또는 캡차 표시undetected_chromedriver 사용
버전 불일치SessionNotCreatedException크롬 업데이트 또는 version_main 지정
선택자 부정확첫 이미지와 다른 요소가 추출됨상위 div까지 올려 범위 재설정
placeholder/미로드data:image로 시작, 이미지가 열리지 않음문자열 패턴 기반으로 필터링해 제외

Jupyter에서 이미지 미리보기

다운로드 전에 선택자가 올바른지 검증하려면 IPython.displayImagedisplay를 활용해 Jupyter 셀에서 직접 이미지를 렌더링할 수 있다. URL이 유효한지, 원하는 이미지인지 빠르게 확인할 수 있어 선택자 디버깅 시간을 줄여준다.

from IPython.display import display, Image

test_url = imgs_list[0]
display(Image(url=test_url))

# 전체 vs 유효 URL 수 확인
print(len(imgs_list), len(imgs_valid))
💡 실무 팁
대기 시간을 고정값(time.sleep(1.5))으로만 두기보다 time.sleep(random.uniform(1.0, 2.5))처럼 랜덤화하면 사람의 행동 패턴에 가까워져 자동화 탐지를 추가로 완화할 수 있다.

Part 3. POST 방식 페이지네이션 수집

GET vs POST 구분 방법

GET 방식은 페이지 번호가 URL에 그대로 드러난다(?page=2). 반면 POST 방식은 URL이 변하지 않고, 페이지 파라미터를 요청 본문(Payload)에 실어 전송한다. 서울 열린데이터 광장처럼 URL이 페이지를 넘겨도 동일하게 유지되는 사이트가 이에 해당한다.

★ 핵심
URL 변화가 없는 사이트를 만나면 반드시 개발자도구 → Network 탭 → 요청 선택 → Request Method를 확인한다. POST라면 Payload 탭에서 페이지 파라미터(pageIndex 등)를 찾아 requests.post()data 인자로 전달한다.
구분GETPOST
파라미터 위치URL 쿼리스트링 (?key=value)요청 본문 (Payload)
URL 변화페이지마다 URL 변경URL 고정
코드 방식requests.get(url, params=...)requests.post(url, data=...)
검색엔진 노출노출됨노출 안 됨 (일부 사이트는 첫 진입만 GET)

requests.post()로 단일 페이지 수집

import requests as req
from bs4 import BeautifulSoup as bs

U_A = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"}

url = "https://data.seoul.go.kr/dataList/datasetList.do"
payload = {"pageIndex": 2}  # Payload에서 확인한 페이지 파라미터

res = req.post(url, data=payload, headers=U_A)
soup = bs(res.text, "lxml")

titles = soup.select(".goView strong")
for t in titles:
    print(t.text)
💡 실무 팁
선택자 후처리 전략 요약: 타이틀에 불필요한 숫자가 섞이면 상위/하위 선택자를 재조정하고, 수정일자처럼 뒷부분 고정 길이([-10:])는 슬라이싱으로, 제공부서처럼 라벨 길이가 가변적인 경우는 len("라벨 텍스트")로 시작 인덱스를 동적으로 구한 뒤 [start:]로 자른다.

1~5페이지 루프 수집 & DataFrame 구성

페이지 루프 안에서 추출한 항목들을 각각의 리스트에 누적한 뒤, 마지막에 딕셔너리 → DataFrame으로 변환한다. 페이지당 10개 × 5페이지 = 총 50개 레코드가 수집된다.

import pandas as pd

titles_list, categories_list, date_list, department_list = [], [], [], []

for page in range(1, 6):  # 외부 루프 변수: page
    url = "https://data.seoul.go.kr/dataList/datasetList.do"
    payload = {"pageIndex": page}

    res = req.post(url, data=payload, headers=U_A)
    soup = bs(res.text, "lxml")

    titles     = soup.select(".goView strong")
    categories = soup.select(".type-b dt em:nth-child(2)")
    date       = soup.select(".list-statistics-info2 span:first-child")
    department = soup.select(".list-statistics-info2 span:nth-child(3)")

    for j in range(len(titles)):  # 내부 루프 변수: j (page와 분리)
        titles_list.append(titles[j].text)
        categories_list.append(categories[j].text.strip("[]"))
        date_list.append(date[j].text.strip()[-10:])
        department_list.append(department[j].text.strip()[20:])

info_df = pd.DataFrame({
    "제목": titles_list,
    "영역": categories_list,
    "일자": date_list,
    "제공부서": department_list
})

info_df
⚠️ 주의
원본 강의 노트의 내부 루프 변수가 i로 외부 루프와 동일하게 작성되어 있다. Python에서 실행 결과에 영향은 없지만, 코드 가독성과 유지보수를 위해 내부 루프는 j처럼 다른 변수명으로 구분하는 것을 권장한다.

선택자별 후처리 전략 요약:

항목선택 전략후처리 전략
타이틀a.goView 링크 범위 내 strong불필요한 번호가 섞이면 상위/하위 선택자 재조정
분야dl.typeB em:nth-child(2) 구조 기반.strip("[]")로 괄호 제거
수정일자span:nth-child(1) 위치 기반 선택.strip()[-10:]로 날짜 10자리만 추출
제공부서span:nth-child(3) 위치 기반 선택.strip()[20:] 또는 라벨 길이 기준 동적 슬라이싱

핵심 요약

  • 이미지 크롤링의 핵심은 src 필터링: data:image/gifhttps가 아닌 URL은 placeholder이므로 리스트 내포 조건식으로 제거한 뒤 urlretrieve()로 저장한다.
  • 구글 자동화는 undetected_chromedriver: CAPTCHA를 우회하고, version_main으로 크롬 버전을 명시해 SessionNotCreatedException을 방지한다.
  • URL이 안 바뀌면 POST 의심: 개발자도구 Network 탭에서 Request Method와 Payload를 확인하고, requests.post(url, data=payload)로 페이지 파라미터를 전달한다.
  • 선택자 후처리는 필수: .strip(), 슬라이싱([-10:], [20:]), nth-child() 위치 선택자를 조합해 원하는 텍스트만 정제한다.
  • 루프 수집 → DataFrame 패턴: 빈 리스트 4개 준비 → 페이지 루프에서 누적 → pd.DataFrame(dict)으로 일괄 변환하는 흐름이 표준 패턴이다.