Skip to main content 집밥서선생

표준 라이브러리

Published: 2022-09-05

본 글은 Golang을 공부하며 주요 내용이라 생각되는 것들을 기록해둔 자료이며, Ubuntu 22.04 LTS 기준으로 작성되었습니다.



Introduction


Go의 표준 라이브러리는 여러 장점이 있다. Python의 라이브러리 철학인 “batteries included” 처럼, 서비스를 빌드하기 위해 필요한 다양한 도구를 제공한다. Go는 비교적 최신 언어인 만큼, 현대 프로그래밍 환경에서 직면하는 문제에 초점을 맞춘 라이브러리를 제공한다.

이 장에서 모든 표준 라이브러리 패키지들을 다룰 수 없고, 그럴 필요도 없다. 표준 라이브러리에 대한 다양한 정보들과 문서가 있으니, 여기를 참조하면 될 것이다. 이 포스트에서는 몇 개의 가장 중요한 패키지, 그리고 이들의 디자인적인 측면이나 사용 방법을 알아볼 것이다. errors, sync, context, testing, reflect, unsafe 등 패키지들은 각각의 챕터에서 집중적으로 다루었거나 다룰 예정이다. 이 챕터에서는 I/O, 시간, JSON, HTTP 등을 지원하는 패키지에 대해 알아볼 것이다.



io and Friends


실용적인 프로그램을 데이터를 읽고 쓸 수 있어야 한다. Go의 입/출력 철학은 io 패키지에서 찾아볼 수 있다. 특히 이 패키지에 정의된 io.Readerio.Writer는 각각 Go에서 두 번째, 세 번째로 많이 사용되는 인터페이스일 것이다. (첫 번째는 error이다)


io.Readerio.Writer는 모두 한 개의 메소드를 정의한다.

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

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

io.WriterWrite() 메소드는 바이트 덩어리를 파라미터로 받고, 바이트의 수와 에러 발생 여부를 반환한다. io.ReaderRead() 메소드는 좀 더 신기하다. 리턴 파라미터로 값을 반환하기보다는 입력 파라미터로 보낸 값을 변경한다. 최대 len(p) 바이트만큼의 데이터가 슬라이스에 쓰일 것이고, 기록된 바이트의 수와 에러 여부가 반환된다.


사실 io.ReaderRead() 메소드는 아래와 같이 정의되는 것이 직관적일 것이다.

type NotHowReaderIsDefined interface {
    Read() (p []byte, err error)
}

하지만 io.Reader가 기존 방식으로 정의되는 데에는 이유가 있다. 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
		}
	}
}

위 예제에서 주목할 점들이 있다.

  1. 버퍼를 한 번 생성하고 r.Read()를 호출할 때마다 재사용할 것이다. 이러한 방식을 쓰면 크기가 클 수도 있는 데이터에 대해 한 번의 메모리 할당으로 값을 읽어올 수 있다. 만약 Read() 메소드가 []byte를 리턴하게끔 작성되어 있다면, 매번 함수를 호출할 때마다 새롭게 메모리 할당을 할 것이고, 가비지 컬렉터가 할 일이 많아질 것이다.
    추후 이렇게 낭비적인 할당을 줄이고 싶다면, 프로그램이 실행될 때 버퍼 풀을 생성해 놓는 방법도 있다. 함수가 실행될 때 해당 버퍼 풀에서 버퍼를 가져와 사용하고, 끝나면 되돌려 놓는 식이다. io.Reader에 slice를 보낼 수 있기 떄문에 메모리 할당을 개발자의 몫으로 남겨둘 수 있다.

  2. r.Read()에서 반환된 n값을 사용하여 버퍼에 바이트가 얼마나 많이 기록되었는지 확인하고, buf slice의 subslice를 이터레이션하여 읽어온 데이터를 처리할 수 있다.

  3. r.Read()의 리턴 타입 중 error로 io.EOF를 반환한 경우(io.EOF는 실제로 에러가 아니다), 읽어올 데이터가 끝났음을 의미한다. 위 예제에서는 io.EOF가 반환될 시 처리를 끝내고 결과물을 리턴한다.

  4. io.Reader에는 특이사항이 있다. 대부분의 함수 또는 메소드에 error 리턴값이 있다면, 오류가 아닌 값을 처리하기 전에 에러를 먼저 확인할 것이다. Read()를 사용할때는 데이터 스트림의 끝 또는 예기치 않은 오류가 발생하기 전에 반환된 바이트가 있을 수 있기 떄문에 이와 반대로 한다.
    만약 io.Reader가 예상치 못하게 끝난다면 다른 종류의 sentinel error(io.ErrUnexpectedEOF)가 반환될 것이다. 이처럼 예측하지 못한 상태를 나타내는 에러는 Err로 시작한다.


이렇듯 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)
	}
	fmt.Println(counts)
}

countLetters 함수가 파라미터로 io.Reader를 사용하므로, countLetters 함수를 써서 gzip으로 압축된 영문자를 카운트할 수도 있다. 진짜 되는지 해보자. 먼저 파일명을 파라미터로 받아 *gzip.Reader를 반환하는 함수를 작성해준다.

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
}

함수의 구조가 그다지 어렵지는 않다. 먼저 os.Open() 함수로 *os.File 인스턴스를 만들고 에러 검사를 한다. 그 후 gzip.NewReader() 함수를 호출하여 *gzip.Reader 인스턴스를 생성한다. 리턴 값들은 *gzip.Reader 인스턴스, 생성된 인스턴스들을 제거하는 closure, 그리고 에러 변수이다.

*gzip.Reader 인스턴스는 *strings.Reader처럼 io.Reader를 충족시키기 때문에 countLettters() 함수에서 사용할 수 있다.

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

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

