파이썬 pathlib 사용법 — os.path 코드를 pathlib로 바꿔 보기 (실측)

os.path.join, os.path.splitext, os.path.dirname이 한 줄에 겹쳐 있는 코드는 쓸 때도 읽을 때도 눈이 아픕니다. 파이썬 pathlib 사용법을 익히면 이런 경로 코드가 Path 객체 하나로 정리됩니다. 이 글에서는 실제로 돌아가는 os.path 백업 스크립트를 pathlib로 옮기면서 생긴 일을 그대로 기록했습니다. 반복문은 17줄에서 12줄로 줄었고 결과 파일은 내용까지 같았지만, 그냥 옮긴 첫 실행은 cp949 오류로 바로 멈췄고, rename()은 파일을 엉뚱한 폴더로 옮겼습니다. 파일 5만 개로 잰 속도 차이도 함께 정리합니다. 환경은 윈도우 11, Python 3.12.10입니다.

출발점 — os.path로 짠 로그 백업 스크립트

테스트용으로 sample 폴더에 파일 7개를 만들었습니다. 한글과 공백이 든 앱 서버.log, 확장자가 대문자인 batch.LOG, 하위 폴더의 worker.log, 로그가 아닌 notes.txt와 archive.tar.gz, 확장자 없는 README입니다. 내용은 UTF-8 한글입니다.

아래 스크립트는 .log 파일을 찾아 ERROR 줄 수를 세고, 같은 폴더 구조로 확장자만 .bak으로 바꿔 복사합니다. 실제로 돌린 코드에는 os.path 버전과 pathlib 버전 모두 표준 출력을 UTF-8로 바꾸는 줄이 더 있는데, 제가 돌린 환경(파이프 출력)에서 한글을 보려고 넣은 것이라 이 글의 코드에서는 뺐습니다.

import os
import shutil

SRC = 'sample'
DST = 'backup_os'

count = 0
total = 0
for root, dirs, files in os.walk(SRC):
    dirs.sort()
    for name in sorted(files):
        base, ext = os.path.splitext(name)
        if ext.lower() != '.log':
            continue
        src = os.path.join(root, name)
        rel = os.path.relpath(src, SRC)
        dst = os.path.join(DST, os.path.dirname(rel), base + '.bak')
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        with open(src, encoding='utf-8') as f:
            lines = f.read().splitlines()
        errors = sum('ERROR' in line for line in lines)
        shutil.copy2(src, dst)
        total += os.path.getsize(src)
        count += 1
        print(rel.replace(os.sep, '/'), '오류', errors)
print('백업', count, '개,', total, '바이트')
$ python old_os.py
2026-10/batch.LOG 오류 2
2026-10/앱 서버.log 오류 1
2026-11/앱 서버.log 오류 0
2026-11/깊은 폴더/worker.log 오류 1
백업 4 개, 155 바이트

기본 대응표 — 14개를 실제로 비교

변환을 시작하기 전에 자주 쓰는 os.path 함수와 pathlib 기능이 정말 같은 값을 주는지, sample/2026-10/앱 서버.log 파일로 비교했습니다. 14개 항목이 모두 일치했습니다.

os / os.pathpathlib비고
os.path.join(a, b)Path(a) / b/ 연산자로 이어 붙임
os.path.basename(s)p.name앱 서버.log
os.path.dirname(s)p.parentPath 객체로 나옴
os.path.splitext(이름)p.stem, p.suffix앱 서버 / .log
os.path.exists / isfile / isdirp.exists() / is_file() / is_dir()메서드라서 괄호 필요
os.path.getsize / getmtimep.stat().st_size / st_mtimestat 한 번에 둘 다
os.path.abspath(s)p.resolve()절대 경로
os.getcwd() / expanduser(~)Path.cwd() / Path.home()클래스에서 바로 호출
os.listdir(s)p.iterdir()이름이 아니라 Path가 나옴

첫 변환 — read_text() 한 줄에서 멈췄다

이 대응표대로 한 줄씩 옮기고, with open(...) 두 줄도 src.read_text() 한 줄로 줄였습니다. 실행하자마자 이렇게 멈췄습니다.

$ python new_pathlib_v1.py
  ...
    lines = src.read_text().splitlines()
            ^^^^^^^^^^^^^^^
UnicodeDecodeError: 'cp949' codec can't decode byte 0xec in position 5: illegal multibyte sequence

