본문으로 건너뛰기

파일과 os

이 챕터에서 다루는 것

파일을 읽고 쓰는 API 자체는 간단하다. 어려운 것은 그 주변이다 — 퍼미션이 왜 요청한 대로 안 붙는지, 사용자가 준 경로를 그대로 열면 무슨 일이 생기는지, 쓰다 만 파일을 어떻게 막는지.

8-4에서 골든 파일을 다루면서 파일 I/O를 잠깐 썼고, 권한과 경로 문제는 이 챕터로 미뤄 뒀다. 여기서 갚는다.

파일 하나를 통째로 다루기

작은 파일은 통째로 읽고 쓰는 게 제일 낫다.

data, err := os.ReadFile("config.json") // []byte, error
err = os.WriteFile("out.json", data, 0o644)

:::note io/ioutil은 폐기됐다 예전 코드에서 ioutil.ReadFile, ioutil.WriteFile, ioutil.TempDir, ioutil.ReadAll을 보게 된다. 전부 osio로 옮겨졌고, io/ioutil 패키지 전체가 deprecated다.

옛날지금
ioutil.ReadFileos.ReadFile
ioutil.WriteFileos.WriteFile
ioutil.ReadAllio.ReadAll
ioutil.TempDiros.MkdirTemp (테스트에서는 t.TempDir())
ioutil.TempFileos.CreateTemp
ioutil.ReadDiros.ReadDir (반환 타입이 []DirEntry로 바뀌었다)
ioutil.Discardio.Discard

새 코드에서는 쓰지 않는다. 오래된 코드를 읽을 때 알아보기만 하면 된다. :::

큰 파일이나 크기를 모르는 입력은 9-1에서 본 대로 스트리밍한다. os.Open*os.File을 주고, 이것이 io.Reader이자 io.Writer이자 io.Seeker이자 io.Closer다.

퍼미션 — 요청한 값과 실제 값이 다르다

WriteFile의 세 번째 인자는 요청이지 결과가 아니다. 프로세스의 umask가 비트를 깎는다.

examples/09-standard-library/02-os-files/main.go
// 0666을 요청해도 umask(보통 022)가 쓰기 비트를 깎는다.
if err := os.WriteFile(p, []byte("한 줄\n두 줄\n"), 0o666); err != nil {
return fmt.Errorf("WriteFile: %w", err)
}
data, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("ReadFile: %w", err)
}
info, err := os.Stat(p)
if err != nil {
return fmt.Errorf("Stat: %w", err)
}

fmt.Printf("ReadFile: %d바이트 %q\n", len(data), data)
fmt.Printf("퍼미션: 요청 0666 → 실제 %04o (umask가 깎은 결과)\n", info.Mode().Perm())

// 이미 있는 파일에 WriteFile을 다시 부르면 perm은 무시된다.
if err := os.WriteFile(p, []byte("덮어씀\n"), 0o600); err != nil {
return fmt.Errorf("WriteFile: %w", err)
}
info, err = os.Stat(p)
if err != nil {
return fmt.Errorf("Stat: %w", err)
}
fmt.Printf("기존 파일에 0600 재요청 → 실제 %04o (perm은 생성할 때만 쓰인다)\n", info.Mode().Perm())
ReadFile: 16바이트 "한 줄\n두 줄\n"
퍼미션: 요청 0666 → 실제 0644 (umask가 깎은 결과)
기존 파일에 0600 재요청 → 실제 0644 (perm은 생성할 때만 쓰인다)

두 가지가 드러난다.

  1. umask가 깎는다. 이 기계의 umask는 022라 0666이 0644가 됐다. umask 값에 따라 이 숫자는 달라진다.
  2. 이미 있는 파일에는 perm이 적용되지 않는다. WriteFile은 내용만 바꾼다. 비밀 파일을 0600으로 만들고 싶으면 os.Chmod를 따로 부르거나, 애초에 그 퍼미션으로 생성해야 한다.

:::warning 비밀을 담는 파일 토큰이나 개인 키를 쓸 때 os.WriteFile(path, key, 0o600)만 믿으면 안 된다. 그 경로에 이미 0644 파일이 있었다면 퍼미션은 그대로다. 새로 만든 임시 파일(9-2 뒤쪽의 safewrite)에 쓰고 Rename하는 방식이면 이 문제가 없다. :::

에러 판별은 문자열이 아니라 센티넬로 한다.

