폴더 용량 확인 스크립트 — 디스크를 잡아먹는 폴더 찾아내기 (파이썬 실측)

C 드라이브가 빨간 막대로 바뀌면 탐색기에서 폴더를 하나씩 우클릭해 속성을 열어보게 됩니다. 폴더가 몇 개만 넘어가도 금방 지칩니다. 그래서 폴더 용량 확인 스크립트를 파이썬으로 만들어 이 노트북(윈도우 11, 파이썬 3.12)의 C:\Program Files에 돌려봤습니다. 흔히 보이는 os.walk 방식은 평균 6.09초, os.scandir로 바꾼 버전은 2.19초가 걸렸습니다. 더 중요한 건 속도가 아니었습니다. os.walk 버전은 50MB짜리 폴더를 100MB로 두 번 세고, 읽지 못한 폴더 174개를 아무 경고 없이 넘어갔습니다. 이 글에서는 직접 돌리며 찾은 이 함정들과, 그걸 피한 최종 스크립트를 정리합니다.

1단계: os.walk로 하위 폴더별 합계 내기

검색하면 가장 먼저 나오는 형태입니다. 대상 폴더 바로 아래의 하위 폴더마다 os.walk로 모든 파일을 돌며 os.path.getsize를 더하고, 큰 순서로 10개를 보여줍니다. 표준 라이브러리만 쓰므로 설치할 것이 없습니다.

"""폴더 용량 확인 (1단계): os.walk + os.path.getsize로 하위 폴더별 합계."""
import os
import sys
import time

sys.stdout.reconfigure(encoding="utf-8")


def human(n):
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return f"{n:,.1f} {unit}" if unit != "B" else f"{n} B"
        n /= 1024


def folder_size(path):
    total = 0
    for root, dirs, files in os.walk(path):
        for name in files:
            total += os.path.getsize(os.path.join(root, name))
    return total


if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "."
    t0 = time.perf_counter()
    sizes = []
    for entry in os.listdir(target):
        full = os.path.join(target, entry)
        if os.path.isdir(full):
            sizes.append((folder_size(full), entry))
    sizes.sort(reverse=True)
    for size, name in sizes[:10]:
        print(f"{human(size):>12}  {name}")
    print(f"소요 {time.perf_counter() - t0:.2f}초")

C:\Program Files에 처음 돌린 결과입니다. 유니티 에디터가 22.2GB로 압도적이었습니다.

$ python du_walk.py "C:/Program Files"
     22.2 GB  Unity
      6.1 GB  Microsoft Visual Studio
      4.4 GB  Norton
    917.9 MB  VITURE
    856.6 MB  AMD
    564.8 MB  Unity Hub
    512.3 MB  Google
    506.3 MB  LogiOptionsPlus
    455.2 MB  obs-studio
    403.5 MB  Git
소요 20.78초

20.78초는 첫 실행이라 디스크 캐시가 비어 있던 영향이 큽니다. 같은 명령을 바로 다시 돌리면 5.86초로 줄었습니다. 그래서 아래 속도 비교는 캐시가 찬 상태에서 두 스크립트를 번갈아 세 번씩 돌린 값으로 했습니다.

2단계: scandir로 바꾸고 오류·정션·큰 파일까지

개선한 버전입니다. 바뀐 점은 네 가지입니다. os.scandir로 직접 재귀하고, 심볼릭 링크와 정션은 따라가지 않고 개수만 세고, 읽지 못한 폴더는 경로와 오류 이름을 모아서 마지막에 보여주고, 큰 파일 N개를 heapq로 함께 뽑습니다. --exact 옵션은 아래 “쓰는 중인 로그” 절에서 설명합니다.

"""폴더 용량 확인 (2단계): os.scandir 재귀 + 오류 집계 + 큰 파일 목록."""
import argparse
import heapq
import os
import sys
import time

sys.stdout.reconfigure(encoding="utf-8")


def human(n):
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return f"{n:,.1f} {unit}" if unit != "B" else f"{n} B"
        n /= 1024


