본문으로 건너뛰기

디렉터리·파일 시스템 — filepath·fs·embed 패키지

파일 하나를 다루는 것과 파일 시스템 전체를 탐색하는 것은 다른 문제입니다. Go는 path/filepath로 경로를 조작하고, fs 패키지로 가상 파일 시스템을 추상화하며, embed로 파일을 바이너리에 내장하는 세 가지 축으로 파일 시스템을 완전히 지원합니다.

path/filepath 패키지 — 플랫폼 독립 경로 처리

filepath 패키지는 운영 체제의 경로 구분자(Windows \, Unix /)를 자동으로 처리합니다. 문자열로 직접 경로를 합치지 말고 항상 filepath 함수를 사용하세요.

경로 조작 함수

package main

import (
"fmt"
"path/filepath"
)

func main() {
// Join: OS에 맞는 구분자로 경로 조합
p := filepath.Join("home", "user", "documents", "file.txt")
fmt.Println("Join:", p) // Linux: home/user/documents/file.txt

// Dir, Base, Ext: 경로 분해
fmt.Println("Dir: ", filepath.Dir(p)) // home/user/documents
fmt.Println("Base:", filepath.Base(p)) // file.txt
fmt.Println("Ext: ", filepath.Ext(p)) // .txt

// 확장자 없는 파일명 추출
base := filepath.Base(p)
name := base[:len(base)-len(filepath.Ext(base))]
fmt.Println("이름(확장자 제외):", name) // file

// Abs: 절대 경로로 변환
abs, err := filepath.Abs("relative/path")
if err == nil {
fmt.Println("절대 경로:", abs)
}

// Rel: 상대 경로 계산
rel, err := filepath.Rel("/home/user", "/home/user/docs/file.txt")
if err == nil {
fmt.Println("상대 경로:", rel) // docs/file.txt
}

// Clean: 경로 정규화
messy := filepath.Join("a", "..", "b", ".", "c")
fmt.Println("Clean:", filepath.Clean(messy)) // b/c

// Split: 디렉터리와 파일명 분리
dir, file := filepath.Split("/home/user/file.txt")
fmt.Printf("Split → dir=%q, file=%q\n", dir, file)
}

filepath.Walk와 WalkDir

package main

import (
"fmt"
"os"
"path/filepath"
"strings"
)

func main() {
// 테스트 디렉터리 구조 생성
os.MkdirAll("testdir/sub1", 0755)
os.MkdirAll("testdir/sub2", 0755)
os.WriteFile("testdir/a.go", []byte("package main"), 0644)
os.WriteFile("testdir/sub1/b.go", []byte("package sub1"), 0644)
os.WriteFile("testdir/sub2/c.txt", []byte("텍스트"), 0644)

fmt.Println("=== filepath.WalkDir (Go 1.16+, 더 효율적) ===")
err := filepath.WalkDir("testdir", func(path string, d os.DirEntry, err error) error {
if err != nil {
return err // 접근 오류 처리
}

// 숨김 디렉터리 건너뜀
if d.IsDir() && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir // 해당 디렉터리 전체 건너뜀
}

indent := strings.Repeat(" ", strings.Count(path, string(os.PathSeparator)))
if d.IsDir() {
fmt.Printf("%s[DIR] %s\n", indent, d.Name())
} else {
info, _ := d.Info()
fmt.Printf("%s[FILE] %s (%d bytes)\n", indent, d.Name(), info.Size())
}
return nil
})

if err != nil {
fmt.Println("WalkDir 오류:", err)
}

// 정리
os.RemoveAll("testdir")
}

filepath.Glob — 패턴 매칭

package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
// 테스트 파일 생성
os.WriteFile("main.go", []byte(""), 0644)
os.WriteFile("util.go", []byte(""), 0644)
os.WriteFile("readme.md", []byte(""), 0644)
os.WriteFile("config.yaml", []byte(""), 0644)

// Glob: 패턴에 맞는 파일 목록
goFiles, _ := filepath.Glob("*.go")
fmt.Println("Go 파일:", goFiles) // [main.go util.go]

// Match: 단일 경로가 패턴에 맞는지 확인
matched, _ := filepath.Match("*.go", "main.go")
fmt.Println("main.go가 *.go 패턴 매칭:", matched) // true

matched, _ = filepath.Match("*.go", "readme.md")
fmt.Println("readme.md가 *.go 패턴 매칭:", matched) // false

// 재귀 패턴 (** 은 지원 안 됨 — filepath.Walk 사용)
allGo, _ := filepath.Glob("**/*.go") // nil 반환됨 주의
fmt.Println("** 패턴 결과:", allGo) // [] — 재귀 미지원

// 정리
for _, f := range []string{"main.go", "util.go", "readme.md", "config.yaml"} {
os.Remove(f)
}
}

