엑셀·문서 업무

CSV의 혼합 날짜 형식을 통일하고 파싱 실패 값을 따로 목록화하기

여러 날짜 형식을 YYYY-MM-DD 형식으로 통일하면서 원본 CSV는 그대로 유지합니다. 허용한 형식에 맞지 않거나 실제로 존재하지 않는 날짜는 별도의 검토 파일에 기록합니다.

목차 보기

이런 분께 맞아요여러 날짜 형식이 섞인 CSV를 받았고, 모호한 값을 임의로 추측하지 않으면서 반복 가능한 정리 절차가 필요한 사람을 위한 안내입니다.

준비할 것
  • Python 3.12와 해당 버전을 실행하는 터미널 명령이 준비되어 있어야 합니다.
  • UTF-8 CSV 파일을 저장할 수 있는 텍스트 편집기가 필요합니다.
  • 스크립트가 outputs 아래에 새 폴더를 만들 수 있는 작업 폴더가 필요합니다.
  • Python 표준 라이브러리인 csv, datetime, pathlib만 사용합니다.

01파싱 전에 허용할 날짜 형식부터 정하기

날짜 정리는 허용할 형식을 명확히 정했을 때 더 안전합니다. 이 예제에서는 숫자로 표현된 6개 형식만 인식하고, 파싱에 성공한 값은 모두 YYYY-MM-DD로 변환합니다. 날짜 형식을 자동으로 추측하지 않습니다.

허용 입력 예시의미Python 형식
2026-09-01연-월-일%Y-%m-%d
09/02/2026월/일/연%m/%d/%Y
2026/09/03연/월/일%Y/%m/%d
04-09-2026일-월-연%d-%m-%Y
2026.09.05연.월.일%Y.%m.%d
20260906연월일%Y%m%d

슬래시와 하이픈 형식을 어떻게 해석할지는 데이터 정책의 문제입니다. 이 글에서는 09/02/2026을 2026년 9월 2일로, 04-09-2026을 2026년 9월 4일로 해석합니다. 실제 원본이 다른 규칙을 사용한다면 실제 데이터 처리 전에 허용 형식을 변경해야 합니다.

02작은 합성 CSV 만들기

아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. 작업 폴더에 mixed_dates.csv로 저장하세요. 서로 다른 허용 형식의 유효 날짜 6개와 실패해야 하는 값 2개가 포함되어 있습니다.

csv
record_id,event_date,note
R001,2026-09-01,ISO format
R002,09/02/2026,US slash format
R003,2026/09/03,year first with slashes
R004,04-09-2026,day first with hyphens
R005,2026.09.05,dot separated
R006,20260906,compact numeric
R007,2026-02-30,invalid calendar date
R008,Sep 7 2026,unsupported text format

데이터 레코드는 8개입니다. 처음 6개는 파싱에 성공해야 합니다. R007은 인식 가능한 형태이지만 2월 30일은 실제 달력에 존재하지 않습니다. R008은 텍스트 월 이름을 사용하며 의도적으로 허용 형식 목록에 포함하지 않았습니다.

03예상 정리 결과를 손으로 계산하기

처음 6개 값은 모두 2026년 9월 1일부터 9월 6일까지 연속된 날짜를 의미합니다. 따라서 표준화된 값 6개가 생성되어야 합니다. 나머지 2개 레코드는 원본 문자열 그대로 검토 파일에 남아야 합니다.

record_id원본 값예상 표준 날짜상태
R0012026-09-012026-09-01PARSED
R00209/02/20262026-09-02PARSED
R0032026/09/032026-09-03PARSED
R00404-09-20262026-09-04PARSED
R0052026.09.052026-09-05PARSED
R006202609062026-09-06PARSED
R0072026-02-30UNPARSED
R008Sep 7 2026UNPARSED

따라서 예상 개수는 전체 8개, 파싱 성공 6개, 파싱 실패 2개입니다. 정리된 CSV에는 8개 레코드가 모두 남으며, 파싱 실패 행의 standardized_date는 빈 값이고 상태는 UNPARSED가 됩니다.

04승인된 형식만 파싱하고 새 파일로 저장하기

다음 스크립트를 date_format_cleanup.py로 저장하세요. 원본 CSV를 읽어 전체 레코드가 포함된 정리 사본을 만들고, 날짜를 표준화하지 못한 레코드만 별도의 검토 CSV에도 기록합니다.

python
import csv
from datetime import datetime
from pathlib import Path

