Devin.KR

저장된 웹 페이지에서 정보 뽑기 - html.parser 로 표 읽기

개발자KR 조회 0

이 장에서 배우는 것

웹 페이지를 파일로 저장해 두었는데 필요한 표를 다시 손으로 옮기는 일이 있다. 표 안에는 줄바꿈, 강조 표시, 링크가 섞여 있다. 화면에서는 한 칸으로 보이지만 파일에서는 여러 태그로 나뉘므로, 보이는 모양만 떠올리고 문자열을 자르면 값이 빠지기 쉽다. 이 장에서는 저장된 HTML에서 행사 신청 현황을 읽어 일정한 형식의 보고서로 바꾸는 프로그램을 만든다.

앞 장에서 로그의 각 줄을 해석했다면, 여기서는 시작 태그와 종료 태그 사이의 관계를 따라간다. Python 표준 라이브러리의 HTMLParser를 이용하고, 추출 뒤에는 열 이름과 값의 의미를 검사한다. 결과가 나왔다는 사실과 결과가 맞다는 판단을 구분하는 것이 핵심이다.

  • html.parser가 전달하는 시작 태그, 본문 조각, 종료 태그를 연결한다.
  • 대상 표를 골라 셀의 글자와 링크 주소를 함께 추출한다.
  • 저장된 파일만 처리하는 범위와 자료 이용 시 확인할 사항을 구분한다.
  • 구조 변화와 잘못된 값을 검사해 조용한 오출력을 막는다.
  • 고정된 예제와 실패 사례를 실행하고 수정 차이를 검토한다.

문제 상황

작은 모임의 운영 담당자가 행사 안내 페이지를 저장했다. 페이지에는 안내 문구, 배치용 표, 실제 신청 현황 표가 함께 있다. 담당자는 행사 이름, 정원, 신청 인원, 상세 안내 주소를 추출하고 남은 자리를 계산하려 한다. 브라우저에서 복사하면 링크 주소가 빠지고, 여러 표를 한꺼번에 읽으면 안내용 표까지 섞인다.

이번 프로그램의 입력 계약은 명확하다. 대상은 id 속성값이 events인 표 하나다. 첫 행에는 행사, 정원, 신청, 안내라는 네 열이 이 순서대로 있어야 한다. 각 데이터 행은 네 칸이며, 안내 칸에는 링크가 정확히 하나 있어야 한다. 행사 이름은 중복되지 않고 정원과 신청 인원은 0 이상의 정수다. 신청 인원은 정원을 넘지 않는다.

정원 초과를 오류로 다루는 것은 이번 업무의 규칙이다. 대기 신청을 받는 현장이라면 다른 규칙이 필요하다. AI에게 단순히 “표를 읽어 줘”라고 하면 이런 업무상의 조건까지 알아서 정해 버릴 수 있으므로, 먼저 허용하는 입력과 실패 조건을 적는다.

이번 프로그램이 지킬 입력과 출력 계약
항목규칙확인할 결과
입력임시 폴더에 만든 UTF-8 HTML네트워크 없이 파일만 읽는다
표 선택id가 events인 표가 하나없거나 여러 개면 오류다
행과 값정확한 머리글, 네 칸, 정수 인원형식과 업무 규칙을 모두 검사한다
출력행사 이름순 CSV와 합계매번 같은 순서와 내용이다

완성 코드는 예제 HTML을 임시 폴더에 직접 쓰고 다시 읽는다. 실행 위치에 입력 파일을 미리 준비할 필요가 없다. 실제 자료에 적용할 때는 파일을 만드는 부분을 입력 경로 처리로 바꾸면 되지만, 이 장에서는 재현 가능한 한 번의 실행을 먼저 완성한다.

태그를 따라 표와 링크를 모은다

HTMLParser는 문서를 읽으며 사건을 전달한다. 시작 태그에서는 handle_starttag, 글자 조각에서는 handle_data, 종료 태그에서는 handle_endtag가 호출된다. 우리는 현재 대상 표 안인지, 행 안인지, 셀 안인지 기억한다. 이렇게 진행 상황을 저장하는 값을 상태(state)라고 한다.

