29장. 파일 입출력
파일을 읽고 쓰는 일은 거의 모든 프로그램의 기본 작업이다. 설정을 읽고, 로그를 남기고, 결과를 저장하고.
Go 는 파일 입출력을 위한 표준 도구가 잘 갖춰져 있다. 이 장에서 자주 쓰는 패턴을 익힌다.
목표:
- 파일을 열고 안전하게 닫기
- 전체 / 줄 단위 / 청크 단위 읽기
- 전체 / 점진적 쓰기
io.Reader/io.Writer인터페이스의 의미 이해- 경로 다루기와 디렉터리 작업
29.1 파일 열기/닫기
os.Open (읽기 전용)
f, err := os.Open("hello.txt")
if err != nil {
return err
}
defer f.Close()
// f 로부터 읽기 수행
- 파일이 없거나 권한이 없으면 에러
- 성공하면
*os.File을 돌려준다 - 다 쓰면 반드시
Close()해야 자원이 해제된다
os.Create (쓰기 전용, 새 파일)
f, err := os.Create("out.txt")
if err != nil {
return err
}
defer f.Close()
- 파일이 없으면 새로 만든다
- 이미 있으면 기존 내용을 비우고 새로 시작한다
- 의도와 다르게 기존 파일을 날리지 않도록 주의
os.OpenFile (옵션 지정)
가장 일반적인 형태다. 플래그로 동작을 세밀하게 정한다.
f, err := os.OpenFile(
"app.log",
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0644,
)
자주 쓰는 플래그:
| 플래그 | 의미 |
|---|---|
O_RDONLY | 읽기 전용 |
O_WRONLY | 쓰기 전용 |
O_RDWR | 읽기 + 쓰기 |
O_APPEND | 끝에 이어 쓰기 |
O_CREATE | 없으면 만들기 |
O_TRUNC | 열 때 내용을 비움 |
세 번째 인자는 권한(퍼미션) 이다.
0644 는 “주인 읽기/쓰기, 나머지 읽기만” 이다.
defer f.Close() 관례
9장에서 본 defer 가 여기서 빛을 발한다.
파일을 열자마자 defer f.Close() 를 적어 두면
함수가 어떻게 끝나든 닫힘이 보장된다.
func readFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// 여기서 어떤 에러로 빠져나가도
// Close() 는 호출된다
...
return nil
}
닫기 자체에서 발생한 에러를 챙겨야 할 때도 있다. 그럴 때는 명시적으로 호출하고 에러를 살핀다.
에러 처리
21장의 에러 처리 관례를 그대로 따른다.
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("파일 열기 실패: %w", err)
}
defer f.Close()
%w 로 래핑해서 호출자가 원인을 추적할 수 있게 한다.
29.2 파일 읽기
상황에 따라 세 가지 방식이 있다.
전체 한 번에: os.ReadFile
작은 파일을 통째로 읽을 때.
data, err := os.ReadFile("config.json")
if err != nil {
return err
}
fmt.Println(string(data))
- 열기, 읽기, 닫기를 한 번에 처리
- 결과는
[]byte - 파일이 크다면 메모리를 통째로 차지하므로 주의 (26장 참고)
io.ReadAll
이미 열린 io.Reader 에서 전체를 읽어 들일 때.
f, err := os.Open("data.txt")
if err != nil {
return err
}
defer f.Close()
data, err := io.ReadAll(f)
os.ReadFile 과 비슷하지만
“이미 손에 든 Reader” 에 쓸 수 있다는 점이 다르다.
한 줄씩: bufio.Scanner
텍스트 파일을 줄 단위로 처리할 때 표준 패턴이다.
f, err := os.Open("big.log")
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
process(line)
}
if err := sc.Err(); err != nil {
return err
}
sc.Scan()이 true 면 다음 줄이 있다sc.Text()가 줄 내용 (개행 제외)- 끝난 뒤
sc.Err()로 에러를 확인
기본 줄 길이 제한이 있다.
긴 줄을 다룬다면 sc.Buffer(...) 로 키운다.
청크 단위: io.Reader + 버퍼
바이너리 파일이거나 줄이 의미 없는 경우.
f, _ := os.Open("video.mp4")
defer f.Close()
buf := make([]byte, 4096)
for {
n, err := f.Read(buf)
if n > 0 {
process(buf[:n])
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
Read 의 반환 규칙은 약간 까다롭다.
n 이 0 이상이고 err 가 io.EOF 일 수 있는 등
모든 조합을 다 처리해야 한다.
귀찮으면 위에서 본 Scanner 나
io.ReadAll 을 쓰는 게 낫다.
29.3 파일 쓰기
전체 한 번에: os.WriteFile
data := []byte("hello\n")
err := os.WriteFile("hello.txt", data, 0644)
- 파일을 만들거나 덮어쓰고
- 데이터를 통째로 적고
- 닫는다
작은 결과를 단번에 저장할 때 편하다.
점진적으로: bufio.Writer
여러 번 나눠서 쓸 때.
f, err := os.Create("out.txt")
if err != nil {
return err
}
defer f.Close()
w := bufio.NewWriter(f)
for _, line := range lines {
w.WriteString(line)
w.WriteByte('\n')
}
if err := w.Flush(); err != nil {
return err
}
bufio.Writer 는 내부 버퍼에 모아 두었다가
한 번에 파일로 보낸다.
시스템 호출 횟수가 줄어 성능이 좋다.
Flush()를 잊지 마라. 내부 버퍼에만 남고 파일에 안 적힌 데이터가 그대로 사라질 수 있다.
defer w.Flush() 보다 명시적으로
끝에 Flush() 후 에러 검사를 하는 게 안전하다.
defer 에서는 에러를 챙기기 까다롭다.
29.4 io.Reader / io.Writer 인터페이스
이 장에 계속 등장하는 두 인터페이스를 정리한다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Go 의 입출력은 거의 전부 이 두 인터페이스로 통일된다.
os.File은 둘 다 구현한다net.Conn(네트워크 연결) 도 구현한다bytes.Buffer,strings.Reader도 구현한다gzip.Reader(압축 해제) 도 구현한다
이게 강력한 이유.
io.Copy(dst, src) 같은 함수 하나로
“파일에서 네트워크로”, “압축 해제 후 파일로” 등
어떤 조합이든 처리할 수 있다.
io.Copy(dstFile, srcFile) // 파일 복사
io.Copy(dstConn, srcFile) // 파일 → 네트워크
io.Copy(dstFile, gzReader) // 압축 해제 → 파일
16장의 인터페이스가 이런 식으로 빛을 발한다.
29.5 경로 다루기
문자열을 직접 자르고 합치는 대신 표준 패키지를 쓴다.
path/filepath 패키지
filepath.Join("dir", "sub", "a.txt")
// "dir/sub/a.txt" (Linux/macOS)
// "dir\\sub\\a.txt" (Windows)
filepath.Dir("/a/b/c.txt") // "/a/b"
filepath.Base("/a/b/c.txt") // "c.txt"
filepath.Ext("c.txt") // ".txt"
abs, err := filepath.Abs("./data")
// 절대 경로로 변환
| 함수 | 의미 |
|---|---|
Join | 경로 조각을 안전하게 합침 |
Dir | 디렉터리 부분 |
Base | 파일명 부분 |
Ext | 확장자 부분 (점 포함) |
Abs | 절대 경로 |
Clean | .., . 등을 정리 |
윈도우/유닉스 경로 차이
filepath 는 OS 의 경로 구분자를 자동으로 쓴다.
| OS | 구분자 |
|---|---|
| Linux / macOS | / |
| Windows | \ |
직접 + "/" + 로 합치면 윈도우에서 문제가 생긴다.
항상 filepath.Join 을 쓴다.
URL 경로처럼 항상
/인 경우는path패키지를 쓴다.filepath와 이름이 비슷하지만 다른 패키지다.
29.6 디렉터리 작업
디렉터리 만들기
os.Mkdir("data", 0755) // 한 단계
os.MkdirAll("data/2026/05", 0755) // 중간 디렉터리 포함
Mkdir은 부모가 없으면 실패한다MkdirAll은 중간 경로를 자동으로 만든다
디렉터리 내용 읽기
entries, err := os.ReadDir("data")
if err != nil {
return err
}
for _, e := range entries {
fmt.Println(e.Name(), e.IsDir())
}
os.ReadDir 은 디렉터리의 한 단계 자식 목록을 돌려준다.
각 항목에 Name(), IsDir(), Type() 같은 메서드가 있다.
파일 / 디렉터리 정보
info, err := os.Stat("hello.txt")
if err != nil {
return err
}
fmt.Println(info.Size()) // 바이트 수
fmt.Println(info.ModTime()) // 마지막 수정 시각
fmt.Println(info.IsDir()) // 디렉터리인가
os.Stat 으로 메타데이터를 얻는다.
“파일이 존재하는가” 확인에도 쓴다.
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// 파일 없음
}
21장에서 본 errors.Is 와 함께 쓰면 깔끔하다.
파일 트리 순회
전체 디렉터리를 재귀적으로 훑을 때.
err := filepath.WalkDir(
"data",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Println(path)
return nil
},
)
- 모든 파일/디렉터리에 대해 콜백이 호출된다
- 콜백이 에러를 반환하면 순회가 중단된다
- 특정 디렉터리를 건너뛰려면
return filepath.SkipDir를 반환한다
파일/디렉터리 지우기
os.Remove("hello.txt") // 한 파일
os.RemoveAll("dir/sub") // 디렉터리 전체 (위험)
RemoveAll 은 강력하다.
경로 계산이 잘못되면 엉뚱한 곳을 다 날릴 수 있다.
넘기는 경로를 두 번 확인하자.
29.7 정리
- 파일 열기 =
os.Open(읽기),os.Create(쓰기),os.OpenFile(옵션)- 항상
defer f.Close()
- 항상
- 읽기
- 전체:
os.ReadFile,io.ReadAll - 줄 단위:
bufio.Scanner - 청크 단위:
io.Reader.Read
- 전체:
- 쓰기
- 전체:
os.WriteFile - 점진적:
bufio.Writer(반드시Flush)
- 전체:
io.Reader/io.Writer는 파일/네트워크/압축 등 모든 입출력을 하나의 모양으로 묶는다- 경로는
path/filepathJoin,Dir,Base,Ext,Abs
- 디렉터리는
os.Mkdir,os.ReadDir,os.Stat,filepath.WalkDir
파일 입출력은 패턴이 정형화돼 있다. “열기 → defer 닫기 → 읽기/쓰기 → 에러 검사” 이 흐름만 손에 익으면 대부분의 작업을 해낼 수 있다.
다음 장에서는 데이터를 파일이나 네트워크로 주고받을 때 가장 자주 쓰이는 형식인 JSON 을 다룬다.