pathlib로 os.path 대체하기: 현대적인 파일 경로 처리

1. 개요

오래된 Python 코드는 os.path.join, os.path.splitext, os.listdir… 같은 함수로 경로를 처리합니다. 이 함수들은 문자열을 받고 반환하기 때문에 코드가 길고, 읽기 어렵고, 실수하기 쉽습니다. pathlib 모듈(Python 3.4부터)은 경로를 Path 객체로 표현하며, 경로를 연결, 분리, 읽기, 쓰기, 디렉토리 탐색할 수 있는 충분한 메서드를 갖추고 있습니다.

이 글을 읽고 나면 대부분의 os.path 코드를 pathlib으로 변환할 수 있고 쉽게 실수할 수 있는 부분들을 알 수 있습니다. 이 글의 예제는 Python 3.12를 사용합니다.

2. Path 객체 생성

Path는 모든 작업의 시작점입니다:

from pathlib import Path

reports = Path("data/reports")
home = Path.home()
cwd = Path.cwd()

print(reports)
print(type(reports))
print(home)
data/reports
<class 'pathlib.PosixPath'>
/home/minh

설명:

  • Path("data/reports")는 경로 객체를 생성합니다. 이 시점에서는 디스크 작업이 없습니다. 디렉토리가 존재할 필요가 없습니다.
  • Linux/macOS에서 Path는 PosixPath를 반환합니다. Windows에서는 WindowsPath를 반환합니다. 구체적인 클래스에 대해 신경 쓸 필요가 없습니다.
  • Path.home()과 Path.cwd()는 각각 os.path.expanduser("~")와 os.getcwd()를 대체합니다.

파일 시스템과 상호작용하지 않고 경로 문자열만 처리하면 되는 경우 PurePath, PurePosixPath, PureWindowsPath를 사용할 수 있습니다. 이는 Linux에서 실행 중일 때 Windows 경로를 처리하는 것이 편리합니다.

3. / 연산자로 경로 연결하기

가장 눈에 띄는 변화입니다. 중첩된 os.path.join 대신 / 연산자를 사용합니다:

import os
from pathlib import Path

# 기존 방식
old = os.path.join("data", "orders", "2026", "order_1001.json")

# 새로운 방식
base = Path("data")
new = base / "orders" / "2026" / "order_1001.json"

print(old)
print(new)
print(new == Path(old))
data/orders/2026/order_1001.json
data/orders/2026/order_1001.json
True

/ 연산자는 오른쪽에서 Path와 str 모두를 받습니다. 결과는 항상 Path이므로 계속 연결할 수 있습니다. 경로 구분자는 운영 체제에 따라 자동으로 변경됩니다: Linux에서는 /, Windows에서는 \.

구성 요소 개수가 런타임에 변경되는 경우 joinpath를 사용합니다:

parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path)  # data/orders/2026/order_1001.json

4. 경로의 구성 요소 분리하기

os.path는 이를 위해 여러 함수가 필요합니다: basename, dirname, splitext. pathlib에서는 모든 것이 속성입니다:

from pathlib import Path

invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")

print(invoice.name)      # 전체 파일명
print(invoice.stem)      # 마지막 확장자를 제외한 이름
print(invoice.suffix)    # 마지막 확장자
print(invoice.suffixes)  # 모든 확장자
print(invoice.parent)    # 포함하는 디렉토리
print(invoice.parents[1])
print(invoice.parts)
invoice_042.final.pdf
invoice_042.final
.pdf
['.final', '.pdf']
/srv/shop/invoices/2026
/srv/shop/invoices
('/', 'srv', 'shop', 'invoices', '2026', 'invoice_042.final.pdf')

설명:

  • stem과 suffix는 마지막 점에서만 분리합니다. .tar.gz 파일의 경우 suffixes를 사용해야 합니다.
  • parent는 os.path.dirname에 해당합니다. parents는 루트로 올라가는 부모 디렉토리들의 시퀀스입니다.
  • parts는 경로를 튜플로 분리하므로 각 레벨을 비교할 때 편리합니다.

새로운 이름이나 확장자를 가진 경로를 만들려면 with_* 메서드를 사용합니다:

invoice = Path("invoices/invoice_042.pdf")

print(invoice.with_suffix(".xml"))    # invoices/invoice_042.xml
print(invoice.with_stem("invoice_043"))  # invoices/invoice_043.pdf
print(invoice.with_name("summary.csv"))  # invoices/summary.csv

이 메서드들은 디스크의 파일명을 변경하지 않습니다. 새로운 Path 객체만 반환합니다. Path는 불변이기 때문입니다.

5. 파일 읽기와 쓰기

작은 파일의 경우 open()이 필요하지 않습니다:

from pathlib import Path

config = Path("app_config.txt")
config.write_text("currency=VND\ntimeout=30\n", encoding="utf-8")

content = config.read_text(encoding="utf-8")
print(content.splitlines())

