본문으로 건너뛰기

파일 I/O — os·bufio·io 패키지 완전 정복

파일 읽기와 쓰기는 모든 실용적인 프로그램의 핵심 기능입니다. Go는 os, bufio, io 세 패키지의 조합으로 강력하고 유연한 파일 I/O를 제공합니다. 유닉스 철학에 따라 파일도 스트림 으로 다루며, 동일한 인터페이스로 파일·네트워크·메모리를 투명하게 처리할 수 있습니다.

os 패키지 — 파일 열기·생성·삭제

파일 열기와 생성

package main

import (
"fmt"
"os"
)

func main() {
// 읽기 전용으로 열기
f, err := os.Open("data.txt")
if err != nil {
fmt.Println("열기 실패:", err)
return
}
defer f.Close() // 반드시 닫아야 fd 누수 방지

// 파일 생성 (없으면 생성, 있으면 덮어씀)
fw, err := os.Create("output.txt")
if err != nil {
fmt.Println("생성 실패:", err)
return
}
defer fw.Close()
fw.WriteString("Hello, Go!\n")

// 세밀한 제어가 필요할 때 OpenFile 사용
// os.O_APPEND: 끝에 추가, os.O_CREATE: 없으면 생성, os.O_WRONLY: 쓰기 전용
fa, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("OpenFile 실패:", err)
return
}
defer fa.Close()
fa.WriteString("로그 메시지\n")

fmt.Println("파일 작업 완료")
}

파일 권한(FileMode) 이해:

의미
0644소유자 읽기/쓰기, 그룹/기타 읽기
0755소유자 전체, 그룹/기타 읽기/실행
0600소유자만 읽기/쓰기 (민감 파일)

파일 전체 읽기·쓰기 (Go 1.16+)

package main

import (
"fmt"
"os"
)

func main() {
// 파일 전체를 한 번에 읽기 (작은 파일에 적합)
data, err := os.ReadFile("data.txt")
if err != nil {
fmt.Println("읽기 실패:", err)
return
}
fmt.Printf("읽은 내용 (%d 바이트):\n%s", len(data), data)

// 파일 전체를 한 번에 쓰기
content := []byte("새로운 내용입니다.\n두 번째 줄입니다.\n")
err = os.WriteFile("output.txt", content, 0644)
if err != nil {
fmt.Println("쓰기 실패:", err)
return
}
fmt.Println("파일 쓰기 성공")
}

파일 정보 조회·삭제·이름변경

package main

import (
"fmt"
"os"
)

func main() {
// 파일 정보 조회
info, err := os.Stat("data.txt")
if err != nil {
if os.IsNotExist(err) {
fmt.Println("파일이 존재하지 않습니다")
} else {
fmt.Println("Stat 오류:", err)
}
return
}
fmt.Printf("이름: %s\n", info.Name())
fmt.Printf("크기: %d 바이트\n", info.Size())
fmt.Printf("수정 시각: %v\n", info.ModTime())
fmt.Printf("디렉터리: %v\n", info.IsDir())
fmt.Printf("권한: %v\n", info.Mode())

// 디렉터리 생성
err = os.Mkdir("mydir", 0755)
if err != nil && !os.IsExist(err) {
fmt.Println("디렉터리 생성 실패:", err)
}

// 중첩 디렉터리 생성 (mkdir -p)
err = os.MkdirAll("a/b/c", 0755)
if err != nil {
fmt.Println("MkdirAll 실패:", err)
}

// 파일 이름 변경 / 이동
err = os.Rename("old.txt", "new.txt")
if err != nil {
fmt.Println("Rename 실패:", err)
}

// 파일 삭제
err = os.Remove("temp.txt")
if err != nil && !os.IsNotExist(err) {
fmt.Println("삭제 실패:", err)
}
}

io 패키지 — 스트림 인터페이스의 핵심

Go의 I/O 철학은 io.Readerio.Writer 인터페이스에서 시작합니다. 이 두 인터페이스만 이해하면 파일·네트워크·압축·암호화 등 모든 I/O를 동일한 방식으로 다룰 수 있습니다.

핵심 인터페이스

// io 패키지에 정의된 핵심 인터페이스들
type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

type Closer interface {
Close() error
}

// 조합 인터페이스
type ReadWriter interface {
Reader
Writer
}

type ReadCloser interface {
Reader
Closer
}

io.Copy — 스트림 복사의 핵심