읽기/쓰기를 위한 표준 인터페이스가 있기 때문에, io.Readerio.Writer에서 데이터를 복사하는 표준 함수 io.Copy()io 패키지에 존재한다. 기존 io.Readerio.Writer인스턴스에 새로운 기능을 추가하기 위한 다른 표준 함수들도 있다.

  • io.MultiReader : 여러 개의 io.Reader 인스턴스로부터 잇따라 데이터를 읽을 수 있는 io.Reader 인스턴스를 반환한다.
  • io.LimitReader : 명시된 수 만큼의 바이트만 읽을 수 있는 io.Reader 인스턴스를 반환한다.
  • io.MultiWriter : 동시에 여러 io.Writer에 데이터를 쓸 수 있는 io.Writer 인스턴스를 반환한다.

표준 라이브러리 내의 다른 패키지에서는 io.Readerio.Writer와 같이 사용할 수 있는 타입이나 함수들을 제공한다. 몇 개는 이미 봤지만 압축 알고리즘, 아카이브, 암호화, 버퍼, byte slice, 문자열 등 다양하게 제공하고 있다.


io 패키지에는 io.Closerio.Seeker등, 또다른 단일 메소드 인터페이스가 있다.

type Closer interface {
    Close() error
}

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

os.File과 같이, 읽기나 쓰기가 끝나면 정리를 해줘야 하는 타입들은 io.Closer를 충족시킨다. 일반적으로 Close() 메소드는 defer를 통해 호출된다.

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

만약 리소스를 반복문 내에서 여는 경우, defer를 이용하지 않는 것이 좋다. 반복문 내에서 열린 리소스는 이터레이션의 끝에서 닫아주는 게 좋겠지만, defer는 함수가 끝날 때 실행된다. 따라서 이터레이션의 끝 부분이나 함수가 끝날 수도 있는 에러가 발생할 만한 부분에 Close()를 호출해 주는 게 좋다.

io.Seeker 인터페이스는 리소스에 임의 접근(random access) 하기 위해 사용된다. 이 때 파라미터 whence에 유효한 값은 상수 io.SeekStart, io.SeekCurrent, io.SeekEnd이다. 사실 이건 제작자 실수인게, whence는 타입을 새로 생성하여 명확히 해줬어야 하는데 whence는 int 타입이다.


io 패키지는 앞서 보았던 io.Reader, io.Writer, io.Closer, io.Seeker, 이렇게 네 개의 인터페이스를 여러 방식으로 합친 인터페이스를 정의한다. io.ReadCloser, io.ReadSeeker, io.ReadWriteCloser, io.ReadWriteSeeker, io.ReadWriter, io.WriteCloser, io.WriteSeeker 등이 있다. 이런 인터페이스들은 함수가 데이터에 대해 어떤 프로세스를 하는지 정확히 명시하기 위해 사용된다.

이를테면 파라미터의 타입으로 os.File를 써주는 것보다는, 그 파라미터를 가지고 뭘 할건지 명시하기 위헤 인터페이스를 사용하는 것이 좋다. 또한 자체 데이터 소스나 싱크를 작성하는 경우, 코드가 이러한 인터페이스와 호환되도록 하는 것이 좋다.

ioutil 패키지는 io.Reader 인스턴스에 대한 다양한 도구들을 제공한다. byte slice로 한 번에 읽기, 파일 읽기 및 쓰기, 임시 파일 작업 등 보다 간단한 도구들이 포함된다. 이를테면 io.Reader, io.Writer, bufio.Scanner 등은 크기가 큰 데이터를 처리하는데 용이한 편이지만, ioutil.ReadAll, ioutil.ReadFile, ioutil.WriteFile은 보다 작은 데이터를 처리하는데 유용하다.

ioutil 패키지에는 유용한 함수들이 더 있다. 가령 io.Reader는 충족하지만 io.Closer는 충족하지 않는 인스턴스(strings.Reader 등)가 있고, 이를 io.ReadCloser를 파라미터로 받는 함수에 넘겨야 한다고 가정해보자. 이 때 io.Readerioutil.NopCloser() 함수로 보내면 io.ReadCloser() 타입을 반환할 것이고, 이걸 해당 함수의 파라미터로 넘기면 된다.

실제로 ioutil.NopCloser()의 구현을 들여다보면 생각보다 단순하다.

type nopCloser struct {
    io.Reader
}

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

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

위 구현을 보면 Go에서 타입에 메소드를 추가하는 패턴을 확인할 수 있다. 인터페이스를 충족할 수 있게끔 타입에 메소드를 추가해주고 싶다면, 이렇게 Embedded type 패턴을 사용해주면 된다.

함수에서 인터페이스를 리턴하지 않는다는 규칙이 기억날 것이다. ioutil.NopCloser() 함수는 이를 위반하긴 한다. 하지만 표준 라이브러리에 속하는 인터페이스끼리의 변환을 해주는 간단한 어댑터 역할만 하기 때문에 예외로 두고 넘어가 주자.



time


다른 언어들처럼 Go에도 시간 연산을 하는 라이브러리인 time 패키지가 있다. 시간을 나타내는 주요 타입이 두 가지 있는데, 바로 time.Durationtime.Time이다.

시각 사이의 시간은 time.Duration으로 표현되며, 이는 int64 기반이다. Go의 시간 최소단위는 나노초(ns)이고, time 패키지에서는 time.Duration 타입의 나노초(nanosecond), 마이크로초(microsecond), 밀리초(milisecond), 초(second), 분(minute), 시간(hour) 상수를 정의한다.

예를 들어, 2시간 30분은 아래와 같이 정의한다.

d := 2*time.Hour + 30*time.Minute