원래 코드에는 encoding='utf-8'이 있었는데 줄을 줄이면서 함께 빠뜨린 것입니다. read_text()도 인코딩을 안 주면 open()처럼 시스템 기본값을 쓰고, 한글 윈도우에서는 그게 cp949입니다. 오류가 난 5번째 바이트는 INFO 다음의 첫 한글이었습니다. 파일이 영어로만 되어 있었다면 그냥 넘어갔을 테니, 한글 데이터를 만나는 날에야 터지는 종류의 버그입니다.

쓰기도 같습니다. write_text('한글')을 인코딩 없이 쓰니 파일이 cp949 바이트(c7 d1 b1 db)로 저장되어 UTF-8로 다시 읽을 때 실패했고, 이모지가 들어간 문자열은 저장 단계에서 UnicodeEncodeError가 났습니다. read_text와 write_text에는 항상 encoding='utf-8'을 적는 것이 안전합니다.

완성본 — 17줄이 12줄로, 결과는 동일

인코딩을 고친 뒤 뒤에서 설명할 두 가지 문제까지 반영한 최종본입니다. 반복문 부분이 17줄에서 12줄로 줄었고, 무엇보다 dirname과 join이 겹치던 대상 경로 계산이 (DST / rel).with_suffix('.bak') 한 줄로 읽히게 됐습니다.

from pathlib import Path
import shutil

SRC = Path('sample')
DST = Path('backup_pathlib')

count = 0
total = 0
for src in sorted(SRC.rglob('*')):
    if not src.is_file() or src.suffix.lower() != '.log':
        continue
    rel = src.relative_to(SRC)
    dst = (DST / rel).with_suffix('.bak')
    dst.parent.mkdir(parents=True, exist_ok=True)
    lines = src.read_text(encoding='utf-8').splitlines()
    errors = sum('ERROR' in line for line in lines)
    shutil.copy2(src, dst)
    total += src.stat().st_size
    count += 1
    print(rel.as_posix(), '오류', errors)
print('백업', count, '개,', total, '바이트')
$ python new_pathlib.py
2026-10/batch.LOG 오류 2
2026-10/앱 서버.log 오류 1
2026-11/깊은 폴더/worker.log 오류 1
2026-11/앱 서버.log 오류 0
백업 4 개, 155 바이트

$ python compare.py backup_os backup_pathlib
목록 동일: True 4 4
내용 동일: True

compare.py는 두 백업 폴더의 파일 목록을 비교하고, filecmp.cmp(shallow=False)로 내용을 바이트 단위까지 비교하는 작은 스크립트입니다. 4개 파일 모두 같았습니다. 출력 순서만 조금 다른데, os.walk는 한 폴더의 파일을 먼저 돌고 하위 폴더를 나중에 돌지만 sorted()는 전체 경로 순으로 정렬하기 때문입니다.

rglob(‘*.log’)가 더 많이 잡는다

처음 버전은 SRC.rglob('*.log')로 간단하게 썼고 sample에서는 결과가 같았습니다. 그런데 정말 같은 동작인지 확인하려고 까다로운 폴더를 따로 만들어 봤습니다. 이름이 old.log인 폴더, 그리고 이름이 그냥 .log인 파일을 넣었습니다.

os.walk + splitext         ['B.LOG', 'a.log']
rglob(*.log)               ['.log', 'B.LOG', 'a.log', 'old.log']
rglob 대소문자 구분(3.12)  ['.log', 'a.log', 'old.log']

Path('edge/old.log').read_text() -> PermissionError: [Errno 13] Permission denied: 'edge\\old.log'
Path('.log').with_suffix('.bak') -> WindowsPath('.log.bak')

os.walk 버전은 파일 2개만 골랐는데, rglob('*.log')는 폴더 old.log와 점 파일 .log까지 4개를 돌려줬습니다. 폴더를 그대로 read_text()하면 윈도우에서는 PermissionError가 나서 권한 문제로 착각하기 쉽습니다.

대소문자도 차이가 납니다. 윈도우에서는 *.log가 batch.LOG도 잡지만, Python 3.12의 case_sensitive=True를 줘서 대소문자를 구분하게 하자 B.LOG가 빠졌습니다. 리눅스 기본 동작이 이쪽이므로, 서버로 옮기면 파일이 조용히 누락될 수 있습니다. 그래서 최종본은 rglob('*')으로 전부 돌면서 is_file()과 suffix.lower()로 거르도록 바꿨습니다. suffix로 거르면 점 파일 .log는 확장자가 빈 문자열이라 자연스럽게 빠집니다.