examples/09-standard-library/02-os-files/main.go
// 없는 파일은 fs.ErrNotExist로 판별한다. 문자열 비교가 아니다.
_, err = os.ReadFile(filepath.Join(base, "없는파일"))
fmt.Printf("없는 파일: errors.Is(err, fs.ErrNotExist) = %t\n\n", errors.Is(err, fs.ErrNotExist))
없는 파일: errors.Is(err, fs.ErrNotExist) = true

fs.ErrNotExist, fs.ErrPermission, fs.ErrExist가 자주 쓰인다. os.IsNotExist(err)라는 옛 함수도 있지만 래핑을 풀지 못하므로 4-7에서 배운 errors.Is를 쓴다.

경로 — filepath.Join은 방어 수단이 아니다

사용자가 준 파일 이름을 디렉터리에 붙이는 코드는 어디에나 있다.

p := filepath.Join(uploadDir, userInput)

filepath.Join..정규화한다. 없애 주는 게 아니라 계산해 준다.

examples/09-standard-library/02-os-files/main.go
// demoNaiveJoin은 filepath.Join만으로는 경로 탈출을 막지 못한다는 것을 보여 준다.
func demoNaiveJoin(base string) error {
uploads := filepath.Join(base, "uploads")
if err := os.MkdirAll(uploads, 0o755); err != nil {
return fmt.Errorf("MkdirAll: %w", err)
}

userInput := "../../etc/passwd"
joined := filepath.Join(uploads, userInput)

fmt.Printf("filepath.Join 결과가 uploads 밖인가: %t\n", !isInside(uploads, joined))
fmt.Printf(" (Join은 ..를 정규화해서 지워 줄 뿐, 막지 않는다)\n\n")
return nil
}
filepath.Join 결과가 uploads 밖인가: true
(Join은 ..를 정규화해서 지워 줄 뿐, 막지 않는다)

직접 방어하려면 filepath.Rel + filepath.IsLocal로 확인해야 하고, 그마저도 심볼릭 링크를 통한 탈출은 못 막는다. 검사 시점과 사용 시점 사이에 링크가 바뀌면(TOCTOU) 검사가 무의미해진다.

os.Root — 사용자 입력 경로의 기본 답

os.OpenRoot(dir)는 디렉터리를 열고, 그 아래를 벗어나는 모든 접근을 커널 수준에서 거부하는 핸들을 준다. Go 1.25에서 메서드 집합이 완성되어, 이제 os 패키지 함수 대부분에 대응물이 있다.

Chmod Chown Chtimes Close Create FS Lchown Link
Lstat Mkdir MkdirAll Name Open OpenFile OpenRoot ReadFile
Readlink Remove RemoveAll Rename Stat Symlink WriteFile

같은 입력을 넣어 보면 결과가 다르다.

examples/09-standard-library/02-os-files/main.go
// 먼저 os.Root 자체가 무엇을 거부하는지 본다. safewrite를 거치지 않는다.
for _, name := range []string{"report.txt", "../../etc/passwd", "a/../../out.txt"} {
f, err := root.Create(name)
if err != nil {
fmt.Printf("root.Create %-20q → %v\n", name, err)
continue
}
if err := f.Close(); err != nil {
return fmt.Errorf("Close: %w", err)
}
fmt.Printf("root.Create %-20q → 성공\n", name)
}

// 심볼릭 링크로 우회하는 것도 막힌다.
if err := root.Symlink("/etc", "sneaky"); err != nil {
return fmt.Errorf("Symlink: %w", err)
}
_, err = root.ReadFile("sneaky/hosts")
fmt.Printf("root.ReadFile %-18q → %v\n", "sneaky/hosts", err)
root.Create "report.txt" → 성공
root.Create "../../etc/passwd" → openat ../../etc/passwd: path escapes from parent
root.Create "a/../../out.txt" → openat a/../../out.txt: no such file or directory
root.ReadFile "sneaky/hosts" → openat sneaky/hosts: path escapes from parent

심볼릭 링크를 만드는 것 자체는 되지만, 그 링크를 따라 밖으로 나가는 것은 막힌다. 직접 짠 경로 검사로는 여기까지 오기 어렵다.

:::tip 규칙 사용자·설정 파일·API 요청에서 온 경로로 파일을 여는 코드는 os.Root를 쓴다. 경로를 직접 검사하는 코드를 쓰고 있다면, 대개 os.Root가 이미 그 일을 더 잘하고 있다. :::

