CSV 날짜에서 불가능한 날짜와 애매한 표기를 구분하기

CSV의 날짜 열에 2024-02-29와 2025-02-29가 함께 들어오면, 모양이 같아도 둘을 같은 상태로 넘길 수 없습니다. 2024년의 윤일은 존재하지만 2025년 2월 29일은 없습니다. 여기에 03/04/2026까지 섞이면 달과 일의 순서를 먼저 확인해야 합니다.

이번 실습은 받은 문자열을 그대로 남기고 날짜를 정상·존재 불가·형식 미확정·누락으로 분류합니다. 잘못된 값을 자동으로 바꾸거나 행을 버리지 않고, 확인할 행을 새 CSV에서 찾는 것이 목표입니다. 입력 다섯 행과 ID는 모두 가상 자료입니다.

가상 CSV의 2024-02-29와 2026-10-05는 정상, 2025-02-29는 존재 불가, 03/04/2026은 형식 미확정, 빈 값은 누락으로 분류한 설명 도해
설명용 도해: 가상 CSV 다섯 행을 정상 2·존재 불가 1·형식 미확정 1·누락 1로 분류합니다. 실제 앱 화면이 아닌 입력과 기대 결과를 보여 주는 도해입니다.

1 먼저 날짜의 허용 형식을 정합니다

이 글의 계약은 ASCII 숫자와 하이픈으로 쓴 정확한 YYYY-MM-DD입니다. 연도 네 자리, 월 두 자리, 일 두 자리가 필요하며 달력 검사는 0001년부터 9999년까지의 그레고리력을 사용합니다. 0000-01-01은 모양은 맞지만 Python의 날짜 범위에 들어가지 않습니다. 이 범위와 달력상 예외는 Python 3.12 date 공식 문서에서 확인할 수 있습니다.

상태판정 기준다음 행동
valid 정상허용 모양이며 달력에 존재함날짜 형식 검수 통과로 기록
impossible 존재 불가허용 모양이지만 달력 범위를 벗어남원자료의 연·월·일 확인
format_unresolved 형식 미확정비어 있지 않으며 허용 모양이 아님제공자의 표기 기준과 원문 확인
missing 누락문자열이 정확히 빈 값임날짜가 빠진 이유와 원자료 확인

valid는 실제 거래일이나 업무상 적절한 날짜까지 보증하는 표시가 아닙니다. 미래 날짜가 허용되는지, 특정 기간 안에 있어야 하는지는 별도의 업무 규칙입니다.

공백도 그대로 검사합니다. 빈 문자열은 missing이지만 공백 세 개나 탭은 format_unresolved입니다. 2026-10-05의 앞뒤에 공백이 붙어도 형식 미확정으로 남습니다. 코드에는 strip()이나 공백 자동 삭제가 없습니다.

03/04/2026은 3월 4일 또는 4월 3일로 읽을 여지가 있으므로 어느 쪽도 선택하지 않습니다. 20261005, 2026-W41-1, 전각 숫자와 아라비아 인도 숫자도 이 계약의 형식이 아닙니다. 다른 형식이 업무상 맞더라도 원자료와 기준을 확인한 뒤 별도의 정정 사본을 만들고 다시 검사하세요.

시간과 시간대도 추정하지 않습니다. 2026-10-05T00:00:00Z는 이 날짜 전용 계약에서 형식 미확정입니다. 뒤의 시간을 잘라 내거나 날짜에 자정·KST·UTC를 덧붙이지 않습니다.

흐릿한 달력 옆 원자료 카드와 확인 메모를 준비한 AI 연출 사진
AI로 만든 연출 이미지. 원래 날짜 표기와 달력 기준을 확인하는 준비 장면이며, 실제 날짜 분류·윤일 검사나 CSV 실행 결과가 아닙니다.

2 입력 CSV의 다섯 행을 확인합니다

실습 ZIP을 새 연습 폴더에 풀면 strict_dates.py와 input/dates.csv가 있습니다. input/dates.csv를 텍스트 편집기로 열어 다음 내용을 확인하세요. 마지막 행의 쉼표 뒤에는 아무 값도 없습니다.

ID,raw_date
R001,2024-02-29
R002,2025-02-29
R003,2026-10-05
R004,03/04/2026
R005,

