2025-12-29
Go 언어를 막 배운 후 처음으로 맞닥뜨리는 실무 과제 중 하나가 바로 파일 입출력(File I/O)입니다. 로그 저장, 설정 파일 로드, CSV 처리, 캐시 직렬화 등 파일을 다루는 상황은 거의 모든 서버 프로그램에 존재합니다.
이 글에서는 Go 언어의 파일 입출력을 담당하는 핵심 패키지인 os, bufio, io의 구조를 이해하고, 단순 읽기/쓰기를 넘어 대용량 파일 처리, 원자적 저장(Atomic Write), 디렉터리 탐색, JSON/CSV 파일 처리 등 실무에서 즉시 활용 가능한 패턴까지 다룹니다.
🛠️ 왜 파일 I/O를 제대로 배워야 하는가?
필자가 처음 Go로 서버를 구축했을 때, 로그 파일을 직접 파일 시스템에 기록하는 시스템이 필요했습니다. 단순히
file.Write()를 호출하는 것이 전부가 아니었습니다. 버퍼 플러시 누락으로 인한 데이터 유실, 파일 핸들 미반납으로 인한 리소스 고갈, 동시 쓰기로 인한 파일 손상까지 — 파일 I/O는 올바른 패턴을 알지 못하면 예상치 못한 버그의 온상이 됩니다.
파일 관련 패키지를 배우기 전에 Go I/O 시스템의 근간인 io.Reader와 io.Writer 인터페이스를 먼저 이해해야 합니다.
// io 패키지의 핵심 인터페이스
type Reader interface {
Read(p []byte) bool (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
os.File은 이 두 인터페이스를 모두 구현합니다. 그 덕분에 파일은 네트워크 연결, HTTP Body, 메모리 버퍼 등 모든 I/O 소스와 동일한 방식으로 다룰 수 있습니다.
// os.File은 io.Reader, io.Writer, io.Closer를 모두 구현
var _ io.Reader = (*os.File)(nil)
var _ io.Writer = (*os.File)(nil)
var _ io.Closer = (*os.File)(nil)
이 인터페이스 기반 설계 덕분에 io.Copy(dst, src) 하나로 파일-파일, 파일-네트워크, 파일-메모리 복사가 모두 가능합니다.
file, err := os.Open("example.txt")
if err != nil {
return fmt.Errorf("파일 열기 실패: %w", err)
}
defer file.Close()
os.Open()은 내부적으로 os.OpenFile(name, os.O_RDONLY, 0)을 호출합니다.defer file.Close()로 파일 핸들을 반납해야 합니다.%w로 래핑하는 것이 좋습니다.file, err := os.Create("output.txt")
if err != nil {
return fmt.Errorf("파일 생성 실패: %w", err)
}
defer file.Close()
os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)입니다.파일을 로그처럼 추가 모드로 열거나, 읽기/쓰기를 동시에 해야 할 때는 os.OpenFile()을 사용합니다.
// 로그 파일 추가 모드 — 없으면 생성, 있으면 끝에 이어 쓰기
file, err := os.OpenFile("server.log",
os.O_RDWR|os.O_CREATE|os.O_APPEND,
0644,
)
if err != nil {
return fmt.Errorf("로그 파일 열기 실패: %w", err)
}
defer file.Close()
주요 플래그:
| 플래그 | 의미 |
|---|---|
os.O_RDONLY | 읽기 전용 |
os.O_WRONLY | 쓰기 전용 |
os.O_RDWR | 읽기+쓰기 |
os.O_CREATE | 없으면 생성 |
os.O_APPEND | 끝에 이어 쓰기 |
os.O_TRUNC | 기존 내용 초기화 |
os.O_EXCL | 이미 존재하면 에러 (중복 생성 방지) |
파일 권한(perm) 0644는 UNIX 기준 소유자 읽기/쓰기, 그룹/기타 읽기만 허용입니다.
// Go 1.16+ 권장 방식
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("설정 파일 읽기 실패: %w", err)
}
fmt.Println(string(data))
🛠️ ioutil.ReadFile은 deprecated
Go 1.16부터
ioutil.ReadFile,ioutil.WriteFile,ioutil.ReadDir등은 deprecated 처리되었습니다. 이제는 각각os.ReadFile,os.WriteFile,os.ReadDir을 사용하세요. 기존 코드에서ioutil을 사용 중이라면 마이그레이션을 권장합니다.
로그 파일이나 TSV/CSV처럼 줄 단위로 처리해야 하는 파일에 사용합니다.
file, err := os.Open("access.log")
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineCount := 0
for scanner.Scan() {
line := scanner.Text()
lineCount++
// 각 줄 처리
fmt.Printf("[%d] %s\n", lineCount, line)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("파일 읽기 중 오류: %w", err)
}
bufio.Scanner는 기본 버퍼 크기가 64KB입니다. 한 줄이 64KB를 초과하는 경우 scanner.Buffer()로 버퍼를 확장해야 합니다.
// 한 줄 최대 1MB까지 허용
const maxLineSize = 1024 * 1024
buf := make([]byte, maxLineSize)
scanner.Buffer(buf, maxLineSize)
줄 단위가 아닌 특정 구분자나 고정 크기로 읽어야 할 때는 bufio.NewReader를 사용합니다.
file, err := os.Open("data.bin")
if err != nil {
return err
}
defer file.Close()
reader := bufio.NewReaderSize(file, 4096) // 4KB 버퍼
for {
chunk, err := reader.ReadString('\n')
if len(chunk) > 0 {
// chunk 처리
fmt.Print(chunk)
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("읽기 오류: %w", err)
}
}
대용량 파일을 메모리에 전부 올리지 않고 스트리밍으로 복사할 때는 io.Copy()를 사용합니다.
src, err := os.Open("large-file.dat")
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create("backup.dat")
if err != nil {
return err
}
defer dst.Close()
// 메모리를 최소한만 사용하면서 파일 전체를 복사
written, err := io.Copy(dst, src)
if err != nil {
return fmt.Errorf("파일 복사 실패: %w", err)
}
fmt.Printf("%d 바이트 복사 완료\n", written)
content := []byte("설정 데이터\n두 번째 줄\n")
err := os.WriteFile("config.txt", content, 0644)
if err != nil {
return fmt.Errorf("파일 쓰기 실패: %w", err)
}
반복적인 쓰기 작업 시 bufio.Writer로 쓰기를 버퍼링하면 시스템 콜 횟수를 줄여 성능이 크게 향상됩니다.
file, err := os.Create("output.txt")
if err != nil {
return err
}
defer file.Close()
// 기본 4KB 버퍼 (대량 로그는 크게 설정)
writer := bufio.NewWriterSize(file, 64*1024) // 64KB 버퍼
for i := 0; i < 10000; i++ {
fmt.Fprintf(writer, "line %d: 처리 완료\n", i)
}
// 반드시 Flush — 버퍼에 남은 데이터를 파일에 기록
if err := writer.Flush(); err != nil {
return fmt.Errorf("파일 플러시 실패: %w", err)
}
🛠️ Flush 누락은 데이터 유실의 주범
bufio.Writer는 내부 버퍼가 가득 찰 때만 자동으로 파일에 씁니다. 프로그램 종료 전 반드시Flush()를 호출해야 합니다.defer와 함께 사용 시 주의할 점:defer writer.Flush()는 에러를 무시합니다. 중요한 데이터라면 명시적으로 호출하고 에러를 확인하세요.
fmt.Fprintf는 io.Writer를 받으므로 파일에 직접 포맷 출력이 가능합니다.
file, err := os.Create("report.txt")
if err != nil {
return err
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush()
fmt.Fprintf(writer, "=== 처리 보고서 ===\n")
fmt.Fprintf(writer, "총 처리 건수: %d\n", 1000)
fmt.Fprintf(writer, "처리 시간: %.2fs\n", 3.14)
설정 파일이나 데이터 파일을 저장할 때, 쓰기 중 프로그램이 종료되면 파일이 손상됩니다. 이를 방지하는 패턴이 원자적 파일 저장입니다.
// 원자적 파일 저장: 임시 파일에 쓴 뒤 rename으로 교체
func writeFileAtomic(path string, data []byte) error {
// 같은 디렉터리에 임시 파일 생성 (다른 파티션이면 rename이 느려짐)
dir := filepath.Dir(path)
tmpFile, err := os.CreateTemp(dir, ".tmp-")
if err != nil {
return fmt.Errorf("임시 파일 생성 실패: %w", err)
}
tmpPath := tmpFile.Name()
// 실패 시 임시 파일 정리
defer func() {
tmpFile.Close()
os.Remove(tmpPath) // 성공하면 이미 없으므로 에러 무시
}()
// 데이터 쓰기
if _, err := tmpFile.Write(data); err != nil {
return fmt.Errorf("임시 파일 쓰기 실패: %w", err)
}
// OS 버퍼 → 디스크 동기화
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("fsync 실패: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("임시 파일 닫기 실패: %w", err)
}
// 원자적 교체: rename은 같은 파티션 내에서 atomic 보장
return os.Rename(tmpPath, path)
}
os.Rename()은 POSIX 표준에 따라 같은 파티션 내에서 원자적으로 동작합니다. 이 패턴은 설정 파일, 캐시 직렬화, 데이터베이스 덤프 등에서 필수적으로 사용됩니다.
info, err := os.Stat("config.json")
if os.IsNotExist(err) {
fmt.Println("파일이 존재하지 않습니다.")
return
}
if err != nil {
return fmt.Errorf("파일 상태 조회 실패: %w", err)
}
fmt.Printf("파일명: %s\n", info.Name())
fmt.Printf("크기: %d 바이트\n", info.Size())
fmt.Printf("수정 시각: %s\n", info.ModTime())
fmt.Printf("디렉터리 여부: %v\n", info.IsDir())
fmt.Printf("권한: %s\n", info.Mode())
func fileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// 사용 예
if !fileExists("config.json") {
// 기본 설정으로 생성
createDefaultConfig("config.json")
}
🛠️ TOCTOU 레이스 컨디션 주의
“존재 여부 확인 → 파일 열기” 사이에 다른 프로세스가 파일을 삭제할 수 있습니다. 중요한 로직에서는
os.OpenFile()에os.O_EXCL플래그를 사용하거나,os.Open()의 에러를 직접 처리하는 방식을 선택하세요.
entries, err := os.ReadDir("./logs")
if err != nil {
return fmt.Errorf("디렉터리 읽기 실패: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
fmt.Printf("[DIR] %s\n", entry.Name())
} else {
info, _ := entry.Info()
fmt.Printf("[FILE] %s (%d bytes)\n", entry.Name(), info.Size())
}
}
// 특정 디렉터리 하위의 모든 .log 파일 수집
var logFiles []string
err := filepath.WalkDir("./logs", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err // 접근 불가 항목 스킵
}
if !d.IsDir() && filepath.Ext(path) == ".log" {
logFiles = append(logFiles, path)
}
return nil
})
if err != nil {
return fmt.Errorf("디렉터리 탐색 실패: %w", err)
}
fmt.Printf("발견된 로그 파일: %d개\n", len(logFiles))
for _, f := range logFiles {
fmt.Println(" -", f)
}
filepath.WalkDir은 Go 1.16에서 추가된 개선 버전으로, filepath.Walk보다 성능이 좋습니다.
// 단일 디렉터리
err := os.Mkdir("output", 0755)
// 중간 경로 포함 재귀 생성 (mkdir -p)
err = os.MkdirAll("output/2025/12/29", 0755)
실무에서 가장 자주 만나는 파일 처리 중 하나가 JSON 설정 파일입니다.
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
LogLevel string `json:"log_level"`
Database struct {
DSN string `json:"dsn"`
MaxConn int `json:"max_conn"`
} `json:"database"`
}
// JSON 파일 로드
func loadConfig(path string) (*ServerConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("설정 파일 로드 실패: %w", err)
}
var cfg ServerConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("설정 파싱 실패: %w", err)
}
return &cfg, nil
}
// JSON 파일 저장 (들여쓰기 포함)
func saveConfig(path string, cfg *ServerConfig) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("설정 직렬화 실패: %w", err)
}
return writeFileAtomic(path, data)
}
Go 표준 라이브러리의 encoding/csv는 RFC 4180을 준수하며, 쉼표·탭·파이프 구분자를 모두 지원합니다.
type Order struct {
ID string
Customer string
Amount float64
}
func readOrders(path string) ([]Order, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("CSV 파일 열기 실패: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = 3 // 컬럼 수 고정
// 헤더 행 건너뛰기
if _, err := reader.Read(); err != nil {
return nil, fmt.Errorf("헤더 읽기 실패: %w", err)
}
var orders []Order
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("CSV 읽기 오류: %w", err)
}
amount, _ := strconv.ParseFloat(record[2], 64)
orders = append(orders, Order{
ID: record[0],
Customer: record[1],
Amount: amount,
})
}
return orders, nil
}
func writeOrders(path string, orders []Order) error {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("CSV 파일 생성 실패: %w", err)
}
defer file.Close()
// BOM 추가 (Excel 한글 호환)
file.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(file)
defer writer.Flush()
// 헤더 쓰기
writer.Write([]string{"주문번호", "고객명", "금액"})
for _, o := range orders {
writer.Write([]string{
o.ID,
o.Customer,
strconv.FormatFloat(o.Amount, 'f', 2, 64),
})
}
return writer.Error()
}
프로덕션 서버에서 로그 파일이 무한정 커지는 것을 방지하기 위한 간단한 로테이션 패턴입니다.
type RotatingLogger struct {
mu sync.Mutex
path string
maxBytes int64
file *os.File
writer *bufio.Writer
}
func NewRotatingLogger(path string, maxMB int) (*RotatingLogger, error) {
l := &RotatingLogger{
path: path,
maxBytes: int64(maxMB) * 1024 * 1024,
}
return l, l.openFile()
}
func (l *RotatingLogger) openFile() error {
f, err := os.OpenFile(l.path,
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
l.file = f
l.writer = bufio.NewWriterSize(f, 64*1024)
return nil
}
func (l *RotatingLogger) Write(msg string) error {
l.mu.Lock()
defer l.mu.Unlock()
// 파일 크기 확인
info, err := l.file.Stat()
if err != nil {
return err
}
// 최대 크기 초과 시 로테이션
if info.Size() >= l.maxBytes {
l.writer.Flush()
l.file.Close()
// 기존 파일 백업
backupPath := l.path + "." + time.Now().Format("20060102150405")
os.Rename(l.path, backupPath)
if err := l.openFile(); err != nil {
return err
}
}
_, err = fmt.Fprintf(l.writer, "%s %s\n",
time.Now().Format(time.RFC3339), msg)
return err
}
func (l *RotatingLogger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
l.writer.Flush()
return l.file.Close()
}
파일 경로는 OS마다 구분자(/ vs \)가 다르므로 path/filepath 패키지를 사용해야 합니다.
import "path/filepath"
// 경로 조합 (OS에 맞는 구분자 자동 사용)
logPath := filepath.Join("logs", "2025", "app.log")
// 디렉터리와 파일명 분리
dir := filepath.Dir(logPath) // "logs/2025"
base := filepath.Base(logPath) // "app.log"
// 확장자 추출 및 변경
ext := filepath.Ext(base) // ".log"
stem := base[:len(base)-len(ext)] // "app"
newName := stem + ".gz" // "app.gz"
// 절대 경로 변환
abs, err := filepath.Abs("./config.json") // 현재 디렉터리 기준 절대 경로
// 경로 클린업 (.. 등 제거)
clean := filepath.Clean("logs/../logs/./app.log") // "logs/app.log"
🛠️ 경로 처리 시 주의사항
- 하드코딩된 경로 구분자(
/또는\) 대신 항상filepath.Join()을 사용하세요.- 사용자 입력 경로는
filepath.Clean()으로 정규화 후strings.HasPrefix()로 허용 범위를 검증하세요. 경로 순회 공격(Path Traversal)을 방지할 수 있습니다.
Go 언어의 파일 입출력은 io.Reader/io.Writer 인터페이스를 기반으로 설계되어 있어, 한번 익혀두면 네트워크·HTTP·메모리 버퍼 등 모든 I/O에 동일한 패턴을 적용할 수 있습니다.
오늘 배운 핵심을 정리하면:
os.ReadFile / os.WriteFile로 간단하게 처리한다.bufio.Scanner 또는 bufio.NewReader를 사용한다.bufio.Writer로 버퍼링하고, 반드시 Flush()를 호출한다.os.Rename() 패턴으로 구현한다.filepath.Join()으로 조합한다.다음 심화편 글에서는 Go 언어로 HTTP 서버를 구축하는 방법을 다룹니다. net/http 패키지의 구조, 라우팅, 미들웨어 패턴, 그리고 이번에 배운 파일 I/O를 서버와 연동하는 실전 예제까지 살펴보겠습니다.