Learning Go 정리 9일차 - 표준 라이브러리

0

Learning Go

목록 보기
10/12

표준 라이브러리

go의 장점 중 하나는 표준 라이브러리를 제공한다는 점이다. 해당 챕터에서는 가장 많이 사용되는 패키지와 디자인와 사용성이 관용적인 go의 원칙을 잘따르는 패키지들에 대해서 알아볼 것이다.

입출력 관련 기능

데이터를 읽어 쓸 일이있는데, go의 입력/출력의 철학 중심은 io패키지에서 찾아볼 수 있따. 특히, 해당 패키지에 정의된 io.Readerio.Writer는 go에서 정말 자주 사용되는 인터페이스이다.

io.Readerio.Writer는 단일 메서드로 정의되어 있다.

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

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

io.Writer 인터페이스의 Write 메서드는 인터페이스 구현에 쓰여지는 바이트 슬라이스를 인자로 받는다. 만약 쓰여진 바이트 수와 잘못된 경우에 오류를 반환한다.

io.ReaderRead메서드는 더 재밌는데, 반환 파라미터를 통해 데이터를 반환 받는 것보다 슬라이스 입력 파라미터를 구현으로 전달하고 이 슬라이스에 데이터를 쓴다. len(p)바이트만큼 슬라이스에 쓰여 질 것이다.

io.ReaderRead메서드는 슬라이스를 입력 파라미터로 받고 데이터를 쓸까? 이런 식으로 정의된 것에는 괜찮은 이유가 있다. io.Reader가 동작하는 방식을 이해하기 위한 대표적인 함수를 작성해보자.

func countLetters(r io.Reader) (map[string]int, error) {
	buf := make([]byte, 2048)
	out := map[string]int{}

	for {
		n, err := r.Read(buf)
		for _, b := range buf[:n] {
			if (b > 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
				out[string(b)]++
			}
		}
		if err == io.EOF {
			return out, nil
		}
		if err != nil {
			return nil, err
		}
	}
}

3가지 주목할 점이 있는데,

첫번째는 버퍼를 하나 생성하고, r.Read호출에 재사용한다는 것이다. 잠재적으로 큰 데이터 소스에서 읽기 위해 단일 메모리 할당을 사용하도록 한다. Read메서드는 []byte를 반환하도록 설계되어 있다면, 매 단일 호출마다 새로운 할당이 필요하다. 각 할당은 힙 메모리 끝에 도달할 것이고, 가비지 컬렉터에게 꽤 많은 작업을 만들게 할 것이다.

할당을 더 줄이려면 프로그램이 시작할 때 버퍼 풀을 생성하도록 한다. 그런 다음 함수가 시작할 때 풀에서 버퍼를 가져오고 종료될 때 반환한다. 이렇게 io.Reader에 슬라이스를 전달함으로서 메모리 할당을 개발자의 통제 하에 둘 수 있다.

두 번째는 버퍼에 얼마나 많은 바이트가 쓰여졌는 지 알기 위해 r.Read에서 반환된 n값을 사용하여 buf슬라이스의 하위 슬라이스를 순회하면서 읽은 데이터를 처리할 수 있다.

마지막으로 r.Read에서 io.EOF가 오류로 반환되면 r에서 읽기는 완료되었다고 판단한다. 이것은 io.Reader에서 읽을 데이터가 남아있지 않다는 것을 의미한다. io.EOF가 반환되면 처리를 완료하고 결과를 반환한다.

여기에 io.ReaderRead메서드에 관한 일반적이지 않은 것이 하나 있다. 함수나 메서드가 반환값으로 오류를 반환하는 대부분의 경우는 처리하기 전에 오류가 아닌 값이 반환된 것인지 확인한다. 데이터 스트림의 끝이나 예상치 못한 조건에서 발생한 오류 전에 읽은 데이터가 있을 수 있기 때문에 Read를 위해서는 반대로 처리한다. 즉, n으로 데이터를 먼저 읽고, err를 처리하는 것이다. 어차피 err가 났다면 n은 0또는 n값이기 때문에 0이어도 문제가 없다.

만약, 예상치 못하게 io.ReaderRead에서 끝에 도달했다면 다른 센티넬 오류인 io.ErrUnexpectedEOF를 반환한다. 예상치 못한 상태를 나타내기위해서 Err문자열로 시작한다는 것을 알 수 있다. 즉, EOF와는 다르다는 것이다.

io.Readerio.Writer는 아주 간단한 인터페이스이기 때문에 다양한 방식으로 구현될 수 있다. strings.NewReader함수를 사용하여 문자열로부터 io.Reader를 생성할 수 있다.

