파일 I/O & 네트워킹 고수 팁
io.Reader 파이프라인 조합 패턴
여러 io.Reader를 조합하면 압축·암호화·해시 계산을 동시에 처리할 수 있습니다.
package main
import (
"compress/gzip"
"crypto/sha256"
"fmt"
"io"
"os"
"strings"
)
// 파일을 읽으면서 동시에 SHA-256 해시 계산 + gzip 압축
func compressWithHash(src io.Reader, dst io.Writer) (hashHex string, bytesRead int64, err error) {
hasher := sha256.New()
// TeeReader: src를 읽으면서 hasher에도 동일 데이터 전달
tee := io.TeeReader(src, hasher)
gz := gzip.NewWriter(dst)
defer gz.Close()
bytesRead, err = io.Copy(gz, tee)
if err != nil {
return "", 0, err
}
return fmt.Sprintf("%x", hasher.Sum(nil)), bytesRead, nil
}
func main() {
src := strings.NewReader("압축하고 해시도 계산할 긴 텍스트 데이터...")
var compressed strings.Builder
hash, n, err := compressWithHash(src, &compressed)
if err != nil {
fmt.Println("실패:", err)
return
}
fmt.Printf("원본 크기: %d 바이트\n", n)
fmt.Printf("압축 크기: %d 바이트\n", compressed.Len())
fmt.Printf("SHA-256: %s\n", hash)
_ = os.Stderr // 컴파일러 경고 방지
}
대용량 파일 처리 — 청크 단위 읽기
package main
import (
"fmt"
"io"
"os"
)
// processChunks: 대용량 파일을 고정 크기 청크로 처리
func processChunks(filename string, chunkSize int, process func(chunk []byte, chunkNum int) error) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
buf := make([]byte, chunkSize) // 버퍼 재사용 — 한 번만 할당
chunkNum := 0
for {
n, err := io.ReadFull(f, buf)
if n > 0 {
chunkNum++
if procErr := process(buf[:n], chunkNum); procErr != nil {
return procErr
}
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
break // 파일 끝
}
if err != nil {
return err
}
}
fmt.Printf("총 %d 청크 처리 완료\n", chunkNum)
return nil
}
func main() {
// 테스트 파일 생성 (1MB)
data := make([]byte, 1024*1024)
for i := range data {
data[i] = byte(i % 256)
}
os.WriteFile("large.bin", data, 0644)
defer os.Remove("large.bin")
// 64KB 청크 단위로 처리
err := processChunks("large.bin", 64*1024, func(chunk []byte, n int) error {
fmt.Printf("청크 %d: %d 바이트\n", n, len(chunk))
return nil
})
if err != nil {
fmt.Println("처리 실패:", err)
}
}
bufio.Scanner 토큰 크기 제한과 해결책
기본 bufio.Scanner 버퍼는 64KB입니다. 한 줄이 64KB를 초과하면 bufio.Scanner: token too long 오류가 발생합니다.
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
// 매우 긴 줄 생성 (128KB)
longLine := strings.Repeat("A", 128*1024)
text := "짧은 줄\n" + longLine + "\n또 다른 줄\n"
// 기본 Scanner — 오류 발생
scanner1 := bufio.NewScanner(strings.NewReader(text))
for scanner1.Scan() {
// 긴 줄에서 중단됨
}
if err := scanner1.Err(); err != nil {
fmt.Println("기본 Scanner 오류:", err) // "token too long"
}
// 해결책 1: Scanner.Buffer로 버퍼 크기 확장
scanner2 := bufio.NewScanner(strings.NewReader(text))
scanner2.Buffer(make([]byte, 256*1024), 256*1024) // 256KB 버퍼
for scanner2.Scan() {
fmt.Printf("줄 길이: %d\n", len(scanner2.Text()))
}
fmt.Println("Scanner2 오류:", scanner2.Err()) // nil
// 해결책 2: bufio.NewReader.ReadString 사용 (크기 제한 없음)
reader := bufio.NewReaderSize(strings.NewReader(text), 64*1024)
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
fmt.Printf("ReadString 줄 길이: %d\n", len(line))
}
if err != nil {
break
}
}
}
네트워크 타임아웃 — connect·read·write 분리
package main
import (
"context"
"fmt"
"net"
"net/http"
"time"
)
func main() {
// 세 단계 타임아웃을 분리 설정:
// 1. TCP 연결 타임아웃 (DialContext)
// 2. TLS 핸드셰이크 타임아웃 (TLSHandshakeTimeout)
// 3. 응답 헤더 타임아웃 (ResponseHeaderTimeout)
// 4. 전체 요청 타임아웃 (http.Client.Timeout)
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second, // TCP 연결만의 타임아웃
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second, // TLS 협상 타임아웃
ResponseHeaderTimeout: 10 * time.Second, // 헤더 수신 타임아웃
}
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second, // 전체 타임아웃 (최종 안전망)
}
// 컨텍스트로 취소 가능한 요청
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil)
resp, err := client.Do(req)
if err != nil {
fmt.Println("요청 실패:", err)
return
}
defer resp.Body.Close()
fmt.Println("상태:", resp.StatusCode)
}
HTTP 클라이언트 커넥션 풀 재사용
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
func main() {
// 잘못된 예: http.Client를 매번 생성 — 커넥션 풀 효과 없음
// for i := 0; i < 100; i++ {
// client := &http.Client{} // 매번 새 인스턴스
// resp, _ := client.Get("https://example.com")
// resp.Body.Close()
// }
// 올바른 예: http.Client 하나를 공유 (고루틴 안전)
client := &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: 20, // 호스트당 유휴 커넥션 20개 유지
},
Timeout: 10 * time.Second,
}
var wg sync.WaitGroup
start := time.Now()
// 10개 동시 요청 — 커넥션 풀 재사용으로 빠름
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
resp, err := client.Get("https://httpbin.org/get")
if err != nil {
fmt.Printf("요청 %d 실패: %v\n", n, err)
return
}
defer resp.Body.Close()
// Body를 반드시 읽어야 커넥션이 재사용됨!
io.Copy(io.Discard, resp.Body)
fmt.Printf("요청 %d 완료: %d\n", n, resp.StatusCode)
}(i)
}
wg.Wait()
fmt.Printf("총 소요 시간: %v\n", time.Since(start))
}
핵심: resp.Body를 읽지 않고 닫으면 해당 TCP 연결은 커넥션 풀로 반환되지 않습니다. io.Copy(io.Discard, resp.Body) 후 resp.Body.Close()를 호출해야 커넥션이 재사용됩니다.
파일 서술자 누수 방지 패턴
package main
import (
"fmt"
"os"
)
// 잘못된 예: 오류 분기에서 fd 누수 가능
func badPattern(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
// 만약 중간 오류 발생 시 f.Close() 누락 위험
data := make([]byte, 100)
n, err := f.Read(data)
if err != nil {
return err // f 닫히지 않음!
}
fmt.Printf("읽은 바이트: %d\n", n)
f.Close()
return nil
}
// 올바른 예: defer로 항상 닫힘 보장
func goodPattern(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // 모든 return 경로에서 자동 실행
data := make([]byte, 100)
n, err := f.Read(data)
if err != nil {
return err // defer에 의해 f.Close()가 실행됨
}
fmt.Printf("읽은 바이트: %d\n", n)
return nil
}
// 여러 파일을 다룰 때 defer 주의 — 루프 안에서는 즉시 닫기
func processFiles(paths []string) error {
for _, path := range paths {
// 잘못된 예: 모든 파일이 함수 종료 시까지 열려 있음
// f, _ := os.Open(path)
// defer f.Close()
// 올바른 예: 클로저로 즉시 닫기
err := func(p string) error {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // 이 클로저가 끝날 때 닫힘
fmt.Println("처리 중:", p)
return nil
}(path)
if err != nil {
return err
}
}
return nil
}
func main() {
// 테스트용 파일 생성
os.WriteFile("test.txt", []byte("테스트"), 0644)
defer os.Remove("test.txt")
goodPattern("test.txt")
processFiles([]string{"test.txt"})
}
임시 파일 안전하게 쓰기 — Atomic Write 패턴
package main
import (
"fmt"
"os"
"path/filepath"
)
// atomicWriteFile: 임시 파일에 쓴 후 원자적으로 교체 (중간 실패 시 원본 보존)
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
// 동일 디렉터리에 임시 파일 생성 (다른 파티션이면 Rename 실패)
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("임시 파일 생성 실패: %w", err)
}
tmpPath := tmp.Name()
// 실패 시 임시 파일 정리
success := false
defer func() {
if !success {
os.Remove(tmpPath)
}
}()
// 임시 파일에 쓰기
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("임시 파일 쓰기 실패: %w", err)
}
// 디스크에 플러시 (데이터 안전성)
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("Sync 실패: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("임시 파일 닫기 실패: %w", err)
}
// 권한 설정
if err := os.Chmod(tmpPath, perm); err != nil {
return fmt.Errorf("권한 설정 실패: %w", err)
}
// 원자적 교체: 이 지점 이후 실패해도 원본은 안전
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("파일 교체 실패: %w", err)
}
success = true
return nil
}
func main() {
content := []byte("중요한 설정 데이터\n버전: 2.0\n")
if err := atomicWriteFile("config.json", content, 0644); err != nil {
fmt.Println("atomic write 실패:", err)
return
}
fmt.Println("설정 파일 안전하게 저장 완료")
// 확인
data, _ := os.ReadFile("config.json")
fmt.Printf("저장된 내용:\n%s", data)
os.Remove("config.json")
}
Atomic Write 패턴이 필요한 이유:
- 직접 파일에 쓰다가 프로세스가 죽으면 파일이 반쪽만 쓰인 상태로 남습니다
- 임시 파일에 완전히 쓴 후
os.Rename으로 교체하면 원자적 연산이 보장됩니다 (같은 파티션 내에서) - 설정 파일, 데이터베이스 파일, 캐시 파일 등 중요 파일 쓰기에 항상 적용하세요