Step 75. BeautifulSoup 크롤링 — HTML에서 원하는 것만 뽑기
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 74를 마쳤다. requests로 요청을 보내고 응답을 다룰 수 있고, lab.py 연습 서버를 가지고 있다. HTML 태그 구조와 CSS 선택자를 안다(Step 71).
- 준비물: 파이썬, requests(설치됨), 오늘 설치할 beautifulsoup4, 그리고 Step 74에서 만든 lab.py. 인터넷은 필요 없습니다 — 오늘의 수집 대상도 우리 집 연습 서버입니다.
- 주의: 오늘 실습은 100% 안전합니다. 크롤링의 대상이 127.0.0.1, 즉 여러분 자신의 서버뿐입니다.
지난 시간에 requests로 페이지를 통째로 가져오는 데 성공했습니다. 그런데 가져온 것을 보면 수천 글자짜리 HTML 문자열일 뿐입니다. 그 안에서 "명언 세 개"만 뽑으려면 어떻게 해야 할까요? 문자열 검색으로는 태그가 얽혀 있어 한계가 있습니다. BeautifulSoup(뷰티풀숲)은 이 긴 문자열을 탐색 가능한 나무 구조로 바꿔 주는 라이브러리입니다. 이 기술을 크롤링(crawling, 기어다니며 수집하기)이라 부르고, 보안 실무의 위협 정보 수집·유출 모니터링에 매일 쓰이는 기본기입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
BeautifulSoup(문자열, "html.parser")로 HTML 문자열을 탐색 가능한 구조로 바꾼다- find/find_all과 CSS 선택자(select/select_one) 두 계열의 검색 도구를 상황에 맞게 쓴다
- 찾아낸 요소에서
.text와["href"]/get()으로 글자와 속성을 꺼낸다 - 못 찾았을 때(None) 프로그램이 죽지 않게 방어 코드를 쓴다
- 수집물을 "사전들의 목록"으로 정리해 저장하기 좋은 형태로 만든다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + beautifulsoup4 (pip install beautifulsoup4). 수집 대상은 Step 74의 lab.py 서버(127.0.0.1:8010) |
| 오늘의 도구 | BeautifulSoup 클래스, find 계열, select 계열 |
| 오늘의 함수 | BeautifulSoup(html, "html.parser"), soup.find / find_all, soup.select / select_one, 요소.text, 요소.get("속성") |
| 필요한 개념 | HTML 태그 구조, CSS 선택자(.class, #id, 태그 자손), 파이썬 목록과 사전 |
| 오늘의 산출물 | quotes.py — 명언 수집기 |
2-1. 파싱 — 문자열을 구조로 바꾸는 마법
긴 문자열을 탐색 가능한 구조로 바꾸는 일을 파싱(parsing, 구문 분석)이라 합니다. BeautifulSoup에 HTML 문자열을 주면, 개발자 도구 Elements 탭에서 보던 것과 같은 나무가 파이썬 안에 만들어집니다. 이제 "h1 태그 하나 줘", "a 태그 전부 줘" 같은 주문이 가능해집니다. soup이라는 변수 이름은 관습입니다.
2-2. 두 가지 찾기 도구
BeautifulSoup의 검색 도구는 두 계열입니다.
- find 계열:
soup.find("h1")은 h1 태그 첫 번째 하나,soup.find_all("a")는 a 태그 전부(목록)를 돌려줍니다. - select 계열:
soup.select(".quote")는 Step 71에서 배운 CSS 선택자 문법 그대로 찾습니다..이름은 class,#이름은 id입니다.select_one은 첫 하나만 찾습니다.
찾아낸 요소에서는 .text(안의 글자), .get("href")(속성 값)로 내용을 꺼냅니다. "태그 찾기 → 글자/속성 꺼내기" 이 두 박자가 크롤링의 전부입니다.
2-3. 크롤링의 예절 — robots.txt와 쉼표
남의 사이트에서 데이터를 긁는 행위는 서버에 부담을 줍니다. 그래서 사이트들은 문 앞에 안내문을 붙여 둡니다 — robots.txt입니다. "사이트 주소/robots.txt"를 열면 "이 경로는 수집을 허락하지 않는다"는 규칙을 누구나 읽을 수 있습니다. 이것을 읽고 존중하는 것, 요청 사이에 time.sleep으로 쉼표를 넣는 것 — 이 둘이 수집가의 예절이자 자격 증명입니다. 오늘은 우리 집 서버가 상대라 부담이 없지만, 습관은 연습장에서 만들어집니다.
3. 따라 하기
서버를 먼저 켭니다. 터미널 1에서 python lab.py (Step 74에서 만든 그 파일입니다). 아래 실험은 전부 터미널 2에서 합니다.
3-1. 설치와 첫 파싱
입력
pip install beautifulsoup4
이제 파이썬에서 실험합니다. soup1.py를 만드세요.
입력
import requests
from bs4 import BeautifulSoup
r = requests.get("http://127.0.0.1:8010/")
soup = BeautifulSoup(r.text, "html.parser")
print(soup.title.text)
출력 (2026-09-09 실측):
명언 연습장
읽는 법: BeautifulSoup(문자열, "html.parser") 한 줄이 파싱의 전부입니다. 파싱된 soup에서 soup.title은 title 태그를, .text는 그 안의 글자를 뜻합니다. 패키지 이름은 beautifulsoup4지만 파이썬에서 불러올 때의 이름은 bs4라는 점이 초보가 처음 걸려 넘어지는 자리입니다.
왜: 문자열 더미가 탐색 가능한 구조로 변하는 순간을 확인하는 의식입니다. 이 한 줄 뒤로 모든 것이 쉬워집니다.
3-2. find와 find_all — 하나와 전부
입력
h1 = soup.find("h1")
print("첫 h1:", h1.text)
links = soup.find_all("a")
print("링크 개수:", len(links))
for a in links:
print(a.text.strip(), "->", a.get("href"))
출력 (2026-09-09 실측):
첫 h1: 오늘의 명언
링크 개수: 4
life -> /tag/life
wisdom -> /tag/wisdom
design -> /tag/design
wisdom -> /tag/wisdom
읽는 법: find는 첫 번째 하나, find_all은 전부를 목록으로 돌려줍니다. 목록이니 len()으로 개수를 세고 반복문으로 돌 수 있습니다. a.get("href")는 a 태그의 href 속성, 즉 링크의 목적지입니다. lab.py의 명언 페이지에 붙어 있던 태그 링크들이 전부 잡혔습니다.
왜: "페이지의 모든 링크 뽑기"는 크롤링의 국민 예제이자, 사이트 지도를 그리는 보안 정찰의 첫 단계이기도 합니다.
3-3. CSS 선택자로 정확하게 저격하기
우리 서버의 명언 하나는 이런 구조였습니다.
<div class="quote">
<span class="text">가장 큰 위험은 위험 없는 삶이다.</span>
<small class="author">알레르 허브</small>
<div class="tags"><a class="tag" href="/tag/life">life</a></div>
</div>
입력
quotes = soup.select(".quote")
print("명언 개수:", len(quotes))
for q in quotes:
text = q.select_one(".text").text
author = q.select_one(".author").text
print(author, ":", text[:20], "...")
출력 (2026-09-09 실측):
명언 개수: 3
알레르 허브 : 가장 큰 위험은 위험 없는 삶이다. ...
속담 : 백 번 듣는 것이 한 번 보는 것만 ...
생텍쥐페리 : 완벽함은 아무것도 더할 것이 없을 때 ...
읽는 법: .quote라는 class 선택자로 명언 상자 세 개를 통째로 가져온 뒤, 각 상자 안에서 다시 .text와 .author를 저격했습니다. "상자를 먼저 잡고, 그 안에서 부품을 꺼내는" 이 중첩 탐색이 실전 크롤링의 표준 패턴입니다. select의 결과를 다시 select_one의 출발점으로 쓸 수 있다는 것이 포인트입니다.
왜: 페이지 전체가 아니라 "내가 원하는 그 부분"만 정확히 집어내는 훈련입니다. 선택자가 정확할수록 수집물이 깨끗합니다.
3-4. 예측해 보기 — 없는 것을 찾으면
예측입니다. 페이지에 없는 태그, 예컨대 soup.find("marquee")(옛날 태그)를 찾으면 어떻게 될까요? ① 오류 발생 ② 빈 문자열 ③ None 반환. 그리고 그 결과에 곧바로 .text를 붙이면요?
입력
nothing = soup.find("marquee")
print("find 결과:", nothing)
print("find_all 결과:", soup.find_all("marquee"))
print(nothing.text)
출력 (2026-09-09 실측):
find 결과: None
find_all 결과: []
AttributeError: 'NoneType' object has no attribute 'text'
읽는 법: 없는 것을 find하면 조용히 None(없음 객체)이 돌아오고, None에 .text를 붙이는 순간에야 오류가 터집니다. find_all은 대신 빈 목록을 주므로 오류가 없습니다. 크롤링 오류의 절반이 바로 이 None에서 나옵니다 — 페이지 구조가 예상과 다를 때 find는 소리 없이 None을 줍니다.
왜: 그래서 .text 전에 "찾았는지" 확인하는 습관이 생존 장치입니다. 예측은 맞았나요?
3-5. 수집물을 표로 정리하기 — 사전들의 목록
뽑아낸 데이터를 출력만 하고 버리면 아깝습니다. 저장과 분석이 쉬운 형태로 정리합니다.
입력
rows = []
for q in soup.select(".quote"):
text_el = q.select_one(".text")
author_el = q.select_one(".author")
if text_el is None or author_el is None:
print("건너뜀")
continue
tags = [t.text for t in q.select(".tag")]
rows.append({"저자": author_el.text, "명언": text_el.text, "태그": tags})
print(len(rows), "건 수집")
print(rows[0])
print(rows[2])
출력 (2026-09-09 실측):
3 건 수집
{'저자': '알레르 허브', '명언': '가장 큰 위험은 위험 없는 삶이다.', '태그': ['life']}
{'저자': '생텍쥐페리', '명언': '완벽함은 아무것도 더할 것이 없을 때가 아니라, 뺄 것이 없을 때다.', '태그': ['design', 'wisdom']}
읽는 법: 한 건의 수집물을 사전 하나로 만들고(이름표: 값), 목록에 차곡차곡 쌓았습니다. None 체크(if … continue)가 함께 들어간 것을 보세요 — 3-4의 교훈이 바로 코드가 됐습니다. 태그처럼 한 상자에 여러 개인 것은 목록으로 모았습니다.
왜: "뽑는 것"과 "쌓는 것"을 분리하는 훈련입니다. 화면 출력은 사람용이고, 이 사전 목록은 프로그램용입니다. 이 구조는 나중에 CSV 파일로 저장할 때 그대로 표의 행이 됩니다.
3-6. 예측해 보기 — soup은 JS를 실행할까
마지막 예측입니다. 만약 페이지의 데이터가 HTML이 아니라 JavaScript가 나중에 그려 넣는 것이라면, requests + BeautifulSoup으로 수집될까요? ① 된다 ② 안 된다.
직접 확인하는 법: lab.py의 QUOTES_PAGE 맨 아래 <script>document.body.innerHTML += "<p>JS가 그린 글자</p>";</script>를 추가해 서버를 재시작하고, 브라우저에서는 보이는 그 글자를 soup.find("p")로 찾아 보세요. soup에는 잡히지 않습니다.
읽는 법: requests는 HTML 원문 문자열만 가져옵니다. JS를 실행하는 것은 브라우저이지 requests가 아닙니다. 그래서 "브라우저에서는 보이는데 soup에는 없는" 데이터는 JS가 나중에 그린 것입니다.
왜: 이런 사이트를 만나면 두 갈래 길이 있습니다. 개발자 도구 Network 탭에서 그 데이터가 별도 요청(JSON)으로 오는지 보고 그 주소를 직접 요청하거나(정석), 브라우저 자동화 도구를 쓰거나(무거운 방법). 원인을 아는 것이 반 이상의 해결입니다.
4. 미션과 연습문제
미션 — 명언 수집기 완성
quotes.py를 만들어 다음을 수행하세요 (lab.py 서버 필요).
- 127.0.0.1:8010의 첫 페이지에서 명언·저자·태그를 수집해 "1. 저자 — 명언 (태그들)" 형식으로 출력합니다
- 어느 단계에서든 요소를 못 찾으면 프로그램이 죽지 않고 "건너뜀"을 출력하게 합니다 (None 체크)
- 수집 건수를 마지막에 출력하고, 그 수가 서버 페이지의 실제 명언 수와 같은지 눈으로 검증합니다
- 스크립트 맨 위 주석에 "수집 대상: 내 연습 서버. 남의 사이트라면 robots.txt 확인과 time.sleep이 먼저"라는 다짐을 적습니다
- (도전) 수집한 사전 목록을
import csv로 quotes.csv 파일에 저장해 봅니다 — 사전 목록이 표의 행이 되는 순간입니다
연습문제
문제 1. soup.find("h1")과 soup.find_all("h1")의 반환값 차이를 말하고, 후자가 왜 오류 없이 안전한지 설명해 보세요.
문제 2. soup.select(".quote")에서 점(.)의 의미와, #menu, div.quote는 각각 무엇을 찾는지 말해 보세요.
문제 3. 3-4에서 find는 조용히 None을 주고 오류는 .text에서 터졌습니다. 이 "조용함"이 왜 디버깅을 어렵게 만드는지 설명해 보세요.
문제 4. requests + BeautifulSoup으로 수집되지 않는 데이터는 어떤 데이터이며, 그때 확인할 두 가지 길을 말해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
# 수집 대상: 내 연습 서버. 남의 사이트라면 robots.txt 확인과 time.sleep이 먼저
import csv
import requests
from bs4 import BeautifulSoup
r = requests.get("http://127.0.0.1:8010/", timeout=3)
soup = BeautifulSoup(r.text, "html.parser")
rows = []
for q in soup.select(".quote"):
text_el = q.select_one(".text")
author_el = q.select_one(".author")
if text_el is None or author_el is None:
print("건너뜀")
continue
tags = [t.text for t in q.select(".tag")]
rows.append({"저자": author_el.text.strip(),
"명언": text_el.text.strip(),
"태그": tags})
for i, row in enumerate(rows, 1):
print(f"{i}. {row['저자']} — {row['명언']} ({', '.join(row['태그'])})")
print("총", len(rows), "건 수집")
with open("quotes.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["저자", "명언", "태그"])
writer.writeheader()
writer.writerows(rows)
실행 결과 (2026-09-09 실측):
1. 알레르 허브 — 가장 큰 위험은 위험 없는 삶이다. (life)
2. 속담 — 백 번 듣는 것이 한 번 보는 것만 못하다. (wisdom)
3. 생텍쥐페리 — 완벽함은 아무것도 더할 것이 없을 때가 아니라, 뺄 것이 없을 때다. (design, wisdom)
총 3 건 수집
검증하는 법: ① 출력 건수(3)가 lab.py의 QUOTES_PAGE에 있는 div.quote 개수와 같은가. ② 서버를 끄고 실행하면 requests의 ConnectionError가 나는가 — 이것까지 우아하게 처리하면 금상첨화 (Step 74의 try/except). ③ quotes.csv를 열었을 때 한글이 안 깨지는가 — utf-8-sig는 엑셀에서 한글이 깨지지 않게 하는 인코딩 선택입니다.
연습문제 해답
문제 1 해답. find는 첫 번째 요소 하나(없으면 None), find_all은 전부의 목록(없으면 빈 목록 [])을 돌려줍니다 (2026-09-09 실측). 빈 목록에 대해 반복문이나 len()은 오류 없이 그냥 "0번 도는" 것으로 끝나므로, 없는 경우를 만나도 죽지 않습니다.
문제 2 해답. 점(.)은 "class라는 별명으로 찾는다"는 뜻입니다. #menu는 id가 menu인 요소 하나를, div.quote는 "div 태그 중에서 class가 quote인 것"을 찾습니다 — Step 71의 CSS 선택자 문법이 그대로입니다.
문제 3 해답. 오류가 "진짜 원인(못 찾은 시점)"이 아니라 "그 결과를 쓰는 시점"에서 터지기 때문입니다. 둘 사이에 코드가 길수록 원인 추적이 어렵습니다. 그래서 find 직후 즉시 None 체크를 하는 것이 해법입니다 — 오류를 원인 가까이로 끌어오는 것입니다.
문제 4 해답. JavaScript가 나중에 그려 넣는 데이터입니다 — requests는 HTML 원문만 가져오고 JS를 실행하지 않기 때문입니다 (3-6 실험). 길은 둘: ① Network 탭에서 그 데이터가 별도 JSON 요청으로 오는지 확인해 그 주소를 직접 requests로 요청, ② 진짜 브라우저 실행이 필요하면 브라우저 자동화 도구 사용.
완료 기준 체크리스트
- [ ] BeautifulSoup으로 HTML 문자열을 파싱할 수 있다
- [ ] find와 find_all의 차이(하나/None vs 목록/빈 목록)를 설명할 수 있다
- [ ] CSS 선택자(select, select_one)로 원하는 요소를 저격할 수 있다
- [ ] .text와 .get("href")로 글자와 속성을 꺼낼 수 있다
- [ ] None 체크의 필요성을 설명하고 코드에 적용할 수 있다
- [ ] 수집물을 사전들의 목록으로 정리할 수 있다
- [ ] 미션: 명언 수집기를 완성했다
6. 흔한 실수와 해결
벽 1. import부터 실패한다
증상: ModuleNotFoundError: No module named 'bs4'
원인: beautifulsoup4를 설치하지 않았거나, 다른 파이썬에 설치했습니다. 참고로 패키지 이름(beautifulsoup4)과 불러오는 이름(bs4)이 다른 것도 혼란의 씨앗입니다.
해결: 지금 쓰는 파이썬으로 python -m pip install beautifulsoup4를 실행하세요. 이렇게 하면 "어느 파이썬에 설치됐는지" 문제가 사라집니다.
벽 2. NoneType 오류가 자꾸 난다
증상 (2026-09-09 실측): AttributeError: 'NoneType' object has no attribute 'text'
원인: find/select_one이 못 찾았는데 곧바로 .text를 불렀습니다. 페이지 구조가 예상과 다른 것입니다.
해결: 찾은 직후 if 요소 is None: 건너뛰기 패턴을 습관화하세요. 그리고 실제 HTML 원문(print(r.text))을 눈으로 다시 확인합니다.
벽 3. 분명 화면에 있는 글자인데 수집이 안 된다
증상: 브라우저에서는 보이는 데이터가 soup에서 안 찾아집니다.
원인: 그 데이터가 HTML이 아니라 JS가 나중에 그린 것입니다 (3-6 실험). requests는 원문만 가져옵니다.
해결: Network 탭에서 그 데이터가 별도 요청(JSON)으로 오는지 확인하세요. JSON으로 온다면 그 주소를 직접 요청하는 것이 정답입니다.
벽 4. 선택자가 기대한 만큼 못 가져온다
증상: 3개를 기대했는데 1개만 옵니다 — 또는 그 반대로 잡동사니까지 딸려 옵니다.
원인: class 이름이 비슷한 다른 요소에 섞여 있거나, 선택자가 너무 좁거나 넓습니다.
해결: 선택자를 한 단계씩 좁혀 가며 실험하세요. "div.quote"처럼 태그와 class를 함께 적으면 정확도가 올라갑니다. print(len(…))으로 개수를 찍어 가며 맞추는 것이 요령입니다.
벽 5. 글자에 이상한 공백이 딸려 온다
증상: 뽑은 텍스트 앞뒤에 줄바꿈과 공백이 잔뜩 붙어 있습니다.
원인: HTML 소스의 들여쓰기가 그대로 딸려 온 것입니다.
해결: .text.strip()처럼 strip(앞뒤 공백 제거)을 붙이는 것이 크롤링의 기본 마무리입니다. 미션 모범 답안에도 들어 있습니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 파싱 | 긴 문자열을 탐색 가능한 나무 구조로 바꾸는 일 |
| BeautifulSoup | HTML 파싱 라이브러리 — 불러올 때의 이름은 bs4 |
| 크롤링/스크래핑 | 페이지를 돌아다니는 행위 / 데이터를 뜯어내는 행위 |
| None | find가 못 찾았을 때의 조용한 대답 — .text 전에 확인 필수 |
| robots.txt | 사이트 문 앞의 수집 규칙 안내문 — 수집가의 첫 확인물 |
| 중첩 탐색 | 상자(.quote)를 먼저 잡고 그 안에서 부품(.text)을 꺼내는 표준 패턴 |
오늘의 함수
| 함수 | 하는 일 |
|---|---|
BeautifulSoup(html, "html.parser") |
HTML 문자열 → 탐색 가능한 soup |
soup.find("태그") |
첫 번째 하나 (없으면 None) |
soup.find_all("태그") |
전부 목록 (없으면 []) |
soup.select("선택자") |
CSS 선택자로 전부 |
soup.select_one("선택자") |
CSS 선택자로 첫 하나 |
요소.text |
태그 안의 글자 |
요소.get("href") |
속성 값 꺼내기 |
요소.text.strip() |
앞뒤 공백 제거까지 |
명령어보다 중요한 감각
오늘의 핵심 감각은 "뽑기 전에 구조를 본다"는 것입니다. 성공적인 크롤링의 절차는 항상 같습니다 — ① 원문 확인(print(r.text)) ② 구조 파악(어느 태그, 어느 class인가) ③ 선택자로 저격 ④ None 체크 ⑤ 사전 목록으로 정리. 그리고 기억하세요 — 사이트가 정식으로 데이터를 내어 주는 창구(API)가 있다면 크롤링보다 그것이 정석입니다. 정문이 열려 있는데 담을 넘을 이유가 없습니다. 보안과의 연결: "수집 → 구조화 → 감시"는 위협 정보 수집, 유출 모니터링 등 보안 실무의 일상 패턴이며, 그 힘이 큰 만큼 예절(robots.txt 확인, time.sleep, 허가된 대상)이 자격 증명입니다. 여러분은 이제 웹에서 데이터를 "얻어 오는 손(requests)"과 "뽑아내는 손(BeautifulSoup)"을 모두 갖췄습니다.
전부 체크되면 Step 75 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.