func main() {
	s := "The quick brown fox jumped over the lazy dog"
	sr := strings.NewReader(s)
	counts, err := countLetters(sr)
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println(counts)
}

결과는 다음과 같다.

map[T:1 a:1 b:1 c:1 d:2 e:4 f:1 g:1 h:2 i:1 j:1 k:1 l:1 m:1 n:1 o:4 p:1 q:1 r:2 t:1 u:2 v:1 w:1 x:1 y:1 z:1]

이전에 인터페이스는 type-safe한 덕 타이핑이라고 하였다. 위의 io.Readerio.Writer의 구현은 종종 데코레이터 패턴으로 함께 연결된다.

countLettersio.Reader에 의존하기 때문에 .gzip으로 압축된 파일에서도 동일하게 사용할 수 있다. 따라서 .gzip파일을 읽는 Read 메서드를 구현한 구조체를 받기만하면 된다.

func buildGZipReader(fileName string) (*gzip.Reader, func(), error) {
	r, err := os.Open(fileName)
	if err != nil {
		return nil, nil, err
	}
	gr, err := gzip.NewReader(r)
	if err != nil {
		return nil, nil, err
	}
	return gr, func() {
		gr.Close()
		r.Close()
	}, nil
}

이 함수는 데코레이터 패턴으로 io.Reader를 구현한 타입을 알맞게 wrapping하는 방법을 보여준다. os.Open으로 *os.File을 반환한다. 그리고 이 객체를 gzip.NewReader에 넘겨 *gzip.Reader인스턴스를 만든다. 반환할 때는 *gzip.Reader*os.File을 앋아주는 클로저를 반환한다.

*gzip.Readerio.Reader를 구현한 것이기 때문에 이전에 *strings.Reader으로 사용한 것과 같이 counterLetters에서 사용할 수 있다.

func main() {
	r, closer, err := buildGZipReader("my_data.txt.gz")
	if err != nil {
		fmt.Println(err)
		return
	}

	defer closer()
	counts, err := countLetters(r)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(counts)
}

읽기와 쓰기를 위한 표준 인터페이스를 가지고 있기 때문에 io패키지 내에 io.Reader에서 io.Writer로 복사하기 위한 io.Copy라는 표준함수도 있다. 기존 io.Readerio.Writer인스턴스에 새로운 기능을 추가하기 위한 다른 표준함수도 있다.

  • io.MultiReader: 여러 io.Reader 인스턴스에서 차례로 읽는 io.Reader를 반환한다.
  • io.LimitReader: 제공된 io.Reader에서 특정 바이트 수만큼 읽어들이는 io.Reader를 반환한다.
  • io.MultiWriter: 동시에 여러 io.Writer 인스턴스에 쓰기를 하는 io.Writer를 반환한다.

표준 라이브러리의 다른 패키지들은 io.Readerio.Writer로 작업하기 위한 자체 타입과 함수를 제공한다.

io에서 정의한 io.Closerio.Seeker와 같은 다른 단일 메서드 인터페이스가 있다.

type Closer interface {
    Close() error
}

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

io.Closer인스터페이스는 읽기와 쓰기가 완료되었을 때, 정리할 필요가 있는 os.File과 같은 타입에서 구현된다. 대부분 Closedefer를 통해서 호출된다.

f, err := os.open(fileName)
if err != nil {
    return nil, err
}

defer f.Close()

만약 loop내에서 자원을 열었다면 defer를 쓰지 않아야 한다. 왜냐하면 defer는 함수가 종료되어 스택 프레임에서 빠져나올 때야 실행되기 때문에 loop내에서의 defer를 통한 자원 반환은 실제로 loop내에서 자원이 반환되지 않는다. 대신에 매 loop마다 Close를 호출하는 것이 좋다.

io.Seeker인터페이스는 자원의 임의 접근을 위해 사용된다. whence의 유효한 값은 io.SeekStart, io.SeekCurrentio.SeekEnd상수이다. 이것은 사용자 지정 타입을 사용하여 더 명확하게 운영이 되었어야 했지만, 초반에 관리를 소홀히 하여 whence는 타입이 int가 되었다.

io패키지는 다양한 방뻡으로 이런 4개의 인터페이스를 결합시키는 인터페이스를 정의한다. io.ReadCloser, io.ReadSeeker, io.ReadWriteCloser, io.ReadWriteSeeker, io.ReadWriter, io.WriterCloserio.WriterSeeker를 포함한다.

