V 실전: CLI 도구 만들기 — 파일 정리기

V 실전: CLI 도구 만들기 — 파일 정리기

문법 시리즈 10편을 모두 따라왔다면, 이제 배운 것들을 실제로 써먹을 차례다. 실전 시리즈의 첫 번째 프로젝트는 다운로드 폴더의 파일을 확장자별로 자동 정리하는 CLI 도구다. 누구나 다운로드 폴더가 뒤죽박죽인 경험이 있을 것이다. 오늘 만드는 도구로 그 문제를 V로 해결한다.

이번 편에서 만드는 것

파일 정리기(File Organizer) — 지정한 폴더에서 파일을 확장자별로 분류해서 정리해주는 명령줄 도구다.

정리 전:                          정리 후:
Downloads/                        Downloads/
├── report.pdf                    ├── Documents/
├── photo.jpg                     │   ├── report.pdf
├── song.mp3                      │   └── notes.txt
├── notes.txt                     ├── Images/
├── movie.mp4                     │   └── photo.jpg
├── data.zip                      ├── Music/
└── app.exe                       │   └── song.mp3
                                  ├── Videos/
                                  │   └── movie.mp4
                                  ├── Archives/
                                  │   └── data.zip
                                  └── Programs/
                                      └── app.exe

사전 준비

이 글은 V Language Grammar 시리즈(1~10편) 를 완료한 독자를 대상으로 한다. 특히 아래 편의 내용을 직접 활용한다.

  • 3편: 함수와 에러 반환
  • 5편: 배열, 맵, map/filter
  • 8편: !T Result 타입, or {}, ! 전파
  • 9편: os 모듈 import

V가 설치되어 있다면 바로 시작하자.


1단계: os 모듈 기초 — 파일 시스템 다루기

모든 파일 작업의 출발점은 os 모듈이다. 필요한 함수들을 하나씩 만나보자.

파일/디렉토리 목록 조회

import os

fn main() {
    // 현재 디렉토리의 파일 목록
    files := os.ls('.') or {
        println('디렉토리를 읽을 수 없습니다: ${err}')
        return
    }

    for f in files {
        println(f)
    }
}

os.ls(경로)는 해당 경로의 파일과 폴더 이름을 배열로 돌려준다. 실패할 수 있으므로 ![]string을 반환하고, or {} 블록으로 에러를 처리한다. (8편에서 배운 패턴이다.)

파일인지 폴더인지 구별하기

import os

fn main() {
    entries := os.ls('.') or { return }

    for entry in entries {
        if os.is_file(entry) {
            println('📄 파일: ${entry}')
        } else if os.is_dir(entry) {
            println('📁 폴더: ${entry}')
        }
    }
}
함수 반환 설명
os.is_file(경로) bool 파일이면 true
os.is_dir(경로) bool 디렉토리면 true
os.exists(경로) bool 파일이든 폴더든 존재하면 true

디렉토리 만들기

import os

fn main() {
    // 폴더가 없으면 생성 (이미 있으면 아무 일도 안 함)
    os.mkdir_all('organized/Images') or {
        println('폴더 생성 실패: ${err}')
        return
    }
    println('폴더 생성 완료!')
}

os.mkdir_all()은 중간 경로가 없어도 한 번에 모두 만들어준다. organized/Images를 호출하면 organized 폴더와 그 안의 Images 폴더를 동시에 생성한다. Linux의 mkdir -p와 같다.

파일 이동(이름 변경)

import os

fn main() {
    // photo.jpg를 Images/photo.jpg로 이동
    os.rename('photo.jpg', 'Images/photo.jpg') or {
        println('이동 실패: ${err}')
        return
    }
    println('파일 이동 완료!')
}

os.rename(원본, 대상)은 파일을 이동하거나 이름을 바꾼다. 대상 경로에 다른 폴더를 적으면 파일이 그 폴더로 이동한다.

파일명에서 확장자 추출하기

파일 정리기의 핵심 — 확장자를 가져오는 방법이다.

import os

fn main() {
    files := ['report.pdf', 'photo.jpg', 'notes.txt', 'archive.tar.gz']

    for f in files {
        ext := os.file_ext(f)          // 확장자 추출
        name := os.file_name(f)        // 파일 이름 (경로 제외)
        println('${name} → 확장자: "${ext}"')
    }
}
// report.pdf → 확장자: ".pdf"
// photo.jpg → 확장자: ".jpg"
// notes.txt → 확장자: ".txt"
// archive.tar.gz → 확장자: ".gz"
함수 입력 출력 설명
os.file_ext(경로) 'report.pdf' '.pdf' 확장자 (점 포함)
os.file_name(경로) '/home/user/report.pdf' 'report.pdf' 순수 파일 이름만

💡 주의: os.file_ext()는 마지막 점(.) 이후를 반환한다. archive.tar.gz의 확장자는 .tar.gz가 아니라 .gz다.


