Devin.KR

안내문 대량 만들기 - 템플릿과 명단 합치기

개발자KR 조회 0

이 장에서 배우는 것

같은 안내문을 여러 사람에게 보내더라도 이름, 신청 항목, 납부 금액은 사람마다 다르다. 문서를 복사한 뒤 일부만 고치는 방식은 받는 사람이 늘수록 실수를 찾기 어렵다. 이번에는 안내문 틀과 명단을 합쳐 사람별 텍스트 파일을 만들고, 생성된 파일 묶음까지 점검하는 프로그램을 완성한다.

앞 장에서 합계를 구하는 데 집중했다면, 이번에는 한 사람의 정보가 한 파일에 정확히 들어갔는지를 확인한다. 파일 생성과 발송은 서로 다른 작업이다. 여기서는 발송 기능을 넣지 않고, 보내기 전에 확인할 파일과 개인정보를 가린 표본을 만든다.

  • string.Template과 str.format의 치환 규칙을 비교하고 안내문에 맞는 방식을 고른다.
  • 필수 값이 비어 있는 행과 형식이 잘못된 행을 구분해 제외한다.
  • 원본 파일의 내용과 화면에 보여 줄 개인정보 가림 결과를 구분한다.
  • 예상 파일 목록과 실제 파일 목록, 내용, 해시를 대조한다.
  • 자동 검사 뒤에도 표본을 읽고 사람이 확인해야 하는 범위를 정한다.

문제 상황

작은 모임에서 체험 수업 신청자에게 준비 안내를 보내려 한다. 담당자는 이름, 이메일, 신청 항목, 금액, 마감일이 들어 있는 CSV 명단을 받았다. 안내문에는 각 신청자의 정보가 들어가야 한다. 어떤 행에는 마감일이 없고, 어떤 이름에는 파일명으로 쓰기 곤란한 문자가 들어올 수도 있다.

명단에서 값을 꺼내 문장을 만드는 일만 보면 단순하다. 그러나 실제 작업에서는 파일 하나를 덜 만들거나, 이전 실행의 파일을 함께 전달하거나, 확인용 출력에 이메일 전체를 남기는 문제가 생긴다. 이름이 같은 두 사람의 파일을 이름으로 저장하면 앞 파일을 덮어쓸 수도 있다.

이번 프로그램의 요구사항은 다음과 같다. 데이터는 모두 가상이며 이메일 주소도 예시용이다. 실행할 때마다 임시 폴더를 새로 만들고, 그 안에 입력 CSV와 안내문 틀을 직접 쓴 뒤 다시 읽는다. 외부 파일, 외부 패키지, 네트워크 연결은 필요하지 않다.

  • CSV 네 행 가운데 필수 값이 빠진 한 행을 제외하고 세 파일을 만든다.
  • 이름, 이메일, 신청 항목, 금액, 마감일을 검사한 뒤 치환한다.
  • 금액은 0 이상의 정수로 받고, 마감일은 실제로 존재하는 날짜인지 검사한다.
  • 파일명은 CSV 데이터 행의 순번으로 만들고, 이름이나 이메일을 넣지 않는다.
  • 생성 목록을 기록한 명세 파일과 실제 결과를 대조한다.
  • 첫 번째와 마지막 안내문을 개인정보를 가린 상태로 출력하고 바로 종료한다.

입력에서 제외된 행이 있으면 원래 순번을 유지한다. 따라서 결과 파일 번호에 빈 번호가 있어도 된다. 이 번호는 정렬과 추적을 위한 값이며, 개인정보를 숨기는 식별자로 보아서는 안 된다. 원본 명단과 연결하면 누구의 파일인지 알 수 있기 때문이다.

치환 문법과 빠진 값의 의미

문자열 틀을 템플릿(template)이라고 부른다. string.Template은 $name 또는 ${name} 같은 표시를 값으로 바꾼다. str.format은 {name} 같은 중괄호 표시를 사용하며, 숫자 자릿수와 정렬 같은 서식 지정에 편리하다. 두 방식 모두 Python 표준 라이브러리에서 사용할 수 있다.

안내문에서 두 치환 방식을 고르는 기준
비교 항목string.Templatestr.format
기본 표시$name, ${name}{name}
값 표현단순한 이름 치환 중심이다서식, 속성 접근 등을 표현할 수 있다
기호를 그대로 쓰기달러 기호는 $$로 쓴다중괄호는 {{와 }}로 쓴다
이번 선택치환할 항목을 알아보기 쉬워 사용한다금액은 치환 전에 별도로 서식화한다