package main

import (
"fmt"
"io"
"os"
"strings"
)

func main() {
// io.Copy: src에서 dst로 EOF까지 모두 복사
src := strings.NewReader("복사할 내용입니다. 긴 텍스트도 효율적으로 처리됩니다.")
dst, err := os.Create("copied.txt")
if err != nil {
fmt.Println("파일 생성 실패:", err)
return
}
defer dst.Close()

n, err := io.Copy(dst, src)
if err != nil {
fmt.Println("복사 실패:", err)
return
}
fmt.Printf("%d 바이트 복사 완료\n", n)

// io.ReadAll: Reader를 끝까지 읽어 []byte 반환
r := strings.NewReader("전체 내용")
data, err := io.ReadAll(r)
if err != nil {
fmt.Println("ReadAll 실패:", err)
return
}
fmt.Println("읽은 내용:", string(data))

// io.LimitReader: 최대 읽기 바이트 제한
limited := io.LimitReader(strings.NewReader("abcdefghij"), 5)
data, _ = io.ReadAll(limited)
fmt.Println("제한 읽기:", string(data)) // "abcde"
}

io.MultiReader·MultiWriter·TeeReader

package main

import (
"fmt"
"io"
"os"
"strings"
)

func main() {
// MultiReader: 여러 Reader를 순서대로 이어붙임
r1 := strings.NewReader("첫 번째 부분 ")
r2 := strings.NewReader("두 번째 부분 ")
r3 := strings.NewReader("세 번째 부분")
multi := io.MultiReader(r1, r2, r3)

data, _ := io.ReadAll(multi)
fmt.Println("합친 결과:", string(data))

// MultiWriter: 한 번 쓰면 여러 Writer에 동시 기록
f, _ := os.Create("multiwrite.txt")
defer f.Close()

// 파일과 표준출력에 동시 기록
mw := io.MultiWriter(f, os.Stdout)
fmt.Fprintln(mw, "파일과 콘솔에 동시 출력!")

// TeeReader: 읽으면서 동시에 다른 Writer에도 기록 (스트림 감청)
src := strings.NewReader("원본 데이터 스트림")
var buf strings.Builder
tee := io.TeeReader(src, &buf) // src 읽으면서 buf에도 복사

// tee에서 읽으면 buf에도 동일 내용이 기록됨
data, _ = io.ReadAll(tee)
fmt.Println("tee 읽기:", string(data))
fmt.Println("버퍼 내용:", buf.String())
}

io.Pipe — 동기 파이프

package main

import (
"fmt"
"io"
)

func main() {
// io.Pipe: 동기 인메모리 파이프 (고루틴 간 스트림 전달에 유용)
pr, pw := io.Pipe()

// 쓰기 고루틴
go func() {
defer pw.Close()
for i := 1; i <= 5; i++ {
fmt.Fprintf(pw, "메시지 %d\n", i)
}
}()

// 읽기 (메인 고루틴)
data, err := io.ReadAll(pr)
if err != nil {
fmt.Println("Pipe 읽기 실패:", err)
return
}
fmt.Print(string(data))
}

bufio 패키지 — 버퍼 기반 I/O

시스템 콜은 비용이 큽니다. bufio는 내부 버퍼를 두어 시스템 콜 횟수를 줄이고 성능을 크게 높입니다.

bufio.NewReader와 bufio.NewWriter

package main

import (
"bufio"
"fmt"
"os"
"strings"
)

func main() {
// bufio.NewReader: 버퍼 읽기
r := bufio.NewReader(strings.NewReader("첫 번째 줄\n두 번째 줄\n세 번째 줄\n"))

// 줄 단위로 읽기
for {
line, err := r.ReadString('\n') // 구분자까지 읽기
if len(line) > 0 {
fmt.Print("읽은 줄:", line)
}
if err != nil {
break // io.EOF 포함
}
}

// bufio.NewWriter: 버퍼 쓰기 (반드시 Flush 호출!)
f, _ := os.Create("buffered.txt")
defer f.Close()

w := bufio.NewWriter(f)
for i := 1; i <= 5; i++ {
fmt.Fprintf(w, "라인 %d\n", i)
}
// Flush 하지 않으면 버퍼의 내용이 파일에 기록되지 않음!
if err := w.Flush(); err != nil {
fmt.Println("Flush 실패:", err)
}
fmt.Println("버퍼 쓰기 완료")
}