SOURCE = Path("mixed_dates.csv")
OUTPUT_DIR = Path("outputs") / "date_cleanup_result"
CLEANED = OUTPUT_DIR / "cleaned_dates.csv"
UNPARSED = OUTPUT_DIR / "unparsed_dates.csv"
DATE_COLUMN = "event_date"

FORMATS = (
    "%Y-%m-%d",
    "%m/%d/%Y",
    "%Y/%m/%d",
    "%d-%m-%Y",
    "%Y.%m.%d",
    "%Y%m%d",
)


def parse_date(value: str) -> str | None:
    text = value.strip()
    for format_string in FORMATS:
        try:
            parsed = datetime.strptime(text, format_string)
        except ValueError:
            continue
        return parsed.strftime("%Y-%m-%d")
    return None


def main() -> None:
    if not SOURCE.is_file():
        raise FileNotFoundError(f"Source CSV not found: {SOURCE}")

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()  # Stop rather than reuse an existing result folder.

    with SOURCE.open("r", encoding="utf-8-sig", newline="") as source:
        reader = csv.DictReader(source)
        if reader.fieldnames is None:
            raise ValueError("CSV has no header row.")
        if DATE_COLUMN not in reader.fieldnames:
            raise ValueError(f"Missing required column: {DATE_COLUMN}")
        if "standardized_date" in reader.fieldnames or "date_status" in reader.fieldnames:
            raise ValueError("Output column name already exists in source CSV.")

        cleaned_fields = [*reader.fieldnames, "standardized_date", "date_status"]
        review_fields = ["row_number", *reader.fieldnames]

        with CLEANED.open("x", encoding="utf-8", newline="") as cleaned_stream, \
             UNPARSED.open("x", encoding="utf-8", newline="") as review_stream:
            cleaned_writer = csv.DictWriter(cleaned_stream, fieldnames=cleaned_fields)
            review_writer = csv.DictWriter(review_stream, fieldnames=review_fields)
            cleaned_writer.writeheader()
            review_writer.writeheader()

            total = 0
            parsed_count = 0
            unparsed_count = 0

            for row_number, row in enumerate(reader, start=2):
                total += 1
                original_value = row[DATE_COLUMN]
                standardized = parse_date(original_value)

                output_row = dict(row)
                if standardized is None:
                    output_row["standardized_date"] = ""
                    output_row["date_status"] = "UNPARSED"
                    review_writer.writerow({"row_number": row_number, **row})
                    unparsed_count += 1
                else:
                    output_row["standardized_date"] = standardized
                    output_row["date_status"] = "PARSED"
                    parsed_count += 1

                cleaned_writer.writerow(output_row)

    if parsed_count + unparsed_count != total:
        raise RuntimeError("Record counts do not balance.")

    print(f"Processed {total} records.")
    print(f"Parsed: {parsed_count}; unparsed: {unparsed_count}.")
    print(f"Cleaned CSV: {CLEANED.as_posix()}")
    print(f"Review CSV: {UNPARSED.as_posix()}")


if __name__ == "__main__":
    main()
text
python date_format_cleanup.py

05예상 출력 파일과 비교하기

cleaned_dates.csv에는 원래 3개 열에 standardized_date와 date_status가 추가되어야 합니다. 원본 레코드 8개는 모두 유지되며, 실패한 2개 행도 삭제하지 않습니다.

record_idevent_datestandardized_datedate_status
R0012026-09-012026-09-01PARSED
R00209/02/20262026-09-02PARSED
R0032026/09/032026-09-03PARSED
R00404-09-20262026-09-04PARSED
R0052026.09.052026-09-05PARSED
R006202609062026-09-06PARSED
R0072026-02-30UNPARSED
R008Sep 7 2026UNPARSED

unparsed_dates.csv에는 데이터 행이 정확히 2개 있어야 합니다. 원본 CSV의 헤더가 물리적인 1번째 줄이므로 R007은 CSV 8번째 행, R008은 CSV 9번째 행입니다.

row_numberrecord_idevent_date
8R0072026-02-30
9R008Sep 7 2026

06개수와 콘솔 출력 확인하기

아래 예상 콘솔 출력은 합성 입력과 스크립트를 바탕으로 손으로 계산한 결과입니다. 실제 실행에서 가져온 로그가 아닙니다.

