INSU's Blog

THU

from pathlib import Path로 경로 다루기

데이터 폴더를 열고, 학습용 CSV를 고르고, 결과 파일을 저장하는 일은 실험 코드에서 매일 반복된다. 이때 경로를 문자열로 이어 붙이면 os.path.join과 os.path.basename이 겹치고, 구분자도 운영체제마다 달라진다. pathlib은 경로를 객체처럼 다루게 해 주는 표준 라이브러리다.

from pathlib import Path

이 한 줄이면 충분하다. 아래에서는 Path가 무엇인지, os.path 대신 쓰는 이유, 그리고 프로젝트 안의 데이터 파일을 훑는 짧은 예까지 이어 간다.

pathlib란

pathlib은 파일 시스템 경로를 나타내는 클래스를 모아 둔 모듈이다. 중심은 Path다. Path("data/train.csv")는 문자열이 아니라 경로 객체이고, 파일 이름, 상위 폴더, 존재 여부, 읽기와 쓰기를 그 객체의 속성과 메서드로 묻는다.

Python 3.4부터 표준 라이브러리에 들어 있다. 3.6 이후로는 open()을 비롯한 많은 표준 함수가 Path를 경로처럼 받는다. 새 코드에서는 os.path로 문자열을 가공하기보다 Path로 시작하는 편이 읽기 쉽다.

객체를 만들 때는 보통 이 형태면 된다.

from pathlib import Path

data_file = Path("data/train.csv")

경로는 만들 때 디스크를 건드리지 않는다. Path("data/train.csv")는 “이 이름”을 가리킬 뿐이고, 파일이 없어도 객체는 생긴다. 확인과 생성은 exists(), mkdir(), write_text()처럼 따로 호출할 때 일어난다.

os.path 대신 Path를 쓰는 이유

os.path는 함수가 경로 문자열을 받아서 다시 문자열을 돌려준다. 할 일이 늘면 호출이 안으로 겹친다.

import os

path = os.path.join(os.path.expanduser("~"), "data", "train.csv")
folder = os.path.dirname(path)
filename = os.path.basename(path)

Path는 같은 일을 객체 하나로 끝낸다. 경로는 /로 잇고, 필요한 조각은 속성으로 꺼낸다.

from pathlib import Path

path = Path.home() / "data" / "train.csv"
folder = path.parent
filename = path.name

차이는 취향만의 문제가 아니다.

  • 경로를 잇는 일과 파일 이름을 떼는 일이 같은 객체에 있다.
  • /는 왼쪽에서 오른쪽으로 읽힌다. join 호출이 겹치지 않는다.
  • open(), write_text(), glob()이 경로와 한자리에 있다.
  • 테스트에서 임시 폴더를 Path로 넘기면 문자열 조합을 따로 맞출 일이 줄어든다.

이미 os.path로 쓰인 코드를 한 번에 바꿀 필요는 없다. 새로 여는 스크립트와 데이터 로더부터 Path로 두면 충분하다.

경로 만들기

가장 단순한 방법은 문자열 하나를 넘기는 것이다. 슬래시로 적힌 상대 경로도 그대로 받는다.

from pathlib import Path

relative = Path("data/raw/train.csv")
absolute = Path("/tmp/experiments/run-01")

조각이 변수로 흩어져 있으면 /로 잇는다. 양변이 Path여도 되고, 한 변은 문자열이어도 된다.

root = Path("projects") / "churn"
raw = root / "data" / "raw" / "train.csv"

자주 쓰는 시작점은 두 가지다. Path.cwd()는 현재 작업 디렉터리, Path.home()은 사용자 홈이다. 둘 다 절대 경로다.

here = Path.cwd()
notes = Path.home() / "notes" / "experiment.md"

스크립트 파일 기준으로 데이터를 찾고 싶다면 __file__을 쓴다. 작업 디렉터리가 어디든, 파일 위치는 그대로다.

SCRIPT_DIR = Path(__file__).resolve().parent
DATA_DIR = SCRIPT_DIR / "data"

/는 새 Path를 돌려줄 뿐 폴더를 만들지 않는다. 폴더가 필요하면 뒤에서 보는 mkdir을 호출한다.

/ 로 경로 잇기

/는 pathlib에서 경로를 잇는 연산자다. os.path.join과 같은 역할이고, 구분자 문자를 직접 넣지 않는다.

from pathlib import Path

root = Path("data")
train = root / "raw" / "train.csv"
labels = train.with_name("labels.csv")

with_name은 마지막 파일 이름만 바꾼다. 확장자만 바꿀 때는 with_suffix가 맞다. 인자에는 점까지 넣는다.

csv_path = Path("data/raw/train.csv")
parquet_path = csv_path.with_suffix(".parquet")

빈 조각이나 절대 경로를 섞으면 결과가 달라진다. / 오른쪽에 절대 경로가 오면 왼쪽을 버리고 그 절대 경로가 된다. os.path.join과 같은 규칙이다.

print(Path("data") / "/tmp/train.csv")

조각은 문자열이나 Path만 넘긴다. 숫자는 str로 바꾼 뒤에 잇는다.

