BeautifulSoup HTML 파싱 정리 | 일반 웹사이트에서 태그와 속성으로 데이터 추출하는 방법

일반 웹사이트 데이터를 수집할 때 핵심은 HTML 문자열을 받는 데 있지 않다.

실제로 중요한 건 개발자도구로 요청 규칙을 먼저 찾고, BeautifulSoup로 태그와 속성을 함께 읽어 원하는 데이터만 정확하게 타겟팅하는 것이다. API처럼 JSON 키값으로 바로 접근할 수 없기 때문에, HTML 파싱은 “어디에 정보가 숨어 있는지 찾는 과정” 자체가 중요하다. 자료에서도 DART 최근공시 페이지를 기준으로 개발자도구 Network에서 요청 URL 규칙을 찾고, HTML을 BeautifulSoup(..., "html.parser")로 파싱한 뒤 find_all()과 속성 접근으로 원하는 값을 뽑는 흐름이 정리되어 있다.

이 글의 포인트

이 글은 API 호출 글이 아니다.

핵심은 명시적으로 JSON/XML을 주지 않는 일반 웹사이트를 어떻게 읽을 것인가다.

즉, 흐름은 이렇게 본다.

  1. 개발자도구 Network에서 요청 규칙 찾기
  2. requests.get()으로 HTML 받기
  3. res.text를 BeautifulSoup(..., "html.parser")에 넣기
  4. 태그 + 속성 기준으로 원하는 블록만 타겟팅하기
  5. .text, .strip(), .get()으로 값 정리하기

일반 웹사이트 수집은 “숨겨진 요청 찾기”부터 시작한다

API는 보통 문서가 있지만, 일반 웹사이트는 그렇지 않다.

그래서 먼저 브라우저 개발자도구 Network 탭에서 어떤 요청이 오가는지 확인해야 한다.

DART 최근공시 예시에서는 자료에 실제로 다음 흐름이 나온다.

  • 개발자도구 Network 확인
  • search.ax 같은 요청 포착
  • selectDate, currentPage 같은 파라미터 확인
  • URL 규칙이 맞는지 브라우저에서 먼저 테스트

즉, 일반 웹사이트 수집은 “HTML 파싱”만이 아니라 요청 규칙을 발견하는 단계까지 포함된다. 자료에서도 일반 사이트는 주소 규칙이 명시적일 수도 있고 아닐 수도 있어서, 개발자도구를 통해 “눈치껏 찾아가며 시도”해야 한다고 설명한다.


DART 최근공시 페이지는 날짜와 페이지 규칙이 보인다

자료 예시에서는 DART 최근공시 페이지를 아래처럼 접근한다.

date = "2026.04.16"
page = "1"

url = f"<https://dart.fss.or.kr/dsac001/mainAll.do?selectDate={date}¤tPage={page}>"
print(url)

이 구조가 중요한 이유는 단순하다.

  • selectDate → 어떤 날짜 공시를 볼지
  • currentPage → 그 날짜의 몇 번째 페이지인지

즉, HTML 페이지라고 해도 내부적으로는 규칙적인 파라미터 요청으로 움직이는 경우가 많다. 자료에서도 selectDate=YYYY.MM.DD, currentPage=숫자 규칙을 먼저 찾고, 이걸 기반으로 원하는 날짜와 페이지를 조합하는 흐름이 나온다.


requests.get()으로 HTML을 받고, res.text를 본다

일반 웹페이지 요청에서는 보통 res.text가 기본이다.

import requests

res = requests.get(url)
print(res)
print(res.text[:300])

자료에서도 requests.get(url)의 응답이 <Response [200]>로 정상 확인되고,

일반적인 사이트는 res.text를 사용하면 된다고 정리되어 있다. 반면 이미지나 동영상은 res.content, JSON 응답은 res.json()이 더 적합하다고 구분한다. 즉, HTML 페이지 수집에서는 res.text가 기본 선택지다.

이 포인트가 중요한 이유는 JSON 글과 안 겹치게 보기 위해서다.

  • JSON API → res.json() 또는 json.loads()
  • HTML 페이지 → res.text 후 BeautifulSoup 파싱

BeautifulSoup는 html.parser로 파싱한다

