쿼카러버의 기술 블로그

[Golang] go가 byte stream을 다루는 방법에 대하여 (io.Reader, io.Writer, Copy, Close, Seeker, Pipe, Buffer 등 개념 뿌시기) 본문

[Golang]

[Golang] go가 byte stream을 다루는 방법에 대하여 (io.Reader, io.Writer, Copy, Close, Seeker, Pipe, Buffer 등 개념 뿌시기)

quokkalover 2022. 2. 13. 00:55

Go언어는 byte stream 을 다루는 프로그래밍 언어다. 다시 말해서 바이너리 데이터를 읽거나 쓸 때 byte형식을 주로 사용한다. network connection으로 부터 넘어오는 데이터를 파일로 저장하거나 처리해야 하는 경우 등의 경우에 모두 byte stream을 다룰 수 있어야 한다.

많은 라이브러리에서 io package를 활용하기 때문에 본 글에서는 golang에서 I/O를 처리할 때 byte stream을 다루는 기본적인 방법에 대해 알아보는 시간을 갖도록 해보자. 매우 간단한 기능이지만 정말 많은데 활용될 수 있다는 점에서 매우 흥미롭고 멋있다. 본 글의 기본적인 골자는 Go의 Walkthrough시리즈를 읽으면서 정리하는 거지만, 그 외에도 내가 몰랐었던 디테일들을 채워 나중에 참고하기 위해 작성한다.

여기서 byte란 2의 8제곱 즉 2^8 으로 256종류의 정보를 나타낼 수 있는 정보의 양으로 정보표현의 기본단위를 의미한다.

 

Go는 byte를 다루기 위해 io 라는 표준 라이브러리를 사용한다. io 패키지는 stream of byte를 다루기 위해 필요한 인터페이스와 helper 함수들을 제공한다. 대표적인게 io.Readerio.Writer로, 아래 사진에서 보여지는 것처럼 데이터의 input과 output을 다룬다.

 

Go에서는 io패키지의 io.Reader, io.Wrtier 인터페이스를 활용하여 I/O stream을 다루는 다양한 방법들을 제공한다. (예: in-memory나 파일에 적재, network connection를 통한 전송)

 

 

자 이제 이들이 무엇인지에 대해 알아보고, 이를 활용하여 구현된 구현체들도 살펴보자

 

 

 

Reader interface : Read Bytes

stream으로부터 byte를 읽기 위해 사용하는 인터페이스가 바로 Reader 인터페이스다. 구현체는 아래와 같다.

type Reader interface {
        Read(p []byte) (n int, err error)
}

Go의 많은 표준 라이브러리에서 이 인터페이스를 사용하고 있다. 이들이 어떻게 구현돼있는지는 아래 링크를 참조해보자.

 

io.Reader의 기본적인 동작 방식은 아래 그림과 같다.

코드가 이해가 쉬운 분들은 아래 코드를 보면 이해할 것이고, 그래도 이해가 안된다면 아래 설명을 더 읽어보자.

 

package main

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

func main() {

    // create data source
    src := strings.NewReader("Hello Amazing World!")

    // create a packet
    p := make([]byte, 3) // slice of length `3`

    // read `src` until an error is returned
    for {

        // read `p` bytes from `src`
        n, err := src.Read(p);
        fmt.Printf( "%d bytes read, data: %s\n", n, p[:n] )

        // handle error
        if err == io.EOF {
            fmt.Println( "--end-of-file--" )
            break;
        } else if err != nil {
            fmt.Println( "Oops! Some error occured!", err )
            break;
        }
    }

}

위는 strings패키지에서 제공하는 Reader인터페이스의 구현체를 통해 stream of bytes of string을 읽는 예제다.

 

이를 조금 더 설명해보면

 

Read(p []byte)메소드에 통해 p를 넘기게 되면 len(p)만큼의 데이터 Data source로부터 읽어서 p에 적재 시킨다. 그러고, 다시 Read가 호출되면, 다음 len(p)만큼의 데이터가 Data source로부터 읽어지고 이 과정이 Data source의 모든 데이터를 읽어낼 때 까지 반복된다. (io.EOF(end of file)가 리턴될 때까지 반복한다)

Read메소드가 리턴하는 nerr를 리턴하는 이유는 무엇일까?

  • n은 Read메소드를 통해 읽어낸 byte의 수를 의미한다.
    • Read를 호출하다보면, Source Data가 len(p)보다 작거나, 데이터를 계속 읽다보니 남은 데이터가 len(p)보다 작은 경우가 발생하기 마련이다. 따라서, 이런 경우를 확인하기 위해 n 이 사용된다.
  • err 는 데이터를 다 읽고나면 io.EOF 에러가 리턴되는데, 이 외에도 읽으면서 발생하는 에러를 리턴하기 위해 사용된다.

사실 이렇게 구현된 것도 다 의도가 있는데, Reader가 byte를 받지 않고 리턴하는 방식으로 구현됐으면 매번 읽을 때마다 새로운 byte slice를 allocate해야 하고, 메모리관점에서 엄청 비효율적이다.(역시 golang의 built-in 패키지는 아름답다)

