Python pathlib 사용법 — os.path를 대체하는 객체지향 경로 처리 심층 분석

목차

Python pathlib 사용법을 다룬 자료는 많지만, 대부분 "os.path 대신 이렇게 쓰면 된다" 수준에서 멈춘다. pathlib은 Python 3.4(2014, PEP 428)부터 표준 라이브러리에 들어온 객체지향 파일 경로 모듈이다. 문자열로 다루던 경로를 Path 객체로 추상화해, 운영체제 차이를 흡수하고 메서드 체이닝으로 조작하게 만든다.

그런데, 프론트엔드에서 백엔드로 넘어온 지 2년이 됐다. Node.js의 path 모듈은 줄곧 함수 기반이라 path.join(__dirname, 'data', 'file.json') 같은 호출이 익숙했다. 그래서 Python으로 옮길 때 os.path.join이 자연스러웠다. 그런데 같은 팀의 시니어가 코드 리뷰에서 Path("data") / "file.json" 형태로 바꿔달라고 요청한 게 시작이었다. 단순히 "최신 스타일"의 문제가 아니라는 걸 한참 뒤에야 알았다.

pathlib을 다시 들여다본 이유

이 글은 pathlib 입문이 아니라 언제, 왜, 어디까지 쓰는가에 대한 정리다. 최근 사내 자동화 스크립트를 리팩토링하면서 os.path로 짜여 있던 코드를 pathlib로 옮겼다. 옮기는 과정에서 흥미로운 현상이 두 가지 있었다. 하나는 디렉터리 스캔이 오히려 느려진 케이스, 다른 하나는 윈도우 환경에서만 발생하는 경로 버그가 자연스럽게 사라진 케이스다. 둘 다 단순히 "pathlib이 좋다/나쁘다"로 정리할 수 없는 결과였다.

따라서, 문제 정의를 먼저 분명히 하자. 파일 경로 처리에서 우리가 실제로 해결하려는 건 세 가지다. 첫째, 운영체제별 구분자 차이(/ vs \)를 코드 한 줄로 흡수하는 것. 둘째, 상대/절대 경로의 정규화와 안전한 결합. 셋째, 메타데이터 조회와 I/O를 같은 흐름에서 처리하는 것. os.path는 첫 두 가지는 처리하지만, 세 번째는 os, os.path, shutil, glob 모듈을 옮겨다녀야 한다. pathlib은 이 세 가지를 한 객체로 묶은 게 핵심이다.

os.path가 만들어내는 미묘한 버그들

문자열 결합의 함정

os.path.join을 잘못 쓴 적이 있는 사람은 의외로 많다. 절대 경로를 두 번째 인자로 넘기면 앞 인자가 통째로 무시된다. os.path.join("/var/log", "/etc/hosts")의 결과는 /var/log/etc/hosts가 아니라 /etc/hosts다. Python 공식 문서(os.path.join)에 명시된 정상 동작이지만, 입력 검증이 부실한 코드에서 경로 탈출 취약점으로 이어진다. pathlib의 / 연산자도 동일한 동작을 하지만, Path.is_absolute() 같은 메서드가 같은 객체에서 바로 호출 가능하다는 점이 다르다. 검증을 잊기 어렵게 만드는 API 설계다.

또 다른 함정은 빈 문자열이다. os.path.join("data", "")는 끝에 슬래시를 붙인 "data/"를 반환한다. 사소해 보이지만 후속 코드에서 endswith("/")로 분기하던 로직이 깨진 적이 있다. pathlib에서는 Path("data") / ""가 그냥 Path("data")로 평탄화된다.

윈도우와 POSIX의 경계

따라서, 크로스 플랫폼 배포가 필요한 도구라면 윈도우/유닉스 경로 차이는 피할 수 없다. os.path로 처리하려면 os.sep, os.path.normpath, os.path.abspath를 조합해야 하는데, 정규화 결과가 늘 직관적이지는 않다. 특히 UNC 경로(\\server\share\...)나 드라이브 문자를 다룰 때 os.path.splitdrive의 결과를 잘못 가정해 버그가 났던 경험이 있다.

한편, pathlib은 PurePosixPathPureWindowsPath라는 별도 클래스를 두고, 실행 환경에 맞는 구현을 Path로 추상화한다. 윈도우에서 실행해도 코드에서 WindowsPath를 직접 다룰 일이 거의 없다. 리팩토링 이후 윈도우 CI에서만 가끔 떨어지던 테스트 두 개가 자연스럽게 통과한 게 가장 인상적인 변화였다.