bufio.Scanner — 유연한 토큰 스캔

package main

import (
"bufio"
"fmt"
"strings"
)

func main() {
text := "첫 번째 줄\n두 번째 줄\n세 번째 줄"

// ScanLines (기본): 줄 단위 스캔
scanner := bufio.NewScanner(strings.NewReader(text))
lineNum := 0
for scanner.Scan() {
lineNum++
fmt.Printf("줄 %d: %s\n", lineNum, scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Println("스캔 오류:", err)
}

// ScanWords: 단어 단위 스캔
wordText := "Go 언어는 빠르고 간결합니다"
scanner2 := bufio.NewScanner(strings.NewReader(wordText))
scanner2.Split(bufio.ScanWords)
for scanner2.Scan() {
fmt.Printf("단어: [%s]\n", scanner2.Text())
}

// ScanBytes: 바이트 단위 스캔
byteText := "ABC"
scanner3 := bufio.NewScanner(strings.NewReader(byteText))
scanner3.Split(bufio.ScanBytes)
for scanner3.Scan() {
fmt.Printf("바이트: %v\n", scanner3.Bytes())
}
}

파일 탐색 (Seek)

package main

import (
"fmt"
"io"
"os"
)

func main() {
// 테스트 파일 생성
os.WriteFile("seek_test.txt", []byte("0123456789ABCDEF"), 0644)

f, _ := os.Open("seek_test.txt")
defer f.Close()

buf := make([]byte, 4)

// 처음부터 4바이트 읽기
f.Read(buf)
fmt.Println("처음 4바이트:", string(buf)) // "0123"

// 절대 위치 이동: 처음(SeekStart)에서 8번째 바이트로
pos, _ := f.Seek(8, io.SeekStart)
fmt.Println("이동 후 위치:", pos)
f.Read(buf)
fmt.Println("위치 8부터 4바이트:", string(buf)) // "89AB"

// 현재 위치 기준으로 이동: 뒤로 2바이트
f.Seek(-2, io.SeekCurrent)
f.Read(buf)
fmt.Println("현재에서 -2 후 4바이트:", string(buf)) // "AB CD"

// 끝에서 이동: 끝(SeekEnd)에서 -4바이트
f.Seek(-4, io.SeekEnd)
f.Read(buf)
fmt.Println("끝에서 -4부터 4바이트:", string(buf)) // "CDEF"

// 현재 위치 확인 (0 이동 트릭)
currentPos, _ := f.Seek(0, io.SeekCurrent)
fmt.Println("현재 파일 포인터 위치:", currentPos)
}

실전 예제 1 — 로그 파일 분석기

대용량 로그 파일에서 ERROR 레벨 로그만 추출하는 분석기입니다.

package main

import (
"bufio"
"fmt"
"os"
"strings"
"time"
)

type LogEntry struct {
Level string
Message string
Line int
}

// analyzeLog: 로그 파일에서 특정 레벨 항목만 추출
func analyzeLog(filename string, targetLevel string) ([]LogEntry, error) {
f, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("파일 열기 실패: %w", err)
}
defer f.Close()

var entries []LogEntry
scanner := bufio.NewScanner(f)
lineNum := 0

for scanner.Scan() {
lineNum++
line := scanner.Text()

// 형식: "[LEVEL] 메시지"
if strings.HasPrefix(line, "["+targetLevel+"]") {
msg := strings.TrimPrefix(line, "["+targetLevel+"] ")
entries = append(entries, LogEntry{
Level: targetLevel,
Message: msg,
Line: lineNum,
})
}
}

if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("스캔 오류: %w", err)
}

return entries, nil
}

// writeReport: 분석 결과를 파일로 저장
func writeReport(filename string, entries []LogEntry) error {
f, err := os.Create(filename)
if err != nil {
return fmt.Errorf("보고서 파일 생성 실패: %w", err)
}
defer f.Close()

w := bufio.NewWriter(f)
fmt.Fprintf(w, "로그 분석 보고서 — %s\n", time.Now().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "총 %d 개의 오류 발견\n\n", len(entries))

for _, entry := range entries {
fmt.Fprintf(w, "줄 %4d | %s\n", entry.Line, entry.Message)
}

return w.Flush()
}