근데 앞에서 Read는 len(p)만큼의 데이터를 읽는다고 했는데, 이런 Partial Read를 핸들링하는건 매우 까다롭다. 이 외에도 다양한 까다로운 케이스들이 있는데 고맙게도 golang에는 이들을 해결해줄 다양한 helper function들이 있고, 그 중 ReadFullReadAll에 대해 알아보자.

 

ReadFull

예를 들어 8byte uint64 value를 읽어야 한다고 해보자. 이럴 때는 fixed size만큼을 꼭 읽어내야 하기 때문에 ReadFull를 사용할 수 있다.

func ReadFull(r Reader, buf []byte) (n int, err error)

이를 활용하면 data가 리턴되기 전에, buffer가 8byte만큼 채워줬는지를 보장하고, 만약 partial read가 발생하면 io.ErrUnexpectedEOF 에러를 리턴한다. 또, 아무런 데이터를 읽지 못했으면 이것도 에러기 때문에 io.EOF를 리턴한다. 따라서 ReadFull를 활용해서 아래와 같이 데이터를 읽을 수 있다.

buf := make([]byte, 8)
if _, err := io.ReadFull(r, buf); err == io.EOF {
        return io.ErrUnexpectedEOF
} else if err != nil {
        return err
}

 

ioutil.ReadAll

Data Source의 모든 byte를 다 읽어내고 싶다면 ioutil.ReadAll를 사용할 수 있다.

func ReadAll(src io.Reader) ([]byte, error)

src의 모든 byte를 담은 []byte를 리턴하고, 만약 그 과정에서 error가 발생하면 error를 리턴한다.

아래는 ioutil.ReadAll를 사용하는 간단한 프로그램이다.

package main

import (
    "fmt"
    "strings"
    "io/ioutil"
)

func main() {

    // create data source
    src := strings.NewReader("Hello Amazing World!")

    // read all data from `src`
    data, _ := ioutil.ReadAll(src)

    // print `data`
    fmt.Printf( "Read data of length %d : %s\n", len(data), data )

}

이 외에도 LimitRead, ReadAtLeast, binary.Read등 다양한 helper함수들이 있는데, 궁금하면 더 알아보는 것을 추천하지만 본 글에서는 다루지 않겠다.

Writer Interface

wrtier 인터페이스는 위에서 소개한 Reader 인터페이스 동작 방식의 반대다. 버퍼에 담긴 byte를 stream형태로 내보낸다. 이를 그림으로 나타내면 아래와 같다.

 

Writer 인터페이스는 아래와 같다.

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

Writer인터페이스는 고맙게도 Reader와 같이 partial read나 복잡한 데이터 핸들링 등과 같은 이슈를 고민할 필요가 없다. 그나마 걱정해야 하는건 partial write인데, 이 땐 에러를 리턴해 확인할 수 있다.

 

Duplicate Writes

특정 데이터를 stderr와 logfile 두 개의 byte stream으로 내보내고 싶은 경우에는 어떻게 해야 할까? 그땐 MultiWriter를 사용할 수 있다.

func MultiWriter(writers ...Writer) Writer

참고로 Reader인터페이스를 사용하는 MultiReader와는 다른 동작을 하기 때문에 헷갈리지말자. MultiReader는 여러개의 Reader를 하나의 Reader로 concatenate하지만, MultiWriter는 매개변수로 넘어오는 각각의 writer들에게 write를 복제해주는 “하나의” writer를 리턴한다.

아래의 예를 보자

type MyService struct {
        LogOutput io.Writer
}
...
var buf bytes.Buffer
var s MyService
s.LogOutput = io.MultiWriter(&buf, os.Stderr)

위는 buf의 컨텐츠를 os.Stderr를 통해 내보내고(실행하면서 확인하기 위해), 또 buf에도 date를 write해주는 MultiWriter의 예시다.

 

Standard I/O stream

os.Stdin, os.Stdout, os.Stderr와 같은 표준 I/O stream도 io.Writerio.StringWriter 인터페이스를 구현했다. 그렇기 때문에 우리가 이 메소드들을 통해 WriteString메소드를 사용할 수 있다. 대표적으로 입출력을 담당하는 패키지인 fmt 패키지에서 다음의 메소드들이 io.Writer객체를 활용해 데이터를 write하고 있다.

func Fprint(w io.Writer, a ...interface{}) (n int, err error)
func Fprintln(w, a) (n int, err error)
func Fprintf(w, format string, a) (n int, err error)

 

string write 최적화

go에는 WriteString메소드를 제공하는 다양한 라이브러리가 있다. 대표적으로 string을 byte slice로 변환할 때 memory를 allocate하지 않아 메모리를 최적화해주는 WriteString을 구현한 라이브러리가 있다면 io.WriteString() 와 함께 사용할 수 있다.

io.WriteString은 writer가 WriteString을 구현했다면 그 구현체를 사용하고 없다면 string을 byte slice로 복제하여 Write()메소드를 통해 write한다.

 

Copying bytes : read and write bytes together

프로그래밍을 하다보면, 입력 출력 뿐 아니라, 런타임중 데이터를 복사해야 하는 경우도 있다. 이를 위해서는 당연히 Reader와 Writer를 연결해주어야 할텐데, 그 방법에 대해 알아보자.