파일은 UTF-8이며 시작 BOM은 있어도 됩니다. 머리글은 대소문자와 순서를 포함해 ID,raw_date 두 열이어야 합니다. 다른 열을 추가하거나 raw_date,ID로 순서를 바꾸면 전체 검사를 중단합니다. 데이터 레코드도 정확히 두 필드여야 하고, ID는 빈 문자열일 수 없으며 같은 문자열이 중복되면 안 됩니다. ID의 앞자리 0과 대소문자는 그대로 남깁니다.

쉼표나 줄바꿈을 담은 필드는 큰따옴표로 감싸고, 필드 안의 큰따옴표는 두 번 씁니다. 이 코드의 CSV 계약에서는 큰따옴표가 필드 시작에만 올 수 있으며, 닫힌 큰따옴표 다음에는 쉼표·줄바꿈·파일 끝만 허용합니다. 인용하지 않은 필드 중간의 큰따옴표도 오류입니다. 탭이나 세미콜론으로 구분한 파일을 자동 판별하지 않습니다.

결과 CSV는 텍스트 편집기로 확인하세요. 원문에 =, +, -, @로 시작하는 값이 있을 수 있으므로 Excel에서 더블클릭해 열거나 수식을 실행하지 마세요.

3 먼저 시험한 뒤 새 출력 이름으로 실행합니다

이 예제는 Linux의 CPython 3.12.14에서 검수했습니다. 다른 버전은 제공된 시험 전체, 특히 CR·CRLF 원문 보존 시험을 먼저 통과해야 사용하세요. 표준 라이브러리만 사용하며 별도 패키지 설치는 필요하지 않습니다. 먼저 터미널에서 ZIP을 푼 폴더로 이동하고 다음 명령으로 버전과 시험 결과를 확인하세요.

python --version
python -m unittest discover -s tests -v

검수 환경의 버전 표시는 Python 3.12.14입니다. 명령을 찾지 못하거나 다른 버전이 표시되면 해당 Python 실행 경로를 확인하세요. 시험 결과가 Ran 53 tests와 OK인지 확인한 뒤 다음 명령으로 기본 입력을 검사합니다. 하나라도 실패하면 자신의 자료를 검사하기 전에 실행 환경과 오류를 확인하세요. Windows와 Microsoft Excel은 실행 검증하지 않았습니다.

python strict_dates.py input/dates.csv date_audit.csv

출력 파일 date_audit.csv가 아직 없을 때 실행하면 다음 메시지가 나옵니다.

COMPLETED records=5 valid=2 impossible=1 format_unresolved=1 missing=1

종료코드 0은 검수가 끝나 결과 파일을 저장했다는 뜻입니다. 이 예제에는 존재 불가·형식 미확정·누락이 각각 한 행씩 있습니다. 종료코드만 보고 모든 날짜가 정상이라고 판단하지 말고 상태별 건수와 행을 함께 확인하세요.

4 원문과 상태를 한 행씩 대조합니다

텍스트 편집기로 date_audit.csv를 열면 다음 다섯 행이 나옵니다. expected/date_audit.csv가 같은 입력의 고정 비교용 파일입니다.

source_record,ID,raw_date,status,reason
1,R001,2024-02-29,valid,calendar_date_valid
2,R002,2025-02-29,impossible,calendar_date_invalid
3,R003,2026-10-05,valid,calendar_date_valid
4,R004,03/04/2026,format_unresolved,requires_source_confirmation
5,R005,,missing,empty_value

source_record는 머리글을 제외한 입력 데이터 레코드의 순서이며 1부터 시작합니다. 큰따옴표 안의 줄바꿈 때문에 한 레코드가 여러 물리적 줄에 걸쳐도 번호는 한 번만 증가합니다. 원래 ID, raw_date, 레코드 순서는 유지합니다.

R002는 YYYY-MM-DD 모양을 통과했지만 2025년 2월 29일이 없어 impossible입니다. R004는 형식 검사 단계에서 보류되므로 달력 날짜로 변환하지 않습니다. R005는 필드 자체가 빈 문자열이므로 missing입니다. 세 행 모두 결과에 남습니다.

여기서 원문 보존은 CSV를 읽은 필드 문자열을 그대로 저장한다는 뜻입니다. 입력 파일의 바이트는 변경하지 않지만 결과 CSV의 인용 방식, BOM 유무, 레코드 줄바꿈 표현까지 입력과 같다는 뜻은 아닙니다. 결과에는 새 상태 열도 추가됩니다.

5 모양 검사 다음에 달력 검사를 합니다

