여러 PDF를 한 파일로 합치는 작업은 단순해 보였지만, 실제로 만들어 보니 파일 순서와 필요한 페이지만 고르는 기능, 잘못 돌아간 페이지의 회전, 손상된 문서 차단까지 함께 처리해야 했습니다. 특히 병합이 끝났다는 메시지만 믿지 않고 저장된 파일을 다시 열어 페이지 수와 회전 상태를 확인하는 과정이 필요했습니다.
이번에는 Python의 tkinter와 pypdf를 사용해 PDF 병합·페이지 정리 도구를 직접 제작했습니다. 정상 PDF 3개뿐 아니라 암호화된 PDF와 일부러 손상시킨 PDF도 넣어 보면서 어떤 상황에서 작업을 중단해야 하는지까지 검증했습니다.
이번에 만든 프로그램의 목표
- 여러 PDF 파일 또는 폴더를 한 번에 불러옵니다.
- 파일별 전체 페이지 수와 오류 상태를 분석합니다.
- 위·아래 버튼으로 병합 순서를 바꿉니다.
전체,1-3,5처럼 사용할 페이지를 지정합니다.- 파일별로 0·90·180·270도 회전을 적용합니다.
- 저장 전 최종 페이지 구성을 미리 확인합니다.
- 원본을 수정하지 않고 새 PDF로 저장합니다.
- 저장된 PDF를 다시 열어 페이지 수와 회전 상태를 검증합니다.

테스트용 PDF 3개를 준비했습니다
기능을 확인하기 위해 내용과 색상이 서로 다른 PDF 3개를 준비했습니다. 보고서는 3쪽, 첨부자료와 안내문은 각각 2쪽으로 만들어 전체를 합치면 7쪽이 되도록 했습니다.
| 테스트 파일 | 전체 쪽 | 적용한 규칙 |
|---|---|---|
| 01_보고서.pdf | 3쪽 | 전체, 회전 없음 |
| 02_첨부자료.pdf | 2쪽 | 상황에 따라 전체 또는 2쪽만, 90도 회전 |
| 03_안내문.pdf | 2쪽 | 전체, 회전 없음 |

순서와 회전을 먼저 미리보기로 확인했습니다
먼저 세 문서를 모두 사용하고 첨부자료 두 쪽에 90도 회전을 적용했습니다. 미리보기에는 결과 1쪽부터 7쪽까지 각각 어느 파일의 몇 번째 페이지인지 표시되었고, 첨부자료에 해당하는 결과 4·5쪽만 90도로 표시되었습니다.

필요한 페이지만 선택하니 결과가 6쪽으로 바뀌었습니다
다음에는 첨부자료의 페이지 범위에 2를 입력하고 90도 회전을 적용했습니다. 파일 순서도 첨부자료, 보고서, 안내문 순서로 바꿨습니다. 미리보기 결과는 총 6쪽이었으며, 첫 번째 결과 페이지만 첨부자료의 원본 2쪽과 90도 회전으로 표시되었습니다.

핵심 코드 1: 1-3,5 형식의 페이지 범위를 안전하게 해석했습니다
화면에서 입력하는 페이지 번호는 1부터 시작하지만 Python 목록은 0부터 시작합니다. 이 차이를 놓치면 다른 페이지가 들어가는 오류가 생기기 쉽습니다. 먼저 사용자 입력을 1부터 시작하는 페이지 번호로 검증한 뒤, 실제 PDF를 읽을 때만 1을 빼도록 분리했습니다.
def parse_page_range(text, page_count):
normalized = (text or "").strip().replace(",", ",")
if not normalized or normalized.casefold() in {"전체", "all", "*"}:
return tuple(range(1, page_count + 1))
pages = []
seen = set()
for token in normalized.split(","):
token = token.strip()
single = re.fullmatch(r"\d+", token)
page_range = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if single:
candidates = [int(token)]
elif page_range:
start, end = map(int, page_range.groups())
if start > end:
raise PdfOrganizerError("내림차순 범위는 사용할 수 없습니다.")
candidates = range(start, end + 1)
else:
raise PdfOrganizerError("페이지 범위 형식이 올바르지 않습니다.")
for number in candidates:
if not 1 <= number <= page_count:
raise PdfOrganizerError("문서 범위를 벗어난 페이지입니다.")
if number in seen:
raise PdfOrganizerError("같은 페이지가 두 번 선택되었습니다.")
seen.add(number)
pages.append(number)
return tuple(pages)
1-3,5를 단순히 문자열로 나누는 것만으로는 부족했습니다. 내림차순 범위, 문서의 마지막 쪽을 넘는 숫자, 중복 선택, 쉼표 사이의 빈 값까지 차단해야 미리보기와 실제 저장 결과를 일치시킬 수 있었습니다.
핵심 코드 2: 선택한 페이지만 회전해 병합했습니다
writer = PdfWriter()
for planned in plan.pages:
reader = PdfReader(str(planned.source_path), strict=False)
if reader.is_encrypted:
raise PdfOrganizerError("암호화된 PDF는 병합할 수 없습니다.")
# 화면의 페이지 번호는 1부터 시작하므로 여기서 1을 뺍니다.
page = reader.pages[planned.source_page - 1]
if planned.rotation:
page.rotate(planned.rotation)
writer.add_page(page)
회전은 페이지를 추가하기 직전에 적용했습니다. rotate(90)처럼 90도 단위로 처리하면 페이지 내용뿐 아니라 PDF 페이지 상자의 방향도 함께 관리할 수 있어 결과가 잘리는 문제를 줄일 수 있었습니다.
저장은 원본과 분리하고, 완료 후 다시 검증했습니다
원본 PDF를 직접 덮어쓰지 않도록 결과 파일은 새 이름으로만 저장했습니다. 같은 이름의 파일이 이미 있거나 원본 경로를 결과 경로로 지정하면 작업을 중단했습니다. 또한 최종 경로에 바로 쓰지 않고 같은 폴더의 임시 파일에 먼저 쓴 뒤 저장이 끝났을 때만 이름을 바꾸도록 했습니다.
with tempfile.NamedTemporaryFile(
mode="wb",
delete=False,
dir=output_path.parent,
prefix=".jklab365_pdf_",
suffix=".tmp",
) as temp_file:
temp_path = Path(temp_file.name)
writer.write(temp_file)
temp_path.replace(output_path)

