inspiration

바탕화면이나, 영상/이미지가 섞여있는 카메라 촬영물 폴더를 정리하다보면, 한 폴더 안에 섞여 있는 여러 확장자들을 분리해낼 필요가 있다.



execution - python

폴더 안에 있는 확장자들을 확인해서 폴더를 생성하고, 각 파일을 해당하는 폴더 안으로 밀어넣는 기능을 코드로 짜두기로 한다. (사실상 큰 의미 없는 일이기도 하다.)

파이썬 라이브러리 찾기 귀찮아서 노가다 수준으로 작업 진행.
룰은 mov, arw 파일만 폴더를 생성하여 옮겨준다. 필요하다면, 확장자 리스트를 변경하거나, 추가해주면 된다.

sample.py

import os
import tkinter
from tkinter import filedialog
import shutil

# 폴더선택
root = tkinter.Tk()
root.withdraw()
dir_path = filedialog.askdirectory(
    parent=root, initialdir="/", title='Please select a directory')
print("\ndir_path : ", dir_path)

# 폴더내파일검사
global_cache = {}

def cached_listdir(path):
    res = global_cache.get(path)
    if res is None:
        res = os.listdir(path)
        global_cache[path] = res
    return res

def moveFile(ext, str):
    # 현재 파일 위치
    filePath = dir_path + '/' + item        
    # 옮길 파일 위치
    finalPath = dir_path + '/' + str + '/' + item           
    if os.path.isfile(filePath):
        shutil.move(filePath, finalPath)          

if __name__ == '__main__':   
    # 기본 폴더 생성
    newdir = ["RAW", "MOV"]
    for nd in newdir :
        newtDir = dir_path + '/' + nd
        if not os.path.isdir(newtDir):
            os.mkdir(newtDir)   

    # 각 기본 폴더에 옮겨질 확장자 리스트 정의
    extList = {"RAW" : ["arw", "nef"],
               "MOV" : ["mov", "mp4"],
               }     
    for item in cached_listdir(dir_path):       
        # RAW에 해당하는 확장자인 경우        
        for extItem in extList["RAW"] :
            if item.rpartition(".")[2] == extItem :
                moveFile(extItem, "RAW")
        # MOV에 해당하는 확장자인 경우
        for extItem in extList["MOV"] :
            if item.rpartition(".")[2] == extItem :
                moveFile(extItem, "MOV")
    print("complete")  


execution - rust

러스트로 구현을 한다면 다음과 같이 되겠다.

빌드 후 exe 파일을 실행하면, 폴더 선택창이 나오는데, 원하는 폴더를 선택하기만 하면 끝이다. 폴더는 무시되고, 파일은 확장자별로 이동이 된다.

sample.rs

// 주석 해제하면 콘솔 창 없이 실행됩니다 (Windows 전용 GUI 서브시스템).
// #![windows_subsystem = "windows"]

use anyhow::{Context, Result};
use std::{
    collections::HashMap,
    ffi::OsStr,
    fs,
    path::{Path, PathBuf},
};

fn main() {
    if let Err(e) = run() {
        // 문제가 생기면 메시지 박스로 알려줍니다.
        let _ = rfd::MessageDialog::new()
            .set_title("오류")
            .set_description(format!("{e:#}"))
            .set_level(rfd::MessageLevel::Error)
            .show();
        // 콘솔 빌드일 경우를 위해서도 에러 출력
        eprintln!("{e:#}");
    }
}