예를 들어 Template("금액: ${amount}원")에는 이미 쉼표를 넣은 문자열을 전달할 수 있다. str.format을 고르면 "금액: {amount:,}원"처럼 숫자 서식을 틀에 둘 수 있다. 어느 쪽이 늘 낫다고 정하기보다, 틀을 수정하는 사람이 무엇을 다룰지 기준을 세운다. 이번에는 문장과 계산을 구분하기 위해 금액 서식을 Python 코드에서 처리한다.

Template.substitute는 필요한 키가 없으면 KeyError를 일으킨다. 반면 safe_substitute는 누락된 표시를 그대로 남길 수 있다. 메서드 이름의 safe가 안내문 발송까지 보장한다는 뜻은 아니다. “마감일: ${due}”가 남은 파일도 저장 자체는 가능하므로, 완성 문서를 만들 때에는 substitute를 사용한다.

키가 존재하는 것과 값이 채워진 것도 별개다. {"name": ""}는 name이라는 키를 갖고 있으므로 치환할 때 오류가 나지 않는다. 따라서 치환 전에 공백을 제거하고 필수 값이 비어 있는지 검사해야 한다. 누락된 날짜를 오늘 날짜로 채우거나 금액을 0으로 바꾸면 잘못된 안내가 자연스러운 문장으로 완성될 수 있다. 이 프로그램은 그런 기본값을 넣지 않고 해당 행을 제외한다.

또한 CSV에는 이름이 있지만 템플릿에서 이름 표시를 실수로 지웠다면, substitute만으로는 문제를 알 수 없다. 반대로 템플릿에 존재하지 않는 키가 추가될 수도 있다. 완성 코드에서는 템플릿 문법을 검사하고, 템플릿의 식별자 집합이 요구한 다섯 항목과 같은지도 검사한다. 식별자 집합 검사는 항목의 존재를 확인할 뿐, 같은 항목을 여러 번 쓰는 것을 금지하지는 않는다.

필수 값과 형식을 통과한 행만 템플릿 치환으로 들어간다

개인정보 가리기는 치환 검증을 대신하지 않는다. 받는 사람에게 전달할 원본 안내문에는 필요한 이름과 이메일이 들어간다. 화면에서 검토할 표본만 별도로 가린다. 이름은 첫 글자와 마지막 글자 사이를 별표로 바꾸고, 이메일은 계정 부분의 첫 글자만 남긴다. 두 글자 이름은 첫 글자만 남기며 한 글자 이름은 전체를 가린다.

이 규칙은 이 예제에서 정한 표시 정책이다. 이름 길이와 이메일 도메인 같은 단서는 남는다. 가린 출력도 누구인지 추측할 수 있으므로 익명 데이터라고 부르지 않는다. 원본 결과 파일에는 개인정보가 남는다는 점도 함께 기억해야 한다.

요청문을 검사 기준으로 바꾸기

AI에 처음 보낸 요청문은 작업의 큰 방향을 설명했지만, 빠진 값과 파일 묶음을 어떻게 확인할지는 충분히 정하지 않았다.

Python 3.12 표준 라이브러리만 사용해서 안내문 대량 생성기를 만들어라. CSV 명단과 string.Template 틀을 합쳐 사람별 텍스트 파일을 저장하라. 입력은 tempfile 임시 폴더에 직접 만들고 다시 읽어라. 이름, 이메일, 신청 항목, 금액, 마감일을 사용하라. 화면에는 개인정보를 가린 표본을 출력하라. main.py 하나로 실행하고 외부 통신 없이 종료하라.

1차 결과에서는 파일이 생성되는지만 확인하기 쉬웠다. 그러나 입력을 조금 바꾸고 출력 경로를 살펴보자 다음과 같은 문제가 드러났다. 아래 문제들은 치환 문법을 이해했더라도 자주 놓치는 경계에 해당한다.

AI 1차 결과에서 발견한 문제와 수정 근거
문제발견 방법고친 방향
빈 마감일도 정상 문장으로 저장했다마감일이 공백인 행을 넣었다공백 제거 뒤 필수 값을 검사했다
safe_substitute로 누락 표시가 남았다틀의 due를 다른 키로 바꾸었다식별자 집합 검사와 substitute를 사용했다
이름을 파일명으로 써 동명이인이 덮어썼다같은 이름을 두 행에 넣었다명단 데이터 행 순번으로 파일명을 만들었다
기본 인코딩에 의존하고 파일 개수만 셌다파일 읽기 코드와 출력 폴더를 검토했다UTF-8을 명시하고 파일 집합과 내용을 대조했다

