[문제 해결] [Python 스크립트] 특정 파일 개수 이상 디렉토리만 선별적으로 복사하는 효율적인 방법

안녕하세요! 복잡한 파일 시스템 관리와 데이터 정리에 어려움을 겪는 개발자분들을 위해, 특정 조건을 만족하는 디렉토리만 선별적으로 복사하는 실용적인 해결책을 제시합니다. 이번 문서에서는 스택오버플로우에서 제기된 ‘음악 라이브러리에서 일정 수 이상의 곡을 가진 아티스트 디렉토리만 복사하기’ 문제에 초점을 맞춰, Python을 활용한 강력하고 유연한 스크립트 개발 방법을 상세히 안내해 드립니다. 구글 SEO에 최적화된 키워드와 사용자 경험을 고려한 설명으로, 여러분의 파일 관리 효율을 극대화할 수 있도록 돕겠습니다.

1. 에러 발생 상황

음악 라이브러리를 관리하다 보면, 방대한 데이터를 효율적으로 재구성해야 할 필요가 생깁니다. 특히 자동차 SD 카드와 같이 저장 공간이 제한적이거나, 특정 기준에 따라 라이브러리를 간소화하고 싶을 때 이러한 요구가 두드러집니다. 스택오버플로우 질문자는 다음과 같은 상황에 직면했습니다:

  • 원본 음악 라이브러리 구조: music/아티스트/앨범/곡.mp3
  • 목표: music 폴더의 내용을 musiclight 폴더로 복사하되, ‘충분한’ 수의 곡(n개 이상)을 가진 아티스트 디렉토리만 복사하고자 함. (예: 1~2곡만 있는 아티스트는 제외)
  • 원본 폴더는 그대로 유지해야 함.
  • 당면한 과제:
    • 재귀적으로 파일을 스캔하고 정확하게 개수를 세는 방법 (특수 파일명 예외 처리 등)
    • 파일 개수 결과를 스크립트의 조건문으로 안전하게 연결하는 방법 (wc 출력을 직접 파이프하는 방식의 혼란)
    • Bash 스크립트와 Python 중 어떤 도구가 더 적합한지 결정.

아래는 질문자가 제시한 예시 디렉토리 구조입니다. 각 아티스트 폴더에는 여러 앨범과 곡 파일이 포함되어 있습니다.

.
├── artist1
│   ├── album1
│   │   └── 1-1-1.txt
│   ├── album2
│   │   ├── 1-2-1.txt
│   │   └── 1-2-2.txt
│   └── album3
│       ├── 1-3-1.txt
│       ├── 1-3-2.txt
│       ├── 1-3-3.txt
│       ├── 1-3-4.txt
│       ├── 1-3-5.txt
│       └── 1-3-6.txt
├── artist2
│   ├── album1
│   │   └── 2-1-1.txt
│   ├── album2
│   │   ├── 2-2-1.txt
│   │   └── 2-2-2.txt
│   └── album3
│       ├── 2-3-1.txt
│       ├── 2-3-2.txt
│       ├── 2-3-3.txt
│       ├── 2-3-4.txt
│       ├── 2-3-5.txt
│       └── 2-3-6.txt
└── artist3
    ├── album1
    │   └── 3-1-1.txt
    ├── album2
    │   ├── 3-2-1.txt
    │   └── 3-2-2.txt
    └── album3
        ├── 3-3-1.txt
        ├── 3-3-2.txt
        ├── 3-3-3.txt
        ├── 3-3-4.txt
        ├── 3-3-5.txt
        └── 3-3-6.txt

13 directories, 27 files

2. 명확한 발생 원인