fn run() -> Result<()> {
    // 1) 폴더 선택 대화상자
    let maybe_dir = rfd::FileDialog::new()
        .set_title("정리할 폴더를 선택하세요")
        .pick_folder();

    let dir = match maybe_dir {
        Some(d) => d,
        None => return Ok(()), // 사용자가 취소
    };

    // 2) 선택한 폴더 내의 파일만 순회 (하위 폴더는 건드리지 않음)
    let mut moved: usize = 0;
    let mut skipped: usize = 0;
    let mut by_ext: HashMap<String, usize> = HashMap::new();

    for entry in fs::read_dir(&dir).with_context(|| format!("폴더 접근 실패: {}", dir.display()))?
    {
        let entry = entry?;
        let path = entry.path();

        // 디렉터리는 스킵
        let meta = match entry.metadata() {
            Ok(m) => m,
            Err(_) => {
                skipped += 1;
                continue;
            }
        };
        if !meta.is_file() {
            continue;
        }

        // 3) 확장자 추출 (없으면 "no_extension")
        let ext = ext_key(path.as_path());

        // 4) 대상 폴더 생성
        let target_dir = dir.join(&ext);
        fs::create_dir_all(&target_dir)
            .with_context(|| format!("하위 폴더 생성 실패: {}", target_dir.display()))?;

        // 5) 파일 이동 (이름 충돌 시 _1, _2 …를 붙여 고유화)
        let file_name = match path.file_name() {
            Some(n) => n.to_owned(),
            None => {
                skipped += 1;
                continue;
            }
        };
        let mut target_path = target_dir.join(&file_name);
        target_path = ensure_unique_path(&target_path);

        match fs::rename(&path, &target_path) {
            Ok(_) => {
                moved += 1;
                *by_ext.entry(ext).or_insert(0) += 1;
            }
            Err(_) => {
                // 오류 종류와 무관하게 copy + remove 로 폴백
                fs::copy(&path, &target_path).with_context(|| {
                    format!(
                        "파일 복사 실패: {}{}",
                        path.display(),
                        target_path.display()
                    )
                })?;
                fs::remove_file(&path)
                    .with_context(|| format!("원본 삭제 실패: {}", path.display()))?;
                moved += 1;
                *by_ext.entry(ext).or_insert(0) += 1;
            }
        }
    }

    // 6) 결과 요약 메시지
    let mut lines = vec![
        format!("정리 대상: {}", dir.display()),
        format!("이동한 파일: {moved}개"),
        format!("스킵: {skipped}개"),
        "".into(),
        "확장자별 이동 결과:".into(),
    ];
    if by_ext.is_empty() {
        lines.push("- (이동된 파일 없음)".into());
    } else {
        let mut items: Vec<_> = by_ext.into_iter().collect();
        // 보기 좋게 정렬 (개수 내림차순, 그다음 확장자)
        items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
        for (ext, cnt) in items {
            lines.push(format!("  • {}: {}개", ext, cnt));
        }
    }

    let _ = rfd::MessageDialog::new()
        .set_title("정리 완료")
        .set_description(lines.join("\n"))
        .set_level(rfd::MessageLevel::Info)
        .show();

    Ok(())
}

/// 파일의 확장자를 소문자로 정규화하여 폴더명으로 사용할 키를 생성.
/// 확장자가 없으면 "no_extension"을 반환.
fn ext_key(path: &Path) -> String {
    path.extension()
        .and_then(OsStr::to_str)
        .map(|s| s.trim_matches('.').to_lowercase())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "no_extension".to_string())
}

/// 대상 경로가 이미 존재하면 뒤에 _1, _2…를 붙여서 충돌을 피함.
fn ensure_unique_path(path: &Path) -> PathBuf {
    if !path.exists() {
        return path.to_path_buf();
    }
    let base = path.to_path_buf(); // mut 제거
    let dir = base
        .parent()
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| PathBuf::from("."));
    let stem = base
        .file_stem()
        .and_then(OsStr::to_str)
        .unwrap_or("file")
        .to_string();
    let ext = base.extension().and_then(OsStr::to_str);

    let mut idx: u32 = 1;
    loop {
        let mut candidate = dir.join(format!("{stem}_{idx}"));
        if let Some(e) = ext {
            candidate.set_extension(e);
        }
        if !candidate.exists() {
            return candidate;
        }
        idx += 1;
    }
}