HTML은 태그 중심 언어라서 문자열 그대로는 다루기 불편하다.

그래서 BeautifulSoup로 파싱해야 한다.

from bs4 import BeautifulSoup

soup = BeautifulSoup(res.text, "html.parser")

자료에서도 XML 시간에는 "xml"을 썼지만, 지금처럼 일반 웹사이트 HTML을 다룰 때는 "html.parser"를 사용한다고 구분해서 설명한다.

즉, 같은 BeautifulSoup라도 파서 지정이 다르다.

  • XML 처리 → "xml"
  • HTML 처리 → "html.parser"

HTML 파싱은 태그만 보면 부족하고 “속성”도 같이 봐야 한다

이게 XML과 HTML의 큰 차이다.

XML은 <movie>, <movieCd>처럼 태그 이름이 비교적 명확해서 태그만으로 접근하는 경우가 많다.

반면 HTML은 div, span, a 같은 태그가 너무 많이 반복된다. 그래서 class, id, title 같은 속성으로 더 좁혀야 한다.

자료에서도 len(soup.find_all("div"))가 61개로 많기 때문에, 그냥 div만 찾으면 의미가 없고 class="headTitle" 같은 속성까지 넣어 타겟팅해야 한다고 설명한다.


태그 + 속성으로 정확히 타겟팅하는 방법

예를 들어 div 태그 중에서도 headTitle 클래스만 찾고 싶다면 이렇게 쓴다.

방법 1. dict 방식

soup.find_all("div", {"class": "headTitle"})

방법 2. class_ 인자 방식

soup.find_all("div", class_="headTitle")

자료에서도 두 방식이 모두 예시로 나온다. HTML은 표준 속성 구조가 있기 때문에, BeautifulSoup에서도 태그만이 아니라 속성을 함께 지정하는 방식이 실전 기본이라고 보면 된다.


페이지 정보는 태그를 찾고, 그 안에서 정규식으로 다듬는다

DART 예시에서 pageInfo 블록을 가져오면 이런 문자열이 나온다.

temp = soup.find_all("div", {"class": "pageInfo"})[0].text
print(temp)
# [1/4] [총 376건]

여기서 중요한 건 두 단계다.

  1. BeautifulSoup로 “어디 블록인지” 찾기
  2. 정규식으로 “정확히 어떤 값인지” 추리기

자료에서도 re.findall(r'\\d+', temp)로 숫자를 추출하고, "376건"에서 "건"을 제거해 총 공시 수를 계산하는 흐름이 나온다. 또 /4] 같은 패턴을 정규식으로 잡아 전체 페이지 수를 읽어내는 예시도 함께 나온다.

즉, HTML 파싱은 보통 BeautifulSoup + 정규식 조합으로 가는 경우가 많다.


실제 데이터는 tbody 아래 tr 단위로 보는 게 안전하다

일반 웹페이지에서 표 데이터를 다룰 때는 보통 row 단위로 가는 게 맞다.

DART 최근공시 페이지도 결국 표 구조이기 때문에, 자료에서는 tbody 아래 tr를 기준으로 각 공시 row를 가져온다.

rows = soup.find("tbody").find_all("tr")
print(len(rows))  # 한 페이지 공시 수

이 방식이 중요한 이유는 11일차 다른 글의 “샘플 단위 수집”이랑도 연결된다.

  • 열 단위로 따로 긁으면 밀릴 수 있음
  • row 단위로 보면 한 공시의 정보가 같이 움직임

즉, HTML도 결국 한 줄(row) = 한 샘플 기준으로 보는 게 안전하다.


첫 번째 공시 row에서 시간 추출하기

자료에서는 첫 번째 tr를 잡고, 그 안의 첫 번째 td에서 제출 시간을 뽑는다.

temp = soup.find("tbody").find_all("tr")[0]
time_text = temp.find_all("td")[0].text
print(time_text)

문제는 .text를 했더니 줄바꿈과 공백이 많이 붙는다는 점이다.

그래서 먼저 strip()을 생각해볼 수 있다.

temp.find_all("td")[0].text.strip()