2단계: 확장자 → 카테고리 매핑

파일 정리기의 두뇌 — "이 확장자는 어떤 폴더로 가야 하는가?"를 결정하는 로직이다.

// 확장자를 카테고리(폴더명)로 변환하는 함수
fn get_category(ext string) string {
    // 확장자별 카테고리 맵
    categories := {
        // 문서
        '.pdf':  'Documents'
        '.txt':  'Documents'
        '.doc':  'Documents'
        '.docx': 'Documents'
        '.xls':  'Documents'
        '.xlsx': 'Documents'
        '.ppt':  'Documents'
        '.pptx': 'Documents'
        '.md':   'Documents'
        '.csv':  'Documents'
        // 이미지
        '.jpg':  'Images'
        '.jpeg': 'Images'
        '.png':  'Images'
        '.gif':  'Images'
        '.bmp':  'Images'
        '.svg':  'Images'
        '.webp': 'Images'
        // 음악
        '.mp3':  'Music'
        '.wav':  'Music'
        '.flac': 'Music'
        '.aac':  'Music'
        // 동영상
        '.mp4':  'Videos'
        '.avi':  'Videos'
        '.mkv':  'Videos'
        '.mov':  'Videos'
        '.wmv':  'Videos'
        // 압축파일
        '.zip':  'Archives'
        '.rar':  'Archives'
        '.7z':   'Archives'
        '.tar':  'Archives'
        '.gz':   'Archives'
        // 프로그램
        '.exe':  'Programs'
        '.msi':  'Programs'
        '.dmg':  'Programs'
        '.deb':  'Programs'
        '.apk':  'Programs'
    }

    // 맵에서 확장자 찾기 — 없으면 'Others'
    return categories[ext.to_lower()] or { 'Others' }
}

5편에서 배운 이 여기서 빛을 발한다. 확장자를 키로, 카테고리를 값으로 매핑했다. ext.to_lower()로 대소문자를 통일하므로 .JPG.JPEG도 모두 Images로 분류된다.

매핑에 없는 확장자(.xyz 같은)는 or { 'Others' }Others 폴더에 넣는다.

테스트해보자.

fn main() {
    test_files := ['.pdf', '.jpg', '.mp3', '.mp4', '.zip', '.exe', '.xyz']

    for ext in test_files {
        println('${ext} → ${get_category(ext)}')
    }
}
// .pdf → Documents
// .jpg → Images
// .mp3 → Music
// .mp4 → Videos
// .zip → Archives
// .exe → Programs
// .xyz → Others

3단계: 명령줄 인자 받기

CLI 도구는 터미널에서 실행하면서 인자(argument) 를 전달한다. "어떤 폴더를 정리할 것인지"를 인자로 받아보자.

import os

fn main() {
    // os.args[0]은 실행 파일 자체, [1]부터가 사용자 인자
    args := os.args

    if args.len < 2 {
        println('사용법: file_organizer <정리할 폴더 경로>')
        println('예시:   file_organizer ./Downloads')
        return
    }

    target_dir := args[1]

    // 경로가 실제로 존재하는 디렉토리인지 확인
    if !os.is_dir(target_dir) {
        println('에러: "${target_dir}"는 존재하지 않거나 디렉토리가 아닙니다.')
        return
    }

    println('정리 대상: ${target_dir}')
}

os.args는 프로그램 실행 시 전달된 인자들의 배열이다.

v run organizer.v ./Downloads
                  ───────────
os.args[0] = 실행파일 경로     (자동)
os.args[1] = "./Downloads"     (사용자 입력)

인자가 없으면 사용법을 안내하고 종료한다. 인자가 있으면 실제 디렉토리인지 검증한다. 항상 입력을 검증하는 습관은 안정적인 도구의 출발점이다.


4단계: 핵심 로직 — 파일 분류 및 이동

이제 모든 조각을 합칠 차례다. 핵심 로직을 별도 함수로 분리해서 깔끔하게 만들자.

단일 파일 이동 함수

import os

// 파일 하나를 적절한 카테고리 폴더로 이동
fn organize_file(base_dir string, file_name string) !string {
    ext := os.file_ext(file_name)

    // 확장자가 없으면 건너뛰기
    if ext == '' {
        return error('확장자 없음: ${file_name}')
    }

    category := get_category(ext)
    dest_dir := os.join_path(base_dir, category)

    // 대상 폴더가 없으면 생성
    if !os.is_dir(dest_dir) {
        os.mkdir_all(dest_dir)!
    }

    src := os.join_path(base_dir, file_name)
    dst := os.join_path(dest_dir, file_name)

    // 이미 같은 이름의 파일이 있으면 건너뛰기
    if os.exists(dst) {
        return error('이미 존재: ${dst}')
    }

    os.rename(src, dst)!
    return category
}