Path 객체가 바꾸는 사고방식

연산자 오버로딩이 주는 일관성

pathlib의 / 연산자는 처음 보면 마법처럼 보인다. Path("/var") / "log" / "app.log" 한 줄이 운영체제별로 알아서 변환된다. JS의 path.join이 가변 인자로 같은 일을 하지만, 가독성 측면에서 연산자 쪽이 더 직관적이라고 본다. 특히 슬래시는 사람이 머릿속에서 경로를 떠올릴 때 쓰는 기호 그 자체다. 객체지향 추상화가 도메인 언어와 가까워진다는 게 PEP 428의 설계 의도이기도 하다.

메서드 체이닝과 메타데이터의 통합

Path 객체는 자기 자신에 대한 거의 모든 질문에 답할 수 있다. path.exists(), path.is_file(), path.stat().st_size, path.read_text(encoding="utf-8"), path.write_bytes(data)까지 모두 같은 객체의 메서드다. os.path.exists(path)open(path).read()os.path.getsize(path)로 이어지는 흐름을 한 객체 안에서 다룰 수 있다는 게 가독성을 크게 끌어올린다.

# 같은 작업의 두 가지 표현
# 1) os.path 스타일
import os
log_dir = os.path.join(os.path.expanduser("~"), "logs")
for name in os.listdir(log_dir):
    full = os.path.join(log_dir, name)
    if os.path.isfile(full) and full.endswith(".log"):
        size = os.path.getsize(full)
        # ...

# 2) pathlib 스타일
from pathlib import Path
log_dir = Path.home() / "logs"
for p in log_dir.glob("*.log"):
    size = p.stat().st_size  # p 자체가 모든 걸 안다
    # ...

두 코드 모두 같은 일을 하지만, pathlib 쪽이 "이 객체가 무엇인지"가 더 명확하다. 프론트엔드에서 백엔드로 넘어온 입장에서 보면, 객체지향 추상화의 효용을 가장 체감한 부분이 이쪽이었다.

실무에서 자주 쓰는 패턴

FastAPI 프로젝트의 업로드 파일 저장 흐름은 pathlib과 잘 맞는다. UploadFile을 받아 임시 디렉터리에 쓰고, 확장자 검증과 안전한 파일명 생성을 거쳐 영구 저장소로 이동시키는 과정이 Path 객체 하나로 흐른다. path.suffix로 확장자를 검사하고, path.with_name()이나 path.with_suffix()로 새 경로를 만들고, path.replace(target)로 원자적 이동까지 같은 흐름에서 처리된다.

그래서, 자동화 스크립트에서는 Path.cwd(), Path(__file__).resolve().parent로 프로젝트 루트를 잡는 패턴을 가장 자주 쓴다. __file__ 기반 경로 추적은 os.path.dirname(os.path.abspath(__file__))라는 길고 외우기 힘든 표현을 Path(__file__).resolve().parent 한 줄로 줄인다. 작은 차이지만 매일 보는 코드에서 누적되는 가독성 이득이 크다.

rglob은 재귀 탐색에서 거의 표준처럼 자리 잡았다. Path("src").rglob("*.py")os.walk 3중 루프를 한 줄로 대체한다. 단, 다음 섹션에서 보겠지만 이 부분은 성능 측면에서 한 번쯤 짚어봐야 한다.

성능을 직접 파보니 — 의외의 병목

예를 들어, 리팩토링 직후 사내 배포 스크립트의 디렉터리 스캔 시간이 늘어났다. 약 12만 개 파일이 들어 있는 빌드 출력 디렉터리에서 특정 확장자만 골라내는 작업이었는데, 체감상 1.5배쯤 느려진 느낌이 들었다. 원인이 pathlib 자체일 거라 가정하고 파봤다.

결론부터 말하자면 pathlib이 본질적으로 느린 게 아니라, Path 객체 생성 비용과 stat 호출 빈도의 합이 병목이었다. Path.rglob은 매 항목마다 새로운 Path 인스턴스를 생성한다. os.walk는 문자열만 반환한다. 12만 개 항목에서 객체 생성 오버헤드가 누적되면 차이가 보인다.

방법 12만 파일 스캔(체감 기준) 메모리 사용 코드 라인 수
os.walk + 문자열 필터 기준(1.0x) 가장 낮음 8~10줄
Path.rglob 약 1.3~1.6x 중간 2~3줄
os.scandir + 직접 재귀 약 0.7~0.9x 가장 낮음 12~15줄

