엑셀·문서 업무

여러 Excel 시트를 출처 시트 열과 함께 하나의 표로 합치기

같은 열 구조를 사용하는 여러 Excel 시트를 하나로 합치면서 각 행이 어느 시트에서 왔는지 기록합니다. 작은 합성 통합문서를 이용해 행 순서, 개수, 합계를 손으로 확인할 수 있습니다.

목차 보기

이런 분께 맞아요구조가 같은 여러 Excel 시트를 하나의 데이터셋으로 합치되 원본 통합문서는 변경하지 않으려는 사람을 위한 안내입니다.

준비할 것
  • Python 3.12와 해당 버전을 실행하는 터미널 명령이 준비되어 있어야 합니다.
  • python -m pip install openpyxl 명령으로 openpyxl이 설치되어 있어야 합니다.
  • 스크립트가 outputs 아래에 새 폴더를 만들 수 있는 작업 폴더가 필요합니다.
  • 합칠 시트들은 같은 열 이름을 같은 순서로 사용해야 합니다.

01무엇을 어떤 규칙으로 합칠지 정의하기

이 작업은 하나의 통합문서에서 지정한 여러 워크시트를 읽어 새로운 통합문서 하나로 합칩니다. 모든 원본 시트의 헤더는 열 순서와 대소문자까지 정확히 같아야 합니다. 출력에서는 첫 번째 열에 source_sheet를 추가해 각 레코드가 어느 시트에서 왔는지 유지합니다.

완전히 빈 행은 무시합니다. 비어 있지 않은 행은 지정한 워크시트 순서대로 합치고, 각 시트 안에서는 원래의 위에서 아래 순서를 유지합니다. 원본 통합문서는 읽기 전용으로 열고 결과는 outputs 아래의 별도 폴더에 저장합니다.

02합성 Excel 통합문서 만들기

아래 통합문서는 이 글을 위해 작성한 합성 데이터입니다. 준비용 스크립트를 create_excel_merge_demo.py로 저장하세요. East, West, Online이라는 시트 3개를 만들며, 모두 order_id, customer, amount라는 동일한 열을 사용합니다.

python
from pathlib import Path
from openpyxl import Workbook

SOURCE_DIR = Path("outputs") / "excel_merge_demo"
SOURCE = SOURCE_DIR / "regional_orders.xlsx"
HEADERS = ["order_id", "customer", "amount"]
DATA = {
    "East": [
        ["E001", "Ana", 120],
        ["E002", "Ben", 80],
    ],
    "West": [
        ["W001", "Cara", 150],
        ["W002", "Dan", 70],
    ],
    "Online": [
        ["O001", "Emi", 90],
        [None, None, None],
        ["O002", "Finn", 110],
    ],
}

SOURCE_DIR.parent.mkdir(parents=True, exist_ok=True)
SOURCE_DIR.mkdir()  # Stop if the synthetic source folder already exists.

workbook = Workbook()
for index, (sheet_name, rows) in enumerate(DATA.items()):
    if index == 0:
        sheet = workbook.active
        sheet.title = sheet_name
    else:
        sheet = workbook.create_sheet(sheet_name)
    sheet.append(HEADERS)
    for row in rows:
        sheet.append(row)

workbook.save(SOURCE)
text
python create_excel_merge_demo.py
시트비어 있지 않은 데이터 행amount 합계
East2200
West2220
Online2200

비어 있지 않은 데이터 레코드는 총 6개입니다. amount의 전체 합계는 620입니다. 계산식은 120 + 80 + 150 + 70 + 90 + 110입니다. Online에는 두 레코드 사이에 완전히 빈 행 하나를 의도적으로 넣어, 합칠 때 빈 행이 제외되는지 확인할 수 있게 했습니다.

03예상되는 결합 행 순서를 손으로 계산하기

스크립트에서는 시트 순서를 East, West, Online으로 명시합니다. 이 순서가 결합된 레코드의 순서를 결정합니다. 각 시트 안에서는 원래의 위에서 아래 행 순서를 그대로 유지합니다.

source_sheetorder_idcustomeramount
EastE001Ana120
EastE002Ben80
WestW001Cara150
WestW002Dan70
OnlineO001Emi90
OnlineO002Finn110

따라서 출력은 4개 열을 가지며 헤더까지 포함하면 워크시트 행은 총 7개입니다. 헤더 1행과 데이터 6행입니다. source_sheet 값만 세어도 수동 검증이 가능합니다. East가 2번, West가 2번, Online이 2번 나와야 합니다.

04시트를 새 통합문서로 합치기

다음 스크립트를 excel_merge_sheets.py로 저장하세요. 원본 통합문서를 읽기 전용으로 열고, 선택한 시트 이름과 헤더를 검증하며, 완전히 빈 행은 건너뜁니다. 또한 수식이 원래 통합문서의 문맥에서 분리된 채 복사되는 일을 피하기 위해 수식 셀이 발견되면 중단합니다.