셀의 글자는 한 번에 오지 않을 수 있다. 예를 들어 행사 이름 가운데 일부가 strong 태그로 강조되어 있으면 앞부분과 강조 부분, 뒷부분이 별도 조각으로 전달된다. 매번 새 값으로 덮어쓰지 않고 목록에 추가한 뒤 셀이 끝날 때 합쳐야 한다. 이번 프로그램은 조각 사이에 임의의 공백을 끼우지 않는다. 원래 있던 공백은 유지하고, br 태그를 만났을 때만 공백 하나를 추가한다.

합친 문자열에는 줄바꿈이나 들여쓰기 공백이 남는다. split과 join을 이용해 연속된 공백을 하나로 줄인다. 이 방식은 행사 이름처럼 공백의 정확한 개수가 중요하지 않은 자료에 맞는다. 코드 예시나 시를 추출한다면 공백을 보존하는 별도 정책이 필요하다.

셀 안의 여러 글자 조각은 모아서 합치고 링크 주소는 속성에서 따로 얻는다

링크의 표시 글자와 주소도 별개다. “상세”라는 글자는 셀 본문으로 들어오고, 주소는 a 태그의 href 속성에서 얻는다. 주소를 셀 문자열에서 찾아내려 하면 화면에 드러나지 않은 href를 놓친다. 따라서 셀마다 글자 조각 목록과 링크 목록을 따로 둔다.

HTMLParser의 문자 참조 변환을 켜면 & 같은 표기는 실제 & 문자로 전달된다. 이 변환을 거친 셀을 다시 수동 치환하면 의도하지 않은 이중 변환이 생길 수 있다. 이번 코드는 파서가 변환한 결과를 그대로 사용한다. 기능의 사실 확인은 Python의 html.parser 공식 문서를 참고할 수 있다.

이 프로그램은 브라우저와 같은 문서 복구기를 만들지 않는다. 대상 표에서는 행과 셀의 종료 태그를 명시해야 한다. 중첩 표, 셀 병합, 셀 안의 script와 style도 받지 않는다. HTML에서 허용될 수 있는 표현이라도 이번 추출기의 지원 범위를 넘으면 오류로 알린다. 지원하지 않는 구조를 어설프게 평탄화하는 것보다 입력 계약을 좁혀 두는 편이 결과를 검토하기 쉽다.

요청문을 검사 기준으로 바꾼다

AI에게는 다음과 같이 요청한다. 요청문에는 구현 도구뿐 아니라 어떤 상황에서 실패해야 하는지도 포함한다.

Python 3.12 이상 표준 라이브러리만 사용해 main.py 하나로 실행되는 프로그램을 작성하라. tempfile로 임시 폴더를 만들고 UTF-8 예제 HTML을 쓴 뒤 읽어라. 네트워크 호출은 하지 마라.

HTMLParser로 id가 events인 표 하나를 찾아라. 머리글은 행사, 정원, 신청, 안내 순서다. 데이터 행마다 행사 이름과 인원을 읽고 안내 칸의 링크 주소를 얻어라. 상대 주소는 고정 기준 주소 https://example.invalid/archive/index.html을 기준으로 결합하라. 결합 결과는 호스트가 있는 HTTPS 주소만 허용하라.

셀 안의 강조 태그와 br, 문자 참조를 처리하라. 대상 표 누락과 중복, 열 수와 머리글 변경, 잘못된 인원, 행사 이름 중복, 중첩 표와 셀 병합을 오류로 처리하라. 행사 이름순 CSV와 인원 합계를 출력하라. 정상 입력 검사와 실패 사례 검사도 같은 파일에 넣어라.

1차 결과는 정상 예제에서는 그럴듯하게 동작할 수 있다. 다음 표는 이 요청으로 코드를 검토할 때 흔히 드러나는 문제와, 문제를 재현하는 방법이다. AI의 설명만으로 통과시키지 않고 입력을 바꿔 확인한다.

AI 1차 결과에서 발견한 문제와 수정 기준
문제발견 방법고친 방향
handle_data에서 이전 글자를 덮어쓴다행사 이름 일부를 strong으로 감싼다셀별 조각 목록을 합친다
문서의 첫 번째 표를 고른다앞에 안내용 표를 추가한다대상 속성을 확인하고 개수를 센다
상대 링크를 현재 작업 폴더와 결합한다../detail/a.html을 넣는다파일 경로와 웹 주소를 구분한다
열 수만 맞으면 머리글 변경을 통과시킨다정원 열 이름을 모집으로 바꾼다열 이름과 순서를 함께 비교한다

