한 줄 요약 · Selenium 동적 크롤링에서 끝을 모르는 반복(더보기 버튼)을 안전하게 종료하는 방법, 페이지 이동 후 발생하는 StaleElement 문제를 요소 재탐색으로 해결하는 패턴, 그리고 get_attribute("disabled")로 구매 가능 사이즈를 분리하는 실무 기법까지 한솥도시락과 뉴발란스 두 사이트로 정리한다.
목차
- Part 1. 한솥도시락 메뉴·가격 수집 자동화
- 1-1. 동적 크롤링을 위한 브라우저 준비
- 1-2. 요소 수집과 find_elements의 반환 자료형
- 1-3. 선택자 정교화: 같은 태그를 공유하는 텍스트 분리
- 1-4. 더보기 버튼 반복 클릭과 종료 처리(while + try/except)
- 1-5. 데이터 정리: zip과 DataFrame
- Part 2. 뉴발란스 상품 상세정보 크롤링
- 2-1. 리스트→상세 진입과 StaleElementReferenceException
- 2-2. 자식 선택자와 자손 선택자
- 2-3. 상세 페이지에서 수집할 5가지 필드
- 2-4. input/disabled 판별과 get_attribute
- 2-5. 상위 N개 품목 반복 자동화
- 핵심 요약
- 참고
Part 1. 한솥도시락 메뉴·가격 수집 자동화
정적 크롤링(requests/BeautifulSoup)은 한 번 받아온 HTML만 다루지만, 동적 크롤링은 브라우저를 실제로 조작하며 클릭·스크롤로 변하는 화면을 따라간다. 첫 실습은 한솥도시락 메뉴 페이지에서 "더보기" 버튼을 끝까지 눌러 전체 메뉴와 가격을 수집하는 것이다.
1-1. 동적 크롤링을 위한 브라우저 준비
한솥 메뉴 페이지는 화면 폭에 따라 카드가 2열·3열로 바뀌는 반응형 웹이다. 같은 페이지라도 화면 크기에 따라 DOM 구조나 요소 위치가 달라질 수 있으므로, 크롤링 안정성을 위해 브라우저를 최대화한 뒤 진행한다.
from selenium import webdriver as wb
from selenium.webdriver.common.by import By
from tqdm import tqdm
import pandas as pd
import time
driver = wb.Chrome()
driver.get("https://www.hsd.co.kr/menu/menu_list")
driver.maximize_window() # 반응형 대응: 화면 조건 고정
time.sleep(2) # DOM 로딩 전 이벤트 발생 방지
💡 실무 팁 · 페이지 진입 직후 DOM 로딩이 끝나기 전에 다음 코드를 실행하면 "요소를 찾지 못함" 오류가 난다.driver.get()직후 짧은time.sleep()을 넣는 습관이 동적 크롤링의 안정성을 크게 높인다.
1-2. 요소 수집과 find_elements의 반환 자료형
메뉴명은 h4.h.fz_03 클래스 조합으로, 여러 건을 한 번에 가져와야 하므로 find_elements()(복수)를 사용한다.
titles = driver.find_elements(By.CSS_SELECTOR, "h4.h.fz_03")
for t in titles:
print(t.text) # 요소 객체가 아니라 .text로 문자열을 추출
⚠️ 주의 ·find_elements()의 반환형은 BeautifulSoup의ResultSet이 아니라 파이썬 리스트(list[WebElement])다.ResultSet은 BeautifulSoup의find_all()이 돌려주는 자료형으로, 정적 크롤링과 동적 크롤링을 공부하다 보면 자주 혼동한다. 어느 쪽이든 요소 자체는 객체이므로, 실제 문자열은.text로 꺼내야 한다는 점은 동일하다.
1-3. 선택자 정교화: 같은 태그를 공유하는 텍스트 분리
가격을 단순히 strong 태그로만 잡으면, 페이지 하단의 "개인정보 처리방침"처럼 같은 태그를 쓰는 다른 텍스트가 섞여 들어온다. 이때는 가격을 감싸는 상위 구조를 함께 지정해 범위를 좁힌다.
# strong 태그에 우클릭 → Copy selector로 얻은 경로(너무 구체적)
# "#menuList_693 > div > div.item-text > div > strong"
# 가장 상위의 유일 id(#menuList_693)를 제거해 다른 카드까지 포괄하도록 일반화
prices = driver.find_elements(By.CSS_SELECTOR, "div > div.item-text > div > strong")
for p in prices:
print(p.text)
💡 실무 팁 · #으로 시작하는 선택자는 페이지 내 유일한 id라 단 하나만 잡힌다. Copy selector로 복사한 경로는 보통 너무 구체적이므로, 맨 앞의 유일 id를 떼어내 "의미 있는 클래스 경로"로 다듬으면 같은 구조의 다른 요소까지 한 번에 수집할 수 있다.
1-4. 더보기 버튼 반복 클릭과 종료 처리(while + try/except)
"더보기" 버튼은 클릭할 때마다 추가 요청이 발생해 DOM이 늘어난다. 버튼이 몇 번 더 있을지 알 수 없으므로 while True로 반복하되, 더 이상 버튼이 없을 때 발생하는 예외를 종료 신호로 처리한다.
⚠️ 주의 · 다음과 같이if more_btn == []: break로 종료를 시도하는 코드는 동작하지 않는다.find_element()(단수)는 단일 객체를 돌려주므로 빈 리스트[]와 절대 같아지지 않고, 버튼이 사라지면 비교에 도달하기 전에NoSuchElementException이 먼저 발생한다. 따라서 종료 조건은try/except로 처리하는 것이 정답이다.
# 올바른 패턴: 버튼이 사라지면 예외가 나고, 그때 반복을 종료
try:
while True:
more_btn = driver.find_element(By.CSS_SELECTOR, "#btn_more > span > a")
more_btn.click()
time.sleep(2) # 사람 행동 수준의 텀 → 과도 요청 오인·로딩 누락 방지
except:
print("더보기 버튼을 모두 눌렀습니다.")
★ 핵심 · 반복 클릭을 페이지 로딩보다 빠르게 연속 실행하면 데이터가 정상적으로 뜨지 않거나, 서버가 과도 요청(공격)으로 판단해 IP를 차단할 수 있다. time.sleep()으로 텀을 두는 것은 단순 안정성을 넘어 크롤링 윤리 측면에서도 중요하다.
1-5. 데이터 정리: zip과 DataFrame
버튼을 모두 누른 뒤 메뉴명과 가격을 각각 리스트로 모으고, 두 리스트의 길이가 같은지 검증한다. 길이가 맞아야 1:1 매핑이 깨지지 않는다.
titles = driver.find_elements(By.CSS_SELECTOR, "h4.h.fz_03")
prices = driver.find_elements(By.CSS_SELECTOR, "div > div.item-text > div > strong")
titles_list, prices_list = [], []
for i in tqdm(range(len(titles))):
titles_list.append(titles[i].text)
prices_list.append(prices[i].text + "원")
print("메뉴 개수 :", len(titles_list))
print("가격 개수 :", len(prices_list)) # 두 값이 같은지 반드시 확인
zip()은 길이가 같은 두 순서형 집합을 1:1로 묶어 튜플로 만들어 준다. zip 객체는 그대로 출력하면 보이지 않으므로, 반복문으로 풀거나 dict()·list()로 형변환해 확인한다.
# zip 객체는 변수로 출력해도 내용이 보이지 않음 → 형변환 필요
temp = zip(titles_list, prices_list)
for row in temp:
print(row) # (메뉴, 가격) 튜플로 풀림
# 메뉴명을 키, 가격을 값으로 갖는 딕셔너리
titles_prices_dic = dict(zip(titles_list, prices_list))
⚠️ 주의 · DataFrame을 만들 때 두 가지 실수에 유의한다. 첫째,pd.DataFrame(hansot_df)처럼 정의하지 않은 변수를 넣으면NameError가 난다(딕셔너리 변수명을 넣어야 한다). 둘째,dict(zip(메뉴, 가격))의 결과를 그대로DataFrame에 넣으면 메뉴명 하나하나가 컬럼이 되어 표가 옆으로 길게 퍼진다. 표로 정리할 때는 아래처럼{"컬럼명": 리스트}형태를 사용한다.
# 표 형태로 정리하려면 컬럼명을 키로 두고 리스트를 값으로
hansot_dic = {"메뉴": titles_list, "가격": prices_list}
hansot_df = pd.DataFrame(hansot_dic, index=range(1, len(titles_list) + 1))
# 전체 행을 보고 싶을 때
pd.set_option("display.min_rows", None)
# 중복 수집에 대비한 정리
hansot_df = hansot_df.drop_duplicates()
Part 2. 뉴발란스 상품 상세정보 크롤링
두 번째 실습은 "리스트 페이지에서 이미지를 클릭해 상세 페이지로 진입 → 정보 수집 → 뒤로가기"를 반복하는 전형적인 이커머스 크롤링 패턴이다. 실제 사용자가 온라인 쇼핑을 할 때의 행동을 그대로 코드로 옮긴다.
2-1. 리스트→상세 진입과 StaleElementReferenceException
상세 페이지에 들어갔다가 driver.back()으로 목록으로 돌아오면, 이전에 잡아 둔 이미지 요소 리스트는 더 이상 유효하지 않다. 뒤로가기 자체가 또 한 번의 통신이라 페이지가 다시 로딩되고 DOM이 새로 생성되므로, 예전 WebElement 참조가 끊기기 때문이다. 이때 발생하는 것이 StaleElementReferenceException이다.
★ 핵심 · 동적 크롤링에서 가장 중요한 안정화 패턴은 매 반복마다 요소를 다시 탐색하는 것이다. 페이지가 새로 로딩될 때마다 find_elements()로 이미지 요소를 재탐색한 뒤 현재 인덱스를 클릭해야 StaleElement 오류를 피할 수 있다.
# 매 루프마다 imgs를 다시 잡아야 함 (재사용 금지)
imgs = driver.find_elements(By.CSS_SELECTOR, "div.pro_thumbNail img.img_goods")
imgs[idx].click()
# ... 상세 정보 수집 ...
driver.back() # 돌아오면 기존 imgs는 무효 → 다음 루프에서 다시 find_elements
2-2. 자식 선택자와 자손 선택자
이미지 요소를 너무 느슨하게 잡으면 인덱스가 기대와 다르게 매핑되어 엉뚱한 상품으로 이동할 수 있다. 이럴 때는 상위 컨테이너(썸네일 영역 div)를 기준으로 범위를 좁힌다. 이때 자식 선택자와 자손 선택자의 차이를 이해해야 한다.
- 자식 선택자(
A > B): A 바로 한 단계 아래의 B만 선택한다. - 자손 선택자(
A B, 공백): 중간 태그를 건너뛰고 A 하위 어디든 매칭한다.
예컨대 div → a → img처럼 2단계 이상 내려가는 구조에서는 공백을 쓴 자손 선택자가 적합하다.
# div.pro_thumbNail 하위 어디든 있는 img.img_goods (자손 선택자)
imgs = driver.find_elements(By.CSS_SELECTOR, "div.pro_thumbNail img.img_goods")
len(imgs)
2-3. 상세 페이지에서 수집할 5가지 필드
상세 페이지에서 상품명·평점·구매 가능 사이즈·발볼 너비·리뷰 개수를 수집한다. 필드마다 난이도와 접근 방식이 다르다.
| 필드 | 접근 방식 | 포인트 |
|---|---|---|
| 상품명 | #displayName의 .text |
단일 요소 추출 |
| 평점 | 평점 컨테이너의 .text |
별 아이콘이 여러 요소로 나뉘어 있어 부모 컨테이너에서 한 번에 추출 |
| 구매 가능 사이즈 | input의 disabled 속성 판별 |
활성/비활성 구분 필요 (2-4 참고) |
| 발볼 너비 | 옵션 영역의 label 텍스트 |
유일 id 기준으로 범위 고정 |
| 리뷰 개수 | review.text[1:-1] |
"(1004)" 형태에서 슬라이싱으로 괄호 제거 |
2-4. input/disabled 판별과 get_attribute
input은 사용자 입력을 받는 특수 태그로 끝태그가 없고, type·value·disabled 같은 속성을 가진다. 사이즈 버튼이 품절이면 해당 input에 disabled 속성이 붙는다. 이를 get_attribute()로 판별한다.
⚠️ 주의 ·get_attribute("disabled")의 반환값을 정확히 알아야 한다. 속성이 있으면 문자열"true", 없으면None을 돌려준다.None은 파이썬 조건문에서 거짓으로 평가되므로, 이 성질을 이용해 활성/비활성 사이즈를 분리한다.
사이즈 텍스트("220" 등)는 input이 아니라 짝을 이루는 label에 들어 있다. label[for="..."] 선택자를 쓰면 input의 id와 label의 for가 동일하게 매핑되는 구조를 활용해 사이즈 텍스트를 정확히 가져올 수 있다. "텍스트가 input에는 없고 label에 있는" 폼 UI에서 자주 쓰는 기법이다.
size_inputs = driver.find_elements(By.CSS_SELECTOR, "#optSizeSection li input")
enabled_size, disabled_size = [], []
for inp in size_inputs:
sid = inp.get_attribute("id")
# input의 id와 동일한 for를 가진 label에서 사이즈 텍스트 추출
label = driver.find_element(By.CSS_SELECTOR, f'label[for="{sid}"]')
size_text = label.text.strip()
is_disabled = inp.get_attribute("disabled") # 있으면 "true", 없으면 None
if is_disabled:
disabled_size.append(size_text)
else:
enabled_size.append(size_text)
print("구매 가능:", enabled_size)
print("품절:", disabled_size)
2-5. 상위 N개 품목 반복 자동화
단일 상품 수집을 상위 N개(예: 8개, 16개) 반복으로 확장한다. 반복 루프 내부에서는 ① 목록에서 이미지 재탐색 → 현재 인덱스 클릭 ② 로딩 대기 후 필드 수집 ③ 각 컬럼 리스트에 append() ④ driver.back() 후 다음 인덱스 진행 순서를 지킨다. 예외에 대비해 try/except Exception as e로 에러 내용을 출력하면 디버깅이 쉬워진다.
⚠️ 주의 · 중첩 반복문에서 바깥 루프와 안쪽 루프가 같은 변수명을 쓰면 안 된다. 예컨대 바깥에서for i in range(N), 안쪽에서for i in sizes처럼i를 재사용하면 인덱스가 덮어써져 의도치 않은 동작이 생긴다. 안쪽 루프 변수는j·inp등으로 분리한다. 또한 리스트에 추가할 때enable.append(...)처럼 정의하지 않은 이름을 쓰면NameError가 나므로, 실제 정의한 변수명(enable_size)을 정확히 써야 한다.
driver = wb.Chrome()
driver.maximize_window()
driver.get("https://www.nbkorea.com/product/featuredList.action?cateGrpCode=250100&cIdx=1385")
time.sleep(3)
product_name_list, rating_list = [], []
foot_width_list, reviews_list, enable_size_list = [], [], []
for idx in tqdm(range(16)):
try:
# 1) 목록에서 이미지 재탐색 후 클릭 (StaleElement 방지)
imgs = driver.find_elements(By.CSS_SELECTOR, ".pro_thumbNail img.img_goods")
imgs[idx].click()
time.sleep(1)
# 2) 필드 수집
product_name = driver.find_element(By.CSS_SELECTOR, "#displayName")
rating = driver.find_element(By.CSS_SELECTOR, ".crema_product_reviews_score__container")
foot_width = driver.find_element(By.CSS_SELECTOR, "#optWidthSection > ul > li > label")
reviews = driver.find_element(By.CSS_SELECTOR, ".crema-product-reviews-count")
# 구매 가능 사이즈 (안쪽 루프 변수는 inp로 분리)
sizes = driver.find_elements(By.CSS_SELECTOR, "#optSizeSection > ul > li")
enable_size = []
for inp in sizes:
check_input = inp.find_element(By.CSS_SELECTOR, "input")
if not check_input.get_attribute("disabled"): # 속성 없음(None) = 구매 가능
label = inp.find_element(By.CSS_SELECTOR, "label")
enable_size.append(label.text)
# 3) 컬럼별 리스트에 누적
product_name_list.append(product_name.text)
rating_list.append(rating.text)
foot_width_list.append(foot_width.text)
reviews_list.append(reviews.text[1:-1]) # 괄호 제거
enable_size_list.append(enable_size)
# 4) 뒤로가기 후 다음 인덱스
driver.back()
time.sleep(2)
except Exception as e:
print("오류명:", e)
driver.back()
time.sleep(2)
continue
nb_dic = {
"제품명": product_name_list,
"평점": rating_list,
"발볼 너비": foot_width_list,
"리뷰 수": reviews_list,
"구매 가능 사이즈": enable_size_list,
}
pd.set_option("display.max_columns", None)
nb_df = pd.DataFrame(nb_dic, index=range(1, len(product_name_list) + 1))
nb_df
💡 실무 팁 ·except Exception as e로 에러 메시지를 출력하고continue로 다음 품목으로 넘어가게 하면, 한 상품에서 요소를 못 찾아도 전체 수집이 멈추지 않는다. "없는 요소", "로딩 지연", "구조 변경" 같은 웹 크롤링의 불확실성에 대응하는 표준 방어 코드다.
핵심 요약
- 요소 재탐색이 동적 크롤링의 핵심: 페이지가 새로 로딩될 때마다
find_elements()로 다시 잡아야StaleElementReferenceException을 피한다. - 끝을 모르는 반복은 예외로 종료:
while True+try/except.find_element는 단일 객체라== []비교로는 종료되지 않는다. get_attribute("disabled")의 반환값: 있으면 문자열"true", 없으면None. 이 성질로 활성/비활성을 분리한다.- label[for=id] 매핑: 텍스트가
input이 아닌label에 있는 폼 UI에서 사이즈 같은 값을 정확히 가져온다. - DataFrame은
{"컬럼명": 리스트}형태:dict(zip())을 그대로 넣으면 값 하나하나가 컬럼이 되니 주의한다.
참고
- 그로스 마케팅 부트캠프 5기 · 동적 크롤링 실전(Selenium) 강의 (2026.06.08)
- 실습 대상: 한솥도시락 메뉴 페이지, 뉴발란스 상품 리스트
'그로스마케터 부트캠프' 카테고리의 다른 글
| 260609 (0) | 2026.06.10 |
|---|---|
| 260602_웹 크롤링 입문(html, css, 정적 크롤링) (0) | 2026.06.09 |
| 260605_그로스마케터 부트캠프_데이터크롤링(정적+동적 시작) (0) | 2026.06.08 |
| 부트캠프 8일차_GA4 UI 익숙해지기 + UTM 개념 (0) | 2026.05.12 |
| 부트캠프5일차_유튜브광고 (0) | 2026.05.07 |