
1. 파이썬 가상환경 설정
- 프로젝트 폴더를 만들고 해당 폴더로 이동한다.
mkdir crawling_project
cd crawling_project
- 현재 폴더에
.venv이름으로 가상환경을 생성한다.
python -m venv .venv
- 가상환경을 활성화한다.
Windows (cmd/PowerShell):
.venv\Scripts\activate
Mac/Linux (bash):
source .venv/bin/activate
가상환경이 활성화되면 터미널의 프롬프트 앞에 (.venv)가 표시된다. 이후 설치하는 모든 패키지는 이 가상환경 안에만 설치된다.
2. 필요한 패키지 설치
- 다음 명령어를 실행하여 크롤링에 필요한 패키지들을 설치한다.
pip install requests beautifulsoup4 lxml pandas
| 패키지 | 설명 |
|---|---|
requests |
웹 서버에 HTTP 요청을 보내고 응답(HTML)을 받아오는 라이브러리다. |
beautifulsoup4 |
받아온 HTML을 파싱(분석)하여 원하는 태그를 쉽게 찾아주는 라이브러리다. |
lxml |
BeautifulSoup이 사용하는 빠르고 안정적인 HTML 파서다. |
pandas |
데이터프레임 형식으로 CSV 파일을 읽고 데이터를 분석하는 라이브러리다. |
3. BeautifulSoup 기초
from bs4 import BeautifulSoup
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | from bs4 import BeautifulSoup |
bs4 라이브러리에서 BeautifulSoup 클래스를 가져온다. HTML/XML을 파싱(분석)하는 도구다. |
html_doc = """
<html><head><title>MangoTitle</title></head>
<body>
<p class="title">Mango</p>
<p class="story">Once upon a time there were three little sisters;
and their names were
<a href="http://example.com/elsie" class="sister" id="link1">Elsie</a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a>
and <a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>
<p class="story">...</p>
"""
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | html_doc |
파싱할 HTML 문서를 담는 변수다. |
| 2-13 | """...""" |
삼중 따옴표로 여러 줄을 하나의 문자열로 저장한다. |
| 3 | <title>MangoTitle</title> |
브라우저 탭에 표시되는 페이지 제목 태그다. |
| 5 | <p class="title"> |
class 속성이 "title"인 단락(<p>) 태그다. |
| 9 | <a href="..." class="sister" id="link1"> |
링크 태그다. href는 이동할 주소, class는 분류명, id는 고유 식별자다. |
soup = BeautifulSoup(html_doc, 'lxml')
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | soup |
파싱된 HTML 트리 전체를 담는 객체다. 이후 모든 탐색은 이 객체를 통해 한다. |
| 1 | BeautifulSoup(html_doc, 'lxml') |
html_doc 문자열을 lxml 파서로 분석해 탐색 가능한 객체를 만든다. |
| 1 | 'lxml' |
HTML을 해석하는 파서의 종류. 빠르고 안정적이다. (pip install lxml 필요) |
print("soup.body.p의 결과 : ", soup.body.p)
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | soup.body |
<body> 태그에 접근한다. |
| 1 | soup.body.p |
<body> 안의 첫 번째 <p> 태그를 반환한다. |
| 1 | print(...) |
결과를 화면에 출력한다. |
print("soup.a['href']의 결과 : ", soup.a['href'])
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | soup.a |
문서 전체에서 첫 번째 <a> 태그에 접근한다. |
| 1 | ['href'] |
태그의 속성값을 딕셔너리처럼 꺾쇠 괄호로 꺼낸다. href 속성의 URL을 반환한다. |
print("soup.title.name의 결과 : ", soup.title.name)
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | soup.title |
<title> 태그에 접근한다. |
| 1 | .name |
태그명 문자열을 반환한다. |
print("soup.title.string의 결과 : ", soup.title.string)
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | .string |
태그 안의 텍스트 내용을 반환한다. 반환 타입은 NavigableString(문자열과 동일하게 사용 가능)이다. |
print("soup.contents의 결과 : ", soup.contents)
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | soup.contents |
soup 객체의 직접 자식 요소들을 리스트로 반환한다. |
print("soup.find()의 결과 : ", soup.find('a', attrs={'class' : 'sister'}))
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | find() |
조건에 맞는 태그를 딱 하나만 찾아서 반환한다. 여러 개여도 첫 번째만 반환한다. |
| 1 | 'a' |
찾을 태그 이름. <a> 태그를 찾겠다는 의미다. |
| 1 | attrs={'class': 'sister'} |
속성 조건. class가 "sister"인 태그만 찾는다. class는 파이썬 예약어라 attrs 딕셔너리로 전달한다. |
print("soup.find_all()의 결과 : ", soup.find_all('a', limit=2))
| 행 | 코드 | 설명 |
|---|---|---|
| 1 | find_all() |
조건에 맞는 태그를 모두 찾아 리스트로 반환한다. |
| 1 | 'a' |
<a> 태그를 모두 찾겠다는 의미다. |
| 1 | limit=2 |
최대 몇 개까지 찾을지 제한한다. 여기서는 2개만 반환한다. |
find() vs find_all() 비교
find() |
find_all() |
|
|---|---|---|
| 반환값 | 태그 1개 (없으면 None) |
태그 리스트 (없으면 빈 리스트 []) |
| 용도 | 첫 번째 결과만 필요할 때 | 모든 결과를 가져올 때 |
limit 옵션 |
없음 | 있음 (개수 제한) |
4. 박스오피스 순위 크롤링
앞에서 익힌 find() 와 select() 를 실제 사이트에 써 본다. 대상은 영화진흥위원회가 운영하는 영화관 입장권 통합전산망(KOBIS) 의 일별 박스오피스다.
왜 네이버 영화가 아닌가. 예전 교안은 네이버 영화의 관람평을 긁었다. 그런데 네이버가 영화 서비스를 접으면서 그 주소들이 전부 통합검색으로 넘어갔고, 파이썬으로 요청하면 차단(403)된다. 실습 대상이 사라진 것이다.
크롤링을 배울 때 반드시 같이 알아야 하는 사실이다. 크롤러는 남의 사이트 구조에 얹혀 사는 프로그램이라, 그 사이트가 바뀌면 그날로 멈춘다. 그래서 대상은 오래 유지될 곳으로 고르고, 코드는 고치기 쉽게 짜야 한다.
4.1. 크롤링해도 되는지부터 확인한다
남의 서버에 요청을 보내는 일이므로 허락 여부를 먼저 확인한다. 사이트는 robots.txt 라는 파일에 “어디까지 긁어도 되는지”를 적어 둔다. 주소 뒤에 /robots.txt 를 붙여 열어 보면 된다.
https://www.kobis.or.kr/robots.txt
KOBIS 의 응답은 다음과 같다.
User-agent: *
allow: /
User-agent: * 는 “모든 프로그램에게”라는 뜻이고 allow: / 는 “전부 허용”이라는 뜻이다. 즉 이 사이트는 크롤링을 막지 않는다. 반대로 Disallow: / 가 적혀 있으면 긁지 않는 것이 맞다.
허용된 곳이라도 짧은 시간에 많이 요청하면 안 된다. 서버 입장에서는 공격과 구분이 안 된다. 이 실습은 요청을 딱 한 번만 보낸다 — 한 페이지에 8일치가 다 들어 있기 때문이다.
4.2. 표 한 줄을 뜯어본다
브라우저에서 일별 박스오피스 를 열고 표 위에서 마우스 오른쪽 → 검사를 누르면 구조가 보인다. 표는 <table class="tbl_comm"> 이고, 한 편이 <tr> 한 줄, 각 항목이 <td> 한 칸이다.
import requests
from bs4 import BeautifulSoup
URL = "https://www.kobis.or.kr/kobis/business/stat/boxs/findDailyBoxOfficeList.do"
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
params = {
"loadEnd": "0",
"searchType": "search",
"sSearchFrom": "20260819",
"sSearchTo": "20260819",
}
response = requests.get(URL, params=params, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "lxml")
table = soup.select_one("table.tbl_comm")
first_row = table.select_one("tbody tr")
cells = first_row.select("td")
print("칸 개수:", len(cells))
print("1번 칸(순위):", cells[0].get_text(strip=True))
print("2번 칸 통째로:", cells[1].get_text(strip=True))
print("2번 칸 안의 a:", cells[1].select_one("a").get_text(strip=True))
실행 결과다.
칸 개수: 12
1번 칸(순위): 1
2번 칸 통째로: 오디세이동일
2번 칸 안의 a: 오디세이
여기서 한 가지를 배운다. 영화명 칸을 get_text() 로 통째로 읽으면 오디세이동일 처럼 이상한 글자가 붙는다. 그 칸 안에 순위 변동 표시(동일·상승·하락)가 같이 들어 있기 때문이다. 제목만 필요하면 안쪽의 <a> 를 집어야 한다.
4.3. 하루치 10편을 모두 읽는다
한 줄을 읽을 수 있으면 나머지는 반복문이다.
import requests
from bs4 import BeautifulSoup
URL = "https://www.kobis.or.kr/kobis/business/stat/boxs/findDailyBoxOfficeList.do"
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
params = {"loadEnd": "0", "searchType": "search"}
response = requests.get(URL, params=params, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "lxml")
table = soup.select_one("table.tbl_comm")
day = table.find_previous("h4").get_text(strip=True)
print(day)
print("-" * 54)
for row in table.select("tbody tr"):
cells = row.select("td")
link = cells[1].select_one("a")
if link is None:
continue
rank = cells[0].get_text(strip=True)
title = link.get_text(strip=True)
open_date = cells[2].get_text(strip=True)
audience = cells[7].get_text(strip=True)
print(f"{rank:>2}위 {title:<24} 개봉 {open_date} 관객 {audience}명")
실행 결과다(날짜에 따라 내용은 달라진다).
2026년 08월 20일(목)
------------------------------------------------------
1위 오디세이 개봉 2026-08-05 관객 188,671명
2위 스파이더맨: 브랜드 뉴 데이 개봉 2026-07-29 관객 55,282명
3위 인시디어스: 그들이 넘어왔다 개봉 2026-08-20 관객 21,976명
4위 명탐정 코난: 하이웨이의 타천사 개봉 2026-08-12 관객 9,850명
5위 오케이 마담2 개봉 2026-08-12 관객 9,623명
6위 사랑의 하츄핑: 고래보석의 전설 개봉 2026-08-05 관객 5,236명
7위 호프 개봉 2026-07-15 관객 3,820명
8위 워페어 개봉 2026-08-19 관객 2,225명
9위 마루 밑 아리에티 개봉 2010-09-09 관객 1,873명
10위 청년 조용기 개봉 2026-09-11 관객 1,727명
select_one("table.tbl_comm") 은 첫 번째 표만 가져온다. 이 페이지에는 같은 클래스를 쓴 표가 여러 개 있어서, 전부 가져오면 엉뚱한 자료가 섞인다. 표가 어느 날짜인지는 표 바로 앞의 <h4> 에 적혀 있어서 find_previous("h4") 로 읽는다.
4.4. 8일치를 모아 CSV 로 저장한다
이 페이지는 한 번 요청으로 최근 8일치를 내려 준다. 표 하나가 하루다. 다만 같은 클래스를 쓰면서 화면에는 안 보이는 표가 섞여 있으므로, caption(표 제목)으로 걸러야 한다.
import csv
import re
import requests
from bs4 import BeautifulSoup
URL = "https://www.kobis.or.kr/kobis/business/stat/boxs/findDailyBoxOfficeList.do"
OUTPUT = "boxoffice.csv"
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
}
response = requests.get(URL, params={"loadEnd": "0", "searchType": "search"}, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "lxml")
def to_number(text):
"""'1,831' 처럼 쉼표가 섞인 문자열을 정수로 바꾼다. 숫자가 없으면 0."""
digits = re.sub(r"[^0-9]", "", text)
return int(digits) if digits else 0
records = []
for table in soup.select("table.tbl_comm"):
caption = table.caption
if caption is None or "일별 박스오피스" not in caption.get_text():
continue
heading = table.find_previous("h4")
day = heading.get_text(strip=True) if heading else ""
for row in table.select("tbody tr"):
cells = row.select("td")
if len(cells) < 12:
continue
link = cells[1].select_one("a")
if link is None:
continue
records.append(
{
"날짜": day,
"순위": to_number(cells[0].get_text()),
"영화명": link.get_text(strip=True),
"개봉일": cells[2].get_text(strip=True),
"관객수": to_number(cells[7].get_text()),
"누적관객수": to_number(cells[9].get_text()),
"스크린수": to_number(cells[10].get_text()),
}
)
print(f"{len(records)}행을 모았다.")
with open(OUTPUT, "w", newline="", encoding="utf-8-sig") as fp:
writer = csv.DictWriter(fp, fieldnames=list(records[0].keys()))
writer.writeheader()
writer.writerows(records)
print(f"{OUTPUT} 저장 완료")
실행 결과다.
80행을 모았다.
boxoffice.csv 저장 완료
encoding="utf-8-sig" 로 저장하는 이유가 있다. 그냥 utf-8 로 쓰면 엑셀이 한글을 깨진 글자로 연다. -sig 는 파일 맨 앞에 “이건 UTF-8 이다”라는 표식을 넣어 준다.
5. 실행 결과
만들어진 boxoffice.csv 를 엑셀이나 메모장으로 열면 이렇게 생겼다.
날짜,순위,영화명,개봉일,관객수,누적관객수,스크린수
2026년 08월 20일(목),1,오디세이,2026-08-05,188671,5788171,1831
2026년 08월 20일(목),2,스파이더맨: 브랜드 뉴 데이,2026-07-29,55282,7740867,1056
...
| 항목 | 설명 |
|---|---|
| 날짜 | 그 순위가 집계된 날 |
| 순위 | 1위부터 10위까지 |
| 영화명 | 순위 변동 표시를 뺀 제목 |
| 개봉일 | 극장 개봉일 |
| 관객수 | 그날 하루 관객 수(쉼표를 뗀 숫자) |
| 누적관객수 | 개봉일부터 그날까지 합계 |
| 스크린수 | 그날 상영한 스크린 수 |
숫자에서 쉼표를 떼어 둔 덕분에 바로 계산에 쓸 수 있다. 예를 들어 판다스로 읽어 영화별 관객 수 추이를 그리거나, 개봉 후 며칠째에 순위가 떨어지는지 볼 수 있다.
6. 정리
requests로 페이지를 받아 오고BeautifulSoup으로 원하는 부분만 골라냈다.select_one은 하나,select는 여러 개를 가져온다.- 칸 전체 텍스트에 군더더기가 섞이면 안쪽 태그를 집는다(
cells[1].select_one("a")). - 같은 클래스를 쓴 요소가 여럿이면
caption같은 다른 단서로 걸러 낸다. - 모은 자료는 CSV 로 저장한다. 한글은
utf-8-sig.
더 배워 볼 것
- 판다스로 분석하기 —
pd.read_csv("boxoffice.csv")로 읽어 영화별 관객 추이를 그려 본다. - 공식 API 써 보기 — KOBIS 는 오픈 API 도 제공한다. HTML 을 뜯는 것보다 안정적이다. 사이트 구조가 바뀌어도 API 는 잘 안 바뀐다.
- 자동으로 돌리기 — 매일 한 번 실행해 CSV 에 이어 붙이면 나만의 박스오피스 기록이 쌓인다.
크롤링한 자료를 그대로 다시 공개하거나 팔면 안 된다. 사이트마다 이용약관이 있고, 자료에는 만든 사람의 권리가 있다. 학습과 개인 분석까지가 안전한 범위다.