수정은 한꺼번에 받아들이지 않는다. 먼저 강조 태그가 있는 정상 입력을 통과시키고, 다음으로 열 이름을 바꾼 실패 입력이 거절되는지 확인한다. 코드의 수정 차이(diff)에서는 셀 누적 방식, 대상 표 선택, 주소 결합, 검증 조건이 요청한 범위에서 바뀌었는지 읽는다. 검사를 지워서 성공처럼 보이게 만들거나 예외를 모두 무시하는 변경은 받아들이지 않는다.

검사는 파서가 모은 자료와 업무 데이터 사이에 둔다. “네 칸을 읽었다”는 구조 확인이고, “신청 인원이 정원 이하다”는 의미 확인이다. 둘 중 하나만 있으면 열이 뒤바뀐 자료나 잘못된 인원이 보고서에 들어갈 수 있다.

추출된 셀은 구조와 값 검사를 모두 통과해야 보고서로 출력된다

저장된 파일의 범위와 이용 예절

이 프로그램은 인터넷에서 페이지를 가져오지 않는다. urllib.parse는 문자열 형태의 주소를 나누고 결합하는 데만 사용한다. 만들어진 상세 주소에 접속하지 않으며, HTML 안의 이미지나 외부 문서도 내려받지 않는다. 예제의 example.invalid는 실제 서비스 자료를 가리키기 위한 주소가 아니다.

저장된 파일이라는 이유로 이용 조건이 사라지는 것은 아니다. 실제 업무에서는 자료를 저장하고 추출할 권한이 있는지, 이용 약관이 자동 처리나 재배포를 제한하는지, 개인 정보가 포함되는지 확인한다. 추출한 이름이나 연락처를 AI 대화에 그대로 붙여 넣기보다 가상의 작은 예제로 구조를 설명하는 편이 적절하다.

나중에 수집 기능을 붙인다면 요청 빈도, 접근 제한, robots.txt, 서비스의 이용 조건을 별도로 검토해야 한다. robots.txt는 접근 권한이나 저작권 이용 허락을 대신하지 않는다. 로그인 제한이나 차단을 우회하는 기능도 이 프로그램의 확장 대상으로 삼지 않는다. 이번에는 이미 적법하게 확보한 파일을 읽는 작업에 집중한다.

주소 결합에도 경계가 있다. 예제는 고정된 기준 주소를 사용하고 HTML의 base 요소는 해석하지 않는다. 다른 날짜나 경로에 저장된 자료라면 원래 페이지 주소를 함께 보관해야 상대 링크를 올바르게 복원할 수 있다. 파일을 저장한 로컬 폴더는 원래 페이지의 주소를 대신하지 못한다.

완성 코드

다음 내용을 main.py로 저장한다. 정상 입력 검사 하나와 실패 입력 검사 일곱 개를 실행한 뒤, 같은 추출 함수로 임시 HTML 파일을 처리한다. 실패 입력 검사는 예상한 ValueError가 발생해야 성공이다.

from dataclasses import dataclass, field
from html.parser import HTMLParser
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import urljoin, urlsplit
import csv
import io


BASE_URL = "https://example.invalid/archive/index.html"
HEADERS = ["행사", "정원", "신청", "안내"]

HTML = """<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>행사 현황</title></head>
<body>
<table><tr><td>안내용 표</td></tr></table>
<table id="events">
<thead><tr>
<th>행사</th><th>정원</th><th>신청</th><th>안내</th>
</tr></thead>
<tbody>
<tr>
<td>사진<br>산책</td><td>8</td><td>8</td>
<td><a href="/detail/photo.html">상세</a></td>
</tr>
<tr>
<td>독서 <strong>&amp;</strong> 대화</td><td>12</td><td>9</td>
<td><a href="../detail/book.html?lang=ko&amp;mode=short">상세</a></td>
</tr>
</tbody>
</table>
</body></html>
"""


@dataclass
class Cell:
    tag: str
    parts: list[str] = field(default_factory=list)
    links: list[str] = field(default_factory=list)

    def text(self) -> str:
        return " ".join("".join(self.parts).split())


class EventTableParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.active = False
        self.target_count = 0
        self.rows: list[list[Cell]] = []
        self.row: list[Cell] | None = None
        self.cell: Cell | None = None

    def handle_starttag(self, tag, attrs):
        attributes = dict(attrs)
        if tag == "table":
            if self.active:
                raise ValueError("중첩 표는 지원하지 않는다")
            if attributes.get("id") == "events":
                self.target_count += 1
                if self.target_count != 1:
                    raise ValueError("대상 표가 여러 개다")
                self.active = True
            return

        if not self.active:
            return

        if tag in {"script", "style"}:
            raise ValueError("대상 표의 script와 style은 지원하지 않는다")
        if tag == "tr":
            if self.row is not None:
                raise ValueError("행이 닫히기 전에 새 행이 시작됐다")
            self.row = []
        elif tag in {"td", "th"}:
            if self.row is None or self.cell is not None:
                raise ValueError("셀의 위치가 올바르지 않다")
            if "rowspan" in attributes or "colspan" in attributes:
                raise ValueError("셀 병합은 지원하지 않는다")
            self.cell = Cell(tag)
        elif tag == "br" and self.cell is not None:
            self.cell.parts.append(" ")
        elif tag == "a" and self.cell is not None:
            href = attributes.get("href")
            if href is None or not href.strip():
                raise ValueError("링크 주소가 비어 있다")
            self.cell.links.append(href.strip())

    def handle_startendtag(self, tag, attrs):
        if tag == "br":
            self.handle_starttag(tag, attrs)
        elif self.active:
            raise ValueError("대상 표의 빈 요소는 br만 지원한다")

    def handle_data(self, data):
        if self.cell is not None:
            self.cell.parts.append(data)

    def handle_endtag(self, tag):
        if not self.active:
            return
        if tag in {"td", "th"}:
            if self.cell is None or self.cell.tag != tag:
                raise ValueError("셀 종료 태그가 맞지 않는다")
            if self.row is None:
                raise ValueError("셀에 대응하는 행이 없다")
            self.row.append(self.cell)
            self.cell = None
        elif tag == "tr":
            if self.row is None or self.cell is not None:
                raise ValueError("행이 올바르게 닫히지 않았다")
            self.rows.append(self.row)
            self.row = None
        elif tag == "table":
            if self.row is not None or self.cell is not None:
                raise ValueError("표 안에 닫히지 않은 행이나 셀이 있다")
            self.active = False

    def finish(self) -> list[list[Cell]]:
        self.close()
        if self.target_count != 1:
            raise ValueError("대상 표가 없다")
        if self.active:
            raise ValueError("대상 표가 닫히지 않았다")
        return self.rows


@dataclass(frozen=True)
class Event:
    name: str
    capacity: int
    applied: int
    url: str

    @property
    def remaining(self) -> int:
        return self.capacity - self.applied


def read_count(text: str) -> int:
    if not text or any(char not in "0123456789" for char in text):
        raise ValueError("인원은 0 이상의 십진 정수여야 한다")
    return int(text)


def resolve_link(href: str) -> str:
    if any(char.isspace() or ord(char) < 32 for char in href):
        raise ValueError("링크 주소에 공백이나 제어 문자가 있다")
    url = urljoin(BASE_URL, href)
    parts = urlsplit(url)
    if parts.scheme != "https" or not parts.hostname:
        raise ValueError("호스트가 있는 HTTPS 주소만 허용한다")
    if parts.username is not None or parts.password is not None:
        raise ValueError("사용자 정보가 있는 주소는 허용하지 않는다")
    return url