class Stats:
    def __init__(self, top_files, exact=False):
        self.exact = exact      # True면 파일마다 os.stat (쓰는 중인 로그도 정확, 대신 느림)
        self.errors = []        # (경로, 오류 이름)
        self.skipped_links = 0  # 심볼릭 링크·정션은 따라가지 않음
        self.files = 0
        self.top_files = top_files
        self.big = []           # (크기, 경로) 최소 힙, 큰 파일 N개만 유지


def scan(path, st):
    """path 아래 모든 파일 크기 합계를 돌려준다. 못 읽은 폴더는 st.errors에 남긴다."""
    total = 0
    try:
        it = os.scandir(path)
    except OSError as e:
        st.errors.append((path, type(e).__name__))
        return 0
    with it:
        for entry in it:
            try:
                if entry.is_symlink() or entry.is_junction():
                    st.skipped_links += 1
                    continue
                if entry.is_dir(follow_symlinks=False):
                    total += scan(entry.path, st)
                else:
                    if st.exact:
                        size = os.stat(entry.path, follow_symlinks=False).st_size
                    else:
                        size = entry.stat(follow_symlinks=False).st_size
                    total += size
                    st.files += 1
                    item = (size, entry.path)
                    if len(st.big) < st.top_files:
                        heapq.heappush(st.big, item)
                    elif size > st.big[0][0]:
                        heapq.heapreplace(st.big, item)
            except OSError as e:
                st.errors.append((entry.path, type(e).__name__))
    return total


def main():
    ap = argparse.ArgumentParser(description="하위 폴더별 용량 순위")
    ap.add_argument("target", nargs="?", default=".")
    ap.add_argument("--top", type=int, default=10, help="표시할 폴더 수")
    ap.add_argument("--files", type=int, default=5, help="표시할 큰 파일 수")
    ap.add_argument("--exact", action="store_true", help="파일마다 os.stat으로 크기 확인")
    args = ap.parse_args()

    t0 = time.perf_counter()
    st = Stats(args.files, args.exact)
    rows = []
    loose = 0  # target 바로 아래에 있는 파일들
    with os.scandir(args.target) as it:
        for entry in it:
            if entry.is_symlink() or entry.is_junction():
                st.skipped_links += 1
            elif entry.is_dir(follow_symlinks=False):
                rows.append((scan(entry.path, st), entry.name))
            else:
                try:
                    loose += entry.stat(follow_symlinks=False).st_size
                    st.files += 1
                except OSError as e:
                    st.errors.append((entry.path, type(e).__name__))
    rows.sort(reverse=True)
    total = sum(s for s, _ in rows) + loose

    print(f"대상: {os.path.abspath(args.target)}  합계 {human(total)}, 파일 {st.files:,}개")
    for size, name in rows[: args.top]:
        pct = size / total * 100 if total else 0
        print(f"{human(size):>12}  {pct:5.1f}%  {name}")
    print(f"\n큰 파일 {len(st.big)}개")
    for size, p in sorted(st.big, reverse=True):
        print(f"{human(size):>12}  {os.path.relpath(p, args.target)}")
    print(f"\n건너뛴 링크·정션 {st.skipped_links}개, 읽지 못한 항목 {len(st.errors)}개")
    for p, err in st.errors[:5]:
        print(f"  {err}: {p}")
    print(f"소요 {time.perf_counter() - t0:.2f}초")


if __name__ == "__main__":
    main()

같은 대상에 돌린 결과입니다. 폴더 순위는 1단계와 같고, 비중(%)과 큰 파일 목록이 붙었습니다.

$ python du_scan.py "C:/Program Files"
대상: C:\Program Files  합계 39.0 GB, 파일 249,771개
     22.2 GB   56.8%  Unity
      6.1 GB   15.7%  Microsoft Visual Studio
      4.4 GB   11.3%  Norton
    917.9 MB    2.3%  VITURE
    856.6 MB    2.1%  AMD
    (중략)