정규식 [0-9]{4}-[0-9]{2}-[0-9]{2}는 ASCII 숫자만 허용합니다. fullmatch()로 문자열 전체를 검사해야 앞뒤에 다른 문자가 붙은 값이 통과하지 않습니다. Python 문자열 정규식에서 기본 \d는 ASCII 외의 숫자도 포함하므로 이 계약에서는 [0-9]를 사용합니다. Python 3.12 re 공식 문서

모양을 통과한 경우에만 연·월·일을 정수로 나눠 date(year, month, day)에 넘깁니다. date()의 ValueError를 존재 불가로 기록하므로 월 13, 일 0, 4월 31일과 잘못된 윤일을 따로 거를 수 있습니다. 정규식만으로는 이런 달력 검사가 끝나지 않습니다.

Python 3.12의 date.fromisoformat()은 하이픈 없는 기본 날짜와 ISO 주차 날짜도 받아들일 수 있습니다. 메서드가 해석해 준다는 이유로 이 CSV의 계약까지 충족했다고 볼 수는 없습니다. 이번 코드는 먼저 허용 모양을 확인하고 date()로 달력만 검사합니다. Python 3.12 fromisoformat 공식 문서

빈 문자열, 정확한 ASCII 날짜 형식, 달력 유효성을 순서대로 검사해 네 상태로 분류하며 원문을 보존하는 설명 도해
설명용 도해: 빈 문자열이면 누락, 정확한 ASCII 형식이 아니면 형식 미확정, 형식은 맞지만 date() 검사가 실패하면 존재 불가, 나머지는 정상으로 분기합니다. 어떤 분기에서도 원래 문자열을 고치지 않습니다.

다음 전체 코드는 ZIP의 strict_dates.py와 같습니다.

"""Audit raw CSV calendar dates without guessing or changing input values."""
import argparse
import csv
import io
import os
import re
import sys
import tempfile
from collections import Counter
from datetime import date
from pathlib import Path

INPUT_HEADER = ['ID', 'raw_date']
OUTPUT_HEADER = ['source_record', 'ID', 'raw_date', 'status', 'reason']
STATES = ('valid', 'impossible', 'format_unresolved', 'missing')
DATE_SHAPE = re.compile(r'[0-9]{4}-[0-9]{2}-[0-9]{2}')


class AuditError(Exception):
    """The file or destination does not satisfy this audit's contract."""


def classify(raw_date):
    if raw_date == '':
        return 'missing', 'empty_value'
    if DATE_SHAPE.fullmatch(raw_date) is None:
        return 'format_unresolved', 'requires_source_confirmation'
    year, month, day = map(int, raw_date.split('-'))
    try:
        date(year, month, day)
    except ValueError:
        return 'impossible', 'calendar_date_invalid'
    return 'valid', 'calendar_date_valid'


def check_quotes(text):
    # Enforce comma-separated CSV: quote only at field start, doubled inside.
    state = 'start'
    for char in text:
        if state == 'quoted':
            if char == '"':
                state = 'after_quote'
        elif state == 'after_quote':
            if char == '"':
                state = 'quoted'
            elif char == ',' or char in '\r\n':
                state = 'start'
            else:
                raise AuditError('CSV quote error: character after closing quote')
        elif char == '"':
            if state != 'start':
                raise AuditError('CSV quote error: quote inside unquoted field')
            state = 'quoted'
        elif char == ',' or char in '\r\n':
            state = 'start'
        else:
            state = 'unquoted'
    if state == 'quoted':
        raise AuditError('CSV quote error: unclosed quoted field')


def read_records(source):
    try:
        with source.open('r', encoding='utf-8-sig', newline='') as file:
            text = file.read()
    except UnicodeError as error:
        raise AuditError('input is not valid UTF-8') from error
    check_quotes(text)
    reader = csv.reader(io.StringIO(text, newline=''), strict=True,
                        delimiter=',', quotechar='"', skipinitialspace=False)
    try:
        header = next(reader, None)
        if header != INPUT_HEADER:
            raise AuditError('header must be exactly ID,raw_date in this order')
        seen_ids = set()
        results = []
        for source_record, row in enumerate(reader, start=1):
            if len(row) != 2:
                raise AuditError(f'record {source_record}: CSV width must be 2')
            record_id, raw_date = row
            if record_id == '':
                raise AuditError(f'record {source_record}: empty ID')
            if record_id in seen_ids:
                raise AuditError(f'record {source_record}: duplicate ID')
            seen_ids.add(record_id)
            status, reason = classify(raw_date)
            results.append([source_record, record_id, raw_date, status, reason])
    except csv.Error as error:
        raise AuditError(f'CSV parsing failed: {error}') from error
    return results