python
from pathlib import Path
from openpyxl import Workbook, load_workbook

SOURCE = Path("outputs") / "excel_merge_demo" / "regional_orders.xlsx"
OUTPUT_DIR = Path("outputs") / "excel_merge_result"
OUTPUT = OUTPUT_DIR / "merged_orders.xlsx"
SHEETS = ("East", "West", "Online")
OUTPUT_SHEET = "Combined"
SOURCE_COLUMN = "source_sheet"


def read_sheet_rows(sheet, expected_header=None):
    first_row = next(
        sheet.iter_rows(min_row=1, max_row=1, values_only=True),
        None,
    )
    if first_row is None:
        raise ValueError(f"Sheet is empty: {sheet.title}")

    header = tuple(first_row)
    if any(not isinstance(name, str) or not name.strip() for name in header):
        raise ValueError(f"Invalid header in sheet: {sheet.title}")
    if len(set(header)) != len(header):
        raise ValueError(f"Duplicate header name in sheet: {sheet.title}")
    if SOURCE_COLUMN in header:
        raise ValueError(f"Reserved column already exists: {SOURCE_COLUMN}")
    if expected_header is not None and header != expected_header:
        raise ValueError(f"Header mismatch in sheet: {sheet.title}")

    rows = []
    for excel_row, values in enumerate(
        sheet.iter_rows(min_row=2, max_col=len(header), values_only=True),
        start=2,
    ):
        if all(value is None for value in values):
            continue
        if any(isinstance(value, str) and value.startswith("=") for value in values):
            raise ValueError(
                f"Formula found in {sheet.title} row {excel_row}; "
                "this example merges literal values only."
            )
        rows.append(tuple(values))

    return header, rows


def main() -> None:
    source_path = SOURCE.resolve(strict=True)
    if not source_path.is_file():
        raise ValueError("SOURCE must be an Excel file.")
    if len(set(SHEETS)) != len(SHEETS):
        raise ValueError("SHEETS contains a duplicate sheet name.")

    source_workbook = load_workbook(
        source_path,
        read_only=True,
        data_only=False,
    )
    try:
        missing = [name for name in SHEETS if name not in source_workbook.sheetnames]
        if missing:
            raise ValueError(f"Missing sheets: {missing}")

        expected_header = None
        combined_rows = []
        for sheet_name in SHEETS:
            sheet = source_workbook[sheet_name]
            header, rows = read_sheet_rows(sheet, expected_header)
            if expected_header is None:
                expected_header = header
            for row in rows:
                combined_rows.append((sheet_name, *row))
    finally:
        source_workbook.close()

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()  # Refuse to reuse an existing output folder.

    output_workbook = Workbook()
    output_sheet = output_workbook.active
    output_sheet.title = OUTPUT_SHEET
    output_sheet.append([SOURCE_COLUMN, *expected_header])
    for row in combined_rows:
        output_sheet.append(row)
    output_workbook.save(OUTPUT)

    check_workbook = load_workbook(OUTPUT, read_only=True, data_only=False)
    try:
        if check_workbook.sheetnames != [OUTPUT_SHEET]:
            raise RuntimeError("Unexpected output worksheet structure.")
        check_sheet = check_workbook[OUTPUT_SHEET]
        written = list(check_sheet.iter_rows(values_only=True))
    finally:
        check_workbook.close()

    expected = [(SOURCE_COLUMN, *expected_header), *combined_rows]
    if written != expected:
        raise RuntimeError("Saved workbook does not match the expected rows.")

    print(f"Merged {len(SHEETS)} sheets into {len(combined_rows)} data rows.")
    print(f"Verified {len(expected_header) + 1} columns and {len(combined_rows)} data rows.")
    print(f"Output: {OUTPUT.as_posix()}")


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

05예상 결과와 비교하기

합성 통합문서의 출력에는 Combined라는 워크시트 하나만 있어야 합니다. 첫 번째 행은 source_sheet, order_id, customer, amount이고 그 아래에는 앞에서 계산한 데이터 6개가 들어가야 합니다.

아래 예상 콘솔 출력은 코드와 합성 데이터를 바탕으로 손으로 도출한 결과입니다. 실제 실행에서 가져온 로그가 아닙니다.

text
Merged 3 sheets into 6 data rows.
Verified 4 columns and 6 data rows.
Output: outputs/excel_merge_result/merged_orders.xlsx
확인 항목예상 결과
선택한 원본 시트3
결합된 데이터 행6
출력 열4
East 행2
West 행2
Online 행2
amount 합계620

06실제 데이터에 적용하기 전에 확인하기

  1. merged_orders.xlsx를 열고 Combined라는 워크시트 하나만 있는지 확인합니다.
  2. source_sheet가 첫 번째 열이고 원래의 3개 열이 동일한 순서로 이어지는지 확인합니다.
  3. source_sheet별 레코드 수를 셉니다. East, West, Online이 각각 정확히 2개여야 합니다.
  4. amount 열을 직접 더하거나 Excel로 합계를 계산합니다. 예상 합계는 620입니다.
  5. Online에 있던 빈 행이 결합된 결과의 빈 레코드로 들어가지 않았는지 확인합니다.
  6. OUTPUT_DIR을 바꾸지 않고 스크립트를 다시 실행합니다. 이전 결과를 교체하지 않고 FileExistsError로 중단되어야 합니다.