수정 요청은 “오류를 고쳐라”로 끝내지 않는다. 어떤 입력을 어떻게 처리할지, 무엇을 확인하면 수정이 끝났다고 볼지를 적는다.

필수 값은 strip 후 검사하고 빈 값이 있으면 데이터 행 번호와 필드 이름만 출력하라. 금액은 0 이상의 정수, 마감일은 YYYY-MM-DD 형식의 실제 날짜로 제한하라. 템플릿 식별자는 name, email, item, amount, due와 정확히 일치해야 한다. 파일명은 notice_0001.txt처럼 데이터 행 순번으로 만들라. UTF-8과 줄바꿈을 명시하라. 생성 목록에는 파일명과 SHA-256을 기록하고, 저장 후 파일 집합·본문·해시를 다시 대조하라. 첫 파일과 마지막 파일을 가린 표본으로 출력하라. 검사 실패를 숨기는 광범위한 예외 처리는 넣지 말라.

수정 코드는 실행뿐 아니라 차이 비교(diff)로도 확인한다. 파일명에 name을 쓰던 부분이 순번으로 바뀌었는지, safe_substitute가 남지 않았는지, 입력과 출력 양쪽에 인코딩이 지정되었는지 살펴본다. 제외 메시지에 원본 행 전체를 출력하는 코드가 새로 들어오지 않았는지도 확인한다.

검사는 작은 함수와 전체 결과에 나누어 둔다. 이름 가림 함수에는 한 글자, 두 글자, 세 글자 사례를 넣는다. 입력 검사에는 금액 0, 음수, 존재하지 않는 날짜, 빈 마감일을 넣는다. 마지막으로 생성된 폴더를 다시 읽어 예상 파일이 모두 있고 내용이 같은지 검사한다. 이 장의 검증 함수는 assert 대신 명시적인 예외를 사용하므로 Python 최적화 옵션 때문에 검사가 사라지지 않는다.

표준 기능의 세부 동작을 확인하려면 Python의 Template 참고 문서와 CSV 참고 문서를 볼 수 있다. 문서는 동작을 확인하는 근거이며, 이 장의 입력 정책과 안내문 문장은 별도로 정한 것이다.

생성 완료와 발송 준비를 구분하기

프로그램이 파일 세 개를 만들었다고 해서 올바른 세 파일이라고 단정할 수 없다. 예상한 파일 하나가 빠지고 엉뚱한 파일 하나가 들어 있어도 개수는 같다. 그래서 먼저 실제 디렉터리 항목의 이름 집합을 예상 집합과 비교한다. 그다음 각 본문을 다시 읽어 메모리에 있는 예상 본문과 비교한다.

명세 파일(manifest)에는 파일명과 SHA-256 해시(hash)를 저장한다. 해시는 파일 바이트가 바뀌었는지 비교하는 데 사용한다. 이것이 안내 내용의 업무적 정확성이나 작성자의 신원을 증명하지는 않는다. 명세 파일까지 함께 바뀌면 별도 기준이 필요하다. 이 예제에서는 같은 실행 안에서 저장과 재읽기가 일치했는지 확인하는 용도로 사용한다.

파일 목록과 내용을 자동 검사한 뒤 가린 표본을 사람이 확인한다

표본은 파일명 순으로 정렬한 첫 번째와 마지막 파일을 선택한다. 실행할 때마다 선택 결과가 같아 재현하기 쉽다. 표본이 하나뿐이면 한 번만 출력한다. 이 방식은 전체 문장과 끝부분 데이터가 어색하지 않은지 확인하는 출발점이다. 모든 오류 유형을 대표하는 표본이라는 뜻은 아니다.

실제 명단에서는 긴 이름, 금액 0, 긴 신청 항목 등 경계 사례를 추가로 확인해야 한다. 가린 표본으로는 이메일 전체의 오타를 확인할 수 없으므로, 발송 전에는 권한이 있는 담당자가 원본 명단과 결과의 대응 관계도 확인한다. 자동 검사와 사람의 확인은 서로 다른 질문에 답한다.