def write_new_output(destination, results):
    # Publish a complete file with a hard link. Never replace an existing path.
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(
            'w', encoding='utf-8', newline='', dir=destination.parent,
            prefix='.strict_dates_', suffix='.tmp', delete=False,
        ) as file:
            temporary = Path(file.name)
            writer = csv.writer(file, lineterminator='\n')
            writer.writerow(OUTPUT_HEADER)
            writer.writerows(results)
            file.flush()
            os.fsync(file.fileno())
        try:
            os.link(temporary, destination)
        except FileExistsError as error:
            raise AuditError('output already exists; choose a new output name') from error
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)


def main(argv=None):
    parser = argparse.ArgumentParser(
        description='Audit exact ASCII YYYY-MM-DD dates; preserve raw strings.'
    )
    parser.add_argument('input', type=Path)
    parser.add_argument('output', type=Path)
    args = parser.parse_args(argv)
    try:
        if args.input.resolve() == args.output.resolve():
            raise AuditError('input and output must be different paths')
        if os.path.lexists(args.output):
            raise AuditError('output already exists; choose a new output name')
        results = read_records(args.input)
        write_new_output(args.output, results)
    except (AuditError, OSError) as error:
        print(f'FAILED: {error}', file=sys.stderr)
        return 2
    counts = Counter(row[3] for row in results)
    summary = ' '.join(f'{state}={counts[state]}' for state in STATES)
    print(f'COMPLETED records={len(results)} {summary}')
    return 0


if __name__ == '__main__':
    raise SystemExit(main())

read_records()는 입력 전체를 읽어 CSV 구조와 ID 중복을 확인합니다. 날짜 값의 문제는 classify()가 상태로 남기고, 파일 구조 문제는 AuditError로 중단합니다. csv.reader()는 필드를 문자열로 읽으며, newline=''과 명시한 UTF-8 인코딩으로 줄바꿈과 인코딩을 처리합니다. Python 3.12 csv 공식 문서

write_new_output()은 같은 출력 폴더의 임시 파일에 전체 결과를 쓴 뒤, 닫힌 완성 파일을 새 출력 이름에 하드링크로 연결합니다. 입력 오류가 마지막 레코드에 있어도 최종 보고서를 먼저 쓰지 않습니다. 이미 존재하는 출력 경로는 덮어쓰지 않으며 검사 후 다른 프로그램이 같은 이름을 만들었을 때도 연결이 실패합니다. Python 3.12 os.link 공식 문서, NamedTemporaryFile 공식 문서

하드링크를 지원하지 않거나 쓰기 권한이 없는 저장 위치에서는 종료코드 2로 실패합니다. 기존 파일을 덮어쓰는 저장 방식으로 자동 전환하지 않습니다. 쓰기와 정리가 정상적으로 끝나는 일반 실행을 시험했으며, 강제 종료·전원 차단은 시험하지 않았습니다. 갑작스러운 종료로 .strict_dates_로 시작하는 .tmp 파일이 남을 수 있으므로 이를 완성된 검수 결과로 읽지 마세요.

6 날짜 불량 행과 파일 전체 실패를 구분합니다

존재 불가·형식 미확정·누락 행을 발견해도 CSV 구조가 정상이라면 결과 파일을 저장합니다. 반면 중복 ID, 머리글 오류, 열 수 오류, 큰따옴표 오류, UTF-8 해석 실패는 어떤 행을 믿고 읽을지 불분명하므로 전체 파일을 거부합니다. 이 경우 종료코드는 2이고 새 최종 결과 파일은 없어야 합니다.

중복 ID 반례는 다음 명령으로 확인할 수 있습니다.

python strict_dates.py input/failures/duplicate_id.csv rejected_duplicate_id.csv

이 파일의 두 번째 데이터 레코드가 같은 ID를 다시 쓰므로 다음 오류가 나오고 rejected_duplicate_id.csv는 생성되지 않습니다.

FAILED: record 2: duplicate ID

원본을 보존하고 정정 사본에서 두 행이 같은 기록인지, 서로 다른 기록에 같은 ID를 붙였는지 확인하세요. 근거 없이 한 행을 지우거나 임의의 ID를 붙이면 기록의 의미가 바뀝니다.