run_id = 3
run_dir = Path("outputs") / f"run_{run_id:02d}"

자주 쓰는 속성

속성은 경로 문자열을 잘라 읽는다. 파일이 디스크에 없어도 동작한다.

아래 표는 reports/2026/q3_summary.csv 기준이다.

속성 값
name q3_summary.csv
stem q3_summary
suffix .csv
parent reports/2026

name, stem, suffix

name은 마지막 조각 전체다. 폴더와 파일 이름, 확장자가 한 문자열에 들어 있다. stem은 그 이름에서 마지막 확장자를 뺀 것이고, suffix는 마지막 확장자에 점까지 붙인 것이다.

from pathlib import Path

path = Path("reports/2026/q3_summary.csv")
path.name     # q3_summary.csv
path.stem     # q3_summary
path.suffix   # .csv

확장자가 두 개면 stem과 suffix는 마지막 점만 본다. 전부 필요하면 suffixes를 쓴다.

archive = Path("models/model.tar.gz")
archive.stem       # model.tar
archive.suffix     # .gz
archive.suffixes   # ['.tar', '.gz']

확장자 비교는 대소문자를 맞춘 뒤에 하는 편이 안전하다. Windows에서는 Data.CSV처럼 들어올 수 있다.

if path.suffix.lower() == ".csv":
    print(path.name)

parent

parent는 바로 위 폴더다. 그 위는 parents 시퀀스로 올라간다. 인덱스가 클수록 루트에 가깝다.

path = Path("data/raw/train.csv")
path.parent       # data/raw
path.parents[0]   # data/raw
path.parents[1]   # data

결과 파일을 데이터 파일 옆에 둘 때 parent로 폴더를 잡은 뒤 이름만 바꾸면 된다.

source = Path("data/raw/train.csv")
preview = source.parent / f"{source.stem}_head.csv"

존재 확인과 디렉터리 만들기

경로 객체는 있어도 파일은 없을 수 있다. 열기 전에 exists(), is_file(), is_dir()로 종류를 구분한다.

from pathlib import Path

path = Path("data/train.csv")

if path.is_file():
    print("파일을 읽는다")
elif path.is_dir():
    print("폴더를 훑는다")
elif not path.exists():
    print("아직 없다")

exists()는 파일과 폴더를 가리지 않는다. 데이터를 읽을 자리라면 is_file()이 더 정확하다. 깨진 심볼릭 링크는 exists()가 거짓이다.

폴더는 mkdir로 만든다. parents=True면 중간 폴더도 함께 만들고, exist_ok=True면 이미 있어도 에러를 내지 않는다. 실험 출력 폴더에 그대로 쓰기 좋다.

out = Path("outputs") / "2026-10-08"
out.mkdir(parents=True, exist_ok=True)

exist_ok 없이 같은 폴더를 다시 만들면 FileExistsError가 난다. 로그와 체크포인트를 매번 같은 위치에 쌓는 코드라면 exist_ok=True를 기본으로 둔다.

glob과 rglob

패턴으로 파일을 찾을 때는 glob과 rglob을 쓴다. glob("*.csv")는 그 폴더의 바로 아래만 본다. rglob("*.csv")는 하위 폴더까지 내려간다. rglob("*.csv")는 glob("**/*.csv")와 같다.

from pathlib import Path

data = Path("data")

for path in data.glob("*.csv"):
    print("바로 아래", path.name)

for path in data.rglob("*.csv"):
    print("하위 포함", path)

반환 순서는 파일 시스템에 따른다. 실험 로그처럼 순서가 중요하면 sorted로 감싼다.

for path in sorted(data.rglob("*.csv")):
    print(path.as_posix())

패턴은 대소문자를 구분하는 환경이 많다. *.CSV와 *.csv를 둘 다 잡으려면 rglob("*") 뒤에 suffix를 비교하는 편이 확실하다. 아래 실습 예가 그 방식이다.

숨김 파일이나 임시 파일은 이름 조건으로 걸러낸다.

for path in data.rglob("*"):
    if path.name.startswith("."):
        continue
    if path.suffix == ".tmp":
        continue

파일 읽고 쓰기

텍스트는 read_text와 write_text로 주고받는다. 인코딩은 utf-8로 고정한다. Windows 기본 인코딩은 환경에 따라 cp949가 될 수 있어서, 노트북과 서버가 다르면 한글이 깨진다.

from pathlib import Path

note = Path("outputs") / "note.txt"
note.parent.mkdir(parents=True, exist_ok=True)

note.write_text("첫 실험 메모\n", encoding="utf-8")
text = note.read_text(encoding="utf-8")

write_text는 파일을 새로 덮어쓴다. 뒤에 이어 붙이려면 open의 추가 모드를 쓴다. Path는 open의 경로 인자로 그대로 넘길 수 있다.

with note.open("a", encoding="utf-8") as handle:
    handle.write("한 줄 추가\n")

바이너리에는 read_bytes와 write_bytes가 있다. 모델 가중치나 이미지처럼 인코딩이 없는 파일에 쓴다.

blob = Path("models/weights.bin")
payload = blob.read_bytes()