실제 통합문서에서는 모든 시트를 자동으로 합치기보다 SHEETS를 명시적으로 지정하는 편이 안전합니다. 같은 파일 안에 있는 조회표, 메모, 숨겨진 시트, 요약 시트 등을 실수로 포함할 가능성을 줄일 수 있습니다.

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

증상확인할 사항
ModuleNotFoundError: No module named openpyxl같은 Python 환경에 python -m pip install openpyxl 명령으로 openpyxl을 설치하세요.
FileNotFoundErrorSOURCE와 터미널의 작업 디렉터리를 확인하세요. 이 예제에서는 합성 통합문서 준비용 스크립트를 먼저 실행해야 합니다.
Missing sheetsSHEETS의 이름을 공백과 대소문자까지 정확히 확인하세요.
Header mismatch선택한 모든 시트가 동일한 열 이름을 동일한 순서로 사용하는지 확인하세요.
Reserved column already exists원본의 source_sheet 열 이름을 바꾸거나 SOURCE_COLUMN을 충돌하지 않는 이름으로 변경하세요.
Formula found합치기 전에 수식을 어떻게 처리할지 결정하세요. 이 예제는 문맥이 없는 수식 표현식을 그대로 복사하지 않고 중단하도록 작성했습니다.
FileExistsError출력 폴더가 이미 존재합니다. 이전 결과를 확인하고 덮어쓰지 말고 새 대상 위치를 사용하세요.

OUTPUT_DIR을 만든 뒤 저장이나 검증에서 실패하면 폴더 또는 통합문서가 일부 결과로 남을 수 있습니다. 검증되지 않은 결과로 취급하고 문제를 수정한 뒤에는 새 대상 위치를 사용하세요.

08적용 범위와 한계 이해하기

이 스크립트는 헤더가 한 줄인 직사각형 데이터 시트를 대상으로 합니다. 여러 줄 헤더, 병합된 헤더 셀, 피벗 테이블, 차트, 이미지, 하나의 시트에 서로 관련 없는 표가 여러 개 있는 구조는 처리 대상으로 삼지 않습니다.

통합되는 것은 셀 값뿐입니다. 숫자 표시 형식, 글꼴, 채우기, 조건부 서식, 데이터 유효성 검사, 하이퍼링크, 메모, 수식, 행 높이, 열 너비, 워크시트 설정은 복제하지 않습니다. 날짜와 숫자 자체는 값으로 전달될 수 있지만 원래의 표시 형식은 복사하지 않습니다.

또한 이 스크립트는 출력 통합문서를 만들기 전에 결합할 모든 행 튜플을 Python 메모리에 저장합니다. 이 작은 검증 예제에는 편리하지만 매우 큰 통합문서에는 적합하지 않을 수 있습니다. 큰 파일이라면 write-only 출력을 사용하는 스트리밍 방식과 별도의 증분 검증을 적용하면 메모리 사용량을 줄일 수 있습니다.

실행·검증 기록

2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · openpyxl 필요 · 실행하지 않음

  • East에서 2개, West에서 2개, Online에서 2개의 비어 있지 않은 레코드를 수동으로 세어 결합 레코드가 총 6개임을 확인했습니다.
  • 시트별 amount 합계를 200, 220, 200으로 계산했고 전체 합계가 620임을 수동으로 확인했습니다.
  • 예상 출력 순서를 East, West, Online 순서로 수동 도출했으며 Online의 완전히 빈 행은 제외되는 것으로 확인했습니다.
  • 원본 통합문서를 읽기 전용으로 열고 결과를 별도의 outputs 폴더에 쓰도록 작성되었는지 코드를 검토했습니다.
  • 헤더 검사, 예약된 source_sheet 검사, 빈 행 처리, 수식 거부, 저장 후 행 비교 로직을 검토했습니다.
  • 예상 출력 열 4개, 데이터 행 6개, 콘솔 메시지를 손으로 도출했습니다.
검증 한계
  • 이 응답의 작성자는 코드를 실행하지 않았으며, XLSX 파일을 실제로 만들거나 열지 않았습니다.
  • 수식이 있는 통합문서, 병합 셀, 서식, 날짜, 하이퍼링크, 숨겨진 시트, 손상된 파일, 매우 큰 통합문서는 시험하지 않았습니다.
  • 합성 통합문서에 대한 openpyxl 동작은 코드를 바탕으로 검토했지만 Python 실행 환경에서 확인하지 않았습니다.
  • 공식 문서 URL은 알려진 문서 위치를 사용했지만 실시간으로 접속해 확인하지 않았습니다.

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

참고 출처

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