이러한 상수들을 사용하면 time.Duration 타입을 readable하고 type-safe하게 만들어준다.


Go는 time,ParseDuration() 함수에 특정한 스트링 포맷이나 숫자들의 배열을 넘겨서 time.Duration 타입으로 파싱할 수 있다. 이에 대한 설명은 표준 라이브러리 문서에 작성되어 있다. 아래 내용은 원문인데, 번역하는 것보다 영어로 읽는게 더 편할것 같아서 따로 번역하지는 않았다.

A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as “300ms”, “-1.5h” or “2h45m”. Valid time units are “ns”, “us” (or “µs”), “ms”, “s”, “m”, “h”.

time.Duration에는 여러 가지 메소드들이 정의되어 있고, fmt.Stringer 인터페이스를 충족시킨다. 따라서 fmt.Stringer에 정의된 String() 메소드를 호출하면 formatted string을 반환받는다. 또한 TruncateRound 메소드를 쓰면 time.Duration을 지정된 time.Duration 단위만큼 반올림하거나 자른다.


특정한 시각의 경우 time.Time 타입으로 표현되는데, 시간대(time zone)를 명시해 주어야 한다. 또한 Time.Now() 함수로 현재 로컬 시간인 time.Time() 인스턴스를 얻을 수 있다.

time.Time 인스턴스는 시간대에 대한 정보까지 포함하기 때문에, 두 개의 time.Time 인스턴스가 같은 시간대를 나타내더라도 == 연산자를 사용하면 제대로 된 결과가 나타나지 않을 수 있다. Equal() 메소드를 사용하면, 표준 시간대를 기준으로 비교해준다.

time.Parse() 함수는 stringtime.Time 타입으로 변환해주고, Format() 메소드는 time.Time 타입을 string으로 변환해준다.

t, err := time.Parse("2006-02-01 15:04:05 -0700", "")
if err != nil {
	fmt.Println(err)
}

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

출력 결과는 다음과 같다.

March 13, 2016 at 12:00:00AM +0000

이러한 날짜나 시간 포맷은 유용하게 사용되게끔 의도되었지만, 기억하기 힘들기 때문에 사용하려 할 때마다 찾아봐야 한다는 단점이 있다. 다행히도 주로 사용되는 날짜 및 시각 포맷은 time 패키지에서 상수로 주어진다.

time.Duration처럼, time.Time에도 Day, Month, Year, Hour, Minute, Second, Weekday, Clock 등 시각의 일부분을 추출할 수 있는 메소드가 존재한다. 이 중 Clock() 메소드는 시, 분, 초를 각각의 int로 반환하고, Date()는 연, 월, 일을 각각의 int로 반환한다. 또한 time.Time 인스턴스는 After, Before, Equal 메소드를 통해 다른 인스턴스와 비교할 수 있다.

time.TimeSub() 메소드는 두 시각 사이의 경과 시간을 time.Duration으로 반환하며, time.TimeAdd() 메소드는 time.Duration을 파라미터로 받아 해당 시간만큼 더해진 시각의 time.Time 인스턴스를 반환한다. time.TimeAddDate() 메소드는 연, 월, 일을 각각 입력받아 그만큼 더해진 날짜의 time.Time 인스턴스를 반환한다. time.Duration 인스턴스를 인자로 주고, 앞서 언급한 Truncate()Round() 메소드를 사용할 수도 있다.

이러한 메소드들은 모두 value receiver로 정의되었기 때문에, 원래의 time.Time 인스턴스를 변경하지 않는다.



Monotonic Time

대부분의 OS에서는 두 종류의 시각을 기록하고 있다. 그중 wall clock은 말 그대로 현재 시각에 대응하는 것이며, monotonic clock은 컴퓨터가 켜진 시각으로부터 얼마만큼 시간이 흘렀는지를 의미한다. 두 종류의 시각을 기록하는 이유는, wall clock이 일정하게 증가하는 것이 아니기 때문이다. 서머타임(Daylight Saving Time), 윤초(leap seconds), Network Time Protocol의 업데이트로 인해 wall clock에는 오차가 생길 수 있으며, 이로 인해 타이머를 실행하거나 경과된 시간을 얻고자 할 때 문제가 생길 수 있다.

이러한 잠재적인 문제점을 해결하기 위해, Go에서는 time.Now()time.Time 인스턴스가 생성되거나 타이머가 설정될 때 monotonic time을 사용하여 경과 시간을 추적한다. Sub() 메소드는 두 time.Time 인스턴스가 모두 monotonic time이 설정된 경우, monotonic time을 이용하여 time.Duration을 계산한다. 만약 그렇지 않다면 Sub() 메소드는 인스턴스에 지정된 시간을 사용하여 time.Duration을 계산한다.



Timers and Timeout

앞선 포스트에서 다루었듯, time 패키지에는 채널을 리턴하여 일정 시간이 지난 후 값이 출력되는 함수가 내장되어 있다. time.After() 함수는 일정 시간이 지나면 값이 단 한번 출력되는 채널을 리턴한다. 반면 time.Tick() 함수에서 리턴되는 채널은 파라미터로 넘어온 time.Duration 만큼의 시간이 흐를 때마다 값이 출력된다. 이러한 함수들은 시간 초과 및 반복 작업을 가능하게 함으로써 Go의 동시성을 지원한다.

다만 Time.Tick() 함수는 멈출 수 없고, 따라서 가비지 컬렉터에 의해 정리되지도 않는다. 그래서 Time.NewTicker()를 사용하는데 이 함수는 채널을 닫거나 간격을 재설정하는 메소드를 함께 제공하니, 되도록 이 쪽을 선택하는 게 좋을 것 같다.

위 함수들을 사용하는 예제가 따로 없길래 대충 짜보았다.