Reader와 Writer를 연결해주는 가장 기본적인 방법은 Copy()함수다.

// Copy uses a temporary buffer to stage the data read from the src. 
// If you want a custom buffer size, use io.CopyBuffer function.
// If src implements the io.WriterTo interface, then Copy function calls src.WriteTo(dst) internally. 
// Otherwise, if dst implements the ReaderFrom interface, the Copy calls dst.ReadFrom(src).
func Copy(dst Writer, src Reader) (written int64, err error)
  • Copy 함수는 32KB Buffer를 사용하여 src로부터 데이터를 읽고 dst로 데이터를 write한다. 중간에 io.EOF말고 다른 에러가 발생하면 작업은 중단되고 에러가 리턴된다.

만약 특정 사이즈만큼만 Copy하고 싶을 때는 CopyN()함수를 사용할 수 있다. 읽고자 하는 byte의 양을 특정할 수 있는 것이다.

func CopyN(dst Writer, src Reader, n int64) (written int64, err error)

그 외에도 Buffer를 재활용하는 CopyBuffer와 같은 메소드도 있다.

 

io.Pipe

io.Pipe()를 활용해서 PipeWriter와 PipeReader를 새로 만들고, 연동되게할 수도 있다. 자세한건 직접 찾아보도록 하자.

func Pipe() (*PipeReader, *PipeWriter)

src, dst := io.Pipe()

 

Close I/O operation

우리가 io.Readerio.Writer를 구현한 객체를 사용해 read든 write이든 어떤 I/O operation을 수행하고 나면 더이상 I/O operation을 위해 필요하지 않게 된다. 그렇기 때문에 performance와 reliability를 위해 해당 객체에게 할당된 system resource를 release할 필요가 있다. 이 때 바로 필요한 메소드가 바로 Close다.

byte stream을 close한다는 것은 매우 심플하다. 첫 번째 호출했을 때 바로 cleanup operation을 수행하면 된다. 물론 다시 한번 더 호출됐을 때는 에러가 리턴된다. 또한 close가 한번 호출됐는데 그 이후에 해당 객체를 통해 read나 write가 수행되면 당연히 에러가 리턴된다.

Close는 파일을 읽고 메모리에서 해제하는 등의 경우 사용된다. 주로 defer함수에 쓰이면서, 이로 인해 발생하는 에러를 잘 신경쓰지 않는 경우가 많지만 ReadCloser, WriteCloser와 같은 인터페이스에 함께 쓰이는 개념이기 때문에 알아둘 필요는 있다.

type Closer interface {
        Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

type WriteCloser interface {
    Writer
    Closer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

 

Seeker interface

파일의 처음 부터 끝까지 읽는 경우도 많지만, 특정 위치부터 읽고 싶을 때가 있는데, 이 때 사용되는 인터페이스가 바로 Seeker인터페이스다.

type Seeker interface {
        Seek(offset int64, whence int) (int64, error)
}

stream안에서 jump를 하는데 주로 사용되는데, 다음 3가지 방식으로 jump 할 수 있고 이는 whence라는 매개변수를 통해 정할 수 있다.

  • beginning부터 jump
  • 지금 위치로부터 jump
  • end로부터 jump

Buffered Stream

Buffer란 temporary region of volatile memory (RAM)을 의미한다. 즉, 메모리에 특정 공간을 차지한다.

built-in 패키지인 bytes[Buffer](https://golang.org/pkg/bytes/#Buffer) 라는 자료구조를 통해 variable size buffer를 제공한다. 대표적으로 많이 활용되는 메소드를 소개하자면 아래와 같다. I/O 작업에 많이 활용되는 타입이기 때문에 본 글에서 추가로 소개한다.

func NewBuffer(buf []byte) *bytes.Buffer
func NewBufferString(s string) *bytes.Buffer
  • NewBuffer는 매개변수로 입력된 byte slice를 initial value로 초기화한 Buffer를 리턴한다.
  • NewBufferString은 비슷하게 bytes of string을 intial value로 초기화한 Buffer를 리턴한다.

Buffer는 Read,Write, ReadFromand WriteTo 메소드 외에도 WriteString와 같이 string을 write하기 위한 메소드도 제공한다. 그 외에도 Reset(Buffer 비우기), Grow(Buffer size키우기) 와 같은 기능들도 있으니 한번 찾아보길 추천한다.

 

주의할 것은 Buffer는 어디 까지 읽었는지에 대한 정보를 기록하고 있기 때문에, Read를 호출하게되면, 마지막에 읽었던 시점 이후부터 데이터를 다시 읽는다.

 

이 외에도 bufio가 제공하는 Writer타입도 매우 흥미로우니 한번 찾아보자~

 

 

 

 

참고자료

https://medium.com/go-walkthrough/go-walkthrough-io-package-8ac5e95a9fbd#.41qxgc1zr

https://medium.com/rungo/introduction-to-streams-and-buffers-d148c0cda0ad

https://medium.com/learning-the-go-programming-language/streaming-io-in-go-d93507931185

Comments