logo = Path("logo.bin")
logo.write_bytes(b"\x89PNG")
print(logo.read_bytes())
['currency=VND', 'timeout=30']
b'\x89PNG'
  • write_text는 파일을 열고, 쓰고, 닫습니다. 기존 파일은 덮어씁니다.
  • read_text는 전체 내용을 문자열로 읽습니다.
  • write_bytes와 read_bytes는 이진 데이터에 대해 동일하게 작동합니다.

큰 파일이거나 한 줄씩 읽어야 할 때는 여전히 context manager를 사용합니다. Path에는 open 메서드가 있습니다:

with Path("orders.csv").open("w", encoding="utf-8") as f:
    f.write("id,amount\n1001,250000\n1002,99000\n")

with Path("orders.csv").open(encoding="utf-8") as f:
    total = sum(int(line.split(",")[1]) for line in f if not line.startswith("id"))

print(total)  # 349000

주의: 항상 encoding="utf-8"을 전달하세요. 생략하면 Python은 시스템의 기본 인코딩을 사용합니다. Windows에서 기본값은 보통 cp1252이므로 베트남어 데이터가 손상됩니다.

6. 상태 확인 및 디렉토리 탐색

6.1. 상태 확인

from pathlib import Path

p = Path("orders.csv")
print(p.exists(), p.is_file(), p.is_dir())
print(p.stat().st_size)
print(p.resolve())
True True False
36
/home/minh/project/orders.csv

resolve()는 절대 경로를 반환하고 symlink를 해석합니다. os.path.abspath와 os.path.realpath를 대체합니다.

6.2. 파일 나열 및 검색

from pathlib import Path

root = Path("shop")
(root / "orders" / "2026").mkdir(parents=True, exist_ok=True)
(root / "orders" / "2026" / "o1.json").write_text("{}", encoding="utf-8")
(root / "orders" / "o0.json").write_text("{}", encoding="utf-8")
(root / "readme.md").write_text("# Shop", encoding="utf-8")

print(sorted(p.name for p in root.iterdir()))
print(sorted(str(p) for p in root.glob("*.md")))
print(sorted(str(p) for p in root.rglob("*.json")))
['orders', 'readme.md']
['shop/readme.md']
['shop/orders/2026/o1.json', 'shop/orders/o0.json']
  • iterdir()는 os.listdir()을 대체합니다. 파일명만이 아니라 전체 Path를 반환하므로 다시 연결할 필요가 없습니다.
  • glob("*.md")는 현재 디렉토리에서만 검색합니다.
  • rglob("*.json")은 재귀적으로 검색하며, glob("**/*.json")과 동일합니다.

Python 3.12부터 Path.walk()는 os.walk()를 대체합니다:

for dirpath, dirnames, filenames in Path("shop").walk():
    print(dirpath, filenames)

7. 생성, 이름 변경 및 삭제

import shutil
from pathlib import Path

backup = Path("backup/daily")
backup.mkdir(parents=True, exist_ok=True)

src = Path("orders.csv")
dst = src.rename(backup / "orders_2026-10-17.csv")
print(dst)

dst.unlink()
Path("not_there.txt").unlink(missing_ok=True)

backup.rmdir()
shutil.rmtree("backup")
backup/daily/orders_2026-10-17.csv
  • mkdir(parents=True, exist_ok=True)는 os.makedirs(..., exist_ok=True)에 해당합니다.
  • rename은 새로운 Path를 반환합니다. Windows에서 대상 파일이 이미 존재하면 오류를 보고합니다. 모든 플랫폼에서 덮어쓰려면 replace를 사용하세요.
  • unlink는 파일을 삭제합니다. missing_ok=True로 설정하면 파일이 없을 때 오류를 보고하지 않습니다.
  • rmdir은 빈 디렉토리만 삭제할 수 있습니다. 전체 디렉토리 트리를 삭제하려면 여전히 shutil.rmtree를 사용해야 합니다. 이 함수는 일반 Path를 받습니다.

8. 빠른 참조 표