def extract_events(source: str) -> list[Event]:
    parser = EventTableParser()
    parser.feed(source)
    rows = parser.finish()

    if not rows or [cell.text() for cell in rows[0]] != HEADERS:
        raise ValueError("머리글의 이름이나 순서가 바뀌었다")
    if any(cell.tag != "th" for cell in rows[0]):
        raise ValueError("머리글은 th 셀이어야 한다")
    if len(rows) == 1:
        raise ValueError("데이터 행이 없다")

    events = []
    names = set()
    for row_number, row in enumerate(rows[1:], start=2):
        if len(row) != 4 or any(cell.tag != "td" for cell in row):
            raise ValueError(f"{row_number}행의 셀 구성이 잘못됐다")
        name = row[0].text()
        if not name or name in names:
            raise ValueError(f"{row_number}행의 행사 이름이 비었거나 중복됐다")
        if any(cell.links for cell in row[:3]) or len(row[3].links) != 1:
            raise ValueError(f"{row_number}행의 링크 위치나 개수가 잘못됐다")
        capacity = read_count(row[1].text())
        applied = read_count(row[2].text())
        if applied > capacity:
            raise ValueError(f"{row_number}행의 신청 인원이 정원을 넘었다")
        url = resolve_link(row[3].links[0])
        names.add(name)
        events.append(Event(name, capacity, applied, url))

    return sorted(events, key=lambda event: event.name)


def run_checks() -> int:
    expected = [
        Event(
            "독서 & 대화", 12, 9,
            "https://example.invalid/detail/book.html?lang=ko&mode=short",
        ),
        Event("사진 산책", 8, 8, "https://example.invalid/detail/photo.html"),
    ]
    if extract_events(HTML) != expected:
        raise RuntimeError("정상 입력 검사 실패")

    bad_sources = [
        ("대상 누락", HTML.replace('id="events"', 'id="other"')),
        ("머리글 변경", HTML.replace("<th>정원</th>", "<th>모집</th>")),
        ("열 누락", HTML.replace("<td>8</td><td>8</td>", "<td>8</td>")),
        ("정원 초과", HTML.replace("<td>9</td>", "<td>13</td>")),
        (
            "주소 형식",
            HTML.replace('href="/detail/photo.html"', 'href="javascript:alert(1)"'),
        ),
        ("대상 중복", HTML + '<table id="events"></table>'),
        ("중첩 표", HTML.replace("사진<br>산책", "<table></table>")),
    ]
    for label, source in bad_sources:
        try:
            extract_events(source)
        except ValueError:
            continue
        raise RuntimeError(f"실패 입력 검사 미검출: {label}")
    return 1 + len(bad_sources)


def main() -> None:
    checked = run_checks()
    with TemporaryDirectory() as folder:
        path = Path(folder) / "saved.html"
        path.write_text(HTML, encoding="utf-8")
        events = extract_events(path.read_text(encoding="utf-8"))

    output = io.StringIO()
    writer = csv.writer(output, lineterminator="\n")
    writer.writerow(["행사", "정원", "신청", "잔여", "안내"])
    for event in events:
        writer.writerow([
            event.name, event.capacity, event.applied,
            event.remaining, event.url,
        ])

    print(f"검사 통과: {checked}개")
    print(output.getvalue(), end="")
    print(
        f"합계: 정원 {sum(event.capacity for event in events)}, "
        f"신청 {sum(event.applied for event in events)}, "
        f"잔여 {sum(event.remaining for event in events)}"
    )


if __name__ == "__main__":
    main()

줄별 해설

가져오기 구문부터 HTML 상수까지는 실행에 필요한 도구와 고정 입력을 준비한다. Path는 파일 경로를 다루고 TemporaryDirectory는 작업이 끝나면 임시 폴더를 정리한다. 예제 파일의 위치는 실행마다 달라도 출력에는 그 경로를 넣지 않으므로 결과 문자열은 달라지지 않는다.

Cell은 셀 하나의 태그 종류, 글자 조각, 링크를 보관한다. field의 default_factory=list는 셀마다 새 목록을 만든다. text 메서드는 조각을 붙인 다음 공백을 정리한다. 문자 참조 변환으로 얻은 &도 이 단계에서는 일반 문자다.

EventTableParser의 active는 대상 표 안에 있는지를 나타낸다. row와 cell은 각각 작성 중인 행과 셀이다. None은 아직 열리지 않았다는 뜻이다. 빈 목록과 None을 구분하므로 내용이 없는 행도 시작된 행으로 판단할 수 있다. 조건을 단순히 if self.row로 쓰면 빈 행을 열리지 않은 행으로 착각한다.

handle_starttag는 table을 먼저 검사한다. 대상 표 안에서 또 table을 만나면 중첩 표 오류를 낸다. 대상 밖에서는 id를 확인하고, 같은 대상이 다시 나오면 개수 검사로 거절한다. 대상과 무관한 안내 표의 셀은 수집하지 않는다.