이 예제는 임시 폴더를 사용하므로 프로그램이 끝나면 원본 안내문과 명세 파일도 삭제된다. 화면에 임시 경로를 출력하지 않아 실행 결과는 일정하다. 실무용으로 바꿀 때에는 보존할 출력 위치, 접근 권한, 보관 기간을 요구사항으로 추가해야 한다.

완성 코드

다음 내용을 main.py로 저장한다. 날짜는 입력에 적힌 고정값만 사용하며 현재 시각을 읽지 않는다. 검사에 실패하면 예외로 중단하고, 정상인 경우에만 묶음 검사 통과와 표본을 출력한다.

import csv
import hashlib
import json
import re
import tempfile
from datetime import date
from pathlib import Path
from string import Template


FIELDS = ("name", "email", "item", "amount", "due")
TEMPLATE_TEXT = (
    "받는 사람: ${name} (${email})\n"
    "신청 항목: ${item}\n"
    "안내 금액: ${amount}원\n"
    "마감일: ${due}\n"
    "문의할 때 신청 항목을 알려 주세요.\n"
)
INPUT_ROWS = [
    ["김하루", "haru@example.invalid", "종이 공작", "12000", "2026-11-15"],
    ["박여울", "yeoul@example.invalid", "생활 기록", "0", "2026-11-16"],
    ["이마루", "maru@example.invalid", "종이 공작", "8000", "   "],
    ["최새봄", "saebom@example.invalid", "사진 정리", "15000", "2026-11-17"],
]


def require(condition, message):
    if not condition:
        raise RuntimeError(message)


def mask_name(value):
    if len(value) == 1:
        return "*"
    if len(value) == 2:
        return value[0] + "*"
    return value[0] + "*" * (len(value) - 2) + value[-1]


def mask_email(value):
    local, domain = value.rsplit("@", 1)
    return local[0] + "***@" + domain


def validate_row(row):
    if None in row or any(row.get(key) is None for key in FIELDS):
        raise ValueError("열 수 불일치")
    clean = {key: row[key].strip() for key in FIELDS}
    for key, value in clean.items():
        if not value:
            raise ValueError("필수 값 누락: " + key)
        if any(char in value for char in "\r\n\t"):
            raise ValueError("제어 문자 포함: " + key)
    if not re.fullmatch(r"[0-9]+", clean["amount"]):
        raise ValueError("금액 형식 오류")
    clean["amount"] = f'{int(clean["amount"]):,}'
    if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", clean["due"]):
        raise ValueError("마감일 형식 오류")
    try:
        date.fromisoformat(clean["due"])
    except ValueError:
        raise ValueError("존재하지 않는 마감일") from None
    if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", clean["email"]):
        raise ValueError("이메일 형식 오류")
    return clean


def check_rules():
    require(mask_name("가") == "*", "한 글자 가림 실패")
    require(mask_name("가나") == "가*", "두 글자 가림 실패")
    require(mask_name("가나다") == "가*다", "세 글자 가림 실패")
    require(mask_email("ab@example.invalid") == "a***@example.invalid",
            "이메일 가림 실패")
    base = dict(zip(FIELDS, INPUT_ROWS[1]))
    require(validate_row(base)["amount"] == "0", "금액 0 검사 실패")
    for key, value in [
        ("amount", "-1"),
        ("due", "2026-02-30"),
        ("due", " "),
    ]:
        bad = dict(base)
        bad[key] = value
        try:
            validate_row(bad)
        except ValueError:
            continue
        raise RuntimeError("잘못된 입력을 허용함: " + key)
    try:
        Template("$missing").substitute({})
    except KeyError:
        pass
    else:
        raise RuntimeError("누락 키 검사 실패")


def verify_bundle(output_dir, expected):
    manifest_path = output_dir / "manifest.json"
    records = json.loads(manifest_path.read_text(encoding="utf-8"))
    require(isinstance(records, list), "명세 형식 불일치")
    names = sorted(expected)
    require([record["file"] for record in records] == names,
            "명세 목록 불일치")
    actual = {path.name for path in output_dir.iterdir()}
    require(actual == set(names) | {"manifest.json"}, "파일 집합 불일치")
    for record in records:
        name = record["file"]
        path = output_dir / name
        require(path.is_file(), "일반 파일 아님: " + name)
        require(path.read_text(encoding="utf-8") == expected[name],
                "본문 불일치: " + name)
        digest = hashlib.sha256(path.read_bytes()).hexdigest()
        require(digest == record["sha256"], "해시 불일치: " + name)


