Go 표준 라이브러리의 net/http, net/url, encoding/json, context를 사용해 HTTP 클라이언트/서버, JSON API, 타임아웃과 취소를 다룬다.
net/http, context, encoding/json으로 실용적인 백엔드 만들기Efron Licht의 소프트웨어 아티클
2023년 9월
이 글은 Go로 백엔드 개발을 다루는 연재의 두 번째 글이다.
첫 번째 글에서는 인터넷의 구성 요소를 HTTP까지(TCP, IP, DNS) 훑고, 직접 HTTP 라이브러리와 기본 서버를 만들어 보았다.
이번 글에서는 Go 표준 라이브러리를 본격적으로 살펴보며, 기본적인 클라이언트/서버 HTTP 통신에 필요한 모든 것을 어떻게 제공하는지 알아본다. net/http와 net/url로 HTTP 요청/응답을 주고받고, encoding/json으로 API 페이로드를 다루며, context로 타임아웃과 취소를 관리한다.
다음 글에서는 미들웨어, 라우팅, 데이터베이스 기초를 다룰 예정이다.
이전과 마찬가지로 각 글의 전체 소스 코드는 gitlab에 있고, 많은 프로그램은 Go playground에서 실행 가능한 예제 링크도 제공한다.
2025-12-09에 자동 생성됨
net/http 패키지net/http는 완전한 HTTP 클라이언트 및 서버 구현을 제공한다. Go로 웹 애플리케이션을 만들기 시작할 때 좋은 출발점이다. 먼저 클라이언트 측부터 살펴보자.
이전 글에서 직접 Request와 Response 타입을 만들었지만, net/http는 자체 타입을 제공한다. Go 개발자라면 이 타입들을 99.9%의 경우에 사용해야 한다. 계속 진행하기 전에 http.Request와 http.Response 구조체 정의를 한 번 훑어보며 우리가 만든 것과 어떻게 다른지 확인해 보자. 가장 중요한 차이는 HTTP가 스트리밍 프로토콜이기 때문에, 본문(body)을 문자열이 아니라 io.Reader로 다룬다는 점이다.
http.Requesthttp.NewRequestWithContext(ctx, method, url, body)로 HTTP 요청을 만든다. 라이브러리가 자동으로 URL을 파싱하고 Host 헤더를 설정해 준다. body 인수는 요청 본문을 제공하는 io.Reader이다. 본문이 없으면 nil을 넘기면 된다.
컨텍스트 없이 http.NewRequest를 절대 사용하지 말자. 어떤 컨텍스트를 써야 할지 모르겠다면 context.TODO()를 사용하라. 나중에 많은 골칫거리를 줄여준다.
가장 기본 예시는 본문이 없는 GET 요청이다:
1ctx := context.TODO() // 어떤 컨텍스트를 써야 할지 모르겠다면 context.TODO()를 사용한다.
2var body io.Reader = nil // nil reader는 OK; 즉 본문이 없다는 뜻.
3const method = "GET"
4const url = "https://eblog.fly.dev/index.html"
5req, err := http.NewRequestWithContext(ctx, method, url, body) // 이 함수가 URL을 파싱하고 Host 헤더를 설정한다; URL이 잘못되면 에러.
POST 요청이라면 본문을 제공해야 한다. 가장 간단한 방법은 strings.NewReader로 문자열에서 reader를 만드는 것이다:
1const method = "POST"
2const url = "https://eblog.fly.dev/index.html"
3var body io.Reader = strings.NewReader("hello, world")
4req, err := http.NewRequestWithContext(ctx, method, url, body)
요청은 Host 헤더(및 User-Agent, Accept-Encoding 같은 몇 가지)를 자동으로 설정하지만, 나머지는 직접 설정해야 한다. Go는 요청/응답의 HTTP 헤더를 표현하는 Header 타입을 제공한다. 즉 이 타입은 개별 키-값 쌍이 아니라 요청(또는 응답) 하나의 전체 헤더 집합을 나타낸다.
http.Header는 작업을 쉽게 해 주는 몇 가지 특별한 메서드를 가진 map[string][]string이다. 왜 []string일까? HTTP는 같은 키로 여러 헤더를 허용하기 때문이다. 예를 들어 아래는 유효한 HTTP 요청이다:
1GET / HTTP/1.1
2Host: eblog.fly.dev
3User-Agent: eblog/1.0
4Accept-Encoding: gzip
5Accept-Encoding: deflate
6Some-Key: somevalue
http.Header의 모든 메서드는 키를 _정규화(canonicalize)_하여 Title-Case로 만든다. 예를 들어 Header.Add("accept-encoding", "gzip")는 키가 Accept-Encoding인 헤더를 추가한다. 자세한 내용은 이전 글을 참고하라.
http.Header 사용을 간단히 정리하면:
Header.Add(key, value)로 헤더를 추가한다: 키를 자동으로 Title-Case로 정규화하고 해당 키의 값 목록 끝에 값을 추가한다. k := AsTitle(key); Header[k] = append(Header[k], value)로 읽으면 된다.
Header.Set(key, value)로 헤더를 설정한다: 키를 자동으로 Title-Case로 정규화하고 해당 키의 값을 value 하나만 담은 단일 요소 리스트로 설정한다. Header[AsTitle(key)] = []string{value}로 읽으면 된다.
Header.Get은 키에 매칭되는 헤더 중 첫 번째 값을 가져오며, 없으면 빈 문자열을 반환한다.
Header.Values(key)는 정규화된 키에 매칭되는 헤더 값 리스트를 반환한다.
http.Request와 http.Response는 모두 같은 헤더 타입을 사용한다.
위 요청을 만들어 보자.
1
2package main
3
4func main() {
5 // https://go.dev/play/p/eE32qPmuDeS
6 const method = "GET"
7 const url = "https://eblog.fly.dev/index.html"
8 var body io.Reader = nil
9 req, err := http.NewRequestWithContext(context.TODO(), method, url, body)
10 if err != nil {
11 log.Fatal(err)
12 }
13 req.Header.Add("Accept-Encoding", "gzip")
14 req.Header.Add("Accept-Encoding", "deflate")
15 req.Header.Set("User-Agent", "eblog/1.0")
16 req.Header.Set("some-key", "a value") // Some-Key로 정규화됨
17 req.Header.Set("SOMe-KEY", "somevalue") // Add가 아니라 Set을 썼으므로 위를 덮어씀
18 req.Write(os.Stdout)
19}
20
http.Request.Write는 제공된 io.Writer에 요청을 HTTP 형식으로 직렬화한다. 여기서는 os.Stdout을 쓰므로 터미널에 요청이 출력된다.
프로그램 실행:
IN:
1go run ./main.go
OUT:
1GET /index.html HTTP/1.1
2Host: eblog.fly.dev
3User-Agent: eblog/1.0
4Accept-Encoding: gzip
5Accept-Encoding: deflate
6Some-Key: somevalue
net/url.Values로 URL 만들기net/url.ValuesURL을 손으로 만들 수도 있지만, 쿼리 파라미터는 올바르게 이스케이프해야 해서 가끔 까다롭다. url.Values 타입은 http.Header와 매우 비슷한 API로 쿼리 파라미터를 편리하게 구성할 수 있게 해준다. 이전 글에서는 이름에 “ice”가 들어가는 매직 카드를 scryfall에서 검색하고, 출시일 기준 오름차순으로 정렬했다. URL은 다음과 같았다:
1GET /search?q=ice&order=released&dir=asc HTTP/1.1
2Host: scryfall.com
이번에는 이름에 “of Emrakul”이라는 구문이 포함된 카드를 검색해 보자. API 문서에 따르면 공백이 포함된 구문 검색에는 큰따옴표가 필요하며, URL이므로 “of”와 “Emrakul” 사이 공백도 이스케이프해야 한다. 손으로 하기 까다로우니 url.Values를 쓰자.
url.Values로 요청을 구성해 보자:
IN:
1// https://go.dev/play/p/OzX3Ule7Q3r
2func main() {
3 const method = "GET"
4 v := make(url.Values)
5 v.Add("q", `"of Emrakul"`) // 큰따옴표 이스케이프를 피하려고 Go의 raw string (`)을 사용.
6 v.Add("order", "released")
7 v.Add("dir", "asc")
8 const path = "https://scryfall.com/search"
9 dst := path + "?" + v.Encode() // Encode()가 값을 이스케이프해 준다. '?' 구분자 기억!
10 req, err := http.NewRequestWithContext(context.TODO(), method, dst, nil)
11 if err != nil {
12 log.Fatal(err)
13 }
14 req.Write(os.Stdout)
15}
OUT:
1GET /search?dir=asc&order=released&q=%22of+Emrakul%22 HTTP/1.1
2Host: scryfall.com
3User-Agent: Go-http-client/1.1
Host와 함께 Go가 User-Agent 헤더도 자동으로 추가해 준 것에 주목하자.
net/url은 쿼리 파라미터만 다루는 것이 아니라, 완전한 URL 파서이자 빌더다. 큰 패키지도 아니니, 시간 날 때 문서를 20분 정도 읽어보길 권한다.
응답은 전반적으로 요청과 비슷하다. 자세한 내용은 진행하면서 더 다루겠지만, 간단히 요약하면:
Response.Body로 응답 본문에 접근하며, 타입은 io.ReadCloser이다.
헤더는 Response.Header에 있고, 상태 코드는 Response.StatusCode에 있다.
Response.Write에 io.Writer를 넘기면 전체 HTTP 응답을 볼 수 있다.
응답은 클라이언트만 다뤄야 하며, 서버는 대신 http.ResponseWriter API를 사용한다.
http.ClientClient는 Do를 사용해 서버로 Request를 보내고 Response를 받게 해 준다.
1func (c *Client) Do(req *Request) (*Response, error)
Do는 http.Client의 핵심 메서드이며, 다른 메서드들은 모두 이것의 래퍼다. 이 글의 목적상 우리는 Do만 사용할 것이다. 단일하고 일관된 API를 제공하니 여러분도 ‘Do’만 쓰길 권한다.
http.Get, http.Post, http.PostForm, http.Do는 http.DefaultClient.Do의 래퍼다.
http.Client.Get, http.Client.Post, http.Client.PostForm도 http.Client.Do의 래퍼다. (PostForm은 가끔 유용하지만, 나머지는 단순화보다 숨기는 게 더 많다고 생각한다.)
다음 완전한 프로그램 download는 http.Client로 인터넷에서 파일을 내려받아 로컬 파일시스템에 저장한다.
1// download는 URL에서 파일을 다운로드하는 커맨드라인 도구이다.
2// usage: download [-timeout duration] url filename
3package main
4
5import (
6 "context"
7 "flag"
8 "io"
9 "log"
10 "net/http"
11 "os"
12 "path/filepath"
13 "time"
14)
15
16func main() {
17 dir := flag.String("dir", ".", "directory to save file")
18 timeout := flag.Duration("timeout", 30*time.Second, "timeout for download")
19 flag.Parse()
20 args := flag.Args()
21 if len(args) != 2 {
22 log.Fatal("usage: download [-timeout duration] url filename")
23 }
24 url, filename := args[0], args[1]
25 // HTTP 요청을 보낼 때는 항상 타임아웃을 설정하라.
26 c := http.Client{Timeout: *timeout}
27
28 // 컨텍스트 세부사항은 지금은 신경 쓰지 말자; 이 글 뒤에서 다룬다.
29 // 어떤 컨텍스트를 써야 할지 모르겠다면 context.TODO()를 사용하라.
30 if err := downloadAndSave(context.TODO(), &c, url, filename); err != nil {
31 log.Fatal(err)
32 }
33}
34func downloadAndSave(ctx context.Context, c *http.Client, url, dst string) error {
35 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
36 if err != nil {
37 return fmt.Errorf("creating request: GET %q: %v", url, err)
38 }
39 resp, err := c.Do(req) // Do는 http.Request를 직렬화해 서버로 보내고, 응답을 http.Response로 역직렬화한다.
40
41 // Do 호출 뒤에는 항상 에러를 확인하라. Do의 에러는 보통 네트워크에서 문제가 생겼다는 뜻이다.
42 if err != nil {
43 return fmt.Errorf("request: %v", err)
44 }
45 defer resp.Body.Close() // 응답 본문은 다 쓴 뒤 항상 닫아라.
46
47 // 에러 체크 직후에는 응답 상태 코드를 확인하라; 서버가 요청 성공 여부를 알려주는 방식이다.
48 if resp.StatusCode != http.StatusOK {
49 return fmt.Errorf("response status: %s", resp.Status)
50 }
51
52 // 성공 응답을 받았다. 파일로 저장하자.
53
54 dstPath := filepath.Join(*dir, filename)
55 dstFile, err := os.Create(dstPath)
56 if err != nil {
57 return fmt.Errorf("creating file: %v", err)
58 }
59 defer dstFile.Close() // 파일도 다 쓴 뒤 항상 닫아라.
60 if _, err := io.Copy(dstFile, resp.Body); err != nil {
61 return fmt.Errorf("copying response to file: %v", err)
62 }
63}
이 블로그의 인덱스 페이지를 내려받아 보자:
IN:
1go build -o download ./download.go # 프로그램 빌드
2./download https://eblog.fly.dev/index.html index.html # index 페이지를 index.html에 저장
3cat index.html # 파일 내용을 출력
OUTPUT:
1<!DOCTYPE html>
2<html>
3 <head>
4 <title>index.html</title>
5 <meta charset="utf-8" />
6 <link rel="stylesheet" type="text/css" href="/dark.css" />
7 </head>
8 <body>
9 <h1>articles</h1>
10 <h4><a href="/backendbasics.html">backendbasics.html</a></h4>
11 <h4><a href="/console.html">console.html</a></h4>
12 <h4><a href="/mermaid_test.html">mermaid_test.html</a></h4>
13 <h4><a href="/benchmark_results.html">benchmark_results.html</a></h4>
14 <h4><a href="/quirks2.html">quirks2.html</a></h4>
15 <h4><a href="/fastdocker.html">fastdocker.html</a></h4>
16 <h4><a href="/faststack.html">faststack.html</a></h4>
17 <h4><a href="/console-autocomplete.html">console-autocomplete.html</a></h4>
18 <h4><a href="/quirks3.html">quirks3.html</a></h4>
19 <h4><a href="/cheatsheet.html">cheatsheet.html</a></h4>
20 <h4><a href="/bytehacking.html">bytehacking.html</a></h4>
21 <h4><a href="/quirks.html">quirks.html</a></h4>
22 <h4><a href="/reflect.html">reflect.html</a></h4>
23 <h4><a href="/startfast.html">startfast.html</a></h4>
24 <h4><a href="/performanceanxiety.html">performanceanxiety.html</a></h4>
25 <h4><a href="/article_list.html">article_list.html</a></h4>
26 <h4><a href="/noframework.html">noframework.html</a></h4>
27 <h4><a href="/onoff.html">onoff.html</a></h4>
28 <h4><a href="/README.html">README.html</a></h4>
29 <h4><a href="/testfast.html">testfast.html</a></h4>
30 <h4><a href="/index.html">index.html</a></h4>
31 </body>
32</html>
타임아웃을 1ms로 설정하면 에러가 난다:
IN:
1./download -timeout 1ms https://eblog.fly.dev/index.html index.html
OUT:
12023/09/09 09:33:15 request: Get "https://eblog.fly.dev/index.html": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
여기서 또 context가 나온다. 타임아웃과 취소가 있는 곳에는 항상 context가 함께한다.
클라이언트는 여러 고루틴에서 동시에 안전하게 사용할 수 있다. 다음 완전한 프로그램 pardownload는 URL 목록을 병렬로 다운로드하여 지정한 디렉터리에 저장한다. 이전 프로그램의 자연스러운 확장이다.
일반적으로 http.Client는 가능한 한 재사용해야 하며, 요청마다 새로 만들지 않는 것이 좋다.
1// pardownload는 URL 목록을 병렬로 다운로드하여 지정한 디렉터리에 저장한다.
2// 다운로드 중 하나라도 실패하면 0이 아닌 상태 코드로 종료하며, 상태 코드는 실패한 다운로드 수이다.
3package main
4
5import (
6 "context"
7 "flag"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "os"
13 "path/filepath"
14 "sync"
15 "time"
16)
17
18func main() {
19 var dstDir string
20 var client http.Client // http.Client의 제로 값은 사용 가능한 클라이언트이다.
21 flag.StringVar(&dstDir, "dst", "", "destination directory; defaults to current directory")
22 // 커맨드라인 플래그로 클라이언트 타임아웃 설정.
23 flag.DurationVar(&client.Timeout, "timeout", 1*time.Minute, "timeout for the request")
24 flag.Parse()
25
26 src := flag.Args()
27 if len(src) == 0 {
28 log.Fatalf("can't copy")
29 }
30 dstDir, err := filepath.Abs(dstDir) // 목적지 디렉터리를 절대 경로로 만들어 에러 메시지 가독성 향상.
31 if err != nil {
32 log.Fatalf("invalid destination directory: %v", err)
33 }
34 dst := make([]string, len(src)) // src와 같은 길이의 슬라이스를 만들어 동기화 없이 병렬 접근.
35 for i := range src {
36 dst[i] = filepath.Join(dstDir, filepath.Base(src[i]))
37 }
38
39 errs := make([]error, len(src)) // 에러도 같은 방식으로 슬라이스 준비.
40
41 wg := new(sync.WaitGroup) // WaitGroup은 고루틴 집합이 끝날 때까지 기다린다.
42 wg.Add(len(src)) // 기다릴 고루틴 수를 더한다.
43
44 now := time.Now()
45 for i := range src {
46 i := i // https://golang.org/doc/faq#closures_and_goroutines 참고
47 go func() {
48 defer wg.Done() // 끝났음을 WaitGroup에 알림.
49 // 단순한 함수라 꼭 defer가 필요하진 않지만, 좋은 습관이다.
50 errs[i] = downloadAndSave(context.TODO(), &client, src[i], dst[i])
51 }()
52 }
53 wg.Wait() // 모든 고루틴이 끝날 때까지 대기.
54
55 log.Printf("downloaded %d files in %v", len(src), time.Since(now))
56 var errCount int // 에러 개수
57 for i := range errs {
58 if errs[i] != nil {
59 log.Printf("err: %s -> %s: %v", src[i], dst[i], errs[i])
60 errCount++
61 } else {
62 log.Printf("ok: %s -> %s", src[i], dst[i])
63 }
64 }
65 os.Exit(errCount) // 0이 아닌 종료 코드는 실패를 의미.
66}
이로써 outbound *http.Request를 다뤘다. 이제 들어오는 요청을 서빙하는 방법을 이야기하자.
http.Handler 인터페이스는 Go HTTP 서버의 핵심이다. 클라이언트의 Do 메서드를 떠올리면, 서버 쪽 인터페이스도 비슷할 것이라고 기대할 수 있다:
1func (c *Client) Do(req *Request) (*Response, error)
아마 이런 형태?
1type NotQuiteHandler interface { ServeHTTP(req *http.Request) (*http.Response, error) }
하지만 실제로는 그렇지 않다. 무엇보다 HTTP는 스트리밍 응답 프로토콜이다. 응답 본문을 생성되는 대로 쓰되 전체를 메모리에 버퍼링하지 않을 방법이 필요하다. 큰 응답(예: 파일 다운로드)에서는 전체를 메모리에 올리는 것이 비권장일 뿐 아니라 불가능할 수도 있다.
그래서 응답을 반환하는 대신 http.Handler는 다음 시그니처를 가진다:
1type Handler interface { ServeHTTP(http.ResponseWriter, *http.Request) }
*http.Request는 충분히 봤으니, 이제 http.ResponseWriter를 보자. 이 인터페이스는 Header, Write, WriteHeader 세 메서드를 가진다. http.Handler는 이 메서드들을 호출해 응답을 구성해야 한다.
1// ResponseWriter 인터페이스는 HTTP 핸들러가 HTTP 응답을 구성할 때 사용한다.
2type ResponseWriter interface {
3 // 응답 헤더에 접근한다. 헤더는 첫 Write 호출 전에 써야 한다.
4 Header() Header // http.Request.Header와 같은 기반 타입
5
6 // 응답 본문에 데이터를 쓴다.
7 Write([]byte) (int, error)
8
9 // WriteHeader는 주어진 상태 코드(200, 404 등)로 HTTP 응답 헤더를 보낸다.
10 // 첫 Write 호출 전에 호출해야 한다; 그렇지 않으면 암묵적으로 WriteHeader(http.StatusOK)가 전송된다.
11 WriteHeader(statusCode int)
12}
Server는 Handler와 바인드할 주소를 넘겨 만들고, Server.ListenAndServe를 호출해 시작한다.
다음 완전한 프로그램은 “hello, world” 텍스트를 담은 200 OK 응답을 반환하는 최소 HTTP 서버를 보여준다.
IN:
1// https://go.dev/play/p/AjoS1drDEpn
2func main() {
3 server := http.Server{Addr: ":8080", Handler: TextHandler("hello, world!\r\n")}
4 go server.ListenAndServe()
5 req, _ := http.NewRequestWithContext(context.TODO(), "GET", "http://localhost:8080", nil)
6 resp, err := new(http.Client).Do(req)
7 _ = err
8 defer resp.Body.Close()
9 resp.Write(os.Stdout) // 응답을 stdout에 출력.
10
11}
12
13// TextHandler는 제공된 텍스트로 200 OK 응답을 반환하는 단순 http.Handler.
14type TextHandler string
15var _ http.Handler = TextHandler("") // TextHandler가 http.Handler를 구현하는지 확인
16func (t TextHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(t)) } // 암묵적 200 OK
OUT:
1HTTP/1.1 200 OK
2Content-Length: 12
3Content-Type: text/plain; charset=utf-8
4Date: Tue, 10 Nov 2009 23:00:00 GMT
서버가 Content-Length, Date, Content-Type 헤더를 자동으로 추가한 점에 주목하자. 작은 페이로드에서는 Content-Length에 의존해도 괜찮지만, Content-Type은 항상 직접 설정해야 한다. Go의 내장 스니퍼는 text/plain과 encoding/json을 종종 헷갈린다.
서버에는 설정 가능한 타임아웃이 네 가지 있다:
| 필드 | 설명 | 상속? |
|---|---|---|
ReadTimeout | 클라이언트가 요청을 보내기까지 기다리는 최대 시간 | N/A |
WriteTimeout | 서버가 응답을 보내기까지 기다리는 최대 시간 | N/A |
IdleTimeout | 지속 연결에서 클라이언트가 새 요청을 보내기까지 기다리는 최대 시간 | ReadTimeout |
ReadHeaderTimeout | 클라이언트가 요청 헤더를 보내기까지 기다리는 최대 시간 | ReadTimeout |
모든 서버에 대해 ReadTimeout과 WriteTimeout을 설정할 것을 강력히 권한다. 기본값 0은 ‘타임아웃 없음’이며, 서비스 거부(DoS) 공격에 취약해진다. IdleTimeout도 설정하는 편이 좋지만 상대적으로 덜 중요하다.
http.HandlerFunchttp.Handler는 ServeHTTP 메서드 하나만 갖는 인터페이스다. 핸들러마다 새 타입을 정의하는 것은 과하다고 느낄 수 있다. 함수만으로 처리하려면 http.HandlerFunc를 사용하라. func(http.ResponseWriter, *http.Request) 타입의 함수를 http.Handler로 바꿔준다.
다음 완전한 프로그램은 이전과 동일하지만, 커스텀 타입 대신 http.HandlerFunc를 쓴다.
1// https://go.dev/play/p/Cc8AMjR-_sc
2func helloWorld(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello, world!\r\n")) }
3func main() {
4 server := http.Server{Addr: ":8080", Handler: http.HandlerFunc(helloWorld)}
5 go server.ListenAndServe()
6 req, _ := http.NewRequestWithContext(context.TODO(), "GET", "http://localhost:8080", nil)
7 resp, err := new(http.Client).Do(req)
8 _ = err
9 defer resp.Body.Close()
10 resp.Write(os.Stdout) // 응답을 stdout에 출력.
11}
실제로는 특히 미들웨어가 끼어들기 시작하면 커스텀 http.Handler보다 HandlerFunc가 훨씬 더 흔하다. 이는 다음 글에서 더 다룰 것이다.
JSON은 웹 API에서 가장 인기 있는 직렬화 포맷이다. HTTP와 JSON의 조합은 흔히(정확하지는 않지만) “REST”라고 불리며, 오늘날 웹 API를 만드는 가장 일반적인 방식이다. encoding/json 패키지는 완전한 JSON 인코더/디코더를 제공한다.
다음 치트시트는 encoding/json API를 요약한다:
| 함수 | 설명 | 참고 |
|---|---|---|
json.Marshal | 값을 JSON으로 인코딩한다. | |
json.Unmarshal | JSON에서 값을 디코딩한다. | Unmarshal에는 항상 nil이 아닌 포인터를 전달하라. |
json.Marshaler | JSON 인코딩 커스터마이즈를 위해 구현. | 보통 불필요. |
json.Unmarshaler | JSON 디코딩 커스터마이즈를 위해 구현. | 보통 불필요. |
json.NewEncoder | io.Writer에 감싼 새 JSON 인코더를 만든다. | 그 후 Encode 호출. |
json.NewDecoder | io.Reader에 감싼 새 JSON 디코더를 만든다. | 그 후 nil이 아닌 포인터를 Decode에 전달. |
json.RawMessage | []byte와 같지만 json.Marshaler/json.Unmarshaler를 구현. | ‘패스스루’ JSON API에 유용. |
다음 완전한 프로그램은 encoding/json으로 JSON을 보내고 받는 방법을 보여준다.
REQUEST 본문은 두 개의 선택 필드 “Format”과 “TZ”를 가진 JSON 객체가 된다:
1{
2 "format": "RFC3339",
3 "tz": "America/New_York"
4}
RESPONSE 본문은 다음 필드 중 정확히 하나만 포함하는 JSON 객체가 된다: "time" 또는 "error".
1{
2 "time": "2021-09-09T09:33:15Z"
3}
1{
2 "error": "unknown time zone faketz"
3}
구조체의 JSON 인코딩/디코딩을 커스터마이즈하기 위해 json 구조체 태그를 사용할 것이다.
IN:
1// https://go.dev/play/p/A8QVJwFEeq3
2type Request struct {
3 Format string `json:"format"` // time.Format의 Format. 비어 있으면 time.RFC3339 사용.
4 TZ string `json:"tz"` // time.LoadLocation의 TZ. 비어 있으면 time.Local 사용.
5}
6 // 요청의 Format과 TZ에 따라 포맷된 시간.
7type Resp struct {Time time.Time `json:"time"`} // omitempty 불필요; zero time을 보내지 않음.
8type Error struct {Error string `json:"error"`} // omitempty 불필요; 빈 에러를 보내지 않음.
핸들러는 이 구조체들과 json.NewDecoder로 요청 본문을 디코딩하고, json.NewEncoder로 응답 본문을 인코딩한다.
1// https://go.dev/play/p/A8QVJwFEeq3
2// http handler: 현재 시간을 JSON 객체로 작성 (`{"Time": <time>}`)
3func getTime(w http.ResponseWriter, r *http.Request) {
4 var req Request
5 w.Header().Set("Content-Type", "encoding/json")
6 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
7 w.WriteHeader(400) // bad request
8 json.NewEncoder(w).Encode(Error{err.Error()})
9 return
10 }
11 r.Body.Close() // 요청 본문은 다 쓴 뒤 항상 닫아라.
12 var tz *time.Location = time.Local
13 if req.TZ != "" {
14 var err error
15 tz, err = time.LoadLocation(req.TZ)
16 if err != nil || tz == nil {
17 w.WriteHeader(400) // bad request
18 json.NewEncoder(w).Encode(Error{err.Error()})
19 return
20 }
21 }
22 format := time.RFC3339
23 if req.Format != "" {
24 format = req.Format
25 }
26
27 resp := Response{time.Now().In(tz).Format(format)}
28 json.NewEncoder(w).Encode(resp)
29
30}
31
IN:
1// https://go.dev/play/p/A8QVJwFEeq3
2var client = &http.Client{Timeout: 2 * time.Second}
3
4func sendRequest(tz, format string) {
5 body := new(bytes.Buffer)
6 json.NewEncoder(body).Encode(Request{TZ: tz, Format: format})
7 log.Printf("request body: %v", body)
8 req, err := http.NewRequestWithContext(context.TODO(), "GET", "http://localhost:8080", body)
9 if err != nil {
10 panic(err)
11 }
12 resp, err := client.Do(req)
13 if err != nil {
14 panic(err)
15 }
16 resp.Write(os.Stdout)
17 resp.Body.Close() // 응답 본문은 다 쓴 뒤 항상 닫아라.
18}
19func main() {
20 server := http.Server{Addr: ":8080", Handler: http.HandlerFunc(getTime)}
21 go server.ListenAndServe()
22
23 sendRequest("", "") // 기본값에 의존
24 sendRequest("America/Los_Angeles", time.RFC3339)
25 sendRequest("America/New_York", time.RFC822Z) // "02 Jan 06 15:04 -0700" // 숫자 시간대 포함 RFC822
26 sendRequest("faketz", "") // 400 Bad Request 예상
27
28}
실행 결과:
OUT:
12009/11/10 23:00:00 request body: {"format":"","tz":""}
2HTTP/1.1 200 OK
3Content-Length: 32
4Content-Type: encoding/json
5Date: Tue, 10 Nov 2009 23:00:00 GMT
6
7{"Time":"2009-11-10T23:00:00Z"}
82009/11/10 23:00:00 request body: {"format":"2006-01-02T15:04:05Z07:00","tz":"America/Los_Angeles"}
9HTTP/1.1 200 OK
10Content-Length: 37
11Content-Type: encoding/json
12Date: Tue, 10 Nov 2009 23:00:00 GMT
13
14{"Time":"2009-11-10T15:00:00-08:00"}
152009/11/10 23:00:00 request body: {"format":"02 Jan 06 15:04 -0700","tz":"America/New_York"}
16HTTP/1.1 200 OK
17Content-Length: 33
18Content-Type: encoding/json
19Date: Tue, 10 Nov 2009 23:00:00 GMT
20
21{"Time":"10 Nov 09 18:00 -0500"}
222009/11/10 23:00:00 request body: {"format":"","tz":"faketz"}
23HTTP/1.1 400 Bad Request
24Content-Length: 37
25Content-Type: encoding/json
26Date: Tue, 10 Nov 2009 23:00:00 GMT
27
28{"error":"unknown time zone faketz"}
좋은 JSON API를 만들기 위한 몇 가지 힌트:
Content-Type 헤더는 항상 application/json으로 설정하라.
최상위 응답은 거의 항상 배열이나 문자열이 아니라 JSON 객체여야 한다. 즉 <data> 대신 {"data": <data>}를 반환하라.
map[string]any를 피하라. 자바스크립트/루아/파이썬에 익숙한 프로그래머에게는 유혹적이지만, Go에서는 좋지 않다. 대신 요청/응답마다 새 타입을 정의하라.
이질적인(heterogeneous) 객체 리스트를 피하라. 가능하면 []any 대신 []int나 []string처럼 동질적인 타입을 쓰도록 하라.
이렇게 하면 나중에 필드를 추가해도 클라이언트를 깨뜨리지 않고 확장할 수 있다. 일부 API는 한 단계 더 감싸는 중첩을 선호하지만, 나는 과하다고 생각한다.
타입 안전성을 유지하면서도 매 응답마다 새 타입을 정의하고 싶지 않다면 익명 구조체를 사용할 수 있다.
JSON 읽기/쓰기는 번거롭게 느껴질 수 있다. 다음 제네릭 함수들은 보일러플레이트를 줄이고, 응답 본문을 닫는 것을 잊는 것 같은 흔한 ‘함정’을 피하는 데 도움이 된다.
1// ReadJSON은 io.ReadCloser에서 JSON 객체를 읽고, 작업이 끝나면 reader를 닫는다.
2// 주로 *http.Request.Body에서 JSON을 읽을 때 유용하다.
3func ReadJSON[T any](r io.ReadCloser) (T, error) {
4 var v T // T 타입 변수 선언
5 err := json.NewDecoder(r).Decode(&v) // JSON을 v로 디코딩
6 return v, errors.Join(err, r.Close()) // reader를 닫고 에러가 있으면 합쳐 반환.
7}
8
9// WriteJSON은 JSON 객체를 http.ResponseWriter에 쓰며, Content-Type을 application/json으로 설정한다.
10func WriteJSON(w http.ResponseWriter, v any) error {
11 w.Header().Set("Content-Type", "application/json")
12 return json.NewEncoder(w).Encode(v)
13}
마찬가지로, 자신의 JSON API를 위해 헬퍼 함수를 더 정의하고 싶을 수 있다.
1// WriteError는 에러를 로깅한 뒤 {"error": <error>} 형태의 JSON 객체로 쓰며,
2// Content-Type을 application/json으로 설정한다.
3func WriteError(w http.ResponseWriter, err error, code int) {
4 og.Printf("%d %v: %v", code, http.StatusText(code), err) // 에러 로깅; http.StatusText는 404에서 "Not Found" 등을 가져온다.
5 w.Header().Set("Content-Type", "encoding/json")
6 w.WriteHeader(code)
7 json.NewEncoder(w).Encode(Error{err.Error()})
8}
익명 구조체와 제네릭을 조합하면, 완전한 웹 프레임워크 없이도 훨씬 더 간결한 핸들러를 작성할 수 있다.
이 기법으로 getTime의 로직을 다시 써 보자.
1
2// http handler: 현재 시간을 JSON 객체로 작성 (`{"Time": <time>}`)
3func getTime(w http.ResponseWriter, r *http.Request) {
4 req, err := ReadJSON[struct {TZ, Format string }](r.Body)
5 if err != nil {
6 WriteError(w, err, 400)
7 return
8 }
9 var tz *time.Location = time.Local
10 if req.TZ != "" {
11 var err error
12 tz, err = time.LoadLocation(req.TZ)
13 if err != nil {
14 WriteError(w, err, 400)
15 return
16 }
17 }
18 format := time.RFC3339
19 if req.Format != "" {
20 format = req.Format
21 }
22 WriteJSON(w, Response{time.Now().In(tz).Format(format)})
23}
JSON이 가장 널리 쓰이는 직렬화 포맷이지만, 모든 경우에 적합한 것은 아니다. JSON은 특히 숫자 데이터나 깊게 중첩된 구조체/배열에서는 느리고 비효율적일 수 있다. 필드명과 중괄호가 반복되면서 공간을 많이 차지하기 때문이다. 보통은 다른 직렬화 포맷을 쓰는 것보다 JSON을 gzip으로 압축하는 편이 더 낫다. 이는 JSON이나 gzip이 특별히 좋아서가 아니라, 둘 다 너무나 보편적이라 언어나 플랫폼에 상관없이 거의 항상 사용 가능하고, 조합했을 때 대부분의 상황에서 충분히 괜찮은 성능을 내기 때문이다.
그럼에도 알아둘 가치가 있는 몇 가지 직렬화 포맷이 있다. 아래 표는 흔한 것들을 요약한다.
| 포맷/위치 | 설명 | 이식성? | 비고 |
|---|---|---|---|
encoding/json | JSON(JavaScript Object Notation). | 어디서나. | 느리고 장황하지만 어디에나 있다. 그냥 JSON을 써라. |
encoding/gob | Go 내장 직렬화 포맷. | Go 전용. | 꽤 빠르지만 엄청나게 빠르진 않다. |
encoding/xml | XML(eXtensible Markup Language). | 대체로 가능. | 느리고 장황하지만 보편적. XML 1.0만. |
encoding/base64 | Base64(바이너리→텍스트 인코딩). | 예 | JSON이나 URL에 바이너리 데이터를 포함할 때 유용. |
encoding/csv | CSV(Comma-separated values). | 사실상 아님. | 느리고 어색하지만 스프레드시트는 어디에나 있다. |
encoding/binary | 바이너리 직렬화. | 엔디안 주의 | 보통 코드 생성이나 신중한 수작업이 필요. |
go-yaml/yaml | YAML(JSON의 상위집합, SAAS 설정에 흔함). | 어디서나. | 피하라; YAML은 지나치게 복잡하고 미묘한 버그가 많다. |
golang/protobuf | Protocol Buffers(바이너리 직렬화 포맷). | 어디서나. | 빠르지만 코드 생성이 필요. v2는 Go에서 매우 고통스럽고, v3는 훨씬 낫다. |
google/flatbuffers | zero-copy 역직렬화를 지원하는 바이너리 직렬화 포맷. | 어디서나. | 빠르지만 코드 생성이 필요하고 API가 다소 어색하다. |
이제 HTTP 서버와 클라이언트의 기초를 다뤘지만, (말장난이지만) 큰 맥락(Context)이 하나 빠져 있다: 타임아웃과 취소.
어떤 인터넷 통신도 실패할 수 있다. 네트워크가 끊길 수도 있고, 서버가 크래시할 수도 있으며, 그냥 느릴 수도 있다. 네트워크 호출을 할 때 나는 암묵적으로 곧 끝나길 기대한다. 단지 ‘언젠가’ 끝나는 건 도움이 되지 않는다. 비행기 표를 사는 요청이 비행기가 이미 떠난 뒤에 끝난다면 아무 의미가 없다.
Go의 context.Context 타입은 함수 _안_의 상태가 아니라, 함수 에 관한 상태를 관리하기 위한 것이다. 크게 두 부류로 나뉜다: 함수 메타데이터(시작 시간, 요청 ID)와 데드라인/취소. 이 패키지는 여기서 상세히 다루기엔 너무 복잡하므로 패키지 문서와 이를 소개한 블로그 글을 읽어볼 것을 강력히 권한다.
요약하면 컨텍스트는 서로 관련은 있지만 구분되는 두 가지 목적을 위해 사용된다:
1func DoSomething(ctx context.Context) {
2 reqID := ctx.Value(trace.Key).(string) // 컨텍스트에서 요청 ID 가져오기.
3 log.Printf("request %s: starting", reqID)
4 defer log.Printf("request %s: done", reqID)
5 // ...
6}
이 부분은 너무 걱정하지 말자. 다음 글에서 미들웨어를 다룰 때 더 자세히 다룬다.
1func MakeRequest(ctx context.Context, someArg string) error {
2 if err := ctx.Err(); err != nil {
3 return err // 시간 초과; 시도조차 하지 말자.
4 }
5}
I/O를 하는 어떤 함수든 실행 시간의 상한을 설정하기 위해 컨텍스트를 사용하라. I/O에는 네트워크 요청, 데이터베이스 쿼리, 파일 작업 등이 포함되며 이에 한정되지도 않는다. 몇 밀리초밖에 안 걸릴 것 같아도 I/O 작업에는 항상 타임아웃을 설정하라. 타임아웃을 설정하지 않으면 무기한 리소스 누수, DoS 공격, 추적하기 어려운 버그로 이어질 수 있다.
I/O를 하거나 타임아웃이 날 수 있는 함수라면 컨텍스트는 항상 첫 번째 인수여야 한다. 그래야 취소 신호를 전파하기 쉽다. 예를 들어 아래 함수는 context.WithTimeout으로 네트워크 요청에 타임아웃을 설정한다.
1func GetGoogle(ctx context.Context) error {
2 // 컨텍스트의 데드라인은 지금부터 1초 후 또는 부모 컨텍스트 데드라인 중 더 이른 것.
3 ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
4 defer cancel() // 컨텍스트 관련 리소스를 해제하기 위해 끝나면 항상 cancel을 호출.
5 req, err := http.NewRequestWithContext(ctx, "GET", "https://google.com", nil)
6 if err != nil {
7 return err
8 }
9 resp, err := new(http.Client).Do(req)
10 if err != nil {
11 return err
12 }
13 resp.Write(os.Stdout) // 응답을 stdout에 출력.
14 return nil
15}
다음 표는 타임아웃과 취소에 관련된 context API를 요약한다.
| 함수 | 설명 | 사용처 |
|---|---|---|
context.Background() | 메타데이터나 취소 신호가 없는 컨텍스트를 반환; 사실상의 ‘제로 값’ | 새 컨텍스트 체인을 시작할 때. |
context.TODO() | Background()와 동일 | 프로토타이핑 중 컨텍스트를 무엇으로 할지 모를 때. |
context.WithCancel(parent) | parent가 취소되면 트리거되는 취소 신호를 가진 컨텍스트를 반환 | 취소 신호 전파; 보통은 WithTimeout/WithDeadline이 더 적합. |
context.WithDeadline(parent, deadline) | deadline이 지나면 트리거되는 취소 신호를 가진 컨텍스트를 반환 | 다른 서비스가 설정한 데드라인을 보존할 때. |
context.WithTimeout(parent, timeout) | 데드라인이 time.Now().Add(timeout)인 컨텍스트를 반환 | 모든 I/O 작업에. |
Go의 핵심 패키지들은 context 버전과 context 없는 버전을 함께 제공하는 경우가 많다. 어떤 상황에서도 항상 context 버전을 사용하라. 어떤 컨텍스트를 써야 할지 모르겠다면 context.TODO()를 쓰면 된다.
다음 표는 컨텍스트를 받는 흔한 함수와 그 대체(권장) 함수를 요약한다.
| 함수 | 대체 | 설명 | 참고 |
|---|---|---|---|
http.NewRequest | http.NewRequestWithContext | outbound HTTP 요청 만들기 | |
sql.DB.Query | sql.DB.QueryContext | DB 조회 | |
sql.DB.Exec | sql.DB.ExecContext | DB 쿼리 실행 | |
| File.Read / File.Write | N/A | 파일 I/O | 대신 별도 고루틴에서 타임아웃 후 파일을 닫아라. |
net.Dial | DialTimeout / net.Dialer.DialContext | 네트워크 연결 다이얼 |
http.NewRequestWithContext(ctx, method, url, body)를 사용해 outbound HTTP 요청에 컨텍스트를 추가하라. 거의 그게 전부다.
세 가지 옵션이 있으며 기능이 일부 겹친다:
http.Server의 BaseContext 필드는 _리스너_를 위한 컨텍스트이며, 각 요청에 전달된다. 이 컨텍스트를 취소하면 해당 리스너 기반의 모든 요청이 취소되므로, 보통 OS의 SIGINT를 처리하는 등의 ‘전역적’ 셧다운에만 적절하다. 시그널 처리와 graceful shutdown에 대한 더 자세한 내용은 내 글 소프트웨어 끄기를 참고하라.
http.Server의 ConnContext 필드는 각 TCP 연결에 대한 기본 컨텍스트다. 이는 연결 내 모든 요청에 대해 타임아웃을 설정할 때 유용하다(패킷이 정상적으로 오가더라도).
http.Handler에서 http.ResponseWriter와 *http.Request를 새 컨텍스트를 가진 새 http.Request로 감싸서 요청 컨텍스트를 수동으로 설정할 수 있다. 개별 요청에 대한 타임아웃을 설정하거나, 컨텍스트에 메타데이터를 추가할 때 유용하다. 이는 다음 글에서 미들웨어를 다룰 때 더 이야기하겠다.
취소 신호가 설정되었는지 확인하고, 설정되었다면 에러를 반환하는 것은 여러분의 몫이다. 취소 신호는 두 가지 방식으로 확인할 수 있다: ctx.Err()는 취소 에러를 반환하고, ctx.Done()은 컨텍스트가 취소될 때 닫히는 채널을 반환한다.
어떤 방식이든, 컨텍스트가 취소되었다면 에러를 반환해야 한다.
첫 글에서는 HTTP 프로토콜의 기초를 다뤘다. 이제 Go의 HTTP 클라이언트/서버 API를 빠르게 훑어보았다. 어떤 웹 서버는 이것만으로도 충분하며, 여기서 다룬 기법을 활용해 간단한 HTTP 서버와 클라이언트를 만들어 보면서 API를 구축하는 연습을 해보길 권한다. 하지만 여전히 퍼즐의 몇 가지 핵심 조각이 남아 있다:
미들웨어(Middleware): 로깅, 인증, 레이트 리미팅 같은 공통 기능을 웹 서버에 어떻게 추가할까?
라우팅(Routing): URL과 메서드를 핸들러에 어떻게 매핑할까?
데이터베이스 & 의존성(Dependencies): 데이터를 어떻게 저장/조회할까? DB에는 어떻게 연결할까? 이런 의존성을 다루기 위해 API를 어떻게 구조화해야 할까?
이 모든 것은 다음 글에서 다룰 예정이다.