execution - excel - vba

생각만치 잘 되지 않았지만, 한번 해놓고 나면, 응용해서, 엑셀프로그램과 연동해서 쓸수 있다는 장점은 있다.
파일 리스트 시트를 만든다거나.. 블로그 글들을 여러개 참고 했는데, 메모를 해놓지 않아, 참조 경로를 나열할 수가 없다.
아쉬운 부분이지만, 혹시 이글을 보시는 분이 있다면 도움이 되길 바란다.





Sub makeFolderMoveFileToFolderByExt()

    Dim sht As Worksheet
    Dim strDir As String
    Dim strFile As String
    Dim fso
    Dim myFile
    Dim ext As String
    Dim extList() As String

    ' 생성을 원하는 폴더리스트를 만들어줌 ==> 요거만 수정해주면 됨
    ReDim extList(4) '전체 확장자의 개수
    ' 확장자 리스트
    extList(1) = "jpg"
    extList(2) = "pdf"
    extList(3) = "png"
    extList(4) = "pdfx"

    '---------------------------------------------------------

    Set sht = ActiveSheet

    ' 폴더 입력창에 표시할 멘트
    Msg = "검색할 폴더명을 입력하세요: " & vbCr
    Msg = Msg & "(예) c:\windows\"

    ' 폴더 경로 설정
    strDir = Trim(InputBox(Msg))

    '폴더 경로 설정시 취소 선택
    If strDir = "" Then
        MsgBox "[취소]를 선택하셨습니다", , "작업 취소"
        Exit Sub
    End If

    ' 입력받은 폴더명에 \가 없을때 강제로 붙여줌
    If Right(strDir, 1) <> "\" Then strDir = strDir & "\"
    

    '---------------------------------------------------------
    ' 원하는 폴더를 추가해줌

    '----------------
    '배열의 개수 계산

    extListCount = UBound(extList) - LBound(extList)

     '디렉토리가 없는 경우 만들어줌
     For i = 1 To extListCount
        If Len(Dir(strDir & extList(i), vbDirectory)) = 0 Then MkDir (strDir & extList(i))
     Next i

    ' 선택한 폴더 안에 있는 파일 변수 지정 => 첫번째 파일이 지정됨
    ' 단순한 파일명임
    strFile = Dir(strDir)

    Set fso = CreateObject("Scripting.FileSystemObject")

    If strFile = "" Then
        MsgBox "폴더에 파일이 없습니다."
        Exit Sub
    Else: Set myFile = fso.getfile(strDir & strFile)
    End If

    ' 첫번째 파일
    'ext = Right(strFile, 3)
    ext = findex(myFile)
        '----------------
        ' 파일 이동
        '----------------

    For i = 1 To extListCount
        Select Case ext
            Case extList(i):
                myFile = fso.MoveFile(strDir & strFile, strDir & extList(i) & "\" & strFile)
        End Select
    Next i

    ' 폴더 내에 있는 파일 순환 - 두번째 파일부터
    Do While strFile <> ""
            ' 다음 파일로 이동
        strFile = Dir()
        'ext = Right(strFile, 3)
        If strFile = "" Then GoTo dd:
        myFile = fso.getfile(strDir & strFile)
        ext = findex(myFile)

        '----------------
        ' 파일 이동
        '----------------

      For i = 1 To extListCount
        Select Case ext
            Case extList(i):
                myFile = fso.MoveFile(strDir & strFile, strDir & extList(i) & "\" & strFile)
        End Select
     Next i

    Loop

dd:

End Sub

Private Function findex(ByVal x As String) As String

    xcount = Len(x) - Len(Replace(x, ".", ""))
    k = Application.WorksheetFunction.Substitute(x, ".", "@", xcount)
    j = Application.WorksheetFunction.Find("@", k)
    findex = Mid(k, j + 1)
    Exit Function

End Function


끝.