os / os.path pathlib
os.path.join(a, b) Path(a) / b
os.path.basename(p) p.name
os.path.dirname(p) p.parent
os.path.splitext(p) p.stem, p.suffix
os.path.exists(p) p.exists()
os.path.isfile(p) / isdir p.is_file() / p.is_dir()
os.path.abspath(p) p.resolve() 또는 p.absolute()
os.path.expanduser("~") Path.home() 또는 p.expanduser()
os.getcwd() Path.cwd()
os.listdir(p) p.iterdir()
glob.glob("**/*.py", recursive=True) p.rglob("*.py")
os.makedirs(p, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
os.remove(p) p.unlink()
os.rename(a, b) a.rename(b)
os.path.getsize(p) p.stat().st_size
os.path.relpath(p, start) p.relative_to(start)

9. pytest로 테스트 작성하기

pytest의 tmp_path fixture는 이미 Path를 반환하므로 pathlib을 사용하는 테스트 코드가 매우 간결합니다. 많은 PDF 송장 파일을 보관 디렉토리로 모으는 함수가 있다고 가정합시다:

# archive.py
from pathlib import Path


def archive_invoices(source: Path, target: Path) -> list[Path]:
    target.mkdir(parents=True, exist_ok=True)
    moved = []
    for pdf in sorted(source.rglob("*.pdf")):
        moved.append(pdf.rename(target / pdf.name))
    return moved

해당 테스트:

# test_archive.py
from pathlib import Path

from archive import archive_invoices


def test_archive_moves_only_pdf_files(tmp_path: Path):
    source = tmp_path / "inbox"
    (source / "2026").mkdir(parents=True)
    (source / "a.pdf").write_bytes(b"%PDF")
    (source / "2026" / "b.pdf").write_bytes(b"%PDF")
    (source / "note.txt").write_text("skip me", encoding="utf-8")
    target = tmp_path / "archive"

    moved = archive_invoices(source, target)

    assert [p.name for p in moved] == ["b.pdf", "a.pdf"]
    assert sorted(p.name for p in target.iterdir()) == ["a.pdf", "b.pdf"]
    assert (source / "note.txt").exists()
    assert not (source / "a.pdf").exists()
pytest -q test_archive.py
.                                                                [100%]
1 passed in 0.02s

moved의 순서는 a.pdf 전에 b.pdf입니다. 이유: sorted는 Path 객체를 비교하며, 문자 2가 a보다 작기 때문에 inbox/2026/b.pdf가 inbox/a.pdf 앞에 옵니다. 파일명 문자열 비교에서 Path 비교로 이동할 때 이 세부 사항을 쉽게 놓칠 수 있습니다.

10. 자주 발생하는 오류

Path와 문자열 더하기. Path("data") + "/x.txt"는 TypeError를 보고합니다. 대신 /를 사용하세요. 파일명에 실제로 추가해야 하면 p.with_name(p.name + "_old")를 사용합니다.

오른쪽이 절대 경로. 결과가 놀랄 수 있습니다:

from pathlib import Path

print(Path("/srv/uploads") / "/etc/passwd")  # /etc/passwd

오른쪽의 절대 경로 구성 요소는 왼쪽 전체를 대체합니다. 파일명이 사용자 입력에서 오는 경우 사용하기 전에 확인해야 합니다:

base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
    raise ValueError("경로가 유효하지 않습니다")

with_suffix에 점 빠짐. p.with_suffix("pdf")는 ValueError를 보고합니다. ".pdf"를 작성해야 합니다.

glob이 generator를 반환한다는 것을 잊음. 한 번만 반복할 수 있습니다. 다시 사용하거나 len()을 호출해야 하면 list(...)로 감싸세요. 결과의 순서도 보장되지 않으므로 순서가 중요하면 sorted를 사용해야 합니다.

숨김 파일과 stem. Path(".env")의 경우 suffix는 빈 문자열이고 stem은 ".env"입니다. 모든 파일에 확장자가 있다고 가정하지 마세요.

오래된 라이브러리는 str만 허용. 대부분의 표준 API는 Python 3.6부터 os.PathLike를 수용합니다. 오래된 라이브러리를 만나면 str(p) 또는 os.fspath(p)를 사용합니다.

주의: pathlib은 os와 shutil을 완전히 대체하지 않습니다. 파일을 복사하려면 여전히 shutil.copy2를 사용해야 합니다(Python 3.14에 Path.copy가 추가됩니다). 환경 변수는 여전히 os.environ을 통해 읽습니다. 복잡한 권한은 여전히 os.chmod 또는 os.access가 필요합니다.

언제 변경하지 않아야 할까요? 디스크와 상호작용하지 않으면서 수백만 개의 경로 문자열을 처리하는 루프의 경우 Path 객체 생성이 순수 문자열 작업보다 느립니다. 이것은 드문 경우이며 최적화 전에 측정해야 합니다. 안정적으로 실행 중인 오래된 코드의 경우 대량으로 수정할 필요가 없습니다. 각 모듈을 건드릴 때 점진적으로 변환하세요.

11. 결론

pathlib은 os, os.path, glob에 흩어져 있는 작업을 단일 객체로 통합합니다. 코드가 더 짧고, 더 자연스럽게 읽히고, 문자열 연결 오류가 적습니다. /로 경로를 연결하고, name, stem, suffix, parent로 구성 요소를 분리하고, read_text와 write_text로 파일을 읽고 쓰고, iterdir, glob, rglob, walk로 디렉토리를 탐색하고, pytest의 tmp_path로 테스트하는 방법을 배웠습니다.

세 가지를 기억해야 합니다. 항상 encoding을 전달하세요. 외부 입력에서 오는 절대 경로와 연결할 때 주의하세요. 복사 및 디렉토리 트리 삭제와 같은 작업에는 여전히 shutil을 사용하세요. 새 코드의 경우 기본적으로 Path를 사용하고 특정한 이유가 있을 때만 os.path로 돌아가세요.

pythonpathlibos.pathfile-iopython3