text
Processed 8 records.
Parsed: 6; unparsed: 2.
Cleaned CSV: outputs/date_cleanup_result/cleaned_dates.csv
Review CSV: outputs/date_cleanup_result/unparsed_dates.csv
  1. cleaned_dates.csv에 성공한 6개만 있는 것이 아니라 데이터 행 8개가 모두 있는지 확인합니다.
  2. R001부터 R006까지 standardized_date가 2026-09-01부터 2026-09-06까지 이어지는지 확인합니다.
  3. R007과 R008의 standardized_date가 비어 있고 date_status가 UNPARSED인지 확인합니다.
  4. unparsed_dates.csv에 R007과 R008만 들어 있는지 확인합니다.
  5. OUTPUT_DIR을 바꾸지 않고 스크립트를 다시 실행합니다. 이전 결과를 덮어쓰지 않고 FileExistsError로 중단되어야 합니다.

07자주 발생하는 오류 확인하기

증상확인할 사항
FileNotFoundErrorSOURCE와 터미널의 현재 작업 디렉터리를 확인하세요.
Missing required column헤더에 event_date가 정확히 있는지 확인하거나 DATE_COLUMN을 의도적으로 변경하세요.
파싱 실패 값이 너무 많음원본 날짜 규칙을 확인한 뒤 해당 데이터셋에서 문서화되고 모호하지 않은 형식만 추가하세요.
월과 일이 예상과 반대로 해석됨슬래시 또는 하이픈 날짜가 월 우선인지 일 우선인지 확인한 뒤 형식을 추가하세요.
FileExistsError결과 폴더가 이미 존재합니다. 기존 결과를 검토하고 덮어쓰지 말고 새 출력 위치를 사용하세요.
정상처럼 보이는 날짜도 실패함공백이나 FORMATS에 없는 형식이 포함되어 있거나, 달력상 존재하지 않는 날짜일 수 있습니다.

실패율이 높다고 해서 추측성 형식을 많이 추가하면 안 됩니다. 그렇게 하면 원래는 발견할 수 있던 잘못된 데이터가 잘못 해석된 날짜로 조용히 바뀔 수 있습니다.

08실제 데이터로 확장하기 전 한계 이해하기

이 예제는 달력 날짜만 처리합니다. 시간, 시간대, Excel 일련번호 날짜, 지역화된 월 이름, 두 자리 연도, 설명 텍스트가 붙은 날짜는 처리하지 않습니다. 이러한 경우에는 별도의 명확한 규칙이 필요합니다.

datetime.strptime는 달력 날짜를 검증하므로 2026-02-30처럼 형태는 맞아 보이지만 존재하지 않는 날짜를 거부합니다. 하지만 파싱에 성공했다고 해서 원본 값이 사실상 정확하다는 뜻은 아닙니다. 예를 들어 2026-09-21을 입력해야 했는데 2026-09-12라고 쓴 경우 두 값 모두 달력상 유효합니다.

둘 이상의 허용 형식이 같은 문자열을 서로 다르게 해석할 수 있다면 FORMATS의 순서가 결과에 영향을 줍니다. 실제 정리 절차에서는 원본 규칙을 문서화하고, 원래 값을 보존하고, 실패 값을 보고하며, 모호한 형식은 단순 파싱에 맡기지 말고 별도로 검토해야 합니다.

실행·검증 기록

2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · 표준 라이브러리: csv, datetime, pathlib · 실행하지 않음

  • 원본 레코드가 8개이며 성공 예상 6개, 실패 예상 2개임을 수동으로 확인했습니다.
  • 허용된 입력 6개를 2026-09-01부터 2026-09-06까지의 연속된 표준 날짜로 수동 변환했습니다.
  • 달력 기준으로 2026-02-30이 유효하지 않고 Sep 7 2026이 FORMATS의 어떤 형식에도 맞지 않음을 확인했습니다.
  • 원본 CSV의 물리적 줄 위치를 기준으로 검토 행 번호가 8과 9임을 수동으로 도출했습니다.
  • 원본 CSV는 읽기만 하고 결과는 별도 폴더에 저장하며, 기존 결과 폴더가 있으면 실행을 중단하도록 작성되었는지 코드를 검토했습니다.
  • 전체, 성공, 실패 개수와 예상 콘솔 메시지를 손으로 계산했습니다.
검증 한계
  • 이 응답의 작성자는 코드를 실행하지 않았으며, CSV 출력 파일을 실제로 만들지 않았습니다.
  • 지역화된 월 이름, 시간, 시간대, Excel 일련번호 날짜, 두 자리 연도, 매우 큰 파일은 시험하지 않았습니다.
  • 허용 날짜 규칙은 예시 정책이며 실제 원본이 다른 의미를 사용한다면 반드시 수정해야 합니다.
  • 공식 문서 URL은 알려진 문서 위치를 사용했지만 실시간으로 접속해 확인하지 않았습니다.

사이트 전체 작성·검증 원칙

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.