큰 파일 5개
    533.2 MB  Norton\mlm\26.10071.58\Whisper_multilingual\decoder.onnx
    431.6 MB  Unity\Hub\Editor\6000.5.10f1\...\binaryen\lib\binaryen.lib
    390.6 MB  Norton\mlm\26.10071.58\Whisper_multilingual\encoder.onnx
    388.9 MB  Unity\Hub\Editor\6000.5.10f1\...\com.unity.burst-1.8.30.tgz
    388.9 MB  Unity\Hub\Editor\6000.0.82f1\...\com.unity.burst-1.8.30.tgz

건너뛴 링크·정션 170개, 읽지 못한 항목 1개
  PermissionError: C:/Program Files\WindowsApps
소요 2.17초

큰 파일 목록에서 바로 쓸모 있는 정보가 나왔습니다. 4위와 5위가 같은 파일(com.unity.burst-1.8.30.tgz, 388.9MB)인데, 유니티 에디터 두 버전에 하나씩 들어 있습니다. 폴더 순위만 봤다면 “유니티가 크다”에서 끝났겠지만, 파일 목록을 보면 “에디터 버전이 두 개 깔려 있다”는 정리 포인트가 보입니다.

속도 비교: 6.09초 대 2.19초

$ python bench.py "C:/Program Files" 3
1회차 du_walk.py: 6.21초
1회차 du_scan.py: 2.18초
2회차 du_walk.py: 6.06초
2회차 du_scan.py: 2.23초
3회차 du_walk.py: 6.01초
3회차 du_scan.py: 2.16초
du_walk.py 평균 6.09초 (최소 6.01, 최대 6.21)
du_scan.py 평균 2.19초 (최소 2.16, 최대 2.23)

약 2.8배 차이입니다. 파일 24만 9천여 개를 도는 동안 os.path.getsize는 파일마다 운영체제에 크기를 따로 물어봅니다. 반면 os.scandir가 돌려주는 항목은 윈도우에서 폴더 목록을 읽을 때 크기 정보가 함께 오기 때문에, entry.stat()이 추가 호출 없이 그 값을 씁니다. 다만 이 “함께 온 크기”에 함정이 하나 있다는 걸 나중에 알게 됐습니다.

함정 1: os.walk는 읽지 못한 폴더를 조용히 건너뛴다

1단계 스크립트를 C:\ProgramData에 돌렸을 때는 오류 메시지 하나 없이 0.90초 만에 끝났습니다. 그런데 2단계 스크립트는 같은 폴더에서 읽지 못한 항목 174개를 보고했습니다(Microsoft\Crypto, Microsoft\Diagnosis 등 관리자 권한이 필요한 폴더). os.walk는 기본값이 오류를 무시하는 것이라 그렇습니다. onerror를 지정해 확인해봤습니다.

$ python walk_onerror.py "C:/Program Files"
onerror 없이: 폴더 19,765개 순회, 예외 없음
onerror 지정: 폴더 19,765개 순회, 오류 2개
  PermissionError: C:/Program Files\Windows NT\보조프로그램
  PermissionError: C:/Program Files\WindowsApps

순회한 폴더 수는 같고, 오류를 받을지 말지만 다릅니다. 용량 합계가 실제보다 작게 나와도 스크립트는 아무 말을 안 한다는 뜻입니다. 정확한 합계가 필요하면 관리자 권한으로 돌리거나, 최소한 “몇 개를 못 읽었는지”는 출력해야 결과를 믿을지 판단할 수 있습니다.

함정 2: 정션은 두 번 센다 (os.walk 기본값으로도)

위 출력에서 이상한 점이 있었습니다. Windows NT\보조프로그램은 os.walk의 오류 목록에만 있고, 2단계 스크립트에는 없었습니다. 확인해 보니 이 폴더는 정션(윈도우의 폴더 바로가기 같은 것)이었고, 2단계 스크립트는 정션을 건너뛰었기 때문에 들어가 보지도 않은 것이었습니다. 반대로 말하면 os.walk는 정션 안으로 들어간다는 뜻입니다.

그래서 50MB 파일 하나가 든 data 폴더와, 그걸 가리키는 정션 link_to_data를 만들어 직접 비교했습니다(정션은 _winapi.CreateJunction으로 만들었습니다).