tr에서는 새 행 목록을 만들고 td와 th에서는 새 Cell을 만든다. 이미 셀이 열린 상태에서 다른 셀을 시작하면 종료 태그가 누락된 것으로 판단한다. rowspan과 colspan은 값이 1이어도 지원 범위 밖의 속성으로 다룬다. 허용 여부를 간단하고 일관되게 유지하려는 선택이다.

br에서는 글자 조각에 공백을 넣는다. a에서는 href만 링크 목록에 넣고, 이후 전달되는 표시 글자는 handle_data가 평소처럼 수집한다. handle_startendtag는 br처럼 슬래시로 닫힌 빈 요소를 별도로 처리한다. 빈 요소 형태의 셀이나 링크를 일반 시작 태그처럼 받아들이지 않는다.

handle_endtag는 셀을 행에, 행을 전체 목록에 넣는다. 표가 닫힐 때 미완성 행이 남으면 오류를 낸다. finish는 입력 종료 처리를 마친 후 대상 존재 여부와 닫힘 상태를 확인한다. 다만 이 검사가 HTML 전체의 문법을 검증하는 것은 아니다. 추출에 필요한 제한된 구조를 검사한다.

Event는 검증이 끝난 업무 자료다. remaining을 저장하지 않고 두 인원의 차로 계산해, 인원과 잔여 값이 서로 어긋나는 중복 저장을 피한다. read_count는 ASCII 숫자만 받으므로 부호, 소수점, 쉼표, 전각 숫자는 거절한다. 이런 표기를 지원하려면 변환 규칙부터 추가해야 한다.

resolve_link는 urljoin으로 기준 주소와 링크를 결합한다. ../detail로 시작한 주소는 archive 경로의 한 단계 위에서 해석된다. 그다음 urlsplit으로 결과의 구성 요소를 확인한다. 이 검사는 이번 주소 정책을 적용하는 절차이며, 실제 접속 가능 여부나 링크 내용의 신뢰성을 보장하지 않는다.

extract_events는 첫 행의 문자열과 태그 종류를 확인하고 데이터 행을 순회한다. 안내 칸 외의 링크도 오류로 다루므로 행사 이름에 새 링크가 붙는 변화가 검토 없이 통과하지 않는다. 정렬은 Python 문자열 비교를 사용한다. 언어별 사전 정렬을 구현한 것은 아니지만 이번 고정 자료에서는 순서가 명확하다.

run_checks는 정상 자료의 전체 결과를 비교하므로 이름만 맞고 주소가 틀린 경우도 잡는다. 실패 자료에서는 ValueError만 예상한다. 다른 예외가 발생하면 숨기지 않고 실행을 멈춘다. main은 검사를 통과한 뒤 파일 입출력을 수행하고, CSV 모듈을 이용해 필드를 출력한다. 이름에 쉼표가 추가되어도 필요한 따옴표 처리를 직접 구현할 필요가 없다.

실행 결과

macOS나 Linux의 터미널에서 다음 순서로 실행한다. 첫 명령은 문법과 컴파일 경고를 확인하고, 두 번째 명령은 내장 검사와 실제 파일 처리를 수행한다. 컴파일 성공 시에는 출력이 없다.

python3 -W error -m py_compile main.py
python3 main.py

프로그램을 실행하면 다음 결과가 나온다. 임시 폴더 이름이나 현재 시각은 출력하지 않는다.

검사 통과: 8개
행사,정원,신청,잔여,안내
독서 & 대화,12,9,3,https://example.invalid/detail/book.html?lang=ko&mode=short
사진 산책,8,8,0,https://example.invalid/detail/photo.html
합계: 정원 20, 신청 17, 잔여 3

확인할 지점은 세 가지다. 앞에 놓인 안내 표가 결과에 없고, 문서에서는 뒤에 있던 독서 행이 이름순으로 먼저 나온다. 또한 강조 태그 사이의 &가 보존되고 상대 링크가 전체 주소로 바뀐다. 숫자 합계만 확인하면 이런 추출 오류는 놓칠 수 있다.

내장 검사 여덟 개는 출발점이다. 완성 코드에는 검증 조건이 더 있으므로 실제 자료를 도입할 때는 행사 이름 중복, 닫히지 않은 표, 링크 두 개, 정원 0인 행 등도 검사에 추가한다. 실행 성공을 확인한 뒤에도 수정 차이를 읽어 검사 조건 자체가 약해지지 않았는지 살핀다.