func main() {
// 샘플 로그 파일 생성
sampleLog := `[INFO] 서버 시작됨
[INFO] 데이터베이스 연결 성공
[ERROR] 쿼리 실행 실패: connection timeout
[INFO] 요청 처리 중
[ERROR] 인증 실패: invalid token
[WARN] 메모리 사용률 80% 초과
[ERROR] 파일을 찾을 수 없음: config.yaml
[INFO] 서버 정상 종료
`
os.WriteFile("app.log", []byte(sampleLog), 0644)

// 오류 로그 분석
errors, err := analyzeLog("app.log", "ERROR")
if err != nil {
fmt.Println("분석 실패:", err)
return
}

fmt.Printf("총 %d 개의 ERROR 발견\n", len(errors))
for _, e := range errors {
fmt.Printf(" 줄 %d: %s\n", e.Line, e.Message)
}

// 보고서 저장
if err := writeReport("error_report.txt", errors); err != nil {
fmt.Println("보고서 저장 실패:", err)
return
}
fmt.Println("보고서가 error_report.txt에 저장되었습니다")

// 정리
os.Remove("app.log")
os.Remove("error_report.txt")
}

실전 예제 2 — 대용량 CSV 라인별 스트리밍 처리

수 GB짜리 CSV 파일도 메모리를 적게 쓰면서 처리하는 패턴입니다.

package main

import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)

type SalesRecord struct {
Region string
Product string
Amount float64
}

// processCSVStream: 대용량 CSV 파일을 스트리밍으로 처리
// 전체 파일을 메모리에 올리지 않고 한 줄씩 처리
func processCSVStream(filename string) (map[string]float64, error) {
f, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("CSV 파일 열기 실패: %w", err)
}
defer f.Close()

// 기본 버퍼(4096)보다 큰 버퍼로 성능 향상
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB 버퍼

// 지역별 매출 합계
regionSales := make(map[string]float64)
lineNum := 0

for scanner.Scan() {
lineNum++
line := scanner.Text()

// 헤더 줄 건너뜀
if lineNum == 1 {
continue
}

// CSV 파싱 (단순 예제 — 실제는 encoding/csv 사용 권장)
fields := strings.Split(line, ",")
if len(fields) < 3 {
fmt.Printf("줄 %d: 필드 부족, 건너뜀\n", lineNum)
continue
}

amount, err := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64)
if err != nil {
fmt.Printf("줄 %d: 금액 파싱 실패 (%s), 건너뜀\n", lineNum, fields[2])
continue
}

region := strings.TrimSpace(fields[0])
regionSales[region] += amount
}

if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("CSV 스캔 중 오류 (줄 %d): %w", lineNum, err)
}

fmt.Printf("총 %d 줄 처리 완료 (헤더 제외 %d 건)\n", lineNum, lineNum-1)
return regionSales, nil
}

func main() {
// 샘플 CSV 파일 생성
csvData := "region,product,amount\n서울,노트북,1200000\n부산,마우스,35000\n서울,키보드,89000\n대구,모니터,450000\n부산,노트북,1150000\n서울,헤드셋,120000\n"
os.WriteFile("sales.csv", []byte(csvData), 0644)

// 스트리밍 처리
sales, err := processCSVStream("sales.csv")
if err != nil {
fmt.Println("처리 실패:", err)
return
}

fmt.Println("\n지역별 매출 합계:")
total := 0.0
for region, amount := range sales {
fmt.Printf(" %-10s: %12.0f 원\n", region, amount)
total += amount
}
fmt.Printf(" %-10s: %12.0f 원\n", "합계", total)

os.Remove("sales.csv")
}

고수 팁

파일 닫기는 항상 defer로: os.Open 직후 바로 defer f.Close()를 작성하는 습관을 들이세요. 오류 분기가 많아도 누락되지 않습니다.

bufio.Writer의 Flush 필수: bufio.NewWriter를 사용한 후 Flush()를 빠뜨리면 버퍼에 남은 데이터가 파일에 기록되지 않습니다. defer w.Flush()로 안전하게 처리하세요.

작은 파일은 os.ReadFile, 큰 파일은 Scanner: 수 MB 이하 파일은 os.ReadFile이 간결하고 충분합니다. 수십 MB 이상이면 bufio.Scanner로 라인별 스트리밍을 선택하세요.

io.Copy가 os.ReadFile보다 효율적인 경우: 파일을 읽어서 다른 Writer(네트워크, 압축 스트림 등)로 전달할 때는 io.Copy가 중간 버퍼 없이 전달하므로 메모리가 절약됩니다.