(data\big.bin 50MB 하나, link_to_data는 data를 가리키는 정션)
$ python du_walk.py junction_case
     50.0 MB  link_to_data
     50.0 MB  data

$ python du_scan.py junction_case
대상: ...\junction_case  합계 50.0 MB, 파일 1개
     50.0 MB  100.0%  data
건너뛴 링크·정션 1개, 읽지 못한 항목 0개

$ python walk_junction.py
python 3.12.10
C:\Program Files\Windows NT\보조프로그램: isjunction=True, islink=False
os.walk가 찾은 파일: data\big.bin
os.walk가 찾은 파일: link_to_data\big.bin

os.walk의 기본값은 followlinks=False인데도 정션 안의 파일을 한 번 더 찾았고, 1단계 스크립트는 실제 50MB를 100MB로 보고했습니다. 정션은 islink()가 False라 “링크”로 취급되지 않기 때문입니다. 파이썬 3.12에서 추가된 is_junction()으로 따로 걸러야 합니다.

테스트용으로 만든 경우만의 일이 아닙니다. 실제 C:\ProgramData에서 정션을 찾아보니 이런 것들이 있었습니다.

$ python list_links.py "C:/ProgramData"
정션 Application Data -> \\?\C:\ProgramData  (0.0 MB)
정션 Documents -> \\?\C:\Users\Public\Documents  (0.0 MB)
정션 Templates -> \\?\C:\ProgramData\Microsoft\Windows\Templates  (0.0 MB)
(중략)
정션 Microsoft\Windows Defender\Platform\CurrentVersion -> \\?\C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.26090.9-0  (118.4 MB)

Application Data는 자기 자신(C:\ProgramData)을 가리킵니다. 접근이 거부돼 있어서 무한 루프는 나지 않았지만, 권한이 열려 있었다면 끝없이 돌았을 구조입니다. 그리고 윈도우 디펜더의 CurrentVersion 정션 아래 118.4MB가 1단계 스크립트에서 한 번 더 더해져, Microsoft 폴더가 1단계에서는 3.1GB, 2단계에서는 2.9GB로 나왔습니다.

함정 3: 쓰는 중인 로그는 실제보다 작게 잡힌다

정션으로 설명되지 않는 차이가 하나 더 있었습니다. C:\ProgramData의 Norton 폴더가 1단계에서는 191.8MB, 2단계에서는 184.9MB였습니다. 정션도 없는 폴더입니다. 그래서 같은 파일의 크기를 entry.stat()과 os.stat()으로 각각 읽어 비교하는 스크립트를 돌렸습니다.

$ python stat_diff.py "C:/ProgramData"
파일 10,268개 비교, 크기 다른 파일 50개, 차이 합계 57.9 MB
  scandir 65,536 / os.stat 48,562,176  ASUS\ASUS System Control Interface\log\ASUS_System_Control_Interface.etl
  scandir 7,327 / os.stat 2,204,954  Norton\Antivirus\log\FwServ.log
  scandir 4,592,186 / os.stat 6,337,227  ASUS\ARMOURY CRATE Diagnosis\LightingService\LightingService.log
  scandir 1,340,129 / os.stat 2,721,706  Norton\Antivirus\log\media_detector.log
  scandir 0 / os.stat 1,245,184  USOShared\Logs\System\UpdateSessionOrchestration.(중략).etl

1만 268개 중 50개, 합계 57.9MB가 달랐고, 전부 .log와 .etl, 즉 프로그램이 열어두고 계속 쓰는 로그 파일이었습니다. 48MB짜리 로그가 폴더 목록에서는 64KB로 나오기도 했습니다. 폴더 목록에 적힌 크기가 늦게 갱신되는 것으로 보입니다. scandir가 빠른 이유가 바로 “폴더 목록에 적힌 크기를 그대로 쓰는 것”이었으니, 속도와 정확도를 맞바꾼 셈입니다.

그래서 --exact 옵션을 넣었습니다. 켜면 파일마다 os.stat()을 부릅니다. 켜고 돌리니 Norton 191.8MB, NVIDIA 224.4MB로 1단계와 일치했습니다.