자료에서도 .strip()은 양쪽 공백 제거에 유용하지만, 중간에 섞인 개행과 탭은 완전히 정리하지 못할 수 있다고 설명한다.


공백 정리는 strip()만으로 안 될 수 있다

HTML에서 .text를 뽑으면 \\n, \\t, \\r 같은 개행/탭 문자가 섞여 나오는 경우가 많다.

이럴 때는 정규식으로 정리하는 편이 더 정확하다.

import re

raw = temp.find_all("td")[0].text
clean_time = re.sub(r'\\n|\\t|\\r|\\s', "", raw)
print(clean_time)

자료에서도 실제 예시 문자열에 대해 re.sub(r'\\n|\\t|\\r|\\s', "", t_str1)을 적용해서 "18:22" 형태로 정리하는 흐름이 나온다.

즉,

  • 간단하면 .strip()
  • 중간 공백까지 복잡하면 re.sub()

이렇게 구분하면 된다.


회사명은 태그 안의 a 텍스트로 뽑는다

첫 번째 공시 row에서 회사명은 두 번째 td 안쪽의 a 태그에 들어 있다.

company_name = temp.find_all("td")[1].find("a").text.strip()
print(company_name)

자료에서도 이 방식으로 "셀루메드"를 추출하는 예시가 나온다.

이 포인트가 중요한 이유는 HTML 정보가 단순히 td.text 한 번으로 정리되지 않는다는 데 있다.

실전에서는 보통 원하는 태그까지 한 번 더 들어가야 정확한 값이 나온다.


HTML은 태그 값 말고 속성값에도 정보가 있다

이게 JSON/XML과 가장 크게 다른 부분 중 하나다.

HTML은 정보가 텍스트 노드에만 있는 게 아니라, 속성값 안에도 들어 있다.

자료에서는 시장 구분 정보가 span 태그의 title 속성에 들어 있고, 이 값을 get("title")로 추출하는 예시가 나온다.

market_name = temp.find_all("td")[1].find_all("span")[1].get("title")
print(market_name)

즉, HTML 파싱에서는 아래 둘 다 봐야 한다.

  • 태그 사이의 텍스트
  • 태그의 속성값

이걸 구분 못 하면 필요한 정보를 놓치기 쉽다.


HTML 파싱의 핵심은 “타이트한 타겟팅”이다

자료에서도 이 표현이 반복된다.

HTML은 태그 이름이 너무 많이 반복되기 때문에, 중복된 상황을 피해서 정확하게 타겟팅해야 한다고 설명한다.

예를 들어 이런 차이가 있다.

  • soup.find_all("div") → 너무 넓음
  • soup.find_all("div", class_="headTitle") → 훨씬 정확함
  • soup.find("tbody").find_all("tr") → 표 row 단위 접근
  • row.find_all("td")[1].find("a") → 회사명 타겟팅

즉, HTML 파싱은 무조건 넓게 찾는 게 아니라 점점 좁혀 들어가는 방식으로 써야 한다.


구현 관점에서 기억할 포인트

1. 일반 웹사이트 수집은 URL 규칙부터 찾아야 한다

API처럼 문서가 없으니, 개발자도구 Network에서 selectDate, currentPage 같은 파라미터를 먼저 찾는 게 중요하다.

2. HTML은 res.text + html.parser 조합이 기본이다

JSON처럼 res.json()이 아니라 BeautifulSoup(res.text, "html.parser")가 기본 흐름이다.

3. HTML은 태그만으로 부족하고 속성까지 봐야 한다

class, title 같은 속성에 실제 필요한 정보가 들어 있는 경우가 많다.

4. row 단위 수집이 안전하다

tbody > tr 기준으로 한 공시씩 보는 게 가장 안정적이다. 열 단위로 따로 긁는 방식보다 데이터가 덜 밀린다.

5. 공백 정리는 꼭 따로 생각해야 한다

.text만 믿지 말고 .strip() 또는 re.sub()까지 같이 써야 깔끔한 값이 나온다.


일반 웹사이트 HTML 파싱은 BeautifulSoup로 태그를 찾는 것보다, 태그와 속성을 함께 좁혀가며 정확한 블록에서 텍스트와 속성값을 추출하는 과정이 핵심이다.