여기서 핵심 포인트를 짚어보자.

os.join_path() — 경로를 안전하게 결합한다. Windows의 \와 Linux의 /를 자동으로 처리하므로, 문자열을 직접 결합(base_dir + '/' + category)하는 것보다 안전하다.

!string 반환 타입 — 8편에서 배운 Result 타입이다. 확장자가 없거나 파일이 이미 존재하면 error()를 반환하고, 성공하면 카테고리 이름을 반환한다.

os.mkdir_all(dest_dir)! — 에러가 나면 !로 호출한 쪽에 전파한다. 8편에서 배운 전파 연산자다.

전체 폴더 정리 함수

import os

// 폴더 전체를 정리
fn organize_directory(dir string) ! {
    println('📂 정리 시작: ${dir}')
    println('─'.repeat(40))

    entries := os.ls(dir)!

    mut moved := 0
    mut skipped := 0
    mut errors := 0

    for entry in entries {
        path := os.join_path(dir, entry)

        // 폴더는 건너뛴다 (파일만 정리)
        if os.is_dir(path) {
            continue
        }

        category := organize_file(dir, entry) or {
            skipped += 1
            continue
        }

        println('  ✅ ${entry} → ${category}/')
        moved += 1
    }

    println('─'.repeat(40))
    println('📊 결과: ${moved}개 이동, ${skipped}개 건너뜀')
}

for 루프로 모든 항목을 순회하면서, 파일이면 organize_file()로 이동하고, 폴더면 continue로 건너뛴다. organize_file()이 에러를 반환하면(확장자 없음, 이미 존재 등) or { skipped += 1; continue }로 건너뛰고 카운트만 올린다.

이 패턴이 중요하다. 에러가 나도 프로그램이 멈추지 않는다. 한 파일에서 문제가 생겨도 나머지 파일은 계속 처리한다. 실전 도구에서는 일부 실패를 허용하는 탄력적 에러 처리가 핵심이다.


5단계: 모든 것을 합치기 — 완성 코드

지금까지 만든 모든 조각을 하나의 파일로 합쳐보자.

import os

// 확장자를 카테고리(폴더명)로 변환
fn get_category(ext string) string {
    categories := {
        '.pdf':  'Documents'
        '.txt':  'Documents'
        '.doc':  'Documents'
        '.docx': 'Documents'
        '.xls':  'Documents'
        '.xlsx': 'Documents'
        '.ppt':  'Documents'
        '.pptx': 'Documents'
        '.md':   'Documents'
        '.csv':  'Documents'
        '.hwp':  'Documents'
        '.jpg':  'Images'
        '.jpeg': 'Images'
        '.png':  'Images'
        '.gif':  'Images'
        '.bmp':  'Images'
        '.svg':  'Images'
        '.webp': 'Images'
        '.mp3':  'Music'
        '.wav':  'Music'
        '.flac': 'Music'
        '.aac':  'Music'
        '.ogg':  'Music'
        '.mp4':  'Videos'
        '.avi':  'Videos'
        '.mkv':  'Videos'
        '.mov':  'Videos'
        '.wmv':  'Videos'
        '.zip':  'Archives'
        '.rar':  'Archives'
        '.7z':   'Archives'
        '.tar':  'Archives'
        '.gz':   'Archives'
        '.exe':  'Programs'
        '.msi':  'Programs'
        '.dmg':  'Programs'
        '.deb':  'Programs'
        '.apk':  'Programs'
    }

    return categories[ext.to_lower()] or { 'Others' }
}

// 파일 하나를 적절한 카테고리 폴더로 이동
fn organize_file(base_dir string, file_name string) !string {
    ext := os.file_ext(file_name)
    if ext == '' {
        return error('확장자 없음')
    }

    category := get_category(ext)
    dest_dir := os.join_path(base_dir, category)

    if !os.is_dir(dest_dir) {
        os.mkdir_all(dest_dir)!
    }

    src := os.join_path(base_dir, file_name)
    dst := os.join_path(dest_dir, file_name)

    if os.exists(dst) {
        return error('이미 존재: ${file_name}')
    }

    os.rename(src, dst)!
    return category
}

// 폴더 전체를 정리
fn organize_directory(dir string) ! {
    println('')
    println('📂 파일 정리기 v1.0')
    println('   대상: ${os.real_path(dir)}')
    println('─'.repeat(50))

    entries := os.ls(dir)!

    mut moved := 0
    mut skipped := 0

    for entry in entries {
        path := os.join_path(dir, entry)
        if os.is_dir(path) {
            continue
        }

        category := organize_file(dir, entry) or {
            println('  ⏭️  ${entry} — 건너뜀 (${err})')
            skipped += 1
            continue
        }

        println('  ✅ ${entry} → ${category}/')
        moved += 1
    }

    println('─'.repeat(50))
    println('📊 완료: ${moved}개 이동 | ${skipped}개 건너뜀')
    println('')
}