이 문제는 단순한 파일 복사가 아닌, 재귀적 디렉토리 탐색, 파일 개수 조건 확인, 조건부 디렉토리 복사라는 여러 단계의 복합적인 로직을 요구합니다. 특히 다음과 같은 부분에서 어려움이 발생할 수 있습니다.

  1. 정확한 파일 개수 카운팅: ls -R | wc -l 또는 find . -type f | wc -l과 같은 Bash 명령어는 파일명에 특수 문자(특히 개행 문자)가 포함될 경우 오작동할 가능성이 있습니다. 또한, 디렉토리나 다른 유형의 파일을 걸러내고 순수 ‘파일’만 세는 것이 중요합니다.
  2. 조건부 로직의 복잡성: Bash 스크립트에서 파일 개수를 변수에 저장하고 이를 기반으로 if 문을 작성하는 것은 가능하지만, 가독성이 떨어지거나 오류 처리가 복잡해질 수 있습니다. 특히 여러 아티스트 디렉토리를 순회하면서 각각의 조건을 평가하고 복사하는 작업은 Bash만으로는 다소 번거로울 수 있습니다.
  3. 견고한 복사 작업: 디렉토리 전체를 안전하게 복사하고, 이미 대상 경로에 같은 이름의 디렉토리가 있을 경우를 처리하는 등의 예외 처리가 필요합니다.

이러한 문제들은 Bash 스크립팅만으로는 구현하기 까다롭거나 오류 발생 가능성이 높으므로, 파일 시스템 처리에 특화된 라이브러리를 제공하는 Python과 같은 고급 언어를 사용하는 것이 훨씬 효율적이고 안전한 해결책이 될 수 있습니다.

3. 해결 방법 및 코드 예시

이 문제를 해결하는 가장 효율적이고 견고한 방법은 Python 스크립트를 사용하는 것입니다. Python은 os 모듈을 통해 파일 시스템을 강력하게 제어할 수 있으며, shutil 모듈은 파일 및 디렉토리 복사 작업을 쉽게 수행하도록 돕습니다. 다음은 Python을 사용하여 이 문제를 해결하는 스크립트 예시입니다.

Python 스크립트를 활용한 해결책

주요 기능:

  • 원본 디렉토리를 재귀적으로 탐색하여 아티스트별 파일 개수를 정확하게 집계합니다.
  • 설정된 최소 파일 개수(n)를 만족하는 아티스트 디렉토리만 대상 경로로 복사합니다.
  • 실제 복사 없이 동작을 미리 확인할 수 있는 Dry Run 모드를 제공하여 안전성을 높입니다.
  • 대상 디렉토리 존재 여부를 확인하고, 필요한 경우 생성합니다.
import os
import shutil
from collections import defaultdict