os.Root의 한계도 문서에 적혀 있다. Unix에서 Chmod/Chown/Chtimes는 심링크 교체 경합에 취약하고, GOOS=js에서는 TOCTOU 방어가 보장되지 않는다. 그래도 직접 만든 검사보다는 훨씬 낫다.

원자적 파일 교체

설정 파일을 갱신하는 도중에 프로세스가 죽으면 반쪽짜리 파일이 남는다. 다음 실행에서 파싱이 실패한다.

해법은 오래된 관용구다. 임시 파일에 전부 쓰고, fsync하고, rename한다. 같은 파일 시스템 안에서 rename은 원자적이라 다른 프로세스는 옛 내용이나 새 내용 중 하나만 본다.

examples/09-standard-library/02-os-files/safewrite/safewrite.go
// File은 root 기준 name 경로에 data를 원자적으로 쓴다.
// name은 슬래시 구분 상대 경로여야 하며, root 밖을 가리키면 실패한다.
func File(root *os.Root, name string, data []byte, perm fs.FileMode) (err error) {
if !fs.ValidPath(name) || name == "." {
return fmt.Errorf("safewrite: 잘못된 경로 %q", name)
}

tmp := path.Join(path.Dir(name), "."+path.Base(name)+".tmp"+rand.Text())

// O_EXCL을 붙여 기존 파일을 덮어쓰지 않는다.
f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
if err != nil {
return fmt.Errorf("safewrite: 임시 파일 생성: %w", err)
}

// 성공 경로에서는 rename까지 끝난 뒤라 Remove가 실패하는 게 정상이다.
// 실패 경로에서만 찌꺼기를 지운다.
defer func() {
if err != nil {
if rmErr := root.Remove(tmp); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) {
err = errors.Join(err, fmt.Errorf("safewrite: 임시 파일 정리: %w", rmErr))
}
}
}()

if _, err = f.Write(data); err != nil {
// Close 에러는 쓰기 에러를 덮지 않도록 Join한다.
return errors.Join(fmt.Errorf("safewrite: 쓰기: %w", err), f.Close())
}
// Sync가 없으면 rename 뒤에도 내용이 디스크에 없을 수 있다.
if err = f.Sync(); err != nil {
return errors.Join(fmt.Errorf("safewrite: sync: %w", err), f.Close())
}
// 쓰기 파일의 Close 에러는 반드시 확인한다. 여기서 처음 보고되는 에러가 있다.
if err = f.Close(); err != nil {
return fmt.Errorf("safewrite: 닫기: %w", err)
}

if err = root.Rename(tmp, name); err != nil {
return fmt.Errorf("safewrite: 교체: %w", err)
}
return nil
}

설계 결정이 여럿 겹쳐 있다.

  • 명명된 반환값 (err error)defer 안에서 실제 반환될 에러를 보고 정리 여부를 정하려면 이름이 필요하다. 2-5에서 "명명 반환값은 함정"이라고 했는데, 정확히 이런 자리가 예외다.
  • fs.ValidPath 선검사os.Root도 막아 주지만, 우리 쪽 에러 메시지가 더 낫고 임시 파일도 안 만든다.
  • O_EXCL — 임시 이름이 우연히 겹쳐도 남의 파일을 덮지 않는다. 이름에는 crypto/rand.Text()가 붙는다.
  • Sync — 없으면 rename은 성공했는데 내용은 디스크에 없는 상태가 가능하다.
  • Close의 에러 확인 — 9-1에서 예고한 자리다. errors.Join으로 원래 에러를 덮지 않고 합친다.

테스트가 세 가지를 확인한다.

examples/09-standard-library/02-os-files/safewrite/safewrite_test.go
// os.Root가 ../ 탈출을 막는지 확인한다.
func TestFileRejectsEscape(t *testing.T) {
outside := t.TempDir()
inside := filepath.Join(outside, "sandbox")
if err := os.Mkdir(inside, 0o755); err != nil {
t.Fatalf("Mkdir() 에러 = %v", err)
}
root, err := os.OpenRoot(inside)
if err != nil {
t.Fatalf("OpenRoot() 에러 = %v", err)
}
defer func() {
if err := root.Close(); err != nil {
t.Errorf("Root.Close() 에러 = %v", err)
}
}()

for _, name := range []string{"../escaped.txt", "a/../../escaped.txt"} {
t.Run(name, func(t *testing.T) {
err := safewrite.File(root, name, []byte("탈출"), 0o600)
if err == nil {
t.Fatal("탈출 경로가 성공했다")
}
if _, statErr := os.Stat(filepath.Join(outside, "escaped.txt")); !os.IsNotExist(statErr) {
t.Fatalf("루트 밖에 파일이 생겼다: %v", statErr)
}
})
}
}