함수가 데이터를 가지고 어떤 행동을 하는 지를 지정하기 위해 위의 인터페이스를 사용한다. 가령 파라미터로 os.File을 사용하는 것이 아니라, 파라미터로 어떤 일을 할 것인지 정확히 지정하기 위해 인터페이스를 사용하는 것이 좋다. 파라미터를 인터페이스로 만들면 함수를 더 범용적으로 만들 뿐만 아니라, 의도를 명확히 할 수 있다. 또한, 자체적으로 데이터 소스와 싱크를 작성하는 경우 코드를 이러한 인터페이스와 호환되도록 만들어야 한다. 일반적으로 io에 정의된 인터페이스처럼 단순하고, 분리된 인터페이스를 만들기 위해 노력해야한다.

ioutil패키지는 바이트 슬라이스로 전체를 읽어들이는 io.Reader구현, 파일에 읽기와 쓰기 그리고 임시 파일로 동작하는 것과 같은 것을 위한 몇 가지 간단한 유틸리티를 제공한다. ioutil.ReadAll, ioutil.ReadFile, ioutil.WriterFile 함수는 작은 데이터 소스를 위헤서는 괜찮지만 큰 데이터를 읽기 위해서는 bufio패키지에 있는 Reader, WriterScanner를 사용하는 것이 좋다.

ioutil패키지는 go type에 메서드를 추가하는 좋은 패턴을 보여준다. 만약 개발자가 io.Reader를 구현하지만 io.Closer를 가지지 않는 타입을 만들었다고 하자. 이를 io.ReadCloser를 받는 함수에 전달할 필요가 있다면, 개발자의 io.Readerioutil.NopCloser로 넘겨면 된다. 그리고, io.ReadCloser를 구현하는 타입을 얻으면 된다. 구현을 보면 더 쉽게 이해할 수 있다.

type nopCloser struct {
    io.Reader
}

func (nopCloser) Close() error { return nil }

func NopCloser(r io.Reader) io.ReadCloser {
    return nopCloser{r}
}

NopCloserioutil.NopCloser의 구현을 보여준다. 위의 예시와 같이 한 타입이 특정 메서드를 구현하지 않아서 인터페이스를 만족하지 않은 경우, 해당 타입을 다른 타입에 임베딩하도록 하고, 다른 타입은 해당 인터페이스를 만족하도록 하는 것이다.

언제든 인터페이스를 충족하기 위해 특정 타입에 추가적인 메서드를 넣을 필요가 있다면, 임베딩 타입 패턴을 사용하도록 하자. (embedded type pattern)

ioutil.NopCloser함수는 함수에서 인터페이스를 반환하지 않도록 하는 규칙을 위반했지만, 표준 라이브러리의 일부이기 때문에 동일하게 유지되는 것을 보장하기 위한 인터페이스용 단순 어댑터이다.

time(시간)

go 표준 라이브러리는 time이라는 시간과 관련된 자원을 포함한다. 시간을 표현하기 위해 사용하는 time.Durationtime.Time이라는 두개의 주요 타입이 있다.

기간은 int64를 기반으로하는 time.Duration으로 표시한다. go가 나타낼 수 있는 최소 시간은 1 nano seconds이지만 time패키지는 time.Duration 타입의 상수를 정의하여 나노초, 마이크로초, 밀리초, 초, 분, 시간을 나타낸다. 가령, 2시간 30분의 기간을 표현하기 위해서 다음과 같이 쓸 수 있다.

d := 2 * time.Hour + 30 * time.Minute // d는 time.Duration 타입

이런 상수는 time.Duration의 사용에서 가독성과 타입 세이프를 제공한다. 이것은 타입 상수의 좋은 사례를 보여준다.

go는 time.ParseDuration 함수를 사용하여 time.Duration으로 구문 분석 할 수 있는 일련의 숫자인 문자열 타입을 정의한다. go 표준 라이브러리 설명은 다음과 같다.

duration string은 '3000ms', '-1.5h' 혹은 '2h45m'과 같이 선택적 분수와 단위 접미사가 있는 부호있는 일련의 10진수이다. 유효한 시간 단위는 'ns', 'us','ms','s','m','h' 이다.

time.Duration에 정의된 몇 가지 메서드가 있다. 하나는 fmt.Stringer 인터페이스를 충족해서 String 메서드를 통해 포매팅된 기간 문자열을 반환한다. 또한, 시간, 분, 초, 밀리초, 마이크로초, 혹은 나노초의 숫자로 값을 얻기 위한 메서드도 있다. TruncateRound 메서드는 time.Duration을 지정된 time.Duration의 단위로 자르거나 반올림한다.

시간의 순간은 time.Time 타입으로 표현되며 표준 시간대가 표함된다. time.Now함수로 현재 시간에 대한 참조를 획득한다. 이것은 현재 지역 시간이 설정된 time.Time 인스턴스를 반환한다.

