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편:
!TResult 타입,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으로 설정할 수 있게 만드는 것도 가능해진다!