io 인터페이스
이 챕터에서 다루는 것
io.Reader와 io.Writer는 각각 메서드가 하나뿐인 인터페이스다. 그런데 표준
라이브러리의 파일·네트워크·압축·암호화·HTTP 본문이 전부 이 둘로 연결된다.
이 챕터는 그 두 인터페이스가 왜 그렇게 작은지, 작은 것들을 어떻게 붙여 쓰는지,
그리고 io.ReadAll을 습관적으로 부르는 것이 왜 위험한지를 다룬다.
문제 — 전체를 메모리에 올리는 습관
다른 언어에서 파일을 다룰 때 흔히 이렇게 쓴다.
data, err := os.ReadFile("upload.bin")
if err != nil {
return err
}
if err := os.WriteFile("copy.bin", data, 0o644); err != nil {
return err
}
파일이 3GB면 3GB를 메모리에 올린다. 동시에 열 명이 업로드하면 30GB다. 사용자가 크기를 정할 수 있는 입력에 이 코드를 쓰면 그것 자체가 서비스 거부 취약점이다.
Go의 답은 값을 옮기는 대신 통로를 잇는 것이다. 통로의 규격이 다음 둘이다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Java의 InputStream/OutputStream이나 Python의 파일 객체와 목적은 같지만,
크기가 다르다. 메서드가 하나뿐이라 아무 타입이나 몇 줄로 이 규격을 만족시킬 수
있고, 그래서 조합이 실제로 일어난다.
io.Copy — 통로를 잇는 기본 도구
func Copy(dst Writer, src Reader) (written int64, err error)
이 함수 하나가 파일→파일, 네트워크→파일, 압축기→HTTP 응답을 전부 처리한다. 내부적으로 32KiB짜리 버퍼 하나를 돌려 쓰므로, 원본이 몇 기가바이트든 메모리 사용량은 일정하다.
// demoCopy는 io.Copy가 Reader와 Writer만 알면 된다는 것을 보여 준다.
func demoCopy() {
src := strings.NewReader("Reader와 Writer만 있으면 된다\n")
n, err := io.Copy(os.Stdout, src)
if err != nil {
fmt.Fprintln(os.Stderr, "copy:", err)
return
}
fmt.Printf("io.Copy: %d바이트\n\n", n)
}
Reader와 Writer만 있으면 된다
io.Copy: 37바이트
strings.NewReader가 Reader이고 os.Stdout이 Writer다. io.Copy는 둘의
정체를 모른다.
:::tip Copy는 지름길을 안다
io.Copy는 dst가 ReaderFrom을, src가 WriterTo를 구현하는지 먼저 본다.
*os.File끼리 복사할 때는 커널의 copy_file_range/sendfile로 내려가
사용자 공간을 아예 거치지 않는다. 직접 버퍼를 돌리는 루프를 쓰면 이 최적화를
잃는다.
:::
조합 — TeeReader, MultiWriter, LimitReader
세 함수는 이름 그대로다.
io.TeeReader(r, w)—r에서 읽히는 바이트를w에도 흘려보낸다.io.MultiWriter(w1, w2, ...)— 한 번 쓰면 모두에게 쓴다.io.LimitReader(r, n)—n바이트까지만 읽히고 그다음은 EOF다.
MultiWriter부터 보자.
// demoMultiWriter는 한 번의 쓰기를 여러 Writer로 복제한다.
func demoMultiWriter() {
var log, backup bytes.Buffer
w := io.MultiWriter(&log, &backup)
if _, err := io.WriteString(w, "같은 바이트가 두 곳으로"); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
return
}
fmt.Printf("MultiWriter: log=%d바이트 backup=%d바이트 동일=%t\n\n",
log.Len(), backup.Len(), log.String() == backup.String())
}
MultiWriter: log=33바이트 backup=33바이트 동일=true
셋을 한꺼번에 쓰는 실제 예를 보자. 업로드를 저장하면서, 같은 통과에서 SHA-256을 계산하고, 크기 상한을 넘으면 거부하는 함수다.
// Save는 src를 dst로 옮기면서 SHA-256을 함께 계산하고,
// limit 바이트를 넘는 입력은 ErrTooLarge로 거부한다.
//
// src 전체를 메모리에 올리지 않는다. io.Copy가 쓰는 32KiB 버퍼 하나만 돈다.
func Save(dst io.Writer, src io.Reader, limit int64) (Result, error) {
if limit < 0 {
return Result{}, fmt.Errorf("streamcopy: limit=%d은 음수", limit)
}
sum := sha256.New()
// limit+1까지 읽어 본다. 딱 limit만 읽으면 "정확히 상한"과
// "상한을 넘음"을 구별할 수 없다.
bounded := io.LimitReader(src, limit+1)
// TeeReader는 읽히는 바이트를 그대로 sum에도 흘려보낸다.
// 해시를 위해 스트림을 두 번 읽을 필요가 없다.
tee := io.TeeReader(bounded, sum)
n, err := io.Copy(dst, tee)
if err != nil {
return Result{}, fmt.Errorf("streamcopy: 복사: %w", err)
}
if n > limit {
return Result{}, fmt.Errorf("%w: %d바이트 초과", ErrTooLarge, limit)
}
return Result{
Bytes: n,
SHA256: hex.EncodeToString(sum.Sum(nil)),
}, nil
}
읽어 둘 지점이 셋이다.
sha256.New()가io.Writer다. 해시 계산기가 Writer라는 발상 덕분에TeeReader에 그냥 꽂힌다.hash.Hash인터페이스가io.Writer를 임베딩한다.limit+1. 정확히limit만 읽으면 "딱 맞았다"와 "넘쳤다"가 똑같이 보인다. 한 바이트를 더 읽어 봐야 구별된다.n이int64다. 파일 크기는int로 세지 않는다.
// demoLimit은 신뢰할 수 없는 입력에 상한을 거는 방법이다.
func demoLimit() {
res, err := streamcopy.Save(io.Discard, strings.NewReader("작은 입력"), 1024)
if err != nil {
fmt.Fprintln(os.Stderr, "save:", err)
return
}
fmt.Printf("Save: %d바이트 sha256=%s...\n", res.Bytes, res.SHA256[:16])
_, err = streamcopy.Save(io.Discard, strings.NewReader(strings.Repeat("A", 2048)), 1024)
fmt.Printf("상한 초과: %v\n", err)
fmt.Printf("errors.Is(err, ErrTooLarge) = %t\n\n", errors.Is(err, streamcopy.ErrTooLarge))
}
Save: 13바이트 sha256=edcb3dfce097cca9...
상한 초과: streamcopy: 입력이 상한을 넘음: 1024바이트 초과
errors.Is(err, ErrTooLarge) = true
io.Discard는 무엇을 써도 버리는 Writer다. 벤치마크나 "읽기만 하고 버릴 때"에
쓴다.
EOF — 에러가 아닌 에러
Read는 스트림이 끝나면 io.EOF를 반환한다. 이것은 정상 종료 신호이지 실패가
아니다. 다른 언어에서 readLine()이 null을 돌려주거나 StopIteration을 던지는
자리다.
문제는 규약의 한 조항이다.
Read는n > 0과err != nil을 동시에 반환할 수 있다.
즉 마지막 데이터와 EOF가 같이 올 수 있다. 그래서 에러를 보기 전에 읽은 바이트를 먼저 처리해야 한다.
// eagerEOF는 마지막 데이터와 io.EOF를 한 번에 반환하는 Reader다.
// 허용된 동작이고, 실제로 이렇게 구현된 Reader가 표준 라이브러리에도 있다.
type eagerEOF struct {
data []byte
}
func (r *eagerEOF) Read(p []byte) (int, error) {
n := copy(p, r.data)
r.data = r.data[n:]
if len(r.data) == 0 {
return n, io.EOF // 데이터와 EOF를 같이 준다
}
return n, nil
}
// demoEOF는 Read가 n>0과 io.EOF를 동시에 반환할 수 있다는 것을 보여 준다.
func demoEOF() {
buf := make([]byte, 8)
// strings.Reader는 EOF를 다음 호출로 미룬다.
sr := strings.NewReader("abc")
for i := 1; ; i++ {
n, err := sr.Read(buf)
fmt.Printf("strings.Reader #%d: n=%d err=%v data=%q\n", i, n, err, buf[:n])
if err != nil {
break
}
}
// 이쪽은 한 번에 준다. err만 보고 break하면 "abc"를 잃는다.
er := &eagerEOF{data: []byte("abc")}
n, err := er.Read(buf)
fmt.Printf("eagerEOF #1: n=%d err=%v data=%q\n\n", n, err, buf[:n])
}
strings.Reader #1: n=3 err=<nil> data="abc"
strings.Reader #2: n=0 err=EOF data=""
eagerEOF #1: n=3 err=EOF data="abc"
strings.Reader는 친절하게 EOF를 다음 호출로 미루지만, 그렇게 하리라고 기대하면
안 된다. 두 번째 줄처럼 데이터와 EOF가 같이 오는 Reader에 대고
if err != nil { break }를 먼저 쓰면 마지막 3바이트가 사라진다.
실무에서 Read를 직접 부를 일은 드물다. io.Copy, io.ReadAll, bufio.Scanner가
이 규약을 대신 지켜 준다. 하지만 직접 Reader를 구현할 때는 이 규약을 알아야 한다.
io.ReadFull — 정확히 n바이트
프로토콜 헤더처럼 길이가 정해진 것을 읽을 때 쓴다. 모자라면 에러다.
// demoReadFull은 "정확히 n바이트"가 필요할 때 쓰는 함수다.
func demoReadFull() {
buf := make([]byte, 8)
n, err := io.ReadFull(strings.NewReader("abc"), buf)
fmt.Printf("ReadFull(3바이트 소스): n=%d err=%v\n", n, err)
fmt.Printf("ErrUnexpectedEOF인가: %t\n", errors.Is(err, io.ErrUnexpectedEOF))
n, err = io.ReadFull(strings.NewReader(""), buf)
fmt.Printf("ReadFull(빈 소스): n=%d err=%v\n\n", n, err)
}
ReadFull(3바이트 소스): n=3 err=unexpected EOF
ErrUnexpectedEOF인가: true
ReadFull(빈 소스): n=0 err=EOF
한 바이트도 못 읽었으면 io.EOF, 읽다가 끊겼으면 io.ErrUnexpectedEOF.
이 구분이 "정상적으로 끝났다"와 "데이터가 잘렸다"를 갈라 준다.
bufio — 시스템 콜을 줄인다
Read를 1바이트씩 부르면 시스템 콜이 1바이트마다 일어난다. bufio.Reader는
큰 덩어리로 읽어 두고 잘라 준다. bufio.Writer는 반대로 모아 뒀다가 한 번에 쓴다.
r := bufio.NewReader(f)
w := bufio.NewWriter(f)
defer w.Flush() // 이걸 빠뜨리면 버퍼에 남은 데이터가 사라진다
bufio.Scanner는 줄 단위 읽기의 표준 도구인데, 기본 토큰 상한이 64KiB다.
한 줄이 그보다 길면 조용히 멈추는 게 아니라 Err()에 에러가 남는다.
// demoScannerTooLong은 bufio.Scanner의 토큰 길이 상한을 실제로 넘겨 본다.
func demoScannerTooLong() {
long := strings.Repeat("x", 70*1024) + "\n"
sc := bufio.NewScanner(strings.NewReader(long))
for sc.Scan() {
fmt.Println("한 줄 읽음:", len(sc.Bytes()))
}
fmt.Printf("Scanner 기본 상한: err=%v\n", sc.Err())
// 상한을 올리면 같은 입력이 통과한다.
sc = bufio.NewScanner(strings.NewReader(long))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Printf("Buffer 확대 후: %d바이트 한 줄\n", len(sc.Bytes()))
}
fmt.Printf("err=%v\n", sc.Err())
}
Scanner 기본 상한: err=bufio.Scanner: token too long
Buffer 확대 후: 71680바이트 한 줄
err=<nil>
sc.Err()를 확인하지 않으면 이 실패가 "파일이 끝났다"와 구별되지 않는다.
for sc.Scan() 뒤에는 반드시 if err := sc.Err(); err != nil이 와야 한다.
자주 쓰는 어댑터 정리
| 필요 | 도구 |
|---|---|
| 문자열/바이트를 Reader로 | strings.NewReader, bytes.NewReader |
| 버퍼에 쓰고 읽기 | bytes.Buffer (Reader이자 Writer) |
| 읽은 것을 버리기 | io.Discard |
| 여러 Reader를 이어 붙이기 | io.MultiReader |
| 문자열을 Writer에 쓰기 | io.WriteString (Writer가 StringWriter면 복사를 피한다) |
| 파일 일부만 읽기 | io.NewSectionReader |
| 고루틴 사이를 잇기 | io.Pipe |
흔한 실수
1. 사용자 입력에 io.ReadAll을 쓴다
body, err := io.ReadAll(r.Body) // 상한이 없다
HTTP 요청 본문에 이렇게 쓰면 클라이언트가 보내는 만큼 메모리를 먹는다.
http.MaxBytesReader(9-8에서 쓴다)나 io.LimitReader로 감싼다.
2. 쓰기 파일의 Close 에러를 버린다
defer f.Close() // 반환값을 무시한다
읽기 전용이면 대개 문제없지만, 쓰기 파일에서는 Close가 마지막 flush를
수행하므로 여기서 처음 보고되는 에러가 있다. 디스크가 가득 찼다는 사실을
Close에서만 알게 되는 경우가 실제로 있다. 9-2의 safewrite가 이것을 어떻게
다루는지 본다.
3. bufio.Writer의 Flush를 빠뜨린다
defer w.Flush()가 없으면 버퍼에 남은 마지막 조각이 사라진다. 게다가
Flush의 에러도 확인해야 한다 — defer로만 부르면 그 에러가 어디에도 안 남는다.
4. Read의 n을 무시하고 err부터 본다
위의 eagerEOF가 그 결과를 보여 준다. 항상 buf[:n]을 먼저 처리한다.
5. sc.Err()를 확인하지 않는다
bufio.Scanner의 루프가 끝난 이유는 두 가지다 — 다 읽었거나, 실패했거나.
확인하지 않으면 잘린 파일을 정상 처리로 착각한다.
6. 인터페이스 대신 구체 타입을 인자로 받는다
func Process(f *os.File) error // 테스트에 파일이 필요하다
func Process(r io.Reader) error // strings.NewReader로 테스트한다
io.Reader를 받으면 파일·네트워크·문자열·gzip 압축 해제기가 전부 들어온다.
8-3에서 본 "인터페이스는 작을수록 테스트가 쉬워진다"의 극단적인 예다.
정리
Reader와Writer는 메서드가 하나뿐이라서 조합이 실제로 일어난다. 파일·네트워크·해시·압축이 전부 같은 규격으로 연결된다.io.Copy가 기본 도구다. 메모리 사용량이 원본 크기와 무관하고,ReaderFrom/WriterTo지름길까지 알아서 쓴다.TeeReader는 한 번 읽으면서 두 가지 일을 하게 하고,LimitReader는 신뢰할 수 없는 입력에 상한을 건다. "상한 + 1"을 읽어야 초과를 감지한다.- EOF는 실패가 아니다.
Read는n > 0과io.EOF를 함께 반환할 수 있으므로 데이터를 먼저 처리하고 에러를 나중에 본다.io.ReadFull은 잘린 입력을io.ErrUnexpectedEOF로 구별해 준다. bufio.Scanner의 기본 토큰 상한은 64KiB이고, 넘으면sc.Err()에token too long이 남는다. 루프 뒤sc.Err()확인은 선택이 아니다.- 쓰기 쪽은
Flush와Close의 에러를 확인한다. 거기서만 보이는 실패가 있다.
연습문제
-
streamcopy.Save에서io.LimitReader(src, limit+1)을limit으로 바꾸고 테스트를 돌려 보자. 어떤 테스트가 깨지는가? 깨지지 않는 테스트도 있다면 그것은 왜 통과하는가? -
Save가 지금은dst에 상한 초과분까지 이미 써 버린 뒤에 에러를 낸다. 초과가 확인되기 전에는 아무것도dst에 남기지 않도록 고쳐 보자. (힌트: 임시 버퍼에 담는 방법과dst를 나중에 여는 방법 중 어느 쪽이 메모리 관점에서 나은가? 9-2의 원자적 쓰기와 같은 문제다.) -
io.Pipe로 "압축하면서 동시에 업로드하는" 통로를 만들어 보자.pw에gzip.Writer를 물리고, 다른 고루틴이pr을 읽어io.Copy로 목적지에 보낸다.pw와gzip.Writer를 각각 언제 닫아야 하는가? 순서를 틀리면 어떤 증상이 나오는가?