참고로, time.Time은 두 시간이 동일한 지 비교하기 위해서 ==을 사용하지 않고 Equal메서드를 사용해야 한다.

time.Parse함수는 문자열에서 time.Time으로 변환하고, Format 메서드는 time.Time을 문자열로 변환한다. go는 자체적으로 날짜 및 시간 포맷팅 언어를 사용한다. 포맷을 지정하기 위해서는 2006년 1월 2일 오후 3:04:05 MST의 날짜 및 시간 포매팅 아이디어에 의존한다. 이는 공식이니 굳이 외울 필요는 없지만, 필요할 때마다 찾아보는 것이 좋다.

가령 다음의 코드를 보도록 하자.

func TimeParse() error {
	t, err := time.Parse("2006-01-02 15:04:05 -0700", "2016-12-03 00:00:00 +0000")
	if err != nil {
		return err
	}
	fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))
	return nil
}

2006-01-02 15:04:05 -0700은 위에서 언급한 2006년 1월 2일 오후 3:04:05 MST이다. -0700은 MST가 UTC보다 7시간 전이기 때문이다.

이제 해당 형식으로 시간을 파싱하여 time.Time으로 변환하겠다는 것이고, 두 번재 인자가 그 대상이다. 따라서 2016년 12월 03일 00시가 맞다. 해당 시간을 다시 문자열로 바꿀 때에도 위의 공식인 2006년 1월 2일 오후 3:04:05 MST와 동일한 시간으로 형식을 맞춰주면 된다.

따라서, t.Format("January 2, 2006 at 3:04:05PM MST")으로 정의해놓으면 월이 문자로 나오고, 그 다음 일, 년, 시간이 나온다.

따라서, 다음의 결과가 나온다.

December 3, 2016 at 12:00:00AM +0000

그러나 이는 기억하기 어렵고 사용하길 원할 때마다 찾아봐야 한다는 문제가 있다. 가장 일반적으로 사용되는 날짜와 서식 기간은 time 패키지 자체에 상수를 가지고 있다.

time.Duration에서 부분을 추출하기 위한 메서드가 있는 것처럼, time.Time에서도 동일한 일을 하는 메서드인 Day, Month, Year, Hour, Minute, Second, WeekDay, Clock(분리된 정수값으로 시,분,초를 반환한다) 그리고 Date(분리된 정수 값으로 년, 월, 일을 반환한다)가 있다.

func TimeParse() error {
	t, err := time.Parse("2006-01-02 15:04:05 -0700", "2016-12-03 00:00:00 +0000")
	if err != nil {
		return err
	}
	fmt.Println(t.Date()) // 2016 December 3
	fmt.Println(t.Year()) // 2016
	fmt.Println(t.Month()) // December
	fmt.Println(t.Second()) // 0

	fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))
	return nil
}

또한, 하나의 time.Time 인스턴스는 다른 인스턴스와 After, Before, Equal메서드로 비교할 수 있다.

Sub메서드는 두 개의 time.Time 인스턴스 사이에 경과 시간을 나타내는 time.Duration을 반환하고, Add메서드는 time.Duration만큼 뒤의 time.Time을 반환한다. 그리고 AddDate는 년, 월, 일만큼 증가된 새로운 time.Time 인스턴스를 반환한다. time.Duration처럼 TruncateRound 메서드도 정의되어 있다. 모든 이런 메서드는 값 리시버로 정의되어 time.Time인스턴스 자체를 수정할 일은 없다.

Monotonic Time

대부분의 운영 체제는 두 가지 시간을 운영하는데, 하나는 현재 시간에 대응되는 'wall clock'이 있고, 다른 하나는 컴퓨터가 부팅된 시점부터 단순히 증가하는 'monotonic clock'이 있다. 이렇게 다른 두 시계를 운영하는 이유는 'wall clock'은 균일하게 증가히자 않기 때문이다.

이런 잠재적 문제를 해결하기 위해 go는 타이머가 설정되거나 time.Time 인스턴스가 time.Now로 생성될 때마다 경과 시간을 추적하기 위해 monotonic clock을 사용한다. 해당 자원은 보이지 않고 타이머가 자동으로 사용한다. Sub 메서드는 time.Time 인스턴스 둘 다 설정되어 있다면 time.Duration을 계산하기 위해 monotonic clock을 사용한다. 만약 그렇지 않으면 두 인스턴스 중 하나가 time.Now로 생성되지 않았기 때문에 Sub메서드는 time.Duration을 계산하기 위해 인스턴스에 지정된 시간을 사용할 것이다. 즉, monotonic clock을 사용하여 정확한 시간 계산을 한다는 것이다.

time 패키지는 지정된 시간 후에 값을 출력하는 채널을 반환하는 함수를 포함한다.