fs 패키지 — 가상 파일 시스템 추상화 (Go 1.16+)

fs.FS 인터페이스는 실제 디스크, 임베디드 파일, 압축 파일, 원격 스토리지 등을 동일한 방식으로 다룰 수 있게 합니다.

package main

import (
"fmt"
"io/fs"
"os"
)

func main() {
// 테스트 디렉터리 준비
os.MkdirAll("fstest/sub", 0755)
os.WriteFile("fstest/hello.txt", []byte("안녕하세요"), 0644)
os.WriteFile("fstest/sub/world.txt", []byte("세계"), 0644)

// os.DirFS: 실제 디렉터리를 fs.FS로 변환
fsys := os.DirFS("fstest")

// fs.ReadFile: fs.FS에서 파일 읽기
data, err := fs.ReadFile(fsys, "hello.txt")
if err != nil {
fmt.Println("읽기 실패:", err)
return
}
fmt.Println("읽은 내용:", string(data))

// fs.ReadDir: 디렉터리 목록
entries, _ := fs.ReadDir(fsys, ".")
fmt.Println("\n루트 디렉터리 내용:")
for _, e := range entries {
fmt.Printf(" %s (디렉터리: %v)\n", e.Name(), e.IsDir())
}

// fs.Stat: 파일 정보 조회
info, _ := fs.Stat(fsys, "hello.txt")
fmt.Printf("\nhello.txt 크기: %d 바이트\n", info.Size())

// fs.WalkDir: fs.FS 전체 순회
fmt.Println("\n전체 파일 목록:")
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Printf(" %s\n", path)
return nil
})

os.RemoveAll("fstest")
}

os 패키지 — 디렉터리 관리

package main

import (
"fmt"
"os"
)

func main() {
// MkdirAll: 중간 디렉터리까지 한 번에 생성
err := os.MkdirAll("project/src/models", 0755)
if err != nil {
fmt.Println("디렉터리 생성 실패:", err)
return
}

// ReadDir: 디렉터리 내용 목록 (한 레벨만)
entries, err := os.ReadDir("project")
if err != nil {
fmt.Println("ReadDir 실패:", err)
return
}
fmt.Println("project 내용:")
for _, e := range entries {
fmt.Printf(" %s (dir=%v)\n", e.Name(), e.IsDir())
}

// 임시 파일 생성 (자동으로 고유한 이름 부여)
tmpFile, err := os.CreateTemp("", "myapp-*.tmp")
if err != nil {
fmt.Println("임시 파일 생성 실패:", err)
return
}
fmt.Println("임시 파일:", tmpFile.Name())
tmpFile.WriteString("임시 데이터")
tmpFile.Close()
os.Remove(tmpFile.Name()) // 사용 후 삭제

// 임시 디렉터리 생성
tmpDir, err := os.MkdirTemp("", "myapp-*")
if err != nil {
fmt.Println("임시 디렉터리 생성 실패:", err)
return
}
fmt.Println("임시 디렉터리:", tmpDir)
defer os.RemoveAll(tmpDir) // 사용 후 전체 삭제

// RemoveAll: 디렉터리와 내용 전부 삭제
os.RemoveAll("project")
fmt.Println("project 삭제 완료")
}

embed 패키지 — 바이너리에 파일 내장 (Go 1.16+)

//go:embed 지시문을 사용하면 빌드 시 파일을 Go 바이너리에 포함할 수 있습니다. 배포 시 별도 파일 없이 단일 실행 파일만으로 동작하는 서버를 만들 수 있습니다.

package main

import (
"embed"
"fmt"
"io/fs"
"net/http"
)

// 단일 파일 임베딩
//
//go:embed config.yaml
var configData []byte

// 문자열로 임베딩
//
//go:embed version.txt
var version string

// 디렉터리 전체 임베딩 (embed.FS 타입 필요)
//
//go:embed static
var staticFiles embed.FS

func main() {
// 임베딩된 파일 내용 출력
fmt.Println("버전:", version)
fmt.Println("설정 파일 크기:", len(configData), "바이트")

// embed.FS에서 파일 읽기
data, err := staticFiles.ReadFile("static/index.html")
if err != nil {
fmt.Println("임베딩 파일 읽기 실패:", err)
} else {
fmt.Println("index.html 내용:", string(data))
}

// embed.FS를 http.FileServer로 서빙
// static/ 서브 디렉터리를 루트로 설정
webRoot, err := fs.Sub(staticFiles, "static")
if err != nil {
fmt.Println("fs.Sub 실패:", err)
return
}

http.Handle("/", http.FileServer(http.FS(webRoot)))
fmt.Println("서버 시작: http://localhost:8080")
// http.ListenAndServe(":8080", nil)
}