def filter_and_copy_music_library(source_root, dest_root, min_files_per_artist, dry_run=True):
    """
    특정 수 이상의 파일을 가진 아티스트 디렉토리만 대상 경로로 복사합니다.

    Args:
        source_root (str): 원본 음악 라이브러리 경로 (예: 'music').
        dest_root (str): 복사될 대상 경로 (예: 'musiclight').
        min_files_per_artist (int): 아티스트 디렉토리가 복사되기 위한 최소 파일 개수.
        dry_run (bool): True이면 실제 복사 없이 시뮬레이션만 수행합니다.
    """
    print(f"--- 음악 라이브러리 필터링 및 복사 스크립트 {'(Dry Run)' if dry_run else '(실제 실행)'} ---")
    print(f"원본 경로: {source_root}")
    print(f"대상 경로: {dest_root}")
    print(f"최소 파일 개수 (N): {min_files_per_artist}")
    print("-" * 50)

    if not os.path.exists(source_root):
        print(f"오류: 원본 경로 '{source_root}'를 찾을 수 없습니다. 스크립트를 종료합니다.")
        return

    # 대상 디렉토리가 없으면 생성 (dry_run이 아닐 경우)
    if not dry_run and not os.path.exists(dest_root):
        print(f"대상 경로 '{dest_root}'를 생성합니다.")
        os.makedirs(dest_root)

    # 아티스트별 파일 개수 집계
    artist_file_counts = defaultdict(int)
    print("\n--- 아티스트별 파일 개수 집계 중 ---")
    # os.walk를 사용하여 source_root의 모든 하위 디렉토리와 파일을 순회합니다.
    for dirpath, dirnames, filenames in os.walk(source_root):
        # source_root로부터의 상대 경로를 계산합니다.
        relative_path = os.path.relpath(dirpath, source_root)
        
        # 최상위 경로 (source_root 자체)는 아티스트가 아니므로 건너뜁니다.
        if relative_path == ".":
            continue
        
        # 'artist/album/song.mp3' 구조에서 첫 번째 디렉토리가 아티스트 이름입니다.
        artist_name = relative_path.split(os.sep)[0]
        
        # 현재 디렉토리의 모든 파일에 대해 개수를 증가시킵니다.
        for filename in filenames:
            # 여기서는 모든 파일을 대상으로 개수를 세지만, 필요하다면
            # 특정 확장자(예: '.mp3', '.flac')만 카운트하도록 필터링할 수 있습니다.
            # 예: if filename.lower().endswith(('.mp3', '.flac', '.ogg')):
            artist_file_counts[artist_name] += 1
    
    print("\n--- 집계 결과 ---")
    for artist, count in artist_file_counts.items():
        print(f"아티스트 '{artist}': 총 {count}개 파일")

    print("\n--- 조건에 따른 복사 진행 ---")
    copied_artists_count = 0
    for artist_name, file_count in artist_file_counts.items():
        source_artist_path = os.path.join(source_root, artist_name) # 원본 아티스트 디렉토리 경로
        dest_artist_path = os.path.join(dest_root, artist_name)     # 대상 아티스트 디렉토리 경로

        # 파일 개수가 최소 기준을 만족하는지 확인
        if file_count >= min_files_per_artist:
            print(f"[복사 대상] 아티스트 '{artist_name}' (파일 개수: {file_count} >= {min_files_per_artist})")
            if not dry_run: # Dry Run 모드가 아니면 실제 복사 수행
                try:
                    # 대상 경로에 이미 아티스트 디렉토리가 존재하면 삭제 후 재복사
                    if os.path.exists(dest_artist_path):
                        print(f"  경고: 대상 경로 '{dest_artist_path}'가 이미 존재합니다. 기존 내용을 삭제하고 복사합니다.")
                        shutil.rmtree(dest_artist_path) 
                    
                    # shutil.copytree를 사용하여 디렉토리 전체를 재귀적으로 복사합니다.
                    shutil.copytree(source_artist_path, dest_artist_path)
                    print(f"  -> '{source_artist_path}'를 '{dest_artist_path}'로 복사 완료.")
                    copied_artists_count += 1
                except shutil.Error as e:
                    print(f"  오류: '{artist_name}' 복사 중 shutil 오류 발생: {e}")
                except OSError as e:
                    print(f"  OS 오류: '{artist_name}' 복사 중 OS 오류 발생: {e}")
            else:
                print(f"  (Dry Run) '{source_artist_path}'를 '{dest_artist_path}'로 복사할 예정입니다.")
        else:
            print(f"[복사 제외] 아티스트 '{artist_name}' (파일 개수: {file_count} < {min_files_per_artist})")

    print("-" * 50)
    print(f"스크립트 완료. 총 {copied_artists_count}개의 아티스트가 복사되었습니다.")
    if dry_run:
        print("현재 Dry Run 모드입니다. 실제 복사를 원하시면 'dry_run=False'로 설정하세요.")