보이는 문제복구 순서
impossible원자료에서 연·월·일 확인, 확인된 값만 정정 사본에 반영
format_unresolved제공자의 표기 기준 확인, 03/04를 추측하지 않고 정정 사본에서 계약 형식으로 기록
missing실제 날짜 누락인지 확인, 오늘 날짜나 0으로 채우지 않음
header 또는 width 오류ID,raw_date 순서와 두 필드 확인, 빈 레코드나 추가 쉼표도 점검
quote 오류원자료의 큰따옴표 시작·끝과 내부 큰따옴표 두 번 쓰기 확인
UTF-8 오류제공자에게 인코딩 확인, 확인한 원본으로 UTF-8 사본 만들기; 깨진 문자 대체 저장 금지
output already exists이전 보고서를 보존하고 새로운 출력 이름 지정

처음 명령을 같은 date_audit.csv 이름으로 다시 실행하면 FAILED: output already exists; choose a new output name 오류로 멈춥니다. 다시 검사하려면 새 이름을 씁니다.

python strict_dates.py input/dates.csv date_audit_02.csv

입력과 출력에 같은 경로를 지정해도 거부합니다. 출력 폴더는 먼저 존재해야 합니다. 경로·권한·하드링크 지원 문제로 저장이 실패했다면 오류 메시지를 확인하고, 일반 로컬 연습 폴더의 새 파일명으로 다시 실행하세요.

7 실습 파일과 경계값을 내려받아 검산합니다

실습 파일은 P08_Practice_Strict_Dates.zip, 버전 1.0.0입니다.

검수된 날짜 분류 실습 ZIP 다운로드

ZIP에는 전체 Python 코드, 기본 다섯 행, 경계값 20행, 머리글만 있는 CSV, 구조 오류 CSV 8개, 고정 기대 결과, README.txt, 시험 코드, 실제 실행 로그, 버전과 SHA-256 목록이 들어 있습니다. 가상 자료만 포함하며 실제 개인·회사 기록은 없습니다.

input/edge_cases.csv의 결과는 정상 3·존재 불가 5·형식 미확정 11·누락 1입니다. 윤년 예외, 연도 범위, ASCII 외 숫자, 공백, 날짜·시간 문자열, 큰따옴표 안의 특수문자를 원문 그대로 대조합니다. input/header_only.csv는 검사한 데이터가 0행이므로 모든 상태 건수가 0입니다. 빈 결과가 실제 업무에서 빠진 날짜가 없다는 증거는 아닙니다.

python strict_dates.py input/edge_cases.csv edge_audit.csv
python strict_dates.py input/header_only.csv header_only_audit.csv
python -m unittest discover -s tests -v

각 출력은 expected의 같은 파일명과 대조하세요. 시험 명령은 새 임시 폴더의 가상 입력만 사용하며 Ran 53 tests와 OK를 확인할 수 있습니다. 실제 Linux·Python 3.12.14 실행에서는 53개 시험과 별도 CLI 14회를 확인했습니다. 기본 결과의 2·1·1·1, 원문 필드와 입력 파일 바이트 보존, 구조 오류 시 최종 출력 없음, 기존 결과 보존을 대조했습니다.

CSV writer의 특수문자 인용 동작은 Python 버전에 따라 다를 수 있습니다. 따라서 CR이나 CRLF를 포함한 원문이 결과에서 같은 문자열로 남는지도 먼저 시험합니다. CPython 3.12.14 CSV writer 소스

이 코드는 작은 CSV 연습을 위해 파일 전체와 결과를 메모리에 보관합니다. 큰 파일의 처리 성능, 공유 폴더의 동시 변경, 데이터의 업무상 진위까지 확인하는 도구는 아닙니다. 자신의 자료를 넣을 때는 복사본을 사용하고, 먼저 날짜 계약과 ID 규칙을 확인하세요.

공식 자료와 이어서 볼 글

공식 문서 확인일은 2026년 10월 5일 UTC입니다. 참고한 Python 3.12 문서는 현재 3.12.15 표제로 제공되며, 이 실습을 실행한 인터프리터는 3.12.14입니다. 문서 열람과 해당 패치 버전의 실제 실행을 구분해 기록했습니다.

파일이 와야 하는 날짜와 실제 수신 파일을 비교하려면 명시 달력과 수신 파일 대조를 이어서 볼 수 있습니다. 날짜·시각과 오프셋을 함께 다뤄야 하는 경우는 KST와 UTC 날짜 경계 확인의 별도 입력 계약을 확인하세요.

작성자 소개