embed 패키지 주의 사항:

  • //go:embed 주석 바로 다음 줄에 변수가 있어야 합니다
  • embed.FS는 읽기 전용입니다 (쓰기 불가)
  • 숨김 파일(.으로 시작)과 _로 시작하는 파일은 기본적으로 제외됩니다
  • all: 접두사로 숨김 파일도 포함: //go:embed all:static

실전 예제 1 — 재귀 디렉터리 탐색기

package main

import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)

type FileStats struct {
TotalFiles int
TotalDirs int
TotalSize int64
ByExt map[string]int
}

// exploreDirectory: 디렉터리를 재귀 탐색하고 통계를 수집
func exploreDirectory(root string) (*FileStats, error) {
stats := &FileStats{ByExt: make(map[string]int)}

err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// 권한 없는 디렉터리는 건너뜀
fmt.Printf("접근 불가: %s (%v)\n", path, err)
return nil
}

if d.IsDir() {
// 숨김 디렉터리 제외
if strings.HasPrefix(d.Name(), ".") && path != root {
return filepath.SkipDir
}
stats.TotalDirs++
return nil
}

stats.TotalFiles++
info, err := d.Info()
if err == nil {
stats.TotalSize += info.Size()
}

ext := strings.ToLower(filepath.Ext(d.Name()))
if ext == "" {
ext = "(확장자 없음)"
}
stats.ByExt[ext]++
return nil
})

return stats, err
}

func printStats(stats *FileStats) {
fmt.Printf("\n파일 탐색 결과\n")
fmt.Printf("─────────────────────────────\n")
fmt.Printf("파일 수: %d\n", stats.TotalFiles)
fmt.Printf("디렉터리 수: %d\n", stats.TotalDirs)
fmt.Printf("총 크기: %.2f KB\n", float64(stats.TotalSize)/1024)

// 확장자별 정렬
type extCount struct {
ext string
count int
}
var sorted []extCount
for ext, cnt := range stats.ByExt {
sorted = append(sorted, extCount{ext, cnt})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].count > sorted[j].count
})

fmt.Println("\n확장자별 파일 수:")
for _, ec := range sorted {
fmt.Printf(" %-20s %d개\n", ec.ext, ec.count)
}
}

func main() {
// 테스트 디렉터리 구조 생성
os.MkdirAll("project/src", 0755)
os.MkdirAll("project/docs", 0755)
os.MkdirAll("project/.git", 0755) // 숨김 디렉터리
os.WriteFile("project/src/main.go", []byte("package main"), 0644)
os.WriteFile("project/src/util.go", []byte("package main"), 0644)
os.WriteFile("project/docs/README.md", []byte("# 문서"), 0644)
os.WriteFile("project/go.mod", []byte("module project"), 0644)
os.WriteFile("project/.git/HEAD", []byte("ref: refs/heads/main"), 0644)

stats, err := exploreDirectory("project")
if err != nil {
fmt.Println("탐색 실패:", err)
return
}
printStats(stats)

os.RemoveAll("project")
}

실전 예제 2 — 임베디드 웹 서버 정적 파일 서빙

실제 embed 패키지를 활용한 정적 파일 서버입니다. 실행 파일 하나만 있으면 어디서든 동작합니다.

//go:build ignore

package main

import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"time"
)

//go:embed web
var webFiles embed.FS

func main() {
// web/ 디렉터리를 루트로 서빙
webRoot, err := fs.Sub(webFiles, "web")
if err != nil {
log.Fatal("웹 루트 설정 실패:", err)
}

mux := http.NewServeMux()

// 정적 파일 서빙
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(webRoot))))

// API 엔드포인트
mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"version":"1.0.0","built":"%s"}`, time.Now().Format(time.RFC3339))
})

// SPA 폴백: 모든 경로를 index.html로
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
data, err := fs.ReadFile(webFiles, "web/index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
})

addr := ":8080"
fmt.Printf("서버 시작: http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}

고수 팁

filepath vs path: filepath는 OS에 맞는 구분자를 사용하고, path(슬래시 전용)는 URL 경로에 사용합니다. 파일 시스템 경로는 항상 filepath를 사용하세요.

WalkDir가 Walk보다 효율적: filepath.Walk는 모든 항목에서 os.Lstat를 호출하지만, filepath.WalkDir(Go 1.16+)는 DirEntry를 재사용해 불필요한 syscall을 줄입니다. 새 코드는 항상 WalkDir를 사용하세요.

embed.FS는 컴파일 타임에 결정: 런타임에 파일을 추가하거나 변경할 수 없습니다. 개발 중에는 실제 파일 시스템을 사용하고, 프로덕션 빌드에서만 embed를 활성화하는 패턴을 사용하면 개발 효율이 올라갑니다.

RemoveAll 사용 주의: os.RemoveAll은 실수로 잘못된 경로를 지울 경우 데이터를 복구할 수 없습니다. 경로를 명시적으로 검증한 후 호출하세요.