여러 Excel 시트를 출처 시트 열과 함께 하나의 표로 합치기
같은 열 구조를 사용하는 여러 Excel 시트를 하나로 합치면서 각 행이 어느 시트에서 왔는지 기록합니다. 작은 합성 통합문서를 이용해 행 순서, 개수, 합계를 손으로 확인할 수 있습니다.
여러 날짜 형식을 YYYY-MM-DD 형식으로 통일하면서 원본 CSV는 그대로 유지합니다. 허용한 형식에 맞지 않거나 실제로 존재하지 않는 날짜는 별도의 검토 파일에 기록합니다.
이런 분께 맞아요여러 날짜 형식이 섞인 CSV를 받았고, 모호한 값을 임의로 추측하지 않으면서 반복 가능한 정리 절차가 필요한 사람을 위한 안내입니다.
날짜 정리는 허용할 형식을 명확히 정했을 때 더 안전합니다. 이 예제에서는 숫자로 표현된 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일로 해석합니다. 실제 원본이 다른 규칙을 사용한다면 실제 데이터 처리 전에 허용 형식을 변경해야 합니다.
아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. 작업 폴더에 mixed_dates.csv로 저장하세요. 서로 다른 허용 형식의 유효 날짜 6개와 실패해야 하는 값 2개가 포함되어 있습니다.
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은 텍스트 월 이름을 사용하며 의도적으로 허용 형식 목록에 포함하지 않았습니다.
처음 6개 값은 모두 2026년 9월 1일부터 9월 6일까지 연속된 날짜를 의미합니다. 따라서 표준화된 값 6개가 생성되어야 합니다. 나머지 2개 레코드는 원본 문자열 그대로 검토 파일에 남아야 합니다.
| record_id | 원본 값 | 예상 표준 날짜 | 상태 |
|---|---|---|---|
| R001 | 2026-09-01 | 2026-09-01 | PARSED |
| R002 | 09/02/2026 | 2026-09-02 | PARSED |
| R003 | 2026/09/03 | 2026-09-03 | PARSED |
| R004 | 04-09-2026 | 2026-09-04 | PARSED |
| R005 | 2026.09.05 | 2026-09-05 | PARSED |
| R006 | 20260906 | 2026-09-06 | PARSED |
| R007 | 2026-02-30 | UNPARSED | |
| R008 | Sep 7 2026 | UNPARSED |
따라서 예상 개수는 전체 8개, 파싱 성공 6개, 파싱 실패 2개입니다. 정리된 CSV에는 8개 레코드가 모두 남으며, 파싱 실패 행의 standardized_date는 빈 값이고 상태는 UNPARSED가 됩니다.
다음 스크립트를 date_format_cleanup.py로 저장하세요. 원본 CSV를 읽어 전체 레코드가 포함된 정리 사본을 만들고, 날짜를 표준화하지 못한 레코드만 별도의 검토 CSV에도 기록합니다.
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()
python date_format_cleanup.pycleaned_dates.csv에는 원래 3개 열에 standardized_date와 date_status가 추가되어야 합니다. 원본 레코드 8개는 모두 유지되며, 실패한 2개 행도 삭제하지 않습니다.
| record_id | event_date | standardized_date | date_status |
|---|---|---|---|
| R001 | 2026-09-01 | 2026-09-01 | PARSED |
| R002 | 09/02/2026 | 2026-09-02 | PARSED |
| R003 | 2026/09/03 | 2026-09-03 | PARSED |
| R004 | 04-09-2026 | 2026-09-04 | PARSED |
| R005 | 2026.09.05 | 2026-09-05 | PARSED |
| R006 | 20260906 | 2026-09-06 | PARSED |
| R007 | 2026-02-30 | UNPARSED | |
| R008 | Sep 7 2026 | UNPARSED |
unparsed_dates.csv에는 데이터 행이 정확히 2개 있어야 합니다. 원본 CSV의 헤더가 물리적인 1번째 줄이므로 R007은 CSV 8번째 행, R008은 CSV 9번째 행입니다.
| row_number | record_id | event_date |
|---|---|---|
| 8 | R007 | 2026-02-30 |
| 9 | R008 | Sep 7 2026 |
아래 예상 콘솔 출력은 합성 입력과 스크립트를 바탕으로 손으로 계산한 결과입니다. 실제 실행에서 가져온 로그가 아닙니다.
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| 증상 | 확인할 사항 |
|---|---|
| FileNotFoundError | SOURCE와 터미널의 현재 작업 디렉터리를 확인하세요. |
| Missing required column | 헤더에 event_date가 정확히 있는지 확인하거나 DATE_COLUMN을 의도적으로 변경하세요. |
| 파싱 실패 값이 너무 많음 | 원본 날짜 규칙을 확인한 뒤 해당 데이터셋에서 문서화되고 모호하지 않은 형식만 추가하세요. |
| 월과 일이 예상과 반대로 해석됨 | 슬래시 또는 하이픈 날짜가 월 우선인지 일 우선인지 확인한 뒤 형식을 추가하세요. |
| FileExistsError | 결과 폴더가 이미 존재합니다. 기존 결과를 검토하고 덮어쓰지 말고 새 출력 위치를 사용하세요. |
| 정상처럼 보이는 날짜도 실패함 | 공백이나 FORMATS에 없는 형식이 포함되어 있거나, 달력상 존재하지 않는 날짜일 수 있습니다. |
실패율이 높다고 해서 추측성 형식을 많이 추가하면 안 됩니다. 그렇게 하면 원래는 발견할 수 있던 잘못된 데이터가 잘못 해석된 날짜로 조용히 바뀔 수 있습니다.
이 예제는 달력 날짜만 처리합니다. 시간, 시간대, Excel 일련번호 날짜, 지역화된 월 이름, 두 자리 연도, 설명 텍스트가 붙은 날짜는 처리하지 않습니다. 이러한 경우에는 별도의 명확한 규칙이 필요합니다.
datetime.strptime는 달력 날짜를 검증하므로 2026-02-30처럼 형태는 맞아 보이지만 존재하지 않는 날짜를 거부합니다. 하지만 파싱에 성공했다고 해서 원본 값이 사실상 정확하다는 뜻은 아닙니다. 예를 들어 2026-09-21을 입력해야 했는데 2026-09-12라고 쓴 경우 두 값 모두 달력상 유효합니다.
둘 이상의 허용 형식이 같은 문자열을 서로 다르게 해석할 수 있다면 FORMATS의 순서가 결과에 영향을 줍니다. 실제 정리 절차에서는 원본 규칙을 문서화하고, 원래 값을 보존하고, 실패 값을 보고하며, 모호한 형식은 단순 파싱에 맡기지 말고 별도로 검토해야 합니다.
2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · 표준 라이브러리: csv, datetime, pathlib · 실행하지 않음
설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.