큰 CSV를 메모리에 한 번에 올리기 어렵다면 read_text 대신 open으로 한 줄씩 읽는다. 경로를 다루는 일과 파싱은 분리하는 편이 낫다.

현재 디렉터리와 홈

Path.cwd()는 프로세스가 실행된 작업 디렉터리다. 터미널에서 cd한 위치와 같다. 같은 스크립트라도 실행 위치가 바뀌면 반환값이 바뀐다.

Path.home()은 사용자 홈 디렉터리다. Unix에서는 ~, Windows에서는 사용자 프로필 폴더에 해당한다. 설정 파일이나 개인 데이터 루트를 잡을 때 쓴다.

from pathlib import Path

print(Path.cwd())
print(Path.home())

config = Path.home() / ".config" / "experiments" / "config.toml"

재현이 중요한 학습 스크립트는 cwd()에만 기대지 않는 편이 안전하다. 저장소 루트나 __file__ 기준으로 잡고, 작업 디렉터리는 로그에만 남긴다.

ROOT = Path(__file__).resolve().parent
print("cwd ", Path.cwd())
print("root", ROOT)

resolve로 절대 경로 만들기

상대 경로에는 .과 ..이 섞일 수 있다. resolve()는 이를 풀고 절대 경로로 만든다. 심볼릭 링크도 실제 대상으로 따라간다.

from pathlib import Path

messy = Path("data/raw/../processed/./train.csv")
print(messy.resolve())

파일이 아직 없어도 기본값인 strict=False에서는 경로 문자열만 정규화한다. strict=True면 대상이 없을 때 FileNotFoundError를 낸다. “있어야만 하는 입력”을 확인할 때 유용하다.

source = Path("data/train.csv")
source.resolve(strict=True)

absolute()는 현재 작업 디렉터리를 앞에 붙여 절대 형태로 만들 뿐, 심볼릭 링크를 풀지는 않는다. 로그에 남길 최종 경로, 그리고 ..를 없앤 경로가 필요하면 resolve()를 쓴다.

두 경로가 같은 파일인지 볼 때도 resolve() 결과가 편하다. 문자열 비교는 data/../data/a.csv와 data/a.csv를 다르게 본다.

운영체제와 구분자

코드 안의 /는 연산자다. Windows라고 해서 \\로 잇지 않는다. Path가 그 운영체제의 구분자로 바꿔 준다.

문자열로 내보낼 때만 차이가 보인다. str(path)는 그 OS의 구분자를 쓰고, as_posix()는 항상 슬래시다. 로그, 설정 파일, S3 키처럼 슬래시를 약속한 곳에는 as_posix()가 맞다.

from pathlib import Path

path = Path("data") / "raw" / "train.csv"
print(str(path))        # OS 구분자
print(path.as_posix())  # data/raw/train.csv

Windows 드라이브 경로도 슬래시로 적어도 Path가 받는다.

win_path = Path("C:/Users/insu/data/train.csv")

피해야 할 습관은 구분자를 문자열로 더하는 일이다. folder + "\\" + name은 다른 OS에서 깨지고, 슬래시가 두 번 들어가기도 한다. 잇기는 /에 맡긴다.

순수하게 문자열만 다루고 디스크는 건드리지 않을 때는 PurePosixPath, PureWindowsPath가 있다. 원격 경로의 형태만 검사할 때 쓰고, 내 컴퓨터의 파일을 열 때는 Path를 쓴다.

실습: 프로젝트의 데이터 파일 훑기

아래 스크립트는 스크립트 옆의 data 폴더를 만들고, 그 아래의 테이블 파일만 골라 상대 경로와 크기를 출력한다. CSV, TSV, Parquet를 대상으로 하고, 숨김 파일은 건너뛴다.

from pathlib import Path

ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
TABLE_SUFFIXES = {".csv", ".tsv", ".parquet"}


def prepare_data_dir(folder: Path) -> Path:
    folder.mkdir(parents=True, exist_ok=True)
    return folder


def iter_tables(folder: Path):
    for path in sorted(folder.rglob("*")):
        if not path.is_file():
            continue
        if path.name.startswith("."):
            continue
        if path.suffix.lower() not in TABLE_SUFFIXES:
            continue
        yield path


def main() -> None:
    folder = prepare_data_dir(DATA_DIR)
    found = list(iter_tables(folder))
    if not found:
        print(f"테이블 파일이 없습니다: {folder}")
        return
    for path in found:
        rel = path.relative_to(ROOT).as_posix()
        size = path.stat().st_size
        print(f"{rel}  ({path.suffix}, {size} bytes)")


if __name__ == "__main__":
    main()

data/raw/train.csv를 넣어 두면 출력은 이런 형태다.

data/raw/train.csv  (.csv, 18240 bytes)

이 흐름이 Path로 경로를 다룰 때의 기본 골격이다. 기준 폴더는 resolve().parent로 고정하고, 하위 경로는 /로 잇고, 대상은 rglob과 suffix로 고르고, 본문은 read_text나 전용 로더에 넘긴다. 문자열 이어 붙이기는 그 사이에 끼어들 자리가 없다.

Top