func main() {
	dura := time.Second * 2
	timer := time.NewTicker(dura)
	defer timer.Stop() // shutdown ticker
	after := time.After(dura * 3)

	time.AfterFunc(dura*1, func() {
		fmt.Println("응애")
	})

loop1:
	for {
		select {
		case <-timer.C: // channel that listens ticking
			fmt.Println("야옹")
			timer.Reset(dura / 2) // reconfirguration tick interval
		case <-after:
			fmt.Println("끝")
			break loop1
		}
	}
}


encoding/json


REST API는 JSON을 주고받는 통신을 한다. 따라서 Go의 표준 라이브러리는 JSON과 Go 데이터 타입의 상호 변환을 지원한다. marshaling이란 Go 데이터 타입에서 인코딩된 JSON으로 변환하는 것을 의미하며, unmarshaling이란 그 반대를 의미한다.



Use Struct Tags to Add Metadata

우리가 주문 관리 시스템을 만들고 있다고 가정하고, 아래 JSON 파일을 확인해보자.

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

이제 이 타입과 대응되는 타입을 정의해 보자.

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으로 변환되는 struct임을 명시하기 위해서는 구조체에 필드를 입력해준 뒤 struct tag를 입력해줘야 한다. struct tag는 backtick(`)으로 문자열을 감싸는 구조이지만, 한 줄 이상 이어서 작성할 수 없다. struct tag는 한 개 이상의 tag/value 쌍으로 이루어져 있으며, tagName: "tagValue"의 구조로 작성되며 공백으로 구분한다.

struct tag는 그냥 문자열이기 때문에, 컴파일러는 얘네가 제대로 작성되어 있는지 알 수가 없다. 다만 go vet 명령어를 치면 검증할 수 있으며, 이러한 모든 필드들은 export된다. 다른 패키지와 마찬가지로, encoding/json 패키지의 코드들은 구조체의 export되지 않은 필드에 접근할 수 없다.

JSON을 처리하기 위해서는 태그명을 json으로 지어서 구조체 필드와 연결되어야 하는 JSON 필드의 이름을 지정해주어야 한다. 만약 json 태그를 지정하지 않으면 기본적으로 JSON 객체의 필드명과 구조체의 필드명을 매칭시킨다. 하지만 실제로는 필드명이 같다고 하더라도 struct tag를 명시해주는 것이 좋다.

JSON을 구조체로 unmarshaling할 때 json 태그가 명시되어있지 않으면, 필드가 매칭될때 대소문자를 구분하지 않는다. 반대로 구조체를 JSON으로 marshaling할 때 구조체 필드가 export되려면 첫 글자가 대문자일 수 밖에 없기 때문에, JSON 태그도 항상 대문자가 된다.

만약 marshaling 또는 unmarshaling을 할 때 무시해야 하는 필드가 있다면 필드명으로 대시(-)를 써주면 된다. 만약 필드가 비어있을 때 출력에서 제외되어야 하는 경우, 이름 뒤에 ,omitempty를 추가한다. 이 때 필드가 비어있다는 것은 Zero value를 의미하는 것이 아니라, zero-length slice나 map 등이 이에 해당한다.

struct tag는 메타데이터를 사용하여 프로그램의 행동을 제어할 수 있게 해준다. Java와 같은 다른 언어에서는 개발자들이 프로그램 요소에 주석을 달아 프로그램이 어떤 처리를 하는 것에 대해 기술하는 것보다는 어떻게 처리되어야 할지 설명하도록 장려한다.

Java에서 주석을 달던 사람들은 무언가 잘못됐을 때, 특히 어떤 코드가 주석이 달려있음에도 어떤 역할을 하는지 이해하지 못할 때 당황하는 경향이 있다. Go에서는 짧은 코드보단 명시적인 코드를 좋아한다. struct tag는 자동으로 evaluate되지 않으며, 구조체 인스턴스가 함수로 전달될 때 처리된다.



Unmarshaling and Marshaling


encoding/json 패키지의 json.Unmarshal() 함수는 byteslicestruct로 변환해준다. 아래 예제는 data라는 문자열 변수를 struct를 위에서 확인한 Order 구조체 타입으로 변환하는 예제이다.

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

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

json.Unmarshal() 함수는 io.Reader처럼 데이터를 입력 파라미터에 생성한다. 이는 두 가지 이유가 있는데, 첫 번째는 쉽게 예상할 수 있듯 동일한 구조체를 재사용하여 효율적으로 메모리를 사용할 수 때문이다. 두 번째는 달리 다른 방법이 없기 때문이다. Go에는 제네릭이 없기 때문에, 인스턴스로 만들 구조체의 타입을 지정할 방법이 달리 없다. 만약 Go에도 제네릭이 생긴다 해도, 메모리 사용의 이점때문에 이 방식이 그대로 사용되리라 예상된다.


encoding/json 패키지의 json.Marshal() 함수는 구조체 인스턴스를 다시 byteslice인 JSON으로 변환해준다. json.Marshal()

	out, err := json.Marshal(o)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println(string(out))


JSON, Readers, and Writers

json.Marshal()json.Unmarshal() 함수는 []byte를 사용한다. 또한 대부분의 데이터 소스와 싱크가 io.Readerio.Writer 인터페이스를 충족시키는 것을 알고 있다. 그래서 ioutil.ReadAll()을 통해 io.Reader의 모든 내용을 []byte에 저장하여 json.Unmarshal()을 사용할 수 있지만, 이 방식은 비효율적이다. 비슷하게 json.Marshal() 함수를 통해 인메모리 []byte 버퍼에 값을 쓰고, 해당 버퍼의 데이터를 네티워크나 디스크에 쓸 수 있다. 다만 그 경우 io.Writer에 직접 데이터를 쓰는 편이 더 효율적일 것이다.

encoding/json 이러한 상황을 다루기 위한 두 가지 타입을 포함하고 있다. 바로 json.Decoderjson.Encoder 타입이며, 이 타입들은 io.Reader, io.Writer 인터페이스를 충족시키면서 읽기/쓰기를 할 수 있게 해준다. 간단한 예제를 확인해보자.

type Person struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

toFile := Person{
	Name: "Fred",
	Age:  40,
}

os.File 타입은 io.Writerio.Reader 인터페이스를 모두 충족시키기 때문에, json.Decoderjson.Encodeer를 시연하는 데 사용해보자. 먼저 임시 파일 인스턴스를 만들어 json.NewEncoder() 함수에 넘기면, 임시 파일에 대한 json.Encoder 인스턴스를 생성한다. 그 후 Encode() 메소드를 호출할 때 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)
}

이렇게 toFile을 임시 파일에 쓴 후 임시 파일을 다시 읽어들여서 json.NewDecoder()로 보내고, Decode() 메소드를 호출하여 Person 타입의 변수로 불러올 것이다.

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)

전체 예제는 여기서 확인할 수 있다.



Encoding and Decoding JSON Streams

여러 개의 JSON 구조체를 한 번에 읽거나 쓰려면 어떻게 해야 할까? 이런 상황에서도 json.Decoderjson.Encoder를 사용할 수 있다.

아래와 같은 데이터가 있다고 해보자.

{ "name": "Fred", "age": 40 }
{ "name": "Mary", "age": 21 }
{ "name": "Pat", "age": 30 }

우리의 예제에서 이 데이터는 data라는 문자열로 저장되어 있다고 가정할 것이다. 다만 실제로 이 데이터는 파일이나 HTTP 요청의 데이터일 수도 있다.

이 데이터를 t라는 구조체의 타입의 인스턴스로 만들 것이다.

var t struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}

이전처럼 json.Decoder를 데이터 소스로 초기화할 것이다. 다만 이번에는 json.DecoderMore() 메소드를 for 루프 조건으로 사용할 것이다. 이렇게 하면 한 번에 한 개의 JSON 객체를 데이터로 읽어올 수 있게 된다.

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

이 예제의 데이터 스트림에는 배열로 감싸지지 않은 여러 개의 JSON 객체들이 있다. 이들을 메모리에 한 번에 로드하지 않고, 위 예제처럼 json.Decoder로 단일 객체를 한 개씩 읽으면 성능이 향상되고 메모리 사용량이 줄어든다는 장점이 있다.


json.Encoder로 여러 개의 값을 쓰는 것은 한 개의 값을 쓰는 것과 유사하다. 예제에서는 bytes.Buffer에 값을 쓸 것이지만, io.Writer 인터페이스를 충족시킨다면 어느 타입이든 사용할 수 있다.

var allInputs = []Person{
	{Name: "Fred", Age: 40},
	{Name: "Mary", Age: 21},
	{Name: "Pat", Age: 30},
}

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()
fmt.Println(out)


Custon JSON Parsing

JSON 파싱 라이브러리의 기본적인 기능으로도 사용하는 데 문제는 없겠지만, 종종 오버라이드하여 사용해야할 때도 있을 것이다. time.Time은 RFC339 포맷은 기본적으로 JSON에서 RFC339 포맷을 사용하는데, 다른 시간 포맷을 사용해야 할 수도 있다. 그러한 경우 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 구조체 타입을 RFC822ZTime 타입에 Embedding하였기 때문에 기존 time.Time의 메소드에 접근할 수 있다. 또한 value receiver로 선언된 MarshalJSON() 메소드에서는 시간 값을 읽기만 하지만, pointer receiver로 선언된 UnmarshalJSON() 메소드에서는 시간 값을 변경한다.


이렇게 하여, 앞선 예제의 Order 타입의 DateOrdered 필드를 RFC822 포맷으로 사용 가능하게끔 수정해주었다.

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

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

전체 예제는 여기서 확인할 수 있다.


사실 이러한 방식에는 원칙적으로는 문제가 있다. 우리는 JSON에 작성된 날짜 포맷을 날짜 자료구조 필드값으로 변환하는데, 이는 encoding/json 방식의 단점이다. RFC822ZTime가 아닌 Order타입이 json.Marshalerjson.Unmarshaler 인터페이스를 충족시키게끔 할 수도 있었을 것이다. 다만 그렇게 하면 모든 필드를 직접 처리하도록 코드를 작성해주어야 한다. 직접 처리할 필요가 없는 필드에 대해서도 말이다. struct tag는 함수가 특정 필드만 파싱하도록 명시할 수 없기 때문에, 필드에 대한 타입을 직접 생성해주어야 하는 것이다.

JSON을 파싱하는 코드의 양을 제한하려면 두 개의 각기 다른 구조체를 정의해야 한다. 하나는 JSON으로 변환하거나 변환되는 것이며, 다른 하나는 데이터 처리에 관련된 것이다. 읽기를 할 때는 JSON을 JSON 인식 유형으로 읽은 다음, 이를 다른 유형으로 복사한다. 반면 JSON으로 쓰기를 하려면 이를 반대로 해주면 된다. 이 방식은 코드에 중복성이 생기기는 하지만, 비즈니스 로직이 통신 프로토콜에 의존하는 것을 방지해준다.

json.Marshal()이나 json.Unmarshal() 함수에 map[string]interface{} 타입을 파라미터로 보낼 수도 있다. 다만 이는 JSON에 무엇이 저장되어 있는지 확인하는 용도로만 쓰고, 확인한 이후에는 concrete type으로 변환해주는 것이 좋다. Go에서 타입은 그 자체로 처리할 데이터에 대한 문서 역할도 하니 말이다.

Go의 표준 라이브러리는 JSON뿐만 아니라 XML, Base64 등, 다양한 인코더와 디코더를 제공한다. 만약 인코딩해야 할 데이터 포맷이 있는데 이를 지원하는 표준 또는 서드 파티 라이브러리를 찾을 수 없다면, 직접 작성해주어야 한다. 직접 작성하는 법은 Reflection을 다루는 포스트에서 추후 다룰 예정이다.

표준 라이브러리 중 encoding/gob 패키지는 Go의 바이너리 데이터 인코딩으로, Java의 serialization이랑 비슷하다. Java serialization을 Java RMI나 Enterprise Java Beans에서 사용하는 것처럼, gob 프로토콜은 Go의 RPC(net/rpc 패키지)에서 사용하는 프로토콜이다. 다만 encoding/gobnet/rpc 둘 다 사용하지 않는 것을 권장한다. 특정 언어에 구애되지 않는 gRPC라는 좋은 프로토콜이 있다.



net/http


Go에는 HTTP/2 클라이언트 및 서버를 작성하는 표준 라이브러리가 있다. 클라이언트 및 서버의 코드 작성 예를 알아보자.



The Client

net/http 패키지에는 http request를 생성하고 http response를 받는, Client 타입이 정의되어 있다. net/http에 정의된 가장 기본적인 클라이언트 인스턴스(DefaultClient)가 있지만, 릴리즈될 어플리케이션에서는 이를 사용하지 않는 것이 좋다. 기본적으로 DefaultClient에는 타임아웃이 없다. 따라서 직접 인스턴스 하나를 생성해 주는 것이 좋다. 기억해야 할 점은 프로그램 전체에서 단 한 개의 http.Client 인스턴스만 생성하면, 여러 개의 고루틴이 돌아간다고 해도 요청들을 충분히 처리할 수 있다는 것이다.

client := &http.Client{
	Timeout: 10 * time.Second,
}

새로운 요청을 생성하려면 http.NewRequsetWithContext() 함수에 context, requset method, URL을 넘겨서 *http.Requset 인스턴스를 생성해준다. 만약 request method가 PUT, POST, PATCH라면, 마지막 파라미터로 io.Reader 타입의 request body를 작성해줘야 한다. 만약 request body가 없다면, nil을 쓰면 된다.

또한 이렇게 생성한 *http.Requset 인스턴스의 Header 필드의 메소드들을 호출하여 request header를 설정할 수 있다. 아래 예제에서는 Add() 메소드로 request header를 추가해주었다.

req, err := http.NewRequestWithContext(
	context.Background(),
	http.MethodGet,
	"https://jsonplaceholder.typicode.com/todos/1",
	nil,
)
if err != nil {
	panic(err)
}
req.Header.Add("X-My-Client", "Learning Go")

*http.Requset 인스턴스의 설정을 마쳤다면, 이제 요청을 전송해보자. http.ClientDo() 메소드를 호출하면 요청이 전송되고, 요청에 대한 응답에 해당하는 http.Response 인스턴스를 리턴 값으로 받을 것이다.

res, err := client.Do(req)
if err != nil {
	panic(err)
}

이렇게 전송받은 http.Response 인스턴스의 필드값들은 요청에 대한 정보를 담고 있다. response status code는 StatusCode 필드에 저장되며, response status message는 Status 필드에 저장된다. 또한 response header는 Header 필드에, response body는 Body 필드에 io.ReadCloser 타입으로 저장된다. 따라서 json.Decoder를 이용해서 REST API 응답을 처리할 수 있다.

defer res.Body.Close()
if res.StatusCode != http.StatusOK {
	panic(fmt.Sprintf("unexpected status: got %v", res.Status))
}
fmt.Println(res.Header.Get("Content-Type"))

var data struct {
	UserID    int    `json:"userId"`
	ID        int    `json:"id"`
	Title     string `json:"title"`
	Completed bool   `json:"completed"`
}
err = json.NewDecoder(res.Body).Decode(&data)
if err != nil {
	panic(err)
}
fmt.Printf("%+v\n", data)

위 예제의 실행 결과는 다음과 같다.

application/json; charset=utf-8
{UserID:1 ID:1 Title:delectus aut autem Completed:false}

net/http 패키지에는 http.Get(), http.Head(), http.Post()등 요청을 보내는 함수가 있다. 이 함수들은 DefaultClient로 요청을 보내기 때문에 사용하지 않는 것이 좋다(얘네 타임아웃이 없다).



The Server

HTTP 서버는 http.Serverhttp.Handler 인터페이스의 개념에 기반하여 구축한다. http.Client는 http request를 전송하는 역할을 하듯, http.Server는 TLS를 지원하며, http request를 핸들링하는 성능 좋은 HTTP/2 서버이다.

서버에 대한 요청은 http.Handler 인터페이스를 충족시키는 타입에 의해 핸들링된다. 이 인터페이스는 단일 메소드를 정의한다.

type Handler interface {
	ServeHTTP(http.ResponseWriter, *http.Request)
}

ServeHTTP()의 파라미터 중 *http.Requesthttp.Client로 요청을 보낼 때 사용했던 타입과 동일한 것이다. http.ResponseWriter 인터페이스는 아래처럼 세 개의 메소드를 정의한다.

type ResponseWriter interface {
	Header() http.Header
	Write([]byte) (int, error)
	WriterHeader(statusCode int)
}

이 세 개의 메소드는 반드시 일정한 순서대로 호출된다.

가장 먼저 호출되는 것은 Header()로, http.Header 인스턴스를 리턴받아서 response header를 설정하기 위해 사용된다. 특별히 response header를 설정할 필요가 없다면 Header()를 반드시 호출할 필요는 없다.

그 다음으로는 response status code를 설정하기 위해 WriteHeader()를 호출한다. 이때 모든 status code는 net/http에 상수로 정의되어 있다. (패키지 레벨에서 status code에 대한 특정한 타입이 정의되었으면 좋았을 텐데, 그렇지 않다. 실제로 status code들은 특별한 타입이 없는 그냥 정수형이다.) 만약 status code 200을 보내려고 한다면 WriteHeader()를 굳이 호출해줄 필요는 없다.

마지막으로 Write()를 호출하여, response body를 설정해준다.


가장 기본적인 형태의 handler를 작성해보자.

type HelloHandler struct{}

func (hh HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello!\n"))
}

어차피 구조체의 필드는 그렇게 중요하지 않으니 비워 두었고, 필요한 ServeHTTP() 메소드만 정의해 주었다.


새로운 http.Server 인스턴스를 생성하여, 서버를 열어보자.

s := http.Server{
	Addr:         ":8080",
	ReadTimeout:  3 * time.Second,
	WriteTimeout: 5 * time.Second,
	IdleTimeout:  100 * time.Second,
	Handler:      HelloHandler{},
}

err := s.ListenAndServe()
if err != nil {
	if err != http.ErrServerClosed {
		panic(err)
	}
}

http.ServerAddr 필드는 서버가 열릴 호스트 주소와 포트를 지정한다. 따로 지정해주지 않으면 모든 호스트 주소에 대해 HTTP 표준 포트인 80으로 서버를 열 것이다.

ReadTimeout, WriteTimeout, IdleTimeout 필드는 서버의 읽기, 쓰기, 유휴 상태의 타임아웃을 time.Duration값으로 명시할 수 있다. 기본적으로 타임아웃을 두지 않기 때문에, 이 필드를 지정해주지 않으면 잘못된 요청을 적절히 핸들링하지 못할 것이다.

Hander 필드에 http.Handler를 충족시키는 타입을 지정해주면 된다.


단일 종류의 요청만 받는 서버는 별로 쓸데가 없을 것이다. Go 표준 라이브러리에는 요청 라우터인, *http.ServeMux를 포함하고 있다. http.NewServerMux() 함수로 새로운 *http.ServeMux 인스턴스를 생성할 수 있으며, 이는 http.Handler 인터페이스를 충족시키기 때문에 http.ServerHandler 필드에 할당될 수 있다.

또한, *http.ServeMux는 요청을 분류할 수 있는 두 개의 메소드를 포함하고 있다. 첫 번째 메소드는 두 개의 파라미터를 받는 Handle()로, 두 개의 파라미터는 각각 주소 경로와 http.Handler 인스턴스이다. 만약 주소가 일치한다면 http.Handler가 호출될 것이다. 또 다른 방법은 더 일반적으로 사용하는 HandleFunc() 메소드이다.

mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello!\n"))
})

이 메소드에서 볼 수 있듯 파라미터로 주소 경로와, http.HandlerServeHTTP()를 만족시키는 closure를 작성해 주었다. 해당 패턴은 인터페이스 포스트의 Function Types Are a Bridge to Interfaces에서 소개하였으니, 확인할 수 있다.

다만 핸들러가 다른 비즈니스 로직을 사용하여 더 복잡해질 경우, 구조체 타입에 메소드를 정의하여 사용해주는 것이 좋다. 이에 관련된 내용도 인터페이스 포스트의 Implicit Interfaces Make Dependency Injection Easier에서 소개하였다.


net/http에는 패키지 레벨 함수인 http.Handle, http.HandleFunc, http.ListenAndServe, http.ListenAndServeTLS 함수가 있으며, 이들은 *http.ServeMux의 패키지 레벨 인스턴스인 http.DefaultServeMux를 기준으로 동작한다. 예상되겠지만, 이 함수들은 릴리즈될 어플리케이션에서는 사용이 권장되지 않는다.

http.Server 인스턴스가 http.ListenAndServehttp.ListenAndServeTLS에서 생성되기 때문에, 타임아웃과 같은 설정을 지정해줄 수 없다. 뿐만 아니라 서드 파티 라이브러리가 http.DefaultServeMux에 자체 핸들러를 등록해버릴 수 있기 때문에, 모든 의존성들을 스캔하지 않고는 이를 알 수 없다. 어플리케이션이 shared state로 인해 통제되는 상황은 되도록 피해야 한다.


*http.ServeMuxhttp.Handler에 요청을 보내고 http.Handler 인터페이스를 충족시키기 때문에, 여러 관련된 요청에 대한 *http.ServeMux 인스턴스를 생성하고, 부모 *http.ServeMux에 등록할 수 있다.

func main() {
	person := http.NewServeMux()
	person.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("greetings!\n"))
	})

	dog := http.NewServeMux()
	dog.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("good puppy!\n"))
	})

	mux := http.NewServeMux()
	mux.Handle("/person/", http.StripPrefix("/person", person))
	mux.Handle("/dog/", http.StripPrefix("/dog", dog))

	s := http.Server{
		Addr:         ":8080",
		ReadTimeout:  3 * time.Second,
		WriteTimeout: 5 * time.Second,
		IdleTimeout:  100 * time.Second,
		Handler:      mux,
	}

	err := s.ListenAndServe()
	if err != nil {
		if err != http.ErrServerClosed {
			panic(err)
		}
	}
}

위 예제의 구조를 이해하는 것이 그렇게 어렵진 않을 것이다.

/person/greet로 요청을 보내면 person에 붙어있는 핸들러로 처리되며, /dog/greet로 요청을 보내면 dog에 붙어있는 핸들러로 처리가 된다.

persondogmux에 등록할 때 http.StripPrefix() 함수를 사용하여, 주소에서 mux에 의해 이미 처리된 부분을 제거해주었다.



Middleware

HTTP 서버의 또 다른 중요한 요소 중 하나는 로그인 여부 확인, 요청 시간 체크, 요청 헤더 체크 등 여러 동작을 수행하는 것이다. Go에서는 이러한 연관된 기능들을 미들웨어를 사용하여 구현한다. 미들웨어는 특별한 타입을 사용하지 않고, http.Handler를 파라미터로 받아 http.Handler를 반환하는 함수를 작성하여 사용한다. 대개 반환된 http.Handlerhttp.HandlerFunc로 반환될 수 있는 closure가 된다.


아래 예제에는 두 개의 미들웨어 생성기가 있다. 하나는 요청의 시간을 재는 것이고, 또 하나는 액세스 제어의 나쁜 예라고 할 수 있다.

func RequestTimer(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		h.ServeHTTP(w, r)
		end := time.Now()
		log.Printf("request time for %s: %v", r.URL.Path, end.Sub(start))
	})
}

var securityMsg = []byte("You didn't give the secret password\n")

func TerribleSecurityProvider(password string) func(http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.Header.Get("X-Secret-Password") != password {
				w.WriteHeader(http.StatusUnauthorized)
				w.Write(securityMsg)
				return
			}
			h.ServeHTTP(w, r)
		})
	}
}

이 두 개의 미들웨어 구현을 보면 미들웨어가 무슨 역할을 하는지 알 수 있을 것이다. 먼저 연산이나 조건 검사를 하기 위한 셋업을 한 후, 조건을 만족하지 않으면 보통 미들웨어에서 error status code와 함께 Write()로 출력을 한 후 함수를 리턴한다. 만약 문제가 없다면, 다음 핸들러의 ServeHTTP() 메소드를 호출한다. 그 후 정리 작업을 수행한다.

TerribleSecurityProvider()는 직접 설정 가능한 미들웨어를 생성하는 예제라고 할 수 있다. 설정 정보(예제에서는 비밀번호)를 TerribleSecurityProvider()의 파라미터로 보내면, 해당 정보를 사용하는 미들웨어를 반환한다. 다만 closure를 반환하는 closure를 반환하기 때문에(쓰기도 어려움;), 살짝 마음에 걸리는 것이다.


미들웨어 레이어에서의 값의 전달은 context를 통해 이루어진다.


미들웨어를 연결하여 request handler에 추가해보자.

func main() {
	terribleSecurity := TerribleSecurityProvider("GOPHER")

	mux := http.NewServeMux()
	mux.Handle("/hello", http.StripPrefix("/person", terribleSecurity(RequestTimer(
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Hello!\n"))
		}),
	))))

	s := http.Server{
		Addr:         ":8080",
		ReadTimeout:  3 * time.Second,
		WriteTimeout: 5 * time.Second,
		IdleTimeout:  100 * time.Second,
		Handler:      mux,
	}

	err := s.ListenAndServe()
	if err != nil {
		if err != http.ErrServerClosed {
			panic(err)
		}
	}
}

위 예제에서 우리는 TerribleSecurityProvider()로부터 미들웨어를 생성하고, 핸들러를 각각의 함수 호출로 감싸주었다. 구조적으로 terribleSecurity() closure가 먼저 호출되고, 그 다음으로 RequestTimer, 그 다음으로 원래의 request handler가 호출된다.

*http.ServeMuxhttp.Handler 인터페이스를 충족시킨다. 아래처럼 적용하면 미들웨어들을 request router에 등록된 모든 핸들러에 적용할 수 있다.

mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("greetings!\n"))
})
wrappedMux := terribleSecurity(RequestTimer(mux))

s := http.Server{
	Addr:         ":8080",
	ReadTimeout:  3 * time.Second,
	WriteTimeout: 5 * time.Second,
	IdleTimeout:  100 * time.Second,
	Handler:      wrappedMux,
}


Use idiomatic third-party modules to enhance the server

서버에 서드파티 라이브러리를 사용하여 기능을 개선할 수 있다. 위 예제에서 보았던 미들웨어의 chain이 마음에 들지 않는다면, alice라는 서드파티 라이브러리를 사용할 수 있다.

helloHandler := func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello!\n"))
}
chain := alice.New(terribleSecurity, RequestTimer).ThenFunc(helloHandler)
mux.handle("/hello", chain)

표준 라이브러리를 사용하여 HTTP 서버를 구축하는 것의 가장 큰 문제점은 *http.ServeMux를 request router로 사용한다는 점이다. 이 request router는 HTTP 메소드나 헤더를 기준으로 구분할 수 없으며, Query parameter도 처리할 수 없다. 게다가 *http.ServeMux 인스턴스가 중첩되면 너무 거대해지기도 한다.

이를 대체하기 위한 프로젝트는 되게 많은데, 대표적인 게 바로 gorilla mux와 [https://github.com/go-chi/chi]이다. 이 두 라이브러리가 이상적이라 여겨지는 이유는 http.Handlerhttp.HandlerFunc 인스턴스와 함께 사용할 수 있기 때문이며, 표준 라이브러리와 잘 어우러질 수 있는 라이브러리를 사용하는 Go의 철학을 보여준다. 또한 관용적인 미들웨어들을 사용할 수 있으며, 주로 사용되는 미들웨어의 구현체를 제공한다.



References


[

Learning Go Book Cover ](https://learning.oreilly.com/library/view/learning-go/9781492077206/)
[Jon Bodner, 『Learning Go』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/learning-go/9781492077206/)


© 2024 JHSeo. All right reserved.