time.After 함수는 한 번 출력하는 채널을 반환하는 반면에 time.Tick에서 반환된 채널은 지정된 time.Duration이 지날 때마다 새로운 값을 반환한다. 타임아웃이나 반복 작업을 활성화하기 위해 go의 동시성 지원과 함께 사용된다.

만약, time.After로도 반복된 시간의 작업을 하고싶다면 다음과 같이하면 된다.

for {
	ticker := time.After(time.Second * 1)
	value := <-ticker
	fmt.Println(value)
}

또한, 단일 함수를 time.AfterFunc함수를 사용하여 지정된 time.Duration이 지난 후에 수행할 수 있도록 할 수 있다. 기본 time.Ticker는 중단할 수 없기 때문에(가비지 컬렉터로 회수되지 않는다) 사소한 프로그램의 외부에 time.Tick을 사용하지 말도록 하자. 대신에 채널을 기다릴 뿐만 아니라 ticker를 리셋하거나 중지할 수 있는 메서드를 가지는 *time.Ticker를 반환하는 time.NewTicker를 사용하도록 하자.

ticker := time.NewTicker(time.Second * 1)
defer ticker.Stop()
for value := range ticker.C {
	fmt.Println(value)
}

NewTicker로 만들어진 ticker는 주기적으로 시간마다 현재 시간을 value로 보내준다. 또한, defer ticker.Stop()을 통해 자원을 회수할 수 있다.

encoding/json

go 표준라이브러는 go 데이터 타입에서 json으로 json에서 go 데이터 타입으로 변환을 위한 자원을 포함한다. mashaling이라는 단어는 go데이터 타입을 인코딩으로 변환하는 것을 의미하고, unmarhsaling은 go 데이터 타입으로 변환하는 것을 의미한다.

다음과 같은 json을 일고 쓰기를 한다고 하자.

{
    "id":"12345",
    "date_ordered":"2020-05-01T13:01:02Z",
    "customer_id":"3",
    "items":[{"id":"xyz123","name":"Thing 1"},{"id":"abc789","name":"Thing 2"}]
}

다음과 같이 json 데아터를 맵핑하기 위한 구조체를 만들 수 있다.

type Order struct {
    ID            string        `json:"id"`
    DateOrdered time.Time 		`json:"date_ordered"`
    CustomerID    string        `json:"customer_id"`
    Items         []Item        `json:"items"`
}

type Item struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

구조체 항목 뒤에 쓰여진 문자열인 구조체 태그로 json을 처리하기 위한 규칙을 지정한다. 구조체 태그는 backtick(백틱)으로 표시된 문자열이다. 구조체 태그는 하나 이상의 태그/값의 쌍으로 구성되어 tag:value으로 쓰고 공백으로 구분한다.

이것도 단순한 문자열이기 때문에 컴파일러는 해당 포맷이 정상적인지 검증할 수 없지만, go vet은 할 수 있다. 또한 이러한 모든 항목은 외부로 노출이 된다는 것이다. 모든 다른 패키지처럼 encoding/json패키지에 있는 코드는 다른 패키지의 구조체 내에 노출되지 않은 항목은 접근할 수 없다. 즉, 구조체 태그로 구조체에 접근하려면 구조체의 첫 글자가 대문자여야 한다.

json 처리를 위한 구조체 항목과 연관되어 있는 json항목의 이름을 지정하기 위한 태그이름으로 json을 사용한다. 무런 json태그가 제공되지 않는다면, 기본 동작으로 구조체 항목의 이름과 일치하는 json 객체 항목의 이름으로 가정한다. 이런 기본 동작에도 불구하고, 항목 이름이 같더라도 명시적으로 항목의 이름을 구조체 태그로 지정해주는 것이 가장 좋다.

json태그가 없는 구조체 항목으로 언마샬링을 하는 경우, 이름은 대소문자를 구분하지 않고 일치시킨다. json태그가 없는 구조체 항목에서 다시 json으로 언마샬링하는 경우 json의 항목은 외부로 노출되기 때문에 첫 글자는 항상 대문자가 된다.

package main

import (
	"encoding/json"
	"fmt"
)

type Temp struct {
	Name    string
	Age     int
	address string
}

func main() {
	temp := Temp{
		Name:    "name",
		Age:     13,
		address: "seoul",
	}
	data, _ := json.Marshal(&temp)
	fmt.Println(string(data)) // {"Name":"name","Age":13}

	var temp2 Temp
	_ = json.Unmarshal(data, &temp2)
	fmt.Println(temp2) // {name 13 }
}