참고로 숨김 파일 처리도 다릅니다. Path.glob('*')는 .env와 .log를 돌려줬지만 glob.glob('*')은 돌려주지 않았습니다.

rename이 파일을 다른 폴더로 옮긴다

이번 실험에서 가장 조심해야 한다고 느낀 부분입니다. edge/mv/a.txt의 이름만 b.txt로 바꾸려고 아래처럼 썼습니다.

from pathlib import Path

d = Path('edge/mv')
d.mkdir(parents=True, exist_ok=True)
f = d / 'a.txt'
f.write_text('x', encoding='utf-8')

# 1) 이름만 주면 현재 작업 폴더로 옮겨진다
f.rename('b.txt')
print((d / 'b.txt').exists())      # False
print(Path('b.txt').exists())      # True
Path('b.txt').unlink()

# 2) 같은 폴더에서 이름만 바꾸려면 with_name
f.write_text('x', encoding='utf-8')
f.rename(f.with_name('b.txt'))     # edge/mv/b.txt

오류 없이 끝났지만 파일은 edge/mv 폴더에서 사라져 현재 작업 폴더로 옮겨졌습니다. rename()의 상대 경로는 원본 파일이 있는 폴더가 아니라 현재 작업 폴더 기준이기 때문입니다. 같은 폴더에서 이름만 바꾸려면 with_name()이나 with_suffix()로 목적지를 만들어 넘기면 됩니다. 실행했더니 edge/mv/b.txt로 정확히 바뀌었습니다.

문자열 습관으로 쓰기 쉬운 replace도 위험합니다. Path.replace()는 문자열 치환이 아니라 파일 이동입니다. f.replace('.txt', '.bak')처럼 인자 2개를 주면 TypeError가 나서 차라리 낫지만, f.replace('c.txt')처럼 1개만 주면 역시 오류 없이 파일이 현재 폴더로 옮겨졌습니다.

Path는 str이 아니다 — 받아 주는 곳과 안 받는 곳

open(), shutil.copy2(), os.path 함수는 Path를 그대로 받았습니다. 그래서 큰 코드베이스도 한 번에 바꾸지 않고 조금씩 옮길 수 있습니다. 다만 문자열만 받는 곳에서는 오류가 납니다.

p.endswith('.log')       -> AttributeError: 'WindowsPath' object has no attribute 'endswith'
json.dumps({'path': p})  -> TypeError: Object of type WindowsPath is not JSON serializable
sqlite3 파라미터         -> ProgrammingError: Error binding parameter 1: type 'WindowsPath' is not supported
os.environ 대입          -> TypeError: str expected, not WindowsPath
os.path.basename(p)      -> 'a.log'
open(Path) 읽기          -> '소문자'

JSON 저장, SQLite 파라미터, 환경 변수처럼 경로가 프로그램 밖으로 나가는 곳은 str(p)로 바꿔서 넘겨야 합니다. 윈도우에서 다른 시스템과 주고받을 경로라면 p.as_posix()로 슬래시 형태를 쓰는 편이 읽기 좋습니다. 백슬래시는 JSON에서 "logs\\a.log"처럼 두 번씩 나왔습니다.

이 밖에 os.path와 미묘하게 다른 동작도 확인했습니다.

Path('archive.tar.gz').suffix                -> '.gz'
Path('archive.tar.gz').suffixes              -> ['.tar', '.gz']
Path('archive.tar.gz').stem                  -> 'archive.tar'
Path('archive.tar.gz').with_suffix('.zip')   -> WindowsPath('archive.tar.zip')
Path('a.txt').with_suffix('bak')             -> ValueError: Invalid suffix 'bak'
Path('.env').suffix                          -> ''
Path('logs') / '/a.txt'                      -> WindowsPath('/a.txt')
Path('logs') + '/a.txt'                      -> TypeError: unsupported operand type(s) for +: 'WindowsPath' and 'str'
Path('logs/./a/../b')                        -> WindowsPath('logs/a/../b')
os.path.normpath('logs/./a/../b')            -> 'logs\\b'
  • suffix는 마지막 확장자만 줍니다. .tar.gz처럼 두 겹이면 suffixes를 봐야 하고, with_suffix()도 마지막 하나만 바꿉니다.
  • with_suffix()는 점으로 시작해야 합니다. 'bak'이라고 쓰면 ValueError가 납니다.
  • / 뒤에 슬래시로 시작하는 문자열이 오면 앞부분이 버려집니다. os.path.join도 같습니다. 사용자 입력을 이어 붙일 때 특히 주의하세요.
  • Path는 ..을 저절로 없애지 않습니다. 그래서 Path('sample/../sample') == Path('sample')은 False였고, 둘 다 .resolve()를 거치면 True였습니다.
  • 없는 폴더에 glob()을 돌리면 오류 없이 빈 결과가 나옵니다. os.listdir은 FileNotFoundError를 냅니다. 경로 오타가 “대상 파일 0개”로 조용히 넘어갈 수 있습니다.