저장이 끝났다는 사실만으로 파일이 정상이라고 판단하지 않았습니다. 저장 파일을 PdfReader로 다시 열어 실제 페이지 수와 각 페이지의 회전값을 미리보기 계획과 비교했습니다.
reader = PdfReader(str(saved_path), strict=False)
page_count = len(reader.pages)
rotations = tuple(
int(getattr(page, "rotation", 0) or 0) % 360
for page in reader.pages
)
if page_count != expected_count:
raise PdfOrganizerError("예상한 페이지 수와 다릅니다.")
if rotations != expected_rotations:
raise PdfOrganizerError("미리보기와 회전 상태가 다릅니다.")

실제 PDF에서도 회전 결과를 확인했습니다
프로그램 안의 숫자만 확인하지 않고 생성된 PDF를 직접 열었습니다. 보고서 뒤에 들어간 첨부자료 두 페이지가 가로 방향으로 회전했고, 그 다음 안내문이 다시 세로 방향으로 이어지는 것을 확인했습니다.

암호화된 PDF와 손상된 PDF는 병합 전에 차단했습니다
정상 파일만 테스트하면 프로그램의 안전성을 확인하기 어렵습니다. 그래서 암호가 설정된 PDF와 PDF 구조가 중간에 끊어진 손상 파일을 별도로 준비했습니다. 프로그램은 두 파일을 빨간색 오류 상태로 표시했고, 페이지 미리보기와 저장을 진행하지 못하도록 막았습니다.
try:
reader = PdfReader(str(path), strict=False)
if reader.is_encrypted:
raise PdfOrganizerError(
f"{path.name}: 암호화된 PDF는 병합할 수 없습니다."
)
page_count = len(reader.pages)
except (PdfReadError, OSError, ValueError, TypeError) as exc:
raise PdfOrganizerError(
f"{path.name}: PDF 형식을 읽을 수 없습니다: {exc}"
) from exc

직접 테스트한 결과
| 검증 항목 | 결과 |
|---|---|
| 정상 PDF 3개 분석 | 3쪽·2쪽·2쪽으로 정상 인식했습니다. |
| 전체 페이지 병합 | 총 7쪽으로 미리보기와 저장에 성공했습니다. |
| 페이지 범위 선택 | 첨부자료 2쪽만 선택해 총 6쪽으로 구성했습니다. |
| 90도 회전 | 미리보기, 저장 파일, 실제 PDF 화면에서 확인했습니다. |
| 원본 보호 | 새 파일로 저장했고 원본 PDF는 변경하지 않았습니다. |
| 저장 후 재검증 | 페이지 수와 회전 상태가 미리보기와 일치했습니다. |
| 암호화 PDF | 분석 단계에서 오류로 차단했습니다. |
| 손상된 PDF | PDF 형식을 읽을 수 없다는 오류로 차단했습니다. |
실수하기 쉬웠던 부분
- 페이지 번호 기준: 화면은 1부터 시작하지만
reader.pages는 0부터 시작합니다. - 미리보기와 저장의 규칙 분리: 두 단계에서 서로 다른 계산을 하면 결과가 달라질 수 있어 동일한 병합 계획을 공유했습니다.
- 회전 적용 위치: 선택한 페이지를 가져온 다음
writer.add_page()전에 회전했습니다. - 원본 덮어쓰기: 결과 경로가 원본과 같거나 기존 파일과 겹치면 저장하지 않았습니다.
- 완료 메시지만 신뢰하지 않기: 저장된 파일을 다시 읽어 실제 쪽 수와 회전값을 비교했습니다.
- 오류 파일 섞임: 한 파일이라도 암호화되었거나 손상됐다면 병합 전에 오류 원인을 표시했습니다.
실행에 필요한 명령어
Python이 설치된 Windows 환경에서 pypdf를 설치한 뒤 프로그램을 실행했습니다.
py -m pip install pypdf
py main.py
이번 프로그램을 만들면서 PDF 병합 자체보다 병합 전에 결과를 예측하고, 잘못된 입력을 차단하며, 저장한 결과를 다시 검증하는 과정이 더 중요하다는 것을 확인했습니다. 다음 단계에서는 이 프로그램도 Python이 설치되지 않은 PC에서 실행할 수 있도록 EXE 파일로 묶어 실제 실행까지 확인해 볼 수 있습니다.