본문으로 건너뛰기

io 인터페이스

이 챕터에서 다루는 것

io.Readerio.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짜리 버퍼 하나를 돌려 쓰므로, 원본이 몇 기가바이트든 메모리 사용량은 일정하다.

examples/09-standard-library/01-io/main.go
// 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.NewReaderReader이고 os.StdoutWriter다. io.Copy는 둘의 정체를 모른다.

:::tip Copy는 지름길을 안다 io.CopydstReaderFrom을, srcWriterTo를 구현하는지 먼저 본다. *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부터 보자.

examples/09-standard-library/01-io/main.go
// 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을 계산하고, 크기 상한을 넘으면 거부하는 함수다.

examples/09-standard-library/01-io/streamcopy/streamcopy.go
// 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
}

읽어 둘 지점이 셋이다.

  1. sha256.New()io.Writer다. 해시 계산기가 Writer라는 발상 덕분에 TeeReader에 그냥 꽂힌다. hash.Hash 인터페이스가 io.Writer를 임베딩한다.
  2. limit+1. 정확히 limit만 읽으면 "딱 맞았다"와 "넘쳤다"가 똑같이 보인다. 한 바이트를 더 읽어 봐야 구별된다.
  3. nint64다. 파일 크기는 int로 세지 않는다.
examples/09-standard-library/01-io/main.go
// 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을 던지는 자리다.

문제는 규약의 한 조항이다.

Readn > 0err != nil동시에 반환할 수 있다.

즉 마지막 데이터와 EOF가 같이 올 수 있다. 그래서 에러를 보기 전에 읽은 바이트를 먼저 처리해야 한다.

examples/09-standard-library/01-io/main.go
// 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바이트

프로토콜 헤더처럼 길이가 정해진 것을 읽을 때 쓴다. 모자라면 에러다.

examples/09-standard-library/01-io/main.go
// 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()에 에러가 남는다.

examples/09-standard-library/01-io/main.go
// 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.WriterFlush를 빠뜨린다

defer w.Flush()가 없으면 버퍼에 남은 마지막 조각이 사라진다. 게다가 Flush의 에러도 확인해야 한다 — defer로만 부르면 그 에러가 어디에도 안 남는다.

4. Readn을 무시하고 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에서 본 "인터페이스는 작을수록 테스트가 쉬워진다"의 극단적인 예다.

정리

  • ReaderWriter는 메서드가 하나뿐이라서 조합이 실제로 일어난다. 파일·네트워크·해시·압축이 전부 같은 규격으로 연결된다.
  • io.Copy가 기본 도구다. 메모리 사용량이 원본 크기와 무관하고, ReaderFrom/WriterTo 지름길까지 알아서 쓴다.
  • TeeReader는 한 번 읽으면서 두 가지 일을 하게 하고, LimitReader는 신뢰할 수 없는 입력에 상한을 건다. "상한 + 1"을 읽어야 초과를 감지한다.
  • EOF는 실패가 아니다. Readn > 0io.EOF를 함께 반환할 수 있으므로 데이터를 먼저 처리하고 에러를 나중에 본다. io.ReadFull은 잘린 입력을 io.ErrUnexpectedEOF로 구별해 준다.
  • bufio.Scanner의 기본 토큰 상한은 64KiB이고, 넘으면 sc.Err()token too long이 남는다. 루프 뒤 sc.Err() 확인은 선택이 아니다.
  • 쓰기 쪽은 FlushClose의 에러를 확인한다. 거기서만 보이는 실패가 있다.

연습문제

  1. streamcopy.Save에서 io.LimitReader(src, limit+1)limit으로 바꾸고 테스트를 돌려 보자. 어떤 테스트가 깨지는가? 깨지지 않는 테스트도 있다면 그것은 왜 통과하는가?

  2. Save가 지금은 dst에 상한 초과분까지 이미 써 버린 뒤에 에러를 낸다. 초과가 확인되기 전에는 아무것도 dst에 남기지 않도록 고쳐 보자. (힌트: 임시 버퍼에 담는 방법과 dst를 나중에 여는 방법 중 어느 쪽이 메모리 관점에서 나은가? 9-2의 원자적 쓰기와 같은 문제다.)

  3. io.Pipe로 "압축하면서 동시에 업로드하는" 통로를 만들어 보자. pwgzip.Writer를 물리고, 다른 고루틴이 pr을 읽어 io.Copy로 목적지에 보낸다. pwgzip.Writer를 각각 언제 닫아야 하는가? 순서를 틀리면 어떤 증상이 나오는가?