if __name__ == "__main__":
    # 테스트 환경 설정을 위한 함수 (실제 실행 시에는 이 부분 주석 처리)
    def setup_test_environment(source, dest):
        if os.path.exists(source):
            shutil.rmtree(source) # 기존 소스 디렉토리 제거
        if os.path.exists(dest):
            shutil.rmtree(dest)   # 기존 대상 디렉토리 제거
        os.makedirs(source) # 새로운 소스 디렉토리 생성

        # 더미 파일을 생성하는 헬퍼 함수
        def create_dummy_files(path, count, prefix=""):
            os.makedirs(path, exist_ok=True) # 디렉토리가 없으면 생성
            for i in range(1, count + 1):
                # 빈 파일을 생성하여 파일 개수를 시뮬레이션
                open(os.path.join(path, f"{prefix}{i}.txt"), "a").close()

        # 스택오버플로우 예시와 유사한 아티스트 디렉토리 생성
        # artist1 (총 9개 파일: album1(1) + album2(2) + album3(6))
        create_dummy_files(os.path.join(source, "artist1", "album1"), 1, "1-1-")
        create_dummy_files(os.path.join(source, "artist1", "album2"), 2, "1-2-")
        create_dummy_files(os.path.join(source, "artist1", "album3"), 6, "1-3-")

        # artist2 (총 9개 파일)
        create_dummy_files(os.path.join(source, "artist2", "album1"), 1, "2-1-")
        create_dummy_files(os.path.join(source, "artist2", "album2"), 2, "2-2-")
        create_dummy_files(os.path.join(source, "artist2", "album3"), 6, "2-3-")
        
        # artist3 (총 9개 파일)
        create_dummy_files(os.path.join(source, "artist3", "album1"), 1, "3-1-")
        create_dummy_files(os.path.join(source, "artist3", "album2"), 2, "3-2-")
        create_dummy_files(os.path.join(source, "artist3", "album3"), 6, "3-3-")

        # artist_small (총 2개 파일) - 최소 파일 개수에 따라 복사 대상에서 제외될 아티스트
        create_dummy_files(os.path.join(source, "artist_small", "single_album"), 2, "small-")

        print(f"테스트 환경 구성 완료: 원본 '{source}', 대상 '{dest}'")

    # 스크립트 실행을 위한 기본 경로 설정
    base_directory = "./my_music_library_test"
    source_directory = os.path.join(base_directory, "music")
    destination_directory = os.path.join(base_directory, "musiclight")

    # 1. 테스트 환경 설정 및 첫 번째 Dry Run 실행 (최소 파일 N=3)
    #    artist1, artist2, artist3 (9개 파일), artist_small (2개 파일)
    #    N=3이므로 artist1,2,3은 복사 대상, artist_small은 제외됩니다.
    setup_test_environment(source_directory, destination_directory)
    print("\n--- 첫 번째 실행: 최소 파일 개수 N=3 (Dry Run 모드) ---")
    filter_and_copy_music_library(source_directory, destination_directory, min_files_per_artist=3, dry_run=True)

    # 2. 실제 복사 실행 (최소 파일 N=3)
    #    실제로 artist1, artist2, artist3이 musiclight로 복사됩니다.
    print("\n--- 두 번째 실행: 최소 파일 개수 N=3 (실제 복사 모드) ---")
    filter_and_copy_music_library(source_directory, destination_directory, min_files_per_artist=3, dry_run=False)

    # 3. 테스트 환경 재설정 및 다른 조건으로 실제 복사 실행 (최소 파일 N=10)
    #    N=10이므로 모든 아티스트(최대 9개 파일)가 제외됩니다.
    print("\n--- 세 번째 실행: 최소 파일 개수 N=10 (실제 복사 모드) ---")
    # musiclight 폴더를 비워 깔끔하게 다시 시작
    if os.path.exists(destination_directory):
        shutil.rmtree(destination_directory)
    filter_and_copy_music_library(source_directory, destination_directory, min_files_per_artist=10, dry_run=False)