다음과 같이 go 구조체를 마샬링할 때, 특정 구조체에 json태그가 없으면 대문자인 구조체 항목은 그 이름으로 json객체의 항목의 이름이 된다. 소문자인 구조체 항목은 json객체의 항목에 반영되지 않는다.

반대로 json객체를 go의 구조체로 언마샬링할 때 go 구조체의 json태그가 없다면 대문자 이름으로 언마샬된다. 만약 소문자이면 무시된다.

그러나, 되도록이면 구조체에 json태그를 넣어서 마샬링하고 언마샬링하는 것이 좋다. 만약 무시되어야 하는 경우는, json 태그의 이름에 -를 사용하도록 하자. 만약, 항목이 비어있을 때만 출력을 제외하고 싶다면 json tag에서 value부분 뒤에 omitempty를 쓰면 된다.

가령 다음과 같다.

package main

import (
	"encoding/json"
	"fmt"
)

type Temp struct {
	Name    string `json:"name"`
	Age     int    `json:"-"`
	Address string `json:"address,omitempty"`
}

func main() {
	temp := Temp{
		Name: "name",
		Age:  13,
	}
	data, _ := json.Marshal(&temp)
	fmt.Println(string(data)) // {"name":"name"}

	var temp2 Temp
	_ = json.Unmarshal(data, &temp2)
	fmt.Println(temp2) // {name 0 }
}

Age의 json tag는 -이기 때문에 json marhsal에 반영되지 않는다.

Addressomitempty이기 때문에 값이 없다면 json marhsal에 반영되지 않는다. 만약, 값을 넣어주면 마샬링에 반영이 될 것이다.

한 가지 조심해야할 것은 언마샬 시에 제로값과 구분되지 못하는 경우가 있다. 가령, 위의 예제에서 Age가 의도적으로 0인 것과 제로값인 0과 구분이 안된다는 것이다. 이런 경우를 때문에 일부 개발자는 구조체 포인터를 쓰거나, 슬라이스, 맵 등을 사용하는데, 이들의 경우는 언마샬 시 없으면 nil이 나오기 때문이다.

구조체 태그는 프로그램의 행동 방식을 제어하는 메타데이터를 사용할 수 있도록 한다. 다른 언어, 특히 자바는 다양한 프로그램 요소가 명시적으로 무엇이 처리될 것인지 지정하지 않고 어떻게 처리되는 지를 기술하기 위해 어노테이션(annotation)을 쓰도록 개발자들에게 권장한다.

그러나 어노테이션은 코드를 어렵게 만든다는 단점이 있다. go는 짧은 코드보다는 명확한 코드를 선호한다. 구조체 태그는 자동으로 평가되지 않고 구조체 인스턴스가 함수로 전달될 때 처리된다. 이 부분에서 차이가 있는 것이다.

언마샬링과 마샬링

encoding/json패키지에 Unmarshal 함수는 바이트 슬라이스를 구조체로 변환하기 위해 사용된다. data라는 json 문자열을 가진다면 이것 data을 통해 go 구조체로 변환할 수 있다.

var o Order
err := json.Unmarshal([]byte(data), &o)
if err != nil {
	return err
}

json.Unmarshal 함수는 io.Reader 인터페이스의 구현과 같이 데이터를 입력 파라미터로 채운다. 이것에는 두 가지 이유가 있따.

첫번째는 io.Reader 구현과 마찬가지로 동일한 구조체를 반복해서 효율적으로 재사용할 수 있어서 메모리 사용을 제어할 수 있따. 두 번째는 이것을 수행하는데 다른 방법이 없다. go는 1.19버전 이전까지만해도 제네릭을 지원하지않아 읽은 바이트를 저장하기 위해 어떤 타입을 인스턴스화 해야 하는 지 지정할 방법이 없다.

위의 Order 인스턴스에서 다시 바이트 슬라이스에 저장되는 json으로 쓰기를 하기 위해 encoding/json 패키지에 있는 Marshal 함수를 사용할 수 있다.

out, err := json.Marhsal(o)

그렇다면 구조체 태그로 어떻게 평가되는 지 궁금할 수 있다. 또한, json.Marhsaljson.Unmarshal이 모든 타입의 구조체를 일곡 쓰는 방법도 궁금하다. 이는 사실 리플렉션과 관련이 있기 때문에 이후에 알아보도록 하자.

JSON Readers and Writers

json.Marshaljson.Unmarshal 함수는 바이트 슬라이스로 동작한다. 대부분의 go 데이터 소스와 싱크는 io.Readerio.Writer 인터페이스로 구현된다.