def main():
    check_rules()
    with tempfile.TemporaryDirectory(prefix="notice_demo_") as folder:
        root = Path(folder)
        csv_path = root / "recipients.csv"
        template_path = root / "template.txt"
        output_dir = root / "output"
        output_dir.mkdir()
        with csv_path.open("w", encoding="utf-8", newline="") as stream:
            writer = csv.writer(stream)
            writer.writerow(FIELDS)
            writer.writerows(INPUT_ROWS)
        template_path.write_text(
            TEMPLATE_TEXT, encoding="utf-8", newline="\n"
        )
        template = Template(template_path.read_text(encoding="utf-8"))
        require(template.is_valid(), "템플릿 문법 오류")
        require(set(template.get_identifiers()) == set(FIELDS),
                "템플릿 항목 불일치")
        accepted = []
        rejected = []
        with csv_path.open("r", encoding="utf-8", newline="") as stream:
            reader = csv.DictReader(stream)
            require(reader.fieldnames == list(FIELDS), "CSV 헤더 불일치")
            for number, row in enumerate(reader, start=1):
                try:
                    clean = validate_row(row)
                except ValueError as error:
                    rejected.append((number, str(error)))
                else:
                    accepted.append((number, clean))
        require(bool(accepted), "생성할 안내문이 없음")
        expected = {}
        previews = {}
        records = []
        for number, clean in accepted:
            name = f"notice_{number:04d}.txt"
            body = template.substitute(clean)
            path = output_dir / name
            path.write_text(body, encoding="utf-8", newline="\n")
            expected[name] = body
            masked = dict(clean)
            masked["name"] = mask_name(clean["name"])
            masked["email"] = mask_email(clean["email"])
            previews[name] = template.substitute(masked)
            records.append({
                "file": name,
                "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
            })
        records.sort(key=lambda record: record["file"])
        (output_dir / "manifest.json").write_text(
            json.dumps(records, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
            newline="\n",
        )
        verify_bundle(output_dir, expected)
        print("입력: " + str(len(accepted) + len(rejected)) + "행")
        print(f"생성: {len(accepted)}개 / 제외: {len(rejected)}행")
        for number, reason in rejected:
            print(f"제외 데이터 행 {number}: {reason}")
        print("묶음 검사: 통과")
        print("파일: " + ", ".join(sorted(expected)))
        names = sorted(expected)
        for name in dict.fromkeys([names[0], names[-1]]):
            print(f"[가린 표본: {name}]")
            print(previews[name], end="")
    print("임시 작업 폴더 정리 완료")


if __name__ == "__main__":
    main()

줄별 해설

가져오기와 상수. csv는 명단을 읽고 쓰며, json은 생성 목록을 기록한다. hashlib는 파일 바이트의 SHA-256을 계산한다. FIELDS는 CSV 헤더의 이름과 순서를 함께 정의한다. TEMPLATE_TEXT는 끝에 줄바꿈을 포함하므로 파일의 마지막 문장도 줄바꿈으로 끝난다.

require. 조건이 거짓이면 RuntimeError를 발생시킨다. 입력 한 행의 잘못과 프로그램 전체의 검사 실패를 구분하기 위한 함수다. 한 행의 ValueError는 제외 목록에 넣지만, 파일 대조 실패 같은 RuntimeError는 숨기지 않는다.

mask_name과 mask_email. 이름 길이에 따라 표시 정책을 적용한다. mask_email은 이미 검증된 이메일을 받는다는 전제가 있다. 원본 문자열에서 이름을 찾아 일괄 교체하지 않고, 치환용 사전의 두 값만 바꾼다. 같은 글자가 신청 항목에 들어 있어도 그 항목은 손대지 않는다.

validate_row의 앞부분. DictReader는 열이 많으면 None 키에 남는 값을 넣고, 열이 부족하면 일부 값을 None으로 채울 수 있다. 두 경우를 먼저 거른다. 이어 각 필드의 앞뒤 공백을 제거하고 빈 문자열과 줄바꿈·탭을 검사한다. 반환하는 clean은 원본 행과 별개의 사전이다.

금액과 날짜 검사. 금액은 ASCII 숫자로 된 정수만 허용한다. “12,000”, “12.5”, “-1”은 이번 입력 규칙에서 제외된다. 검증 후 정수로 바꾸어 쉼표를 넣는다. 날짜는 정규식으로 모양을 확인하고 date.fromisoformat으로 실제 달력에 존재하는지 확인한다. 오늘보다 과거인지 여부는 요구사항에 없으므로 검사하지 않는다.

이메일 검사. 공백이 없고 @가 하나이며 도메인에 점이 있는 기본 형태만 확인한다. 이 검사는 주소의 존재나 수신 가능 여부를 확인하지 않는다. 네트워크를 쓰지 않는 이번 프로그램에서 할 수 있는 형식 검사로 범위를 한정했다.

check_rules. 본 작업 전에 경계 사례를 확인한다. 음수와 잘못된 날짜가 거부되는지뿐 아니라 금액 0이 허용되는지도 검사한다. 실패해야 할 입력이 성공하면 직접 RuntimeError를 발생시킨다. 예외가 없었다는 이유만으로 검사가 통과했다고 판단하지 않는 구조다.

verify_bundle. 디스크에서 명세를 다시 읽고 그 목록이 예상 파일 목록과 같은지 확인한다. 실제 폴더에는 안내문과 manifest.json만 있어야 한다. 각 파일은 예상 본문과 일치해야 하며, 바이트 해시도 명세에 기록한 값과 같아야 한다. 본문 비교는 이번 실행의 예상값과 저장 결과를 연결하고, 해시 비교는 명세와 파일을 연결한다.

main의 입력 준비. TemporaryDirectory가 관리하는 범위 안에서 입력과 출력을 모두 만든다. CSV 파일에는 newline=""를 지정해 csv 모듈이 줄바꿈을 다루게 한다. 안내문과 명세에는 newline="\n"을 지정한다. 헤더는 순서까지 정확히 일치해야 하므로, 다른 형식의 CSV가 들어오면 전체 작업을 중단한다.

행 분류와 파일 생성. enumerate의 번호는 헤더를 제외한 데이터 행 순번이다. 따옴표 안 줄바꿈이 있는 CSV에서는 물리적인 텍스트 줄 번호와 다를 수 있다. 모든 행을 먼저 검사한 뒤 정상 행만 파일로 만든다. 이 프로그램은 잘못된 행만 제외하는 정책이므로, 제외가 하나라도 있으면 전체를 중단하는 정책과 혼동하지 않아야 한다.

표본과 종료. 가린 값으로 같은 템플릿을 다시 치환해 표본을 만든다. 원본 파일이 예상 본문과 같다는 검사가 먼저 끝나므로 그다음에 대응하는 가린 표본을 표시할 수 있다. dict.fromkeys는 첫 파일과 마지막 파일이 같을 때 중복 출력을 막는다. with 범위를 벗어나면 임시 폴더를 정리하고 마지막 메시지를 출력한다.

실행 결과

터미널에서 실행한다. 명령은 다음과 같다.

python3 main.py

정상 실행의 표준 출력은 다음과 같다. 임시 폴더 이름이나 현재 날짜는 출력하지 않으므로 반복 실행해도 같다.

입력: 4행
생성: 3개 / 제외: 1행
제외 데이터 행 3: 필수 값 누락: due
묶음 검사: 통과
파일: notice_0001.txt, notice_0002.txt, notice_0004.txt
[가린 표본: notice_0001.txt]
받는 사람: 김*루 (h***@example.invalid)
신청 항목: 종이 공작
안내 금액: 12,000원
마감일: 2026-11-15
문의할 때 신청 항목을 알려 주세요.
[가린 표본: notice_0004.txt]
받는 사람: 최*봄 (s***@example.invalid)
신청 항목: 사진 정리
안내 금액: 15,000원
마감일: 2026-11-17
문의할 때 신청 항목을 알려 주세요.
임시 작업 폴더 정리 완료

확인할 핵심은 세 가지다. 데이터 행 3은 빠진 마감일 때문에 제외되었고, 네 번째 행의 파일 번호는 0004로 유지되며, 표본에는 이메일 계정 전체가 나오지 않는다. 금액 0인 두 번째 행은 정상적으로 생성되지만 처음과 마지막만 고르는 표본에는 나타나지 않는다.

수정 뒤에는 먼저 경고를 오류로 취급하는 컴파일 명령을 실행하고, 이어 정상 실행 출력을 비교한다.

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

추가로 verify_bundle 호출 직전에 시험용 파일을 하나 만들면 파일 집합 검사가 실패해야 한다. 안내문 한 파일의 내용을 바꾸면 본문 검사가 실패해야 한다. 이런 변형 검사를 할 때는 임시 수정 부분을 제거한 뒤 최종 차이를 다시 확인한다. 정상 출력만 보는 것보다 검사가 실제로 잘못된 결과를 잡는지 확인하는 데 도움이 된다.

실무에서 자주 틀리는 것

누락 표시를 남긴 채 저장한다

다음 코드는 실행은 되지만, 완성 안내문으로 쓰기에는 값이 빠져 있다. 이름의 safe를 업무적 안전성으로 해석하면 안 된다.

from string import Template

text = Template("마감일: $due")
print(text.safe_substitute({}))

고친 코드는 누락을 잡아 작업 실패로 다룬다. 완성 프로그램에서는 그보다 앞서 필수 값과 식별자도 검사한다.

from string import Template

text = Template("마감일: $due")
try:
    result = text.substitute({})
except KeyError:
    print("필수 값이 없어 저장하지 않는다")
else:
    print(result)

빈 문자열을 기본값으로 덮는다

아래 코드는 누락된 금액을 실제 무료 신청과 구분할 수 없게 만든다. 오류를 편리한 기본값으로 바꾸기 전에 그 의미가 맞는지 결정해야 한다.

row = {"amount": " "}
amount = row["amount"].strip() or "0"
print(amount)

고친 코드는 누락을 드러낸다. 금액 0이 입력된 경우와 입력하지 않은 경우를 별도로 처리한다.

row = {"amount": " "}
amount = row["amount"].strip()
if not amount:
    print("금액 누락으로 제외한다")
else:
    print(amount)

이름으로 파일 경로를 만든다

다음 코드는 파일을 쓰기 전 경로 문자열만 출력하지만, 이 경로를 그대로 저장에 쓰면 이름의 슬래시가 하위 경로로 해석될 수 있다. 동명이인의 덮어쓰기 문제도 남는다.

from pathlib import Path

name = "가상/이름"
target = Path("output") / f"{name}.txt"
print(target.as_posix())

고친 코드는 사용자 값 대신 정해진 번호 형식을 사용한다. 출력 폴더는 프로그램이 관리하는 경로로 별도 결정한다.

from pathlib import Path

number = 7
target = Path("output") / f"notice_{number:04d}.txt"
print(target.as_posix())

개수만 같으면 통과시킨다

서로 다른 파일 집합도 길이는 같을 수 있다. 오래된 파일이 하나 남고 새 파일 하나가 빠졌다면 개수 검사는 이를 찾지 못한다.

expected = {"notice_0001.txt", "notice_0002.txt"}
actual = {"notice_0001.txt", "old_notice.txt"}
print(len(expected) == len(actual))

고친 코드는 집합 자체를 비교한다. 실제 프로그램에서는 명세 파일도 예상 항목에 포함하고, 목록이 맞은 뒤 본문을 검사한다.

expected = {"notice_0001.txt", "notice_0002.txt"}
actual = {"notice_0001.txt", "old_notice.txt"}
print(expected == actual)
print("빠진 파일:", ", ".join(sorted(expected - actual)))
print("불필요한 파일:", ", ".join(sorted(actual - expected)))

한눈에 보기

생성 단계마다 확인할 질문과 근거
단계확인할 질문사용한 근거
입력필수 값과 형식이 맞는가헤더, 공백 제거, 금액·날짜·이메일 검사
템플릿필요한 항목이 모두 있는가문법과 식별자 집합 검사
저장파일명이 겹치거나 경로가 바뀌지 않는가프로그램이 만든 행 순번 파일명
묶음예상 파일과 실제 결과가 같은가파일 집합, 본문, SHA-256 대조
검토안내 문장과 개인별 값이 자연스러운가가린 표본과 권한 있는 원본 확인

검증은 서로 다른 층에서 수행한다. CSV가 읽혔다는 사실은 값이 채워졌다는 뜻이 아니고, 치환이 성공했다는 사실은 파일 묶음이 맞다는 뜻이 아니다. 묶음 검사가 통과해도 마감일 자체를 담당자가 잘못 제공했을 가능성은 남는다. 프로그램이 확인한 범위를 구체적으로 설명할 수 있어야 결과를 업무에 연결할 수 있다.

연습 문제

  1. 현재 표본은 처음과 마지막 파일뿐이다. 금액이 0인 신청자의 파일도 표본에 포함하도록 수정하라. 같은 파일이 여러 조건에 해당해도 한 번만 출력하고, 출력 순서는 파일명 순으로 고정하라.
  2. 이메일의 도메인도 가리기로 했다. 화면에는 계정 첫 글자와 고정 문자열만 남겨 h***@*** 형태로 표시하라. 원본 안내문과 명세의 해시가 바뀌지 않는지 확인하라.
  3. 명단에 오류가 하나라도 있으면 안내문을 전혀 만들지 않는 정책으로 바꾸라. 입력 검사를 모두 마친 다음 어디에서 중단해야 하는지 설명하라.
  4. 아래 확장 요청문을 AI에 보내는 상황을 가정하라. 제안된 코드에서 추가로 확인할 검사 사례를 세 가지 적고, 직접 수정한다면 어떤 함수를 분리할지 설명하라.

현재 안내문 생성기에 선택 입력인 준비물 필드를 추가하라. 공백이면 “별도 준비물 없음”을 넣고, 값이 있으면 해당 내용을 넣어라. 기존 필수 필드 정책은 유지하라. 입력 CSV, 템플릿, 검증, 표본, 실행 결과를 함께 수정하라. 표준 라이브러리만 사용하고 실행 결과를 결정적으로 유지하라. 변경 전후 차이와 경계 사례 검사 결과를 제시하라.

정답과 해설

1. 생성 반복문에서 파일명과 정리된 행의 대응 관계를 함께 보관한다. 표본 후보 집합에 첫 파일과 마지막 파일을 넣고, amount가 "0"인 행의 파일명을 추가한다. 금액을 서식화한 뒤에도 0은 "0"이므로 현재 구조에서는 이 비교가 가능하다. 마지막에 후보 집합을 sorted로 정렬해 출력한다. 주어진 입력에서는 notice_0002.txt가 표본에 추가되어야 한다. 별도 금액 계산이 늘어난다면 정수 금액을 보관하고 표시 문자열을 나중에 만드는 구조가 더 명확하다.

2. mask_email의 반환식을 local[0] + "***@***"로 바꾼다. 가린 사전만 바뀌므로 원본 body와 저장 파일은 영향을 받지 않아야 한다. 검증 함수의 이메일 가림 기대값도 함께 고쳐야 한다. 수정 전후에 원본 안내문 바이트로 계산한 해시를 비교하면 표시 정책 변경이 원본 내용에 섞이지 않았는지 확인할 수 있다. 실제로 해시를 비교하려면 임시 폴더가 지워지기 전에 결과를 수집해야 한다.

3. CSV 읽기 반복문이 끝난 뒤, expected를 만들고 파일을 쓰기 전에 rejected가 비어 있는지 검사한다. 거부된 행 번호와 이유를 보고한 다음 중단하도록 흐름을 정한다. accepted가 한 명 이상인지 확인하는 기존 검사도 유지한다. 입력과 템플릿은 이미 만들어졌지만 안내문 결과는 아직 하나도 쓰지 않은 시점이다. “안내문을 전혀 만들지 않는다”는 요구와 “어떤 파일도 만들지 않는다”는 요구는 범위가 다르다.

4. 준비물 키가 있지만 빈 값인 경우, 공백만 있는 경우, 실제 준비물 문자열이 있는 경우를 검사한다. CSV 열 자체가 빠진 경우는 선택 값이 비어 있는 것과 다르므로 헤더 정책도 명시해야 한다. 필수 필드 목록과 전체 CSV 열 목록을 분리하고, 준비물의 공백 제거와 기본 문구 선택을 전담하는 함수를 둘 수 있다. 템플릿 식별자 집합에는 준비물 키를 추가한다. 수정 뒤에는 새 기본 문구뿐 아니라 기존 마감일 누락이 여전히 제외되는지도 확인한다.

AI가 준 설명에 “기존 동작 유지”라고 적혀 있어도 그 문장을 검사 결과로 받아들이지 않는다. 실제 실행에서 제외 행이 유지되는지, 가린 출력에 원본 이메일이 나타나지 않는지, 파일 목록과 본문 대조가 통과하는지 확인한다. 그리고 차이 비교에서 준비물과 관계없는 파일 경로·인코딩·예외 처리까지 바뀌지 않았는지 살펴본다.

댓글 0

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

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