실무에서 자주 틀리는 것

글자 조각 사이에 공백을 무조건 넣는다

태그 경계가 단어 경계는 아니다. 글자 조각을 공백으로 연결하면 “독서” 중간에 강조 태그가 있을 때 “독 서”로 바뀔 수 있다. 다음은 틀린 결합과 고친 결합을 같은 조각으로 비교하는 실행 가능한 예다.

parts = ["독", "서"]
wrong = " ".join(parts)
fixed = " ".join("".join(parts).split())
assert wrong == "독 서"
assert fixed == "독서"

반대로 br을 아무 처리 없이 무시하면 “사진산책”이 된다. 원문 글자 조각은 그대로 붙이고, 의미상 줄바꿈을 공백으로 바꾸는 처리는 태그를 읽는 곳에 둔다.

상대 주소를 문자열 덧셈으로 붙인다

기준 주소 뒤에 상대 주소를 더하면 파일명 뒤에 경로가 붙는다. 디렉터리 경계를 해석하는 함수를 사용해야 한다.

from urllib.parse import urljoin

base = "https://example.invalid/archive/index.html"
href = "../detail/book.html"
wrong = base + href
fixed = urljoin(base, href)
assert wrong.endswith("index.html../detail/book.html")
assert fixed == "https://example.invalid/detail/book.html"

urljoin은 주소를 결합할 뿐 허용 여부를 판단하지 않는다. 외부 호스트 링크를 금지하는 업무라면 결합 후 hostname도 기준 호스트와 비교해야 한다. 이번 프로그램은 외부 호스트를 포함한 HTTPS 주소를 허용하며 접속은 하지 않는다.

열이 빠졌는데 zip으로 조용히 줄인다

zip은 기본적으로 짧은 쪽 길이에서 끝난다. 열 개수 검사를 생략하면 빠진 열이 오류가 아니라 누락된 항목으로 남는다.

headers = ["행사", "정원", "신청", "안내"]
values = ["사진 산책", "8", "8"]
wrong = dict(zip(headers, values))
assert "안내" not in wrong

try:
    fixed = dict(zip(headers, values, strict=True))
except ValueError:
    pass
else:
    raise RuntimeError("열 개수 불일치를 놓쳤다")

완성 코드는 네 칸인지 먼저 검사한다. strict=True는 일반적인 병렬 목록 처리에서도 길이 불일치를 드러내는 방법이다. 어느 방식이든 열 이름과 순서 검사는 별도로 필요하다.

읽기 실패를 무시하고 글자를 버린다

errors="ignore"로 읽으면 해석할 수 없는 바이트가 사라져도 처리가 이어진다. 이름 일부가 빠진 결과를 정상 자료로 오인할 수 있다. 다음 예는 같은 문제를 바이트 변환으로 재현한다.

raw = b"\xff"
wrong = raw.decode("utf-8", errors="ignore")
assert wrong == ""

try:
    fixed = raw.decode("utf-8")
except UnicodeDecodeError:
    pass
else:
    raise RuntimeError("인코딩 오류를 놓쳤다")

완성 코드는 쓰기와 읽기에 UTF-8을 명시한다. 실제 저장 파일이 다른 인코딩이라면 출처와 저장 설정을 확인하고 그 인코딩을 명시한다. 디코딩 오류와 표 구조 오류를 구분해야 수정할 위치도 분명해진다.

한눈에 보기

추출 과정마다 확인할 기준
단계사용한 방법검토할 질문
파일 읽기Path와 명시적 UTF-8글자를 버리지 않고 읽었는가
대상 선택표 속성과 대상 개수 검사다른 표가 섞이지 않았는가
내용 수집셀별 글자 조각과 링크 목록태그 경계에서 내용이 빠지지 않았는가
자료 검증머리글, 열 수, 인원 규칙구조와 업무 의미가 맞는가
결과 출력문자열 정렬과 CSV 모듈반복 실행해 같은 결과가 나오는가