ioutil.ReadAll을 사용하여 io.Reader의 전체 내용을 바이트 슬라이스로 복사하고 해당 내용을 json.Unmarshal로 읽을 수 있지만, 그것은 비효율적이다. 비슷하게 json.Marshal을 사용하여 인메모리 바이트 슬라이스 버퍼에 쓴 다음 바이트 슬라이스를 네트워크나 디스크에 쓸 수 있지만, io.Writer로 직접 쓸 수 있다면 더 좋을 것이다.

encoding/json패키지는 이런 상황을 처리하기 위해 허용하는 두 가지 타입을 포함한다. json.Decoderjson.Encoder 타입은 io.Readerio.Writer 인터페이스를 충족하는 모든 것에서 읽거나 해당 디렉터리, 파일로 쓰기가 가능하다. 어떻게 동작하는지 살펴보도록 하자.

다음의 간단한 예제를 보도록 하자.

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
toFile := Person {
    Name: "Fred",
    Age:  40,
}

위의 Person구조체를 가지는 toFile 인스턴스를 파일에 저장하기로하자. 이전과 같으면 json.Marshal을 한다음 바이트 슬라이스 째로 파일에 저장했어야 했는데, 이는 io.Writer를 사용하는 이점이 떨어진다.

os.File 타입은 io.Readerio.Writer 인터페이스를 구현해서 json.Decoderjson.Encoder를 시연하기 위해 사용할 수 있다. 먼저 임시 파일에 인코딩된 데이터를 쓰기위해서, json.Encoder를 반환하는 json.NewEncoder를 만들어, 임시 파일에 toFile을 쓰도록 해보자.

tmpFile, err := ioutil.TempFile(os.TempDir(), "sample-")
if err != nil {
    panic(err)
}
defer os.Remove(tmpFile.Name())
err = json.NewEncoder(tmpFile).Encode(toFile)
if err != nil {
    panic(err)
}
err = tmpFile.Close()
if err != nil {
    panic(err)
}

tmpFile을 열고 json.NewEncoder에 넣어주면 tmpFile에 파일을 쓸 준비가 완료된 것이다. json.NewEncoderio.Writer를 받아, 인코딩된 바이트를 해당 파일에 저장하도록 한다. 따라서, Encode뒤에 toFile을 써주면 해당 go구조체를 바이트로 변형하여 파일에 써주는 것이다.

일단 toFile이 쓰여지면, 임시 파일에 대한 참조를 json.NewDecoder로 전달한 다음 타입 Person의 변수로 반환된 json.DecoderDecode메서드를 호출함으써 json을 다시 읽을 수 있다.

tmpFile2, err := os.Open(tmpFile.Name())
if err != nil {
    panic(err)
}
var fromFile Person
err = json.NewDecoder(tmpFile2).Decode(&fromFile)
if err != nil {
    panic(err)
}
err = tmpFile2.Close()
if err != nil {
    panic(err)
}
fmt.Printf("%+v\n", fromFile)

다음과 같이 tempFile2tmpFile에 대한 os.File 포인터이다. 이를 json.NewDecoder에 넣어주면 io.Reader로 해당 파일에서 데이터를 읽어 Decode안에있는 fromFile로 디코딩해준다.

JSON 스트림의 인코딩과 디코딩

여러 json구조체를 한 번에 읽거나 쓰기를 하려고 할 때 어떻게 해야할까? 이런 상황에서 json.Decoderjson.Encoder를 사용하면 된다.

const data = `
	{"name": "Fred", "age": 40}
	{"name": "Mary", "age": 21}
	{"name": "Pat", "age": 30}
`

위의 json 데이터 스트림이 한 번에 들어왔다고 하자. 이를 Person 구조체로 변경하기 위해서 Unmarshal을 사용할 수가 없다. 따라서, NewDecoder를 사용하여 이를 변경해보도록 하자.

dec := json.NewDecoder(strings.NewReader(data))
for dec.More() {
    err := dec.Decode(&t)
    if err != nil {
        panic(err)
    }
    // process t
}

string 자체에는 io.Reader인터페이스를 충족하지 않으니, strings.NewReaderio.Reader를 만족하여 문자열을 읽도록 한다. json.NewDecoder로 json stream data를 디코딩할 수 있도록 준비한다.

stream이기 때문에 fordec.More()로 순회하도록 한다. string stream 데이터들은 매 순회를 돌면서 dec.Decode(&t)로 디코딩되어 t에 데이터가 들어간다.

json.Encoder를 사용하여 여러 값을 쓰는 것은 단일 값을 쓰는 데 사용하는 것과 동일하다. 해당 예제는 bytes.Buffer에 쓰지만 io.Writer 인터페이스를 충족하는 모든 타입이 가능하다.