fn main() {
    if os.args.len < 2 {
        println('')
        println('📂 파일 정리기 v1.0')
        println('')
        println('사용법: file_organizer <정리할 폴더 경로>')
        println('예시:   file_organizer ./Downloads')
        println('')
        println('확장자별로 파일을 자동 분류합니다:')
        println('  Documents/ — pdf, txt, doc, xls, ppt, md, csv, hwp')
        println('  Images/    — jpg, png, gif, bmp, svg, webp')
        println('  Music/     — mp3, wav, flac, aac, ogg')
        println('  Videos/    — mp4, avi, mkv, mov, wmv')
        println('  Archives/  — zip, rar, 7z, tar, gz')
        println('  Programs/  — exe, msi, dmg, deb, apk')
        println('  Others/    — 위에 해당하지 않는 파일')
        return
    }

    target := os.args[1]

    if !os.is_dir(target) {
        println('에러: "${target}"는 존재하지 않거나 디렉토리가 아닙니다.')
        return
    }

    organize_directory(target) or {
        println('에러 발생: ${err}')
    }
}

실행하기

# 컴파일 + 실행
v run organizer.v ./Downloads

# 또는 컴파일 후 실행
v organizer.v
./organizer ./Downloads

실행 결과 예시

📂 파일 정리기 v1.0
   대상: /home/user/Downloads
──────────────────────────────────────────────────
  ✅ report.pdf → Documents/
  ✅ photo.jpg → Images/
  ✅ song.mp3 → Music/
  ✅ notes.txt → Documents/
  ✅ movie.mp4 → Videos/
  ✅ data.zip → Archives/
  ✅ app.exe → Programs/
  ⏭️  README — 건너뜀 (확장자 없음)
──────────────────────────────────────────────────
📊 완료: 7개 이동 | 1개 건너뜀

코드 분석: 문법 시리즈에서 배운 것들

완성된 코드에서 문법 시리즈의 어떤 내용이 활용되었는지 정리해보자.

사용된 문법 배운 편 코드에서의 역할
함수 정의, 반환값 3편 get_category, organize_file 등 함수 분리
for ... in 루프 4편 파일 목록 순회
if/else, continue 4편 파일/폴더 구분, 건너뛰기
맵(map[string]string) 5편 확장자 → 카테고리 매핑
!T Result, or {}, ! 8편 에러 처리와 전파
os 모듈 import 9편 파일 시스템 조작

10편 분량의 문법을 배웠더니, 실용적인 도구를 만들 수 있게 되었다. 이것이 문법을 익히는 진짜 목적이다.


🔧 확장 아이디어

기본 파일 정리기를 만들었으니, 더 발전시켜볼 수 있는 아이디어를 소개한다.

1. --dry-run 옵션

실제로 파일을 이동하지 않고, 어떤 파일이 어디로 갈지 미리 보여주는 기능이다. 실수를 방지할 수 있다.

// 인자에 --dry-run이 있는지 확인
dry_run := '--dry-run' in os.args

if dry_run {
    println('  [미리보기] ${entry} → ${category}/')
} else {
    os.rename(src, dst)!
    println('  ✅ ${entry} → ${category}/')
}

2. --undo 되돌리기

정리한 내역을 로그 파일에 저장해두면, 나중에 되돌릴 수 있다.

3. 커스텀 규칙

사용자가 JSON 설정 파일로 자기만의 분류 규칙을 정의할 수 있게 만든다. (다음 편 "JSON 처리기"에서 이 기법을 배운다.)


📝 정리

이번 글에서 배운 핵심 포인트를 체크리스트로 정리한다.

  • [x] os 모듈 파일 조작ls(), is_file(), is_dir(), exists()로 탐색
  • [x] 디렉토리 생성os.mkdir_all()로 중간 경로까지 한 번에 생성
  • [x] 파일 이동os.rename(원본, 대상)
  • [x] 확장자 추출os.file_ext(), 경로 결합은 os.join_path()
  • [x] 명령줄 인자os.args로 사용자 입력 받기, 검증 후 사용
  • [x] 맵으로 분류 로직 — 확장자 → 카테고리 매핑
  • [x] 탄력적 에러 처리 — 일부 파일 실패해도 나머지는 계속 처리

다음 편 예고

실전 2편: JSON 처리기 — 설정 파일 관리 도구

V의 json 모듈로 JSON을 읽고, 쓰고, 변환하는 법을 배운다. 구조체 ↔ JSON 자동 변환, 중첩 JSON 처리, 그리고 설정 파일 관리 도구를 완성한다. 오늘 만든 파일 정리기의 분류 규칙을 JSON으로 설정할 수 있게 만드는 것도 가능해진다!