속도 차이 — 파일 5만 개로 실측

객체를 만드는 pathlib가 문자열을 다루는 os.path보다 얼마나 느린지 직접 쟀습니다. 폴더 100개에 파일 5만 개를 만들고 항목마다 3회 반복한 최솟값을, 스크립트를 2번 실행해 함께 적었습니다.

작업os 방식pathlib 방식
.log 파일 2만 5천 개 찾기os.walk 0.101초 / 0.097초rglob 0.108초 / 0.101초
파일 5만 개 크기 합계getsize 0.474초 / 0.476초rglob + stat 0.885초 / 0.892초
경로 합치기 20만 회join 0.246초 / 0.248초/ 연산자 0.361초 / 0.375초
확장자 얻기 20만 회splitext 0.121초 / 0.120초Path().suffix 0.510초 / 0.546초

파일 찾기는 사실상 같았고, 크기 합계는 약 1.9배, 경로 합치기는 약 1.5배, 확장자 얻기는 4배 이상 차이가 났습니다. 확장자 쪽은 매번 Path 객체를 새로 만드는 비용이 포함되어 있습니다. 그래도 절대 시간으로 보면 파일 5만 개에 0.4초, 20만 회 반복에 많아야 0.4초 정도의 차이입니다. 같은 벤치마크에서 파일 5만 개를 만드는 데만 18~20초가 걸렸으니, 파일을 실제로 읽고 쓰는 스크립트라면 경로 처리 차이는 거의 드러나지 않을 것입니다.

정말 탐색 속도가 중요하다면 os.scandir을 직접 쓰는 방법이 있습니다. 같은 탐색이 0.052~0.063초로 가장 빨랐습니다. Python 3.12에 들어온 Path.walk()는 os.walk처럼 폴더별로 파일 이름 목록을 받는 방식이라, 기존 os.walk 코드를 옮길 때 구조를 거의 그대로 둘 수 있습니다. 속도는 0.064~0.104초였습니다.

주의할 점

  • read_text와 write_text에는 encoding='utf-8'을 적으세요. 줄을 줄이다 빠뜨리면 첫 한글에서 멈춥니다.
  • rename('새이름')은 현재 폴더 기준입니다. 같은 폴더에 두려면 with_name()을 쓰세요. 오류가 나지 않아서 더 위험합니다.
  • Path.replace()는 문자열 치환이 아니라 파일 이동입니다.
  • rglob('*.log') 결과에는 폴더와 점 파일도 들어옵니다. is_file()로 한 번 거르세요.
  • 대소문자 처리는 OS마다 다릅니다. 윈도우에서 잡히던 .LOG가 대소문자 구분 모드에서는 빠졌습니다. suffix.lower()로 비교하면 양쪽에서 같습니다.
  • JSON·SQLite·환경 변수에 넘길 때는 str()로 바꾸세요.

마무리

경로를 다루는 코드는 pathlib로 짧아졌고 속도 걱정도 크지 않았습니다. 다만 인코딩, rename, rglob 세 곳은 그대로 옮기면 동작이 달라졌습니다.

가장 작은 첫걸음은 이렇습니다.

  1. 가지고 있는 스크립트에서 os.path.join이 가장 많이 겹친 한 줄만 Path(...) / ...로 바꿔 봅니다. Path는 open()과 os.path 함수에 그대로 넘어가므로 나머지 코드는 그대로 둬도 됩니다.
  2. 바꾸기 전 결과 폴더를 남겨 두고, 바꾼 뒤 결과와 filecmp로 비교합니다. 이번에도 이 비교로 내용이 같다는 걸 확인했습니다.
  3. read_text, rename, rglob이 들어간 줄은 이 글의 주의할 점과 대조합니다.

다음 글에서는 AI가 쓴 글을 사람 눈 대신 숫자로 점검하는 스크립트를 만들어, 문장 길이와 반복 표현을 재 보겠습니다.

댓글 남기기