"에러가 났다"만 보지 않고 "밖에 파일이 안 생겼다"까지 확인한다. 보안 테스트는 결과를 봐야 한다.

io/fs — 읽기 전용 파일 시스템 추상

io/fs.FS는 인터페이스 하나짜리 추상이다.

type FS interface {
Open(name string) (File, error)
}

os.DirFS(dir)는 디렉터리를 fs.FS로 감싸고, embed.FS는 바이너리에 박힌 파일들을 같은 인터페이스로 노출한다. 덕분에 "디스크에서 읽기"와 "바이너리에서 읽기"를 같은 코드로 처리할 수 있다.

경로 규칙이 os와 다르다는 점이 중요하다. fs.FS의 경로는 항상 슬래시 구분, 항상 상대 경로, .. 금지다(fs.ValidPath). 윈도우에서도 그렇다. OS 경로로 바꿔야 하면 filepath.Localize를 쓴다.

examples/09-standard-library/02-os-files/main.go
fsys := os.DirFS(filepath.Join(base, "site"))

err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Printf("WalkDir: %-16s dir=%t\n", p, d.IsDir())
return nil
})
if err != nil {
return fmt.Errorf("WalkDir: %w", err)
}

// os.DirFS는 io/fs.ReadLinkFS를 구현한다.
linkFS, ok := fsys.(fs.ReadLinkFS)
fmt.Printf("os.DirFS가 ReadLinkFS인가: %t\n", ok)
if ok {
target, err := linkFS.ReadLink("home.html")
if err != nil {
return fmt.Errorf("ReadLink: %w", err)
}
info, err := linkFS.Lstat("home.html")
if err != nil {
return fmt.Errorf("Lstat: %w", err)
}
fmt.Printf("ReadLink(home.html) = %q, Lstat 모드에 심링크 비트: %t\n\n",
target, info.Mode()&fs.ModeSymlink != 0)
}
WalkDir: . dir=true
WalkDir: css dir=true
WalkDir: css/main.css dir=false
WalkDir: home.html dir=false
WalkDir: index.html dir=false
os.DirFS가 ReadLinkFS인가: true
ReadLink(home.html) = "index.html", Lstat 모드에 심링크 비트: true

fs.ReadLinkFSReadLinkLstat을 추가한 인터페이스다. os.DirFSos.Root.FS()가 구현한다 — 심볼릭 링크를 따라가지 않고 링크 자체를 검사해야 하는 아카이브·백업 도구가 쓴다.

fs.WalkDir의 순회 순서는 디렉터리마다 이름순으로 정렬되어 있다. os.ReadDir도 마찬가지다. 그래서 위 출력이 실행마다 같다.

embed — 파일을 바이너리에 박기

설정 템플릿, 정적 웹 자산, 마이그레이션 SQL을 배포물과 함께 옮기는 문제는 //go:embed가 해결한다. 바이너리 하나만 배포하면 된다.

examples/09-standard-library/02-os-files/assets/assets.go
// files 디렉터리 전체가 바이너리에 들어간다.
// 주석과 var 선언 사이에 빈 줄이 있으면 안 된다.
//
//go:embed files
var embedded embed.FS

// Version은 파일 하나를 문자열로 박아 넣는 형태다.
//
//go:embed files/version.txt
var Version string

// FS는 files 디렉터리를 루트로 하는 읽기 전용 파일 시스템을 반환한다.
// fs.Sub로 "files/" 접두사를 벗긴다.
func FS() fs.FS {
sub, err := fs.Sub(embedded, "files")
if err != nil {
// //go:embed 디렉티브가 컴파일 시점에 보장하므로 여기 도달하면 버그다.
panic(err)
}
return sub
}
embed greeting.txt: "안녕, embed\n"
embed Version: "v1.0.3\n"