var b bytes.Buffer
enc := json.NewEncoder(&b)
for _, input := range allInputs {
    t := process(input)
    err = enc.Encode(t)
    if err != nil {
        panic(err)
    }
}
out := b.String()

binput 데이터를 차곡차곡 string stream을 쓰는 것이다.

배열에 wrapping되지 않은 데이터 스트림에서 여러 JSON 객체를 읽는 예제를 보았다. 뿐만 아니라, json.Decoder를 사용하여 배열로 wrapping된 데이터를 읽을 수도 있는데, 이는 한번에 메모리로 전체 배열을 로딩하지 않고 배열로부터 단일 객체를 읽을 수 있어, 훌륭한 성능 향상을 만들고 메모리 사용량을 줄일 수 있다.

사용자 지정 json 파싱

만약, 특정 구조체의 항목을 json으로 마샬하거나 언마샬할 때 특별한 로직을 해주어야하는 경우는 어떻게해야할까? 가령, time.TimeRFC 339 포맷 내에서 json 항목을 지원하지만 다른 시간 포맷을 처리해야할 수도 있다. 이는 json.Marshalerjson.Unmarshaler 두 인터페이스를 구현하여 새로운 타입을 생성하여 처리할 수 있다.

type RFC822ZTime struct {
    time.Time
}

func (rt RFC822ZTime) MarshalJSON() ([]byte, error) {
    out := rt.Time.Format(time.RFC822Z)
    return []byte(`"` + out + `"`), nil
}

func (rt *RFC822ZTime) UnmarshalJSON(b []byte) error {
    if string(b) == "null" {
        return nil
    }
    t, err := time.Parse(`"`+time.RFC822Z+`"`, string(b))
    if err != nil {
        return err
    }
    *rt = RFC822ZTime{t}
    return nil
}

위의 예제는 time.Time인스턴스를 RFC8227Time이라는 새로운 구조체에 임베딩하여 time.Time의 다른 메서드에도 접근할 수 있도록 하였다. 또한, RFC8227Time 구조체에 메서드로 MarshalJSONUnmarhsalJSON을 구현하도록 하여, json.Marshalerjson.Unmarshaler 두 인터페이스를 만족시키도록 하였다.

이제 위의 RFC8227Time을 사용해보자.

type Order struct {
    ID          string      `json:"id"`
    DateOrdered RFC822ZTime `json:"date_ordered"`
    CustomerID  string      `json:"customer_id"`
    Items       []Item      `json:"items"`
}

json marhsal, unmarsal을 해보면 문제없이 동작하는 것을 볼 수 있을 것이다.

만약, 입력으로 들어오는 json의 구조를 모른다면 어떻게해야할까? 가령, 입력으로 들어오는 json이 무엇인지는 모르겠지만 뭐든 받아서 바이패스해야하는 상황도 있다. 이런 경우 map[string]interface{}를 사용하여 marshal, unmarshal에 전달할 수 있다.

package main

import (
	"encoding/json"
	"fmt"
)

type DataJSON struct {
	Data map[string]interface{}
}

func main() {
	data := []byte(`{"name":"gyu", "age": 12, "info": {
		"address": "seoul",
		"gender": "man"
	}}`)

	var dataJson DataJSON
	json.Unmarshal(data, &dataJson.Data)
	fmt.Println(dataJson.Data)                             // map[age:12 info:map[address:seoul gender:man] name:gyu]
	fmt.Println(dataJson.Data["name"])                     // gyu
	fmt.Println(dataJson.Data["age"])                      // 12
	info := dataJson.Data["info"].(map[string]interface{}) // interface type -> map[string]interface{}
	fmt.Println(info["address"])                           // seoul
	fmt.Println(info["gender"])                            // man
}

위와 같이 data에 대한 정보를 잘 모를 때, DataJSON 구조체에서 Data라는 map[string]interface{}타입을 만들어 모든 json을 받도록 하는 것이다. 단, 이는 interface{}로 값을 받기 때문에 반드시 구체 타입으로 변경해주는 타입 단언(assertion)을 해주어야 한다. 또한, 타입 세이프하지도 않기 때문에 되도록이면 문자열로 받거나, json 구체 타입을 협약해서 하는 것이 좋다.

json말고도 csv, xml, base64 등과 같은 다른 인코더들도 제공한다. 만약, 인코딩하고 싶은 데이터 포맷이 있찌만 표준 라이브러리 및 서드-파티 모듈에서 지원하지 않는다면 직접 개발할 수 있다. 가령, 3GPP와 같은 4G,5G 협약 단체에서 사용하는 데이터 타입은 매우 마이너하고 회사에 specific할 수 있다. 이러한 것들은 따로 인코더와 디코더를 만드는 것이 좋다.

0개의 댓글