코드 설명

  • os.walk(source_root): 지정된 source_root 디렉토리 아래의 모든 디렉토리와 파일을 재귀적으로 순회합니다. 이는 파일 개수를 안정적으로 세는 데 핵심적인 역할을 합니다.
  • os.path.relpath(dirpath, source_root): 현재 탐색 중인 디렉토리의 source_root에 대한 상대 경로를 얻습니다. 이를 통해 아티스트 이름을 정확히 파싱할 수 있습니다.
  • artist_name = relative_path.split(os.sep)[0]: '아티스트/앨범/곡' 구조에서 os.sep (운영체제별 경로 구분자, 예: '/' 또는 '\')를 기준으로 분할하여 아티스트 이름을 추출합니다.
  • defaultdict(int): collections 모듈의 defaultdict를 사용하여 아티스트별 파일 개수를 편리하게 집계합니다. 존재하지 않는 키에 접근할 때 자동으로 0으로 초기화됩니다.
  • shutil.copytree(source_artist_path, dest_artist_path): 특정 아티스트 디렉토리 전체를 대상 경로로 재귀적으로 복사합니다. 디렉토리 구조를 그대로 유지합니다.
  • dry_run 매개변수: 스크립트가 실제 파일 시스템에 변경을 가하기 전에 어떤 작업이 수행될지 미리 보여주는 안전 기능입니다. 항상 dry_run=True로 먼저 테스트하여 예상치 못한 결과를 방지하는 것이 좋습니다.
  • 오류 처리: try-except 블록을 사용하여 파일 복사 중 발생할 수 있는 shutil.ErrorOSError를 처리합니다.

4. 향후 예방을 위한 팁

더욱 강력하고 유연하며 유지보수하기 쉬운 스크립트를 만들기 위한 몇 가지 팁입니다.

  1. 모듈화 및 함수화: 스크립트의 각 기능을 독립적인 함수로 분리하여 코드를 더욱 읽기 쉽고 재사용 가능하게 만드세요. (위 예시에서는 filter_and_copy_music_library 함수로 이미 모듈화되어 있습니다.)
  2. 명령줄 인수 사용: argparse 모듈을 사용하여 source_root, dest_root, min_files_per_artist, dry_run과 같은 매개변수를 명령줄에서 입력받도록 변경하면 스크립트의 유연성이 크게 향상됩니다.
    import argparse
    
    # ... (기존 filter_and_copy_music_library 함수) ...
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser(description="특정 파일 개수 이상인 아티스트 디렉토리만 복사합니다.")
        parser.add_argument("source", type=str, help="원본 음악 라이브러리 경로")
        parser.add_argument("destination", type=str, help="복사될 대상 경로")
        parser.add_argument("-n", "--min-files", type=int, default=5,
                            help="아티스트 디렉토리가 복사되기 위한 최소 파일 개수 (기본값: 5)")
        parser.add_argument("--dry-run", action="store_true",
                            help="실제 복사 없이 시뮬레이션만 수행합니다.")
    
        args = parser.parse_args()
    
        filter_and_copy_music_library(args.source, args.destination, 
                                      args.min_files, args.dry_run)
    
  3. 로그 기록: 어떤 파일이 복사되었는지, 어떤 오류가 발생했는지 등을 기록하는 로깅 기능을 추가하여 스크립트 실행 후 문제 발생 시 원인을 쉽게 파악할 수 있도록 합니다. logging 모듈을 활용할 수 있습니다.
  4. 파일 확장자 필터링: '음악 파일'만을 정확히 세고 싶다면, if filename.lower().endswith(('.mp3', '.flac', '.ogg'))와 같이 특정 오디오 파일 확장자를 필터링하는 로직을 추가할 수 있습니다.
  5. 버전 관리: 스크립트를 Git과 같은 버전 관리 시스템에 등록하여 변경 이력을 관리하고, 필요한 경우 이전 버전으로 되돌릴 수 있도록 합니다.
  6. 테스트: 실제 중요한 데이터를 다루기 전에 항상 작은 규모의 테스트 데이터를 사용하여 스크립트가 예상대로 동작하는지 확인하십시오. dry_run 모드는 이를 위한 훌륭한 도구입니다.

이 문서를 통해 파일 시스템 관리 문제를 Python 스크립트로 효율적으로 해결하고, 더 나아가 견고하고 유연한 스크립트를 개발하는 데 필요한 통찰력을 얻으셨기를 바랍니다. 여러분의 개발 작업에 이 정보가 큰 도움이 되기를 기대합니다!

댓글 남기기