방식Program Files 소요정션읽기 실패쓰는 중인 로그
1단계 os.walk + getsize평균 6.09초두 번 셈조용히 무시정확
2단계 scandir (기본)평균 2.19초건너뜀개수·경로 출력작게 잡힐 수 있음
2단계 scandir –exact5.12초 / 4.90초건너뜀개수·경로 출력정확

“어느 폴더가 큰가”를 찾는 용도라면 기본 모드로 충분합니다. 로그 폴더가 얼마나 불어나는지 매일 기록하는 것처럼 로그 크기 자체가 관심사라면 --exact를 쓰는 게 맞습니다.

이 블로그 프로젝트 폴더에 돌려보니

마지막으로 이 블로그를 운영하는 자동화 프로젝트 폴더에 돌렸습니다. 결과를 보고 잠깐 놀랐습니다.

$ python du_scan.py . --top 5 --files 3
대상: C:\프로그램\12. 애드센스  합계 102.9 MB, 파일 752개
     50.7 MB   49.3%  drafts
     21.7 MB   21.1%  .git
     19.3 MB   18.7%  thumbs
      3.0 MB    2.9%  assets
      3.0 MB    2.9%  .playwright-mcp

큰 파일 3개
     50.0 MB  drafts\evidence\run\diskuse\junction_case\data\big.bin
     18.8 MB  .git\objects\pack\pack-e0965b75(중략).pack
      2.4 MB  node_modules\@resvg\resvg-wasm\index_bg.wasm

1위 drafts 50.7MB 중 50MB가 방금 정션 실험용으로 만든 테스트 파일이었습니다. 큰 파일 목록이 없었다면 “원고 폴더가 왜 이렇게 크지” 하고 헤맸을 겁니다. 이 파일을 그대로 두었으면 다음 커밋에 50MB가 저장소에 들어갈 뻔했기 때문에, 바로 지웠습니다. 지울 때도 정션이 걸려 있으니 os.rmdir로 정션만 먼저 떼어낸 뒤(대상 폴더는 남습니다) 나머지를 삭제했습니다. 실험 파일을 빼면 이 프로젝트는 깃 기록 21.7MB, 썸네일 PNG 19.3MB가 대부분입니다.

용량을 찾은 다음 단계는 대개 지우는 것인데, 정션이 섞인 폴더라면 지우기 전에 위 출력처럼 정션 개수와 위치부터 확인하세요. 저는 정션을 os.rmdir로 먼저 떼어내고, 원본 data\big.bin이 그대로 남아 있는 걸 확인한 뒤에 나머지를 지웠습니다.

정리

  1. os.walk + getsize보다 os.scandir 재귀가 실측 약 2.8배 빨랐습니다(24만 9천여 개 파일, 6.09초 대 2.19초).
  2. os.walk는 읽지 못한 폴더를 조용히 건너뜁니다. onerror로 받거나, 못 읽은 개수를 출력하세요.
  3. 윈도우 정션은 islink()로 걸러지지 않습니다. 파이썬 3.12 이상이면 is_junction()으로 건너뛰세요. 안 그러면 같은 용량을 두 번 셉니다.
  4. scandir의 크기는 쓰는 중인 로그에서 실제보다 작을 수 있습니다. 로그 크기가 중요하면 os.stat()으로 다시 읽으세요.
  5. 폴더 순위와 함께 큰 파일 목록을 보면 “버전이 두 개 깔려 있다”, “실험 파일이 남아 있다” 같은 구체적인 정리 대상이 보입니다.

가장 작은 첫걸음은 위 du_scan.py를 저장한 뒤 명령 프롬프트에서 python du_scan.py "%USERPROFILE%" --files 10(파워셸이라면 $env:USERPROFILE)처럼 내 사용자 폴더에 한 번 돌려보는 것입니다. 표준 라이브러리만 써서 설치할 것은 없습니다.

다음 글에서는 이렇게 찾은 큰 로그 파일에서 원하는 줄만 빠르게 뽑아내는 방법을 다루겠습니다.

댓글 남기기