규칙 몇 가지가 있다.

  • 대상 변수 타입은 string, []byte, embed.FS 셋 중 하나다.
  • //go:embed 주석과 var 선언 사이에 빈 줄이 있으면 안 된다. 있으면 그냥 평범한 주석이 되고 변수는 비어 있다. 컴파일 에러가 아니라서 알아채기 어렵다.
  • 패턴은 그 .go 파일이 있는 디렉터리 기준이고, 상위 디렉터리(..)로는 못 나간다.
  • _.으로 시작하는 파일은 기본적으로 제외된다. 포함하려면 all: 접두사를 붙인다 (//go:embed all:files).
  • fs.Sub로 접두사를 벗기면 사용하는 쪽이 files/를 몰라도 된다.

흔한 실수

1. filepath.Join이 방어라고 생각한다

Join은 정규화 함수다. ..를 계산해서 실제로 상위 디렉터리를 가리키는 경로를 만들어 준다. 사용자 입력에는 os.Root를 쓴다.

2. 퍼미션을 확인하지 않는다

WriteFile(p, key, 0o600)은 파일이 이미 있으면 퍼미션을 바꾸지 않는다. 그리고 새로 만들어도 umask가 깎는다.

3. defer f.Close()로 쓰기 에러를 놓친다

읽기는 괜찮지만 쓰기는 아니다. 마지막 flush가 Close에서 일어난다.

4. os.Rename이 파일 시스템을 넘어간다고 생각한다

/tmp에 임시 파일을 만들고 목적지로 Rename하면, 두 곳이 다른 마운트일 때 invalid cross-device link가 난다. 임시 파일은 목적지와 같은 디렉터리에 만든다safewrite가 그렇게 한다.

5. fs.FS 경로에 OS 구분자를 쓴다

fs.FS는 언제나 슬래시다. 윈도우에서 filepath.Join으로 만든 css\main.cssfs.ReadFile에 넘기면 실패한다. path.Join을 쓴다.

6. //go:embed 앞에 빈 줄을 둔다

//go:embed files

var embedded embed.FS // 비어 있다

빌드는 되고 값만 비어 있다. 가장 조용한 실패다.

7. os.ReadDir의 결과가 정렬되어 있다는 사실을 모른다

os.ReadDirfs.WalkDir은 이름순으로 정렬해서 준다. 반대로 os.File.ReadDir(메서드)은 정렬하지 않는다. 골든 파일 테스트가 가끔 깨진다면 이 차이를 의심한다.

정리

  • os.ReadFile/WriteFile은 작은 파일용이고, 크기를 모르는 입력은 9-1의 스트리밍으로 다룬다. io/ioutil은 전부 폐기됐다.
  • 퍼미션 인자는 요청이다. umask가 깎고, 기존 파일에는 적용되지 않는다.
  • filepath.Join은 경로 탈출을 막지 않는다. 사용자 입력 경로에는 os.Root를 쓴다 — ../ 탈출도, 심볼릭 링크 우회도 커널 수준에서 거부된다. 실제 에러 메시지는 path escapes from parent다.
  • 원자적 교체는 임시 파일 → SyncRename이다. 임시 파일은 목적지와 같은 디렉터리에 만든다. Close의 에러를 확인한다.
  • io/fs는 읽기 전용 추상이고 경로는 항상 슬래시 상대 경로다. os.DirFSos.Root.FS()fs.ReadLinkFS까지 구현해 심링크 자체를 검사할 수 있다.
  • //go:embed는 파일을 바이너리에 넣는다. 주석과 var 사이에 빈 줄이 있으면 조용히 실패한다.

연습문제

  1. safewrite.File에서 f.Sync() 호출을 지우고 테스트를 돌려 보자. 전부 통과할 것이다. 그렇다면 Sync는 왜 필요한가? 이 테스트로는 잡을 수 없는 실패가 무엇인지 말로 설명해 보자. 그런 실패를 자동 테스트로 만들려면 무엇이 필요한가?

  2. os.Root 대신 filepath.Relfilepath.IsLocal로 직접 검사하는 safeJoin(base, userInput string) (string, error)를 써 보자. ../../etc/passwd는 막힐 것이다. 그다음 base 안에 /etc를 가리키는 심볼릭 링크를 만들고 link/passwd를 넣어 보자. 무엇이 다른가?

  3. assets.FS()가 반환하는 fs.FShttp.FileServerFS에 물려 정적 파일 서버를 만들어 보자. 그다음 같은 핸들러를 os.DirFS("./files")로 바꿔 보자. 핸들러 코드가 몇 줄 바뀌는가? (9-7에서 이 서버를 라우터에 붙인다.)