수치는 환경에 따라 다르고 정밀 벤치마크가 아니라는 점을 분명히 한다. 핵심은 추세다. Python 3.12 이후 os.scandir 기반 구현이 들어가면서 격차가 줄었다는 보고가 있고(CPython issue tracker 참고), 3.13에서도 점진적 개선이 이어진다. 작성 시점(2026년 6월) 기준 Python 3.12.x를 쓰면 대부분의 워크로드에서 차이가 무시할 만하다.

따라서, 다만 수십만 개 파일을 매번 스캔하는 배치 작업이라면 os.scandir를 직접 쓰는 편이 여전히 빠르다. 이건 pathlib의 한계라기보다 객체지향 추상화의 일반적인 트레이드오프다. 매번 객체를 만드는 비용은 어디서나 존재한다.

예를 들어, 흥미로웠던 건 진짜 병목이 다른 곳에 있었다는 점이다. 스크립트가 path.stat().st_size를 항목별로 호출하고 있었는데, rglob이 반환하는 객체에 이미 DirEntry 캐시가 붙어 있지 않다 보니 stat 시스템 콜이 반복됐다. os.scandirDirEntry 단계에서 일부 메타데이터를 미리 들고 있다. 이 차이가 누적되면 전체 스캔 시간의 절반 가까이를 차지한다. pathlib이 느린 게 아니라 stat 호출이 많은 게 진짜 원인이었다.

이걸 알고 나니 결론은 단순해졌다. 평범한 스크립트라면 pathlib을 그냥 쓴다. 수만 건 이상의 대량 파일 처리라면 os.scandir를 섞어 쓴다. 둘을 적대적으로 보지 않고 도구로 본다.

그래서 어디까지 쓰는가 — pathlib의 경계

그러나, pathlib이 모든 경우의 정답은 아니다. 작성 시점 기준으로 다음 케이스에서는 os.path 또는 다른 도구를 섞는 게 낫다고 본다.

물론, 대량 디렉터리 스캔에서 매우 빠듯한 성능이 필요할 때는 앞서 본 이유로 os.scandir가 더 적합하다. 단순 문자열 결합만 필요하고 메타데이터 조회가 일절 없을 때는 os.path.join도 충분히 명확하다. 외부 라이브러리 API가 문자열 경로를 요구하면 str(path)로 변환해서 넘겨야 하는데, 일부 오래된 라이브러리가 Path 객체를 받지 못한다(Python 3.6의 PEP 519os.fspath 프로토콜이 도입된 이후 표준 라이브러리는 대부분 호환되지만, 외부 라이브러리는 여전히 편차가 있다).

또 한 가지 짚을 점은 pathlib의 비동기 지원이 표준에 없다는 것이다. aiofiles.osanyio의 경로 유틸을 따로 써야 한다. FastAPI 같은 비동기 컨텍스트에서 동기 I/O를 그대로 부르면 이벤트 루프가 막힌다. 이 부분은 pathlib의 책임 영역 밖이지만, 마이그레이션할 때 자주 놓치는 지점이라 적어둔다.

마지막으로 테스트 가능성 측면. Path 객체는 클래스 상속이 비교적 까다롭다. Python 3.12에서 pathlib.Path의 서브클래싱이 공식 지원되긴 했지만(Python 3.12 What’s New), 그 전 버전에서는 모킹이 번거롭다. 테스트 코드에서 파일 시스템을 추상화하려면 pyfakefs 같은 도구를 함께 쓰는 편이 낫다.

마무리

그런데, 개인적으로는, pathlib을 표준으로 두되 성능이 진짜 발목을 잡는 좁은 영역만 os.scandir로 내려가는 방식이 가장 균형 잡힌 선택이라고 본다. 처음 1년 동안은 "최신 문법이라 좋다"는 인상으로만 썼다면, 직접 병목을 파본 다음부터는 "어디까지 객체를 신뢰할 것인가"를 의식적으로 결정하게 됐다. 지금 당장 적용한다면 세 가지다. 새로 짜는 코드는 pathlib으로 통일하고, 기존 os.path.join 호출은 PR 단위로 점진적으로 옮기고, 대량 스캔 코드는 옮기기 전에 한 번 측정해본다. 이 정도면 두 도구 사이에서 헤맬 일은 거의 없다.

관련 글