구조가 바뀌면 깨지는 이유는 추출기가 특정한 약속에 기대기 때문이다. 표의 id가 바뀌면 대상을 찾지 못하고, 열 이름이 바뀌면 해석 기준이 사라진다. 오류를 없애려고 조건을 느슨하게 만들기 전에 새 구조가 같은 의미인지 확인한다. 조용히 잘못된 보고서를 만드는 것보다 변경 사실을 드러내는 실패가 운영에서 다루기 쉽다.

연습 문제

확장은 다음 요청문을 출발점으로 삼는다. 조건 하나를 추가할 때 정상 사례와 거절 사례를 함께 요구하고, 기존 여덟 검사가 유지되는지 확인한다.

현재 main.py를 바탕으로 아래 과제 중 하나만 구현하라. 네트워크 호출과 외부 패키지를 추가하지 마라. 변경할 입력 계약을 먼저 설명하고, 정상 사례와 실패 사례를 제시하라. 기존 검사와 고정 출력을 유지하거나 출력 변경 이유를 명시하라. 수정 뒤에는 실행 결과와 검토할 변경 지점을 제시하라.

  1. 정원 0, 신청 0인 행을 정상 입력 검사에 추가하라. 잔여가 0인지 확인하고 정원 0, 신청 1인 행은 거절되는지 검사하라.
  2. 링크를 기준 주소와 같은 호스트로 제한하라. 같은 호스트의 상대 주소와 전체 주소는 허용하고, 다른 호스트의 HTTPS 주소는 거절하라.
  3. 행사 이름 중복과 닫히지 않은 대상 표를 실패 입력 목록에 추가하라. 각각 어떤 검증 조건에서 거절되는지 설명하라.
  4. 출력 순서를 잔여 자리 내림차순, 동률이면 행사 이름 오름차순으로 바꾸라. 잔여가 같은 두 행을 넣어 두 번째 정렬 기준도 검사하라.

정답과 해설

첫 번째 과제는 정원과 신청 값이 모두 0인 새 행을 정상 예제의 tbody 안에 넣고, 기대 결과에도 해당 Event를 추가하면 된다. read_count는 "0"을 허용하고 applied > capacity만 거절하므로 기존 구현을 바꿀 필요가 없다. 0과 1인 행에서는 이 비교 조건이 참이 된다. 모든 과제가 코드 수정으로 이어져야 하는 것은 아니다. 이미 충족한 조건을 검사로 고정하는 것도 작업이다.

두 번째 과제에서는 resolve_link의 주소 분해 뒤에 아래 조건을 추가한다. 문자열의 접두부로 호스트를 비교하면 비슷한 이름의 다른 호스트를 허용할 수 있으므로 hostname끼리 비교한다. 포트까지 같아야 한다는 요구는 이번 과제에 없으므로 별도 정책으로 남긴다.

    if parts.hostname != urlsplit(BASE_URL).hostname:
        raise ValueError("기준 주소와 같은 호스트만 허용한다")

세 번째 과제에서는 행사 이름의 두 번째 항목을 첫 번째와 같게 바꾼 자료를 만든다. 글자 조각을 합치고 공백을 정리한 이름이 names에 이미 있으면 거절된다. 닫히지 않은 표는 마지막 대상 table 종료 태그만 제거해 만든다. finish에서 active가 참인 상태를 확인하므로 대상 표가 닫히지 않았다는 오류가 발생한다. 셀 종료 태그까지 함께 지우면 다른 검증이 먼저 실패할 수 있으므로 한 번에 한 조건만 바꾼다.

네 번째 과제는 extract_events 마지막 반환문의 정렬 키를 다음과 같이 바꾼다. 음수 잔여 값으로 첫 기준의 방향만 뒤집고 이름은 그대로 비교한다. reverse=True를 전체 정렬에 쓰면 이름까지 역순이 되므로 요구와 다르다.

    return sorted(events, key=lambda event: (-event.remaining, event.name))

예제의 두 행은 원래도 이 순서이므로 기존 출력만 보면 수정 여부를 판단하기 어렵다. 잔여가 같은 두 행을 역순으로 입력하고, 이름순으로 나오는지 기대 결과를 작성해야 한다. AI가 제안한 코드도 이처럼 차이가 드러나는 입력으로 실행하고, 마지막에는 수정 차이를 읽어 요구한 정렬 외의 동작이 바뀌지 않았는지 확인한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.