Go로 프레임워크 없이 백엔드 웹 개발의 기초를 바닥부터 쌓기 위해 TCP, DNS, HTTP를 설명하고, 간단한 TCP 클라이언트/서버와 HTTP 요청/응답, 그리고 HTTP 라이브러리를 직접 만들어본다.
Efron Licht의 소프트웨어 글
2023년 9월
이 글은 Go로 백엔드 웹 개발을 다루는 시리즈의 일부다. 프레임워크를 사용하지 않고, 백엔드 웹 개발의 기본을 바닥부터 다룬다. TCP, DNS, HTTP의 기초와 net/http, encoding/json 패키지, 미들웨어와 라우팅을 다룰 것이다. Go로 전문적인 백엔드 웹 서비스를 작성하기 위해 시작하는 데 필요한 모든 것을 제공하는 것이 목표다.
이 글(그리고 블로그 전체)의 소스 코드는 내 gitlab에 공개되어 있다.
2025-12-09에 자동 생성됨
Go를 시작하는 새 개발자들에게서 가장 흔히 받는 질문 중 하나는 **“어떤 웹 프레임워크를 써야 하죠?”**다. 내가 늘 하는 대답은 “프레임워크는 필요 없다”지만, 문제는 백엔드 개발자들이 프레임워크에 익숙 하다는 점이다.
생각해 보면 동기는 이해된다. 엔지니어는 큰 압박을 받고 있고, 인터넷은 겉보기엔 정말 복잡해 보인다. (TCP, HTTP 같은) 여러 추상화 계층을 배운다는 생각은 벅차고, 다들 프레임워크를 쓰는 것처럼 보인다 — 대부분의 언어(자바스크립트, 파이썬 등)에서는 사실상 필수다. 다만 여기에 딱 하나의 문제가 있다. 그러면 실제로 어떻게 동작하는지 절대 배우지 못한다는 것이다. 기본을 익히기보다 전문 도구 모음에 계속 의존하는 건 칼을 쓸 줄 모르는 시니어 셰프와 같다. 물론 비싼 푸드 프로세서가 더 빨리 다진다고 주장할 수는 있다. 하지만 포장된 도구가 설계되지 않은 일을 해야 하는 순간 끝장이다. 직접 하는 방법도 모르고 배울 시간도 없다.
과장처럼 들릴지 모르지만, 나는 이제까지 서로 다른 시니어 소프트웨어 엔지니어 4명을 만났는데, 프레임워크 없이 구글에 HTTP 요청을 보내는 방법을 설명하지 못했다.
참고로, 142.250.189.14:80에 이 메시지를 보내면 된다:
1GET / HTTP/1.1
2Host: google.com
다섯 단어다.
이게 뭔 뜻인지, 저 IP 주소를 어떻게 얻었는지 모른다고 걱정하지 마라. 곧 다룰 것이다. 요점은 실제로는 그렇게 어렵지 않다는 것이다. 어려운 건 콜백 1만 겹, 라이브러리와 프레임워크와 도구와 언어와 추상화와 간접화와 래퍼 위에 래퍼 같은 것들이다. 문제는 대부분의 소프트웨어 엔지니어가 자신과 네트워크 사이에 너무 많은 추상화 계층이 있는 상태에 익숙해서 ‘바닥까지 내려간다’는 게 불가능해 보인다는 점이다. 어떤 사람은 프레임워크의 강력함과 유연성이 더 빠르고 더 나은 소프트웨어 개발로 이어지니 괜찮다고 주장할 수도 있다. 하지만 문제는 소프트웨어가 더 좋아지는 게 아니라, 측정 가능할 정도로 나빠지고 있다는 점이다. 데스크톱 소프트웨어도 웹 페이지도 해마다 측정 가능할 만큼 느려지고 있다. 소프트웨어는 컴퓨터가 빨라지는 속도보다 더 빠른 속도로 느려지고 있다. 이런 상황에서 소프트웨어가 복잡성에 익사하고 있다는 건 놀랄 일이 아니다. 매년 추상화 계층을 더하고, 라이브러리를 더하고, 프레임워크를 더하고, 도구를 더하고, 언어를 더하고… 모든 걸 더하지만, ‘전문가’조차 자신이 만드는 것의 기본을 이해하지 못한다. 소프트웨어가 느리고 버그가 많고 유지보수가 불가능한 게 당연하다.
“멍청이는 복잡성을 숭배하고, 천재는 단순함을 숭배한다”
물론 일이 엉망이라는 걸 아는 것만으로는 잘 하는 법을 배우는 데 도움이 되지 않는다. 그래서 이 시리즈를 써서, Go로 백엔드 웹 개발의 기초를 가르쳐 그 간극을 메우려 한다. 각 글에는 컴퓨터에서 실제로 실행할 수 있는 진짜 프로그램이 들어갈 것이며, 컴파일도 안 되는 짜깁기 코드 조각이 아니다.
이 시리즈만으로 모든 것을 배우긴 어렵다. 잘해야 ‘실제로’ 어떻게 동작하는지에 대해 충분히 노출시켜서, 여러분의 지식 경계가 보이게 하고 스스로 빈틈을 메우기 시작할 수 있도록 하는 정도일 것이다. 또한 이해를 쉽게 하기 위해 일부 세부 사항을 단순화하거나 생략하거나 “선의의 거짓말”을 해야 할 수밖에 없다. 경험(또는 표준 라이브러리의 소스 코드와 문서를 읽는 것)을 대체할 수는 없다. 데이터베이스도 대부분 생략할 것이다. 그건 추후 시리즈로 다루고 싶다.
그래도 도움이 되길 바란다.
인터넷이란 무엇인가? 어떤 문제를 해결하나? TCP/IP란 무엇인가? 컴퓨터는 어떻게 서로 대화하나?
DNS란 무엇인가? www.google.com을 어떻게 IP 주소로 바꾸나?
HTTP란 무엇이고 어떻게 동작하나? 라이브러리 없이 HTTP 요청/응답을 손으로 읽거나 쓰려면 어떻게 하나?
Request/Response 라이브러리를 바닥부터 만들기
net/http와 encoding/json두 번째 글에서는 net/http와 encoding/json 패키지를 사용해, 일상적인 백엔드 작업 대부분을 처리할 수 있는 기본 웹 클라이언트와 서버를 만든다. Go 표준 라이브러리를 본격적으로 파고들며, 기본적인 클라이언트/서버 HTTP 통신에 필요한 모든 것을 어떻게 제공하는지 살펴본다. net/http와 net/url로 HTTP 요청/응답을 보내고 받으며, encoding/json으로 API 페이로드를 다루고, context로 타임아웃과 취소를 관리한다.
세 번째 글에서는 net/http 패키지의 두 가지 ‘빠진 조각’인 미들웨어와 라우팅을 다룬다. 보통 이 때문에 사람들이 프레임워크를 찾곤 하지만, 사실 직접 구현하기에 꽤 단순하다. 또한 database/sql 패키지를 이용한 기본 데이터베이스 접근도 다룬다.
‘백엔드’는 인터넷을 통해 컴퓨터들을 연결하는 것이다. 컴퓨터가 뭔지는 알 테니…
인터넷이란 무엇인가? 아니, 진짜로. 인터넷이 해결하는 문제는 무엇인가? 인터넷은, 중간에 있는 일부 컴퓨터가 다운되더라도 서로 안정적으로 통신할 수 있는 컴퓨터들의 네트워크 다. 다른 컴퓨터들이 어디에 있는지, 나와 어떻게 연결되어 있는지 몰라도, 다른 컴퓨터로 메시지(즉 텍스트 또는 바이너리 데이터)를 안정적으로 보낼 수 있다. 나( LOCALADDR)에서 목적지 컴퓨터( REMOTEADDR)까지 컴퓨터들의 경로가 존재하기만 하면 메시지를 보낼 수 있다.
이를 위해 인터넷은 두 가지 문제를 해결해야 한다:
다른 컴퓨터에 직접 연결되어 있지 않더라도 어떻게 메시지를 보낼 것인가? ROUTING
네트워크를 통해 메시지를 보낼 때, 올바른 곳으로, 순서대로, 빠짐없이 전달되게 하려면? COHERENCE
두 문제 모두 프로토콜로 해결된다. Internet Protocol(IP)이 ROUTING을, Transmission Control Protocol(TCP)이 COHERENCE를 해결한다. 둘을 합쳐 TCP/IP라고 부른다. 그게 인터넷이 동작하는 방식이다.
TCP의 세부는 이 글의 범위를 벗어나지만, 큰 그림에서는 이렇게 보인다:
원격 컴퓨터로 데이터 패킷을 보낸다. 각 패킷에는 시퀀스 번호(“이게 몇 번째 패킷이지?”)와 체크섬(“전송 중 이 패킷이 손상됐나?”)이 있다. 원격 컴퓨터는 내가 보낸 각 패킷에 대해 확인 응답(“5번 패킷 받았어”)을 돌려준다.
어떤 패킷에 대한 확인 응답을 받지 못하면 다시 보낸다. 손상된 패킷을 받으면 다시 보낸다.
이런 주고받기를 통해 모든 데이터가 순서대로 빠짐없이 전달되며, 전달되지 않았을 때도 알 수 있다.
IP는 더 복잡하다. 아래 설명은 자세히 들여다보면 거의 모든 수준에서 틀리지만, 우리 목적에는 충분히 근사치다:
인터넷의 각 컴퓨터는 address를 가진다. 다른 컴퓨터에게 ‘어떻게 그 컴퓨터로 갈지’를 알려주는 식별자다. 이를 IP address, 또는 줄여서 IP라고 부른다.
또한 자신이 알고 있는 다른 컴퓨터들의 목록과, 그들에게 도달하는 방법을 가진다. 이를 라우팅 테이블(routing table) 이라고 한다.
다른 컴퓨터로 메시지를 보낼 때, 내 컴퓨터는 라우팅 테이블을 보고 그 컴퓨터로 가는 법을 아는지 확인한다. 안다면 체인의 다음 컴퓨터로 메시지를 보낸다. 모른다면, 자신이 알고 있는 다음 컴퓨터로 메시지를 보내고, 그 컴퓨터가 또 같은 일을 반복한다. 이 과정을 메시지가 올바른 컴퓨터에 도달할 때까지 반복한다.
내 컴퓨터에서 목적지 컴퓨터로 가는 경로가 없다면, 메시지는 실패한다.
좋다. 그럼 실제로 다른 컴퓨터로 메시지를 보내려면 어떻게 해야 할까? 두 가지를 알아야 한다. 메시지를 보낼 컴퓨터의 address, 그리고 메시지를 보낼 서비스의 port다.
IP 주소는 두 형태가 있다. 32비트 숫자인 ipv4, 또는 128비트 숫자인 ipv6. 생김새는 다음과 같다:
IPV4: DDD.DDD.DDD.DDD 형태이며, DDD는 0~255 사이의 숫자.
IPV6: XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX 형태이며, XXXX는 16비트 16진수 숫자. 즉 각 X는 0..=9 또는 a..=f 중 하나.
| IP 주소 | 타입 | 비고 |
|---|---|---|
| 192.168.000.001 | ipv4 | localhost; 호스팅 컴퓨터를 가리킴 |
| 192.168.0.1 | ipv4 | 위와 동일; 앞의 0은 생략 가능 |
| 0000:0000:0000:0000:0000:ffff:c0a8:0001 | ipv6 | 위와 같은 컴퓨터를 가리킴; ipv4 주소는 ::ffff:를 접두로 붙여 ipv6 주소에 포함할 수 있음 |
| ::ffff:c0a8:0001 | ipv6 | 위와 동일; 앞의 0은 생략 가능 |
| 2a09:8280:1::a:791 | ipv6 | fly.io |
한 컴퓨터가 서로 다른 동작을 하는 여러 인터넷 서비스를 호스팅하고 싶어하는 것은 흔한 일이다. 예를 들어 게임 서버(starcraft 같은), 웹 서버(이 웹사이트 같은), 데이터베이스(postgresql 같은)를 모두 같은 컴퓨터에서 돌리고 싶을 수 있다. 모두 같은 물리적 컴퓨터에 있으니 IP 주소도 공유한다. 따라서 파일 서버로 가는 요청과 게임 서버로 가는 요청을 구분할 방법이 필요하다. 이를 위해 각 서비스에 PORT를 할당한다. 포트는 0~65535 사이의 숫자일 뿐이다. 하나의 서비스만 호스팅하더라도, 각 서비스는 (최소 한 개의) 포트를 필요로 한다.
eblog는 6483 포트에서 호스팅된다. 아래 표는 흔한 서비스들의 기본 포트를 나열한다:
| 서비스 | 포트 |
|---|---|
| HTTP | 80 |
| HTTPS | 443 |
| SSH | 22 |
| SMTP | 25 |
| DNS | 53 |
| FTP | 21 |
| Postgres | 5432 |
이 동작 방식을 보여주기 위해 기본 TCP/IP 서버와 클라이언트를 만들어 보자. 6483 포트에서 리슨(listen)하는 서버를 만들고, 이에 연결하는 클라이언트를 만들 것이다. 클라이언트에서 stdin(즉 터미널에 입력하는 것)으로 들어오는 모든 내용은 한 줄씩 서버로 전송된다. 서버가 받은 각 줄은 대문자로 바뀐 뒤 다시 클라이언트로 전송된다.
즉, 예시 세션은 이렇게 될 수 있다:
1SERVER: (6483 포트에서 리슨 시작)
2CLIENT: (서버에 연결)
3CLIENT: "hello, world!"
4SERVER: "HELLO, WORLD!"
5CLIENT: "goodbye, world!"
6SERVER: "GOODBYE, WORLD!"
7CLIENT: (연결 종료)
간단히 복습하면, 예제에 관련된 함수/타입은 다음과 같다:
| 함수/구조체 | 설명 | implements |
|---|---|---|
net.Listen | 포트에서 연결을 대기(listen) | |
net.Dial | IP 주소와 포트로 서버에 연결 | |
net.TCPConn | 양방향 TCP 연결 | io.Reader, io.Writer, net.Conn |
net.Conn | 양방향 네트워크 연결 | io.Reader, io.Writer |
bufio.Scanner | io.Reader에서 라인 단위로 읽음 | |
fmt.Fprintf | fmt.Printf와 같지만 io.Writer로 씀 | |
flag.Int | 정수 커맨드라인 플래그 등록 | |
flag.Parse | 등록된 커맨드라인 플래그 파싱 | |
log.Printf | fmt.Fprintf(os.Stderr, ...)와 같지만 타임스탬프와 개행 포함 | |
log.Fatalf | log.Printf와 같지만 출력 후 os.Exit(1) 호출 |
먼저 클라이언트를 작성하자. 이름은 writetcp로 하겠다.
1// writetcp는 지정된 포트(기본 8080)로 localhost의 TCP 서버에 연결하고, stdin을 서버로
2// EOF에 도달할 때까지 한 줄씩 전달(forward)한다.
3// 서버로부터 수신한 라인은 stdout에 출력한다.
4package main
5
6import (
7 "bufio"
8 "flag"
9 "fmt"
10 "log"
11 "net"
12 "os"
13)
14
15func main() {
16 const name = "writetcp"
17 log.SetPrefix(name + "\t")
18
19 // 커맨드라인 플래그 등록: -p는 연결할 포트를 지정
20 port := flag.Int("p", 8080, "port to connect to")
21 flag.Parse() // 등록된 플래그 파싱
22
23 conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{Port: *port})
24 if err != nil {
25 log.Fatalf("error connecting to localhost:%d: %v", *port, err)
26 }
27 log.Printf("connected to %s: will forward stdin", conn.RemoteAddr())
28
29 defer conn.Close()
30 go func() { // 서버로부터 들어오는 라인을 읽어 stdout에 출력하는 고루틴 생성
31 // TCP는 풀-듀플렉스(full-duplex)라서 동시에 읽고 쓸 수 있다; 읽기를 담당할 고루틴만 만들어주면 된다.
32
33 for connScanner := bufio.NewScanner(conn); connScanner.Scan(); {
34
35 fmt.Printf("%s\n", connScanner.Text()) // 주의: printf는 개행을 붙이지 않으므로 직접 추가해야 함
36
37 if err := connScanner.Err(); err != nil {
38 log.Fatalf("error reading from %s: %v", conn.RemoteAddr(), err)
39 }
40 }
41 }()
42
43 // stdin으로부터 들어오는 라인을 읽어 서버로 전달
44 for stdinScanner := bufio.NewScanner(os.Stdin); stdinScanner.Scan(); { // stdin에서 다음 개행을 찾음
45 log.Printf("sent: %s\n", stdinScanner.Text())
46 if _, err := conn.Write(stdinScanner.Bytes()); err != nil { // scanner.Bytes()는 다음 개행 전까지(개행은 제외)의 바이트 슬라이스를 반환
47 log.Fatalf("error writing to %s: %v", conn.RemoteAddr(), err)
48 }
49 if _, err := conn.Write([]byte("\n")); err != nil { // 개행을 다시 붙여야 함
50 log.Fatalf("error writing to %s: %v", conn.RemoteAddr(), err)
51 }
52 if stdinScanner.Err() != nil {
53 log.Fatalf("error reading from %s: %v", conn.RemoteAddr(), err)
54 }
55 }
56
57}
이제 서버를 구성해 보자. 받은 것을 대문자로 바꿔 에코(echo)하므로 이름은 tcpupperecho로 하겠다.
보통 백엔드에서는 ‘비즈니스 로직’을 네트워킹 코드와 분리하고 싶다. Go의 네트워킹 API는 모두 net.Conn 인터페이스를 사용하는데, 이는 io.Reader와 io.Writer 둘 다를 구현한다. 따라서 fmt.Fprintf나 bufio.Scanner 같은 표준 텍스트 처리 함수/구조체로 비즈니스 로직을 작성할 수 있다.
서버의 ‘비즈니스 로직’은 이렇게 생긴다:
1// echoUpper는 r에서 라인을 읽어 대문자로 바꾸고, w로 쓴다.
2func echoUpper(w io.Writer, r io.Reader) {
3 scanner := bufio.NewScanner(r)
4 for scanner.Scan() {
5 line := scanner.Text()
6 // scanner.Text()는 라인 끝의 개행 문자를 제거하므로,
7 // w에 쓸 때 다시 붙여야 한다.
8 fmt.Fprintf(w, "%s\n", strings.ToUpper(line))
9 }
10 if err := scanner.Err(); err != nil {
11 log.Printf("error: %s", err)
12 }
13}
1이를 서버에서 이렇게 사용할 수 있다:
1
2// tcpupperecho는 8080 포트에서 tcp 연결을 서비스하며, 각 연결에서 라인 단위로 읽어
3// 각 라인의 대문자 버전을 클라이언트에 다시 쓴다.
4
5package main
6
7import (
8 "bufio"
9 "flag"
10 "fmt"
11 "io"
12 "log"
13 "net"
14 "strings"
15)
16
17func main() {
18 const name = "tcpupperecho"
19 log.SetPrefix(name + "\t")
20
21 // 커맨드라인 인터페이스 구성; 자세한 내용은 https://golang.org/pkg/flag/ 참고.
22 port := flag.Int("p", 8080, "port to listen on")
23 flag.Parse()
24
25 // ListenTCP는 주어진 주소에서 연결을 받아들이는 TCP 리스너를 생성한다.
26 // TCPAddr는 TCP 엔드포인트의 주소를 나타내며 IP, Port, Zone을 가진다(모두 선택 사항).
27 // Zone은 IPv6에서만 중요하므로 여기서는 무시한다.
28 // IP를 생략하면 사용 가능한 모든 IP 주소에서 리슨한다는 뜻이고, Port를 생략하면 랜덤 포트에서 리슨한다는 뜻이다.
29 // 우리는 커맨드라인으로 지정된 포트에서 리슨하고 싶다.
30 // 자세한 내용은 https://golang.org/pkg/net/#ListenTCP 와 https://golang.org/pkg/net/#Dial 참고.
31 listener, err := net.ListenTCP("tcp", &net.TCPAddr{Port: *port})
32 if err != nil {
33 panic(err)
34 }
35 defer listener.Close() // main()을 빠져나갈 때 리스너를 닫음
36 log.Printf("listening at localhost: %s", listener.Addr())
37 for { // 연결을 하나씩 받아들이며 무한 루프
38
39 // Accept()는 연결이 만들어질 때까지 블록한 뒤, 연결을 나타내는 Conn을 반환한다.
40 conn, err := listener.Accept()
41 if err != nil {
42 panic(err)
43 }
44 go echoUpper(conn, conn) // 연결을 처리할 고루틴 생성
45 }
46}
직접 실행해 보자. 한 터미널에서 서버를 실행한다:
IN
1go build -o tcpupperecho ./tcpupperecho.go
2./tcpupperecho -p 8080 # 8080 포트에서 리슨하는 서버 실행
OUT:
1tcpupperecho 2023/09/07 10:13:13 listening at localhost: [::]:8080
다른 터미널에서 클라이언트를 실행하고 메시지를 보낸다:
1$ go build -o writetcp ./writetcp.go
2$ ./writetcp -p 8080 # localhost:8080에 연결하는 클라이언트 실행
3> writetcp 2023/09/07 10:20:32 connected to 127.0.0.1:8080: will forward stdin
4hello
5writetcp 2023/09/07 10:20:49 sent: hello
6HELLO
그리고 서버 터미널을 확인하면:
1tcpupperecho 2023/09/07 10:20:49 received: hello
이건 로컬 주소에서는 잘 된다. 하지만 인터넷 상의 서버에 연결하고 싶다면? 대부분의 경우 우리는 연결하려는 서버의 IP 주소를 모른다. google.com이나 eblog.fly.dev 같은 domain name만 안다. 도메인 이름으로 된 서버에 어떻게 연결할까?
Domain Name Service, 즉 DNS는 도메인 이름을 IP 주소로 매핑하는 서비스다. 본질적으로는 이런 큰 표와 같다:
| 도메인 | 마지막으로 알려진 ipv4 | 마지막으로 알려진 ipv6 |
|---|---|---|
| google.com | 142.250.217.142 | 2607:f8b0:4007:801::200e |
| eblog.fly.dev | 66.241.125.53 | 2a09:8280:1::37:6bbc |
DNS 제공자는 여러 곳이 있다. 보통 ISP가 하나 제공하며, 구글 같은 공용 DNS도 있다. 구글 DNS는 8.8.8.8과 4.4.4.4에서 사용 가능하다. (DNS 서버의 IP 주소를 모르면 도메인 이름을 해석할 수 없으므로, 시작하려면 최소 하나의 IP는 ‘외워’야 한다.)
브라우저와 다른 클라이언트는 도메인 이름의 IP 주소를 찾기 위해 DNS 서비스를 사용한다.
2021/08/18 16:00:00 tcpupperecho listening at localhost:
좋아. 그럼 웹 주소(예: https://eblog.fly.dev)의 서버에 연결하고 싶다면 어떻게 할까? 먼저 서버의 IP 주소를 알아야 한다. 도메인 네임 서비스(DNS)는 도메인 이름을 IP 주소로 매핑하는 서비스다. 윈도우/맥/리눅스에서 커맨드라인에 기본으로 있는 nslookup 명령으로 도메인의 IP 주소를 조회할 수 있다.
IN:
1nslookup eblog.fly.dev
OUT:
1Server: UnKnown
2Address: 192.168.1.1
3
4Non-authoritative answer:
5Name: eblog.fly.dev
6Addresses: 2a09:8280:1::37:6bbc
7 66.241.125.53
Go 프로그램 안에서는 net.LookupIP로 도메인 이름의 IP 주소(들)를 조회할 수 있다. 아래 전체 프로그램은 nslookup의 기능을 복제한다:
1// dns는 호스트의 IP 주소를 조회하는 간단한 커맨드라인 도구다.
2// 찾은 첫 번째 ipv4와 ipv6 주소를 출력하고, 없으면 "none"을 출력한다.
3package main
4
5import (
6 "fmt"
7 "log"
8 "net"
9 "os"
10)
11
12func main() {
13 if len(os.Args) != 2 {
14 log.Printf("%s: usage: <host>", os.Args[0])
15 log.Fatalf("expected exactly one argument; got %d", len(os.Args)-1)
16 }
17 host := os.Args[1]
18 ips, err := net.LookupIP(host)
19 if err != nil {
20 log.Fatalf("lookup ip: %s: %v", host, err)
21 }
22 if len(ips) == 0 {
23 log.Fatalf("no ips found for %s", host) // 절대 일어나지 않아야 하지만, 혹시 몰라서
24 }
25 // 찾은 첫 번째 ipv4 출력
26 for _, ip := range ips {
27 if ip.To4() != nil {
28 fmt.Println(ip)
29 goto IPV6 // goto는 awesome
30 }
31 }
32 fmt.Printf("none\n") // ipv4를 못 찾은 경우에만 "none" 출력
33
34IPV6: // 찾은 첫 번째 ipv6 출력
35 for _, ip := range ips {
36 if ip.To4() == nil {
37 fmt.Println(ip) // 여기서는 nil 체크가 필요 없다. 적어도 하나의 IP가 있는 건 이미 아니까.
38 return
39 }
40 }
41 fmt.Printf("none\n")
42}
43
IN:
1go build -o dns ./dns.go # dns 명령 빌드
2./dns eblog.fly.dev # dns 명령 실행
OUT:
166.241.125.53
22a09:8280:1::37:6bbc
DNS & HTTP이제 인터넷 브라우징의 기본에 필요한 모든 것을 갖췄다. 도메인 이름의 IP 주소를 조회할 수 있고, IP 주소와 포트로 서버에 연결할 수 있다.
브라우저에 URL을 입력하면 다음을 수행한다:
도메인 이름의 IP 주소를 조회한다
해당 IP 주소와 포트의 서버에 연결한다
서버로 HTTP 요청을 보낸다
HTTP 응답을 읽고 표시한다 — 보통 웹 페이지.
그런데 잠깐, HTTP는 뭐지? HyperText Transfer Protocol은 인터넷을 통해 메시지를 전송하기 위한 텍스트 기반 프로토콜이다.
HTTP는 겉보기만큼 무섭지 않다. 요청부터 시작해 보자.
HTTP 요청은 평문 텍스트이며, 이렇게 생겼다:
1<METHOD> <PATH> <PROTOCOL/VERSION>
2Host: <HOST>
3[<HEADER>: <VALUE>]
4[<HEADER>: <VALUE>]
5[<HEADER>: <VALUE>] (이 친구들은 선택 사항)
6
7[<REQUEST BODY>] (이것도 선택 사항).
좀 더 구체적인 예로, 이 웹페이지를 가져오기 위해 보낼 수 있는 가장 기본 HTTP 요청은 다음과 같다:
1GET /backendbasics.html HTTP/1.1
2Host: eblog.fly.dev
(여기에는 몇 가지 함정이 있다. 줄바꿈은 유닉스 스타일 \n이 아니라 윈도우 스타일 \r\n이다. 그리고 요청은 빈 줄로 끝나야 한다.)
분해해 보자. 이건 이렇게 읽을 수 있다:
호스트 eblog.fly.dev에서
경로 /backendbasics.html에 있는 리소스를
HTTP/1.1 프로토콜로
GET 하라.
첫 줄은 요청 라인(REQUEST LINE) 이다. 세 부분으로 구성된다:
메서드(METHOD) (GET, POST, PUT, DELETE 등): 서버에 어떤 종류의 요청인지 알려준다. 지금은 두 개만 알면 된다. GET은 “읽기(READ)”, POST는 “쓰기(WRITE)”.
경로(PATH): 접근하고 싶은 리소스의 경로. 웹 주소에서 .com 또는 .dev 뒤에 나오는 부분이다. 여기서는 /backendbasics.html.
프로토콜/버전(PROTOCOL/VERSION): 요청의 프로토콜과 버전. 대부분 HTTP/1.1 또는 HTTP/2.0.
요청 라인 뒤에는 하나 이상의 헤더(HEADER) 가 온다.
헤더는 콜론(:)으로 구분된 키-값 쌍이다. 키는 Title-Case, 값은 lower-case 형식을 권장한다. 예: Content-Type: application/json. 몇몇 헤더는 HTTP 스펙에서 공식 의미를 갖지만, 대부분은 서버에게 요청을 어떻게 처리하면 좋을지에 대한 힌트일 뿐이다. 기술적으로는 MIME 헤더지만, 이미 약어가 너무 많으니 여기서는 그냥 헤더라고 부르겠다.
HOST 헤더는 필수다. 어떤 도메인에 접근하려는지 서버에 알려준다. 이 글의 예에서는 Host: eblog.fly.dev이다. 다른 헤더는 선택이며, 서버에 추가 정보를 보내는 데 사용된다. 흔한 헤더는 다음과 같다:
| 헤더 | 설명 | 예시 |
|---|---|---|
Accept-Encoding | 나는 이 인코딩으로 인코딩된 응답을 받을 수 있다 | gzip, deflate |
Accept | 클라이언트가 수용할 수 있는 응답 타입 | text/html |
Cache-Control | 클라이언트가 서버에게 원하는 캐싱 방식 | no-cache |
Content-Encoding | 내 응답 바디는 이 인코딩으로 인코딩되어 있다 | gzip, deflate |
Content-Length | 내 바디는 N 바이트 길이다 | 47 |
Content-Type | 요청 바디의 타입 | application/json |
Date | 요청 날짜/시간 | Tue, 17 Aug 2021 23:00:00 GMT |
Host | 접근하려는 서버의 도메인 이름 | eblog.fly.dev |
User-Agent | 요청을 만드는 클라이언트의 이름과 버전 | curl/7.64.1, Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) |
브라우저는 이보다 훨씬 많은 헤더를 보낸다. 개발자 도구를 열고 네트워크 탭에서 확인할 수 있다.
크롬에서 개발자 도구 네트워크 탭으로 이 페이지를 열었을 때(즉 https://eblog.fly.dev/backendbasics.html로 GET 요청을 보냈을 때) 크롬이 보낸 헤더는 다음과 같다:
1GET / HTTP/1.1
2Host: eblog.fly.dev
3Accept-Encoding: gzip, deflate, br
4Accept-Language: en-US,en;q=0.9
5Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
6Cache-Control: no-cache
7Pragma: no-cache
8Sec-Ch-Ua-Mobile: ?1
9Sec-Ch-Ua-Platform: "Android"
10Sec-Ch-Ua: "Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"
11Sec-Fetch-Dest: document
12Sec-Fetch-Mode: navigate
13Sec-Fetch-Site: none
14Sec-Fetch-User: ?1
15Upgrade-Insecure-Requests: 1
16User-Agent: Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36
같은 키의 헤더가 여러 번 있어도 된다. 예를 들어 Accept-Encoding 헤더가 여러 개 있을 수 있고, 각각 다른 인코딩을 지정할 수 있다. 또는 여러 값을 콤마로 구분할 수도 있다.
즉, 아래 두 가지는 원칙적으로 동일해야 한다:
1Accept-Encoding: gzip
2Accept-Encoding: deflate
그리고:
1Accept-Encoding: gzip, deflate
서버는 보통 이해할 수 있는 값 중 첫 번째를 선택한다. 서버는 키를 대소문자 구분 없이 처리하도록 되어 있지만, 실제로는 그렇지 않은 경우도 있다. 마찬가지로, 어떤 웹 서버는 같은 키의 헤더가 여러 개 있을 때 이를 제대로 처리하지 못하기도 한다.
HTTP 요청은 섹션을 구분하기 위해 ' ', '\r', '\n', : 같은 문자를 사용한다. 즉, 요청 라인이나 헤더 안에 이런 문자를 그대로 쓰면 서버가 헷갈린다. 구분하려는 건지, 문자 자체를 보내려는 건지 알 수 없다.
따라서 URL 경로와 헤더에는 이런 문자를 포함할 수 없고, 서버에 보내기 전에 URL %-인코딩으로 ‘이스케이프’해야 한다. URL 인코딩은 꽤 단순하다. 어떤 ASCII 문자를 그 값의 16진수로 바꾸고, 앞에 %를 붙이면 된다. 예를 들어 공백 문자는 16진수로 0x20이므로 %20으로 인코딩한다. 퍼센트 문자 자체는 0x25이므로 %25로 인코딩한다.
다음 문자들은 이스케이프 없이 URL 경로나 헤더에 항상 사용할 수 있다:
| 카테고리 | 문자 |
|---|---|
| 소문자 ASCII 문자 | abcdefghijklmnopqrstuvwxyz |
| 대문자 ASCII 문자 | ABCDEFGHIJKLMNOPQRSTUVWXYZ |
| 숫자 | 0123456789 |
| 예약되지 않은 문자(unreserved) | -._~ |
| 이스케이프 | % 다음에 16진수 두 자리 |
하지만 어떤 문자는 특정 컨텍스트에서만 이스케이프 없이 사용할 수 있다:
| 문자 집합 | 컨텍스트 | 비고 |
|---|---|---|
:/?#[]@ | path | []는 본 적이 없다; @는 인증용 |
& | query parameter | 쿼리 파라미터를 구분 |
+ | query parameter | 쿼리 파라미터에서 공백 인코딩에 사용 |
= | query parameter | 키와 값을 구분 |
; | path | path 세그먼트 구분; 잘 안 씀 |
$ | path | 잘 안 씀 |
그 외는 모두 이스케이프해야 한다. 예를 들어 다음 요청 경로는 유효하다:
1GET /backendbasics.html HTTP/1.1
2Host: eblog.fly.dev
하지만 이건 유효하지 않다:
1GET /backend basics.html HTTP/1.1
2Host: eblog.fly.dev
따라서 다음처럼 인코딩해야 한다:
1GET /backend%20basics.html HTTP/1.1
2Host: eblog.fly.dev
표준 라이브러리의 url.PathEscape와 url.PathUnescape로 URL 경로나 헤더에 쓸 문자열을 이스케이프/언이스케이프할 수 있다. 이 패키지는 다음 글에서 더 자세히 다룰 것이다.
PATH는 쿼리 파라미터를 포함할 수도 있다. 이는 key=value 형태의 키-값 쌍이며 경로 뒤에 붙는다. 경로의 ‘일반’ 부분은 ?로 끝내고, 쿼리 파라미터를 &로 구분해 추가한다.
구글에서 “backend_basics”를 검색하고 싶다면 다음 요청을 보낸다:
1GET /search?q=backend_basics HTTP/1.1
2Host: google.com
이는 하나의 쿼리 파라미터를 갖는다. 키(KEY) 는 q, 값(VALUE)은 backend_basics다. &로 구분해 추가 쿼리 파라미터를 더할 수 있다.
scryfall API는 다양한 쿼리 파라미터로 매직 카드를 검색할 수 있다. 이름에 “ice”가 들어가는 카드를 출시일 순으로 정렬해 찾고 싶다면 다음 요청을 보낸다:
1GET /search?q=ice&order=released&dir=asc HTTP/1.1
이 요청에는 “q=ice”, “order=released”, “dir=asc” 세 개의 쿼리 파라미터가 있다. =와 & 문자는 쿼리 파라미터 안에서 이스케이프하지 않는다는 점에 주의하자.
HTTP 요청은 대략 여기까지다. 이제 TCP를 사용해 eblog.fly.dev에 HTTP 요청을 보내 보자. 아래 전체 프로그램 sendreq는 주어진 host, port, path에 HTTP 요청을 보내고 응답을 stdout으로 출력한다.
sendreq.go로 HTTP 요청 보내기1// sendreq는 지정된 host, port, path로 요청을 보내고 응답을 stdout으로 출력한다.
2// flags: -host, -port, -path, -method
3package main
4
5import (
6 "bufio"
7 "flag"
8 "fmt"
9 "log"
10 "net"
11 "os"
12 "strings"
13)
14
15// 플래그 정의
16var (
17 host, path, method string
18 port int
19)
20
21func main() {
22 // 플래그 초기화 & 파싱
23 flag.StringVar(&method, "method", "GET", "HTTP method to use")
24 flag.StringVar(&host, "host", "localhost", "host to connect to")
25 flag.IntVar(&port, "port", 8080, "port to connect to")
26 flag.StringVar(&path, "path", "/", "path to request")
27 flag.Parse()
28
29 // ResolveTCPAddr는 TCPAddr를 만드는 좀 더 편한 방법이다.
30 // net.LookupIP로 손으로 하는 법을 알았으니, 이제 이걸 써도 된다.
31 ip, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port))
32 if err != nil {
33 panic(err)
34 }
35
36 // 방금 만든 TCPAddr로 원격 호스트에 다이얼...
37 conn, err := net.DialTCP("tcp", nil, ip)
38 if err != nil {
39 panic(err)
40 }
41
42 log.Printf("connected to %s (@ %s)", host, conn.RemoteAddr())
43
44 defer conn.Close()
45
46 var reqfields = []string{
47 fmt.Sprintf("%s %s HTTP/1.1", method, path),
48 "Host: " + host,
49 "User-Agent: httpget",
50 "", // 헤더 종료를 위한 빈 줄
51
52 // 바디가 있다면 여기에 위치
53 }
54 // 예: http://eblog.fly.dev/ 에 대한 요청
55 // GET / HTTP/1.1
56 // Host: eblog.fly.dev
57 // User-Agent: httpget
58 //
59
60 request := strings.Join(reqfields, "\r\n") + "\r\n" // 윈도우 스타일 줄바꿈에 주의
61
62 conn.Write([]byte(request))
63 log.Printf("sent request:\n%s", request)
64
65 for scanner := bufio.NewScanner(conn); scanner.Scan(); {
66 line := scanner.Bytes()
67 if _, err := fmt.Fprintf(os.Stdout, "%s\n", line); err != nil {
68 log.Printf("error writing to connection: %s", err)
69 }
70 if scanner.Err() != nil {
71 log.Printf("error reading from connection: %s", err)
72 return
73 }
74 }
75
76}
이 블로그의 인덱스 페이지(로컬호스트:8080에서 실행 중이라고 가정)에 대해 실행해 보자:
1go build -o sendreq ./sendreq.go
2./sendreq -host eblog.fly.dev -port 8080
12023/09/07 13:59:19 connected to localhost (@ 127.0.0.1:8080)
22023/09/07 13:59:19 sent request:
1GET / HTTP/1.1
2Host: localhost
3User-Agent: httpget
그리고 응답을 받는다. /index.html로의 리다이렉트 다:
1HTTP/1.1 308 Permanent Redirect
2Content-Type: text/html; charset=utf-8
3E-Req-Id: b641130b240142ae82ae8b122c35c80f
4E-Trace-Id: 086e9e55-364b-4cfd-b8fe-6497214af367
5Location: /index.html
6Date: Thu, 07 Sep 2023 20:59:19 GMT
7Content-Length: 47
8
9<a href="/index.html">Permanent Redirect</a>.
다음 섹션에서 HTTP 응답을 살펴보자.
HTTP 응답도 평문 텍스트이며, 이렇게 생겼다:
1<PROTOCOL/VERSION> <STATUS CODE> <STATUS MESSAGE>
2[<HEADER>: <VALUE>] (이 친구들은 선택 사항)
3[<HEADER>: <VALUE>]
4[<HEADER>: <VALUE>]
5
6[<RESPONSE BODY>] (이것도 선택 사항).
첫 줄은 상태 라인(STATUS LINE) 이다. 세 부분으로 구성된다:
프로토콜/버전(PROTOCOL/VERSION): 응답의 프로토콜과 버전. 요청과 항상 같아야 한다.
상태 코드(STATUS CODE): 요청 성공/실패를 나타내는 3자리 숫자. 첫 자리 숫자가 응답의 범주를 말해준다:
1xx: “정보(informational)”. 거의 쓰이지 않는다.
2xx: “성공(success)”. 실무에서 보는 건 200 OK, 201 Created 정도다.
3xx: “리다이렉트(redirect)”. 실무에서 보는 건 301, 308 정도다.
4xx: “클라이언트 오류(client error)”. 404 Not Found, 403 Forbidden은 익숙할 텐데, 더 많은 종류가 있다.
5xx: “서버 오류(server error)”. 실무에서 보는 건 500 Internal Server Error가 대부분이며, 처리되지 않은 오류의 기본 코드다.
각 상태 코드에는 정확히 하나의 상태 메시지(STATUS MESSAGE) 가 대응한다. 예: 200 OK, 404 Not Found. 상태 메시지는 상태 코드에 대한 사람이 읽기 쉬운 설명일 뿐이다.
헤더는 요청과 같은 방식으로 동작한다. 콜론(:)으로 구분된 키-값 쌍이며, 키는 Title-Case, 값은 lower-case 형식 권장. 응답 헤더는 보통 요청 헤더와 ‘대칭’이다. Accept-Encoding: gzip을 보내면 보통 Content-Encoding: gzip이 돌아온다.
마지막 섹션은 응답 바디(response body) 다. 여기서는 브라우저에게 /index.html로 리다이렉트하라고 알려주는 HTML이다. 걱정 마라. HTML은 다루지 않는다. 이건 프론트엔드 글이 아니라 백엔드 글이다.
리다이렉트를 따라 /index.html을 요청해 보자:
1./sendreq -host eblog.fly.dev -port 8080 -path /index.html
200 OK 응답과 (아주 듬성듬성한) 인덱스 페이지 내용을 받는다:
1HTTP/1.1 200 OK
2E-Req-Id: 47cf0abba4fd4629a9a926769649f653
3E-Trace-Id: dc2c9528-0322-4a16-8688-8ce760fff374
4Date: Thu, 07 Sep 2023 21:04:28 GMT
5Content-Length: 1300
6Content-Type: text/html; charset=utf-8
7
8<!DOCTYPE html><html><head>
9 <title>index.html</title>
10 <meta charset="utf-8"/>
11 <link rel="stylesheet" type="text/css" href="/dark.css"/>
12 </head>
13 <body>
14 <h1> articles </h1>
15<h4><a href="/performanceanxiety.html">performanceanxiety.html</a>
16</h4><h4><a href="/onoff.html">onoff.html</a>
17</h4><h4><a href="/fastdocker.html">fastdocker.html</a>
18</h4><h4><a href="/README.html">README.html</a>
19</h4><h4><a href="/mermaid_test.html">mermaid_test.html</a>
20</h4><h4><a href="/quirks3.html">quirks3.html</a>
21</h4><h4><a href="/console-autocomplete.html">console-autocomplete.html</a>
22</h4><h4><a href="/console.html">console.html</a>
23</h4><h4><a href="/cheatsheet.html">cheatsheet.html</a>
24</h4><h4><a href="/testfast.html">testfast.html</a>
25</h4><h4><a href="/quirks2.html">quirks2.html</a>
26</h4><h4><a href="/bytehacking.html">bytehacking.html</a>
27</h4><h4><a href="/benchmark_results.html">benchmark_results.html</a>
28</h4><h4><a href="/index.html">index.html</a>
29</h4><h4><a href="/noframework.html">noframework.html</a>
30</h4><h4><a href="/faststack.html">faststack.html</a>
31</h4><h4><a href="/backendbasics.html">backendbasics.html</a>
32</h4><h4><a href="/startfast.html">startfast.html</a>
33</h4><h4><a href="/quirks.html">quirks.html</a>
34</h4><h4><a href="/reflect.html">reflect.html</a>
이 정도면 되지만, 로우(raw) HTTP 요청/응답을 직접 다루는 건 꽤 귀찮다. Go의 net/http 패키지로 들어가기 전에, 우리가 HTTP 라이브러리를 직접 만든다면 어떻게 구현할지 생각해 보자.
줄바꿈이 유닉스 \n이 아니라 윈도우 \r\n인지 확인한다든가, 헤더를 Title-Case로 만든다든가 같은 프로토콜 세부에 신경 쓰지 않고 요청/응답을 작성할 수 있는 방법이 있으면 좋겠다.
즉, 대략 난이도 순(쉬운 것부터)으로 다음 네 가지가 필요하다:
HTTP 요청 또는 응답을 메모리에서 표현하는 방법
요청/응답에 헤더를 추가하는 방법
이를 HTTP 포맷의 텍스트로 직렬화(serialize)하는 방법
이를 HTTP 포맷의 텍스트에서 파싱(parse)하는 방법
일단 HTTP 1.1로 제한하면, HTTP 요청을 다음 필드를 가진 구조체로 생각할 수 있다:
1
2// Header는 HTTP 헤더를 나타낸다. HTTP 헤더는 콜론(:)으로 구분된 키-값 쌍이며,
3// 키는 Title-Case 형식이어야 한다.
4// Request.AddHeader() 또는 Response.AddHeader()를 사용해 요청/응답에 헤더를 추가하면 키가 Title-Case가 되도록 보장할 수 있다.
5type Header struct {Key, Value string}
6// Request는 HTTP 1.1 요청을 나타낸다.
7type Request struct {
8 Method string // 예: GET, POST, PUT, DELETE
9 Path string // 예: /index.html
10 Headers []struct {Key, Value string} // 예: Host: eblog.fly.dev
11 Body string // 예: <html><body><h1>hello, world!</h1></body></html>
12}
HTTP 응답은 다음 필드를 가진 구조체로 생각할 수 있다:
1type Response struct {
2 StatusCode int // 예: 200
3 Headers []struct {Key, Value string} // 예: Content-Type: text/html
4 Body string // 예: <html><body><h1>hello, world!</h1></body></html>
5}
다음 함수는 요청 또는 응답을 만들어 준다:
1func NewRequest(method, path, host, body string) (*Request, error) {
2 switch {
3 case method == "":
4 return nil, errors.New("missing required argument: method")
5 case path == "":
6 return nil, errors.New("missing required argument: path")
7 case !strings.HasPrefix(path, "/"):
8 return nil, errors.New("path must start with /")
9 case host == "":
10 return nil, errors.New("missing required argument: host")
11 default:
12 headers := make([]Header, 2)
13 headers[0] = Header{"Host", host}
14 if body != "" {
15 headers = append(headers, Header{"Content-Length", fmt.Sprintf("%d", len(body))})
16 }
17 return &Request{Method: method, Path: path, Headers: headers, Body: body}, nil
18 }
19}
20
21func NewResponse(status int, body string) (*Response, error) {
22 switch {
23 case status < 100 || status > 599:
24 return nil, errors.New("invalid status code")
25 default:
26 if body == "" {
27 body = http.StatusText(status)
28 }
29 headers := []Header {"Content-Length", fmt.Sprintf("%d", len(body))}
30 return &Response{
31 StatusCode: status,
32 Headers: headers,
33 Body: body,
34 }, nil
35 }
36}
요청/응답에 헤더를 추가할 때 키의 대소문자를 신경 쓰고 싶지 않다. 이를 위해 *Request와 *Response에 ‘빌더(builder)’ 메서드를 만든다:
1func (resp *Response) WithHeader(key, value string) *Response {
2 resp.Headers = append(resp.Headers, Header{AsTitle(key), value})
3 return resp
4}
5func (r *Request) WithHeader(key, value string) *Request {
6 r.Headers = append(r.Headers, Header{AsTitle(key), value})
7 return r
8}
이걸로 헤더를 하나씩 붙여 요청을 만들 수 있다:
1req, err := NewRequest("POST", "/api/v1/users", "eblog.fly.dev", `{"name": "eblog", "email": "efron.dev@gmail.com"}`)
2if err != nil {
3 panic(err)
4}
5req = req.WithHeader("Content-Type", "application/json").
6 WithHeader("Accept", "application/json").
7 WithHeader("User-Agent", "httpget")
그런데 AsTitle은 어떻게 구현될까? 요구사항을 제대로 이해했는지 확인하기 위해 간단한 테스트를 먼저 써 보자:
1func TestTitleCaseKey(t *testing.T) {
2 for input, want := range map[string]string{
3 "foo-bar": "Foo-Bar",
4 "cONTEnt-tYPE": "Content-Type",
5 "host": "Host",
6 "host-": "Host-",
7 "ha22-o3st": "Ha22-O3st",
8 } {
9 if got := AsTitle(input); got != want {
10 t.Errorf("TitleCaseKey(%q) = %q, want %q", input, got, want)
11 }
12 }
13}
MIME 헤더는 ASCII만 있다고 가정하므로 유니코드는 신경 쓸 필요가 없다.
1// AsTitle은 주어진 헤더 키를 타이틀 케이스로 반환한다. 예: "content-type" -> "Content-Type"
2// 키가 비어 있으면 panic을 일으킨다.
3func AsTitle(key string) string {
4 /* 설계 노트 --- 빈 문자열도 '타이틀 케이스'라고 볼 수 있지만,
5 실무에서는 보통 프로그래머 오류일 가능성이 크다. 추측하기보다 panic을 내자.
6 */
7 if key == "" {
8 panic("empty header key")
9 }
10 if isTitleCase(key) {
11 return key
12 }
13 /* ---- 설계 노트: 할당(allocation)은 매우 비싸고, 문자열 순회는 매우 싸다.
14 일반적으로, 한 번 할당하느니 두 번 검사하는 편이 낫다. ----
15 */
16 return newTitleCase(key)
17}
18
19
20
21// newTitleCase는 주어진 헤더 키를 타이틀 케이스로 반환한다. 예: "content-type" -> "Content-Type";
22// 항상 새 문자열을 할당한다.
23func newTitleCase(key string) string {
24 var b strings.Builder
25 b.Grow(len(key))
26 for i := range key {
27
28 if i == 0 || key[i-1] == '-' {
29 b.WriteByte(upper(key[i]))
30 } else {
31 b.WriteByte(lower(key[i]))
32 }
33 }
34 return b.String()
35}
36
37
38// K&R C 2판 43쪽에서 그대로 가져왔다. 고전은 영원하다.
39func lower(c byte) byte {
40 /* 이게 이해가 어렵다면:
41 아이디어는 이렇다. A..=Z는 65..=90이고, a..=z는 97..=122이다.
42 즉 대문자는 소문자보다 32 작다(또는 'a'-'A' == 32).
43 '마법 숫자' 32를 쓰는 대신 'a'-'A'를 사용해 같은 결과를 얻는다.
44 */
45 if c >= 'A' && c <= 'Z' {
46 return c + 'a' - 'A'
47 }
48 return c
49}
50func upper(c byte) byte {
51 if c >= 'a' && c <= 'z' {
52 return c + 'A' - 'a'
53 }
54 return c
55}
56
57
58
59// isTitleCase는 주어진 헤더 키가 이미 타이틀 케이스인지 반환한다.
60// 즉 "Content-Type"이나 "Content-Length", "Some-Odd-Header" 같은 형태인지.
61func isTitleCase(key string) bool {
62 // 이미 타이틀 케이스인지 검사.
63 for i := range key {
64 if i == 0 || key[i-1] == '-' {
65 if key[i] >= 'a' && key[i] <= 'z' {
66 return false
67 }
68 } else if key[i] >= 'A' && key[i] <= 'Z' {
69 return false
70 }
71 }
72 return true
73}
74
테스트를 실행하면 통과하므로 준비됐다. 표준 라이브러리의 textproto.CanonicalMIMEHeaderKey 구현과 비교해 보면, 우리 구현은 몇몇 코너 케이스와 흔한 헤더에 대한 최적화가 없을 뿐 본질적으로 거의 같다.
net.Conn이나 다른 io.Writer에 효율적으로 쓸 수 있도록 두 구조체에 io.WriterTo 인터페이스를 구현하자.
1// WriteTo는 Request를 주어진 io.Writer에 쓴다.
2func (r *Request) WriteTo(w io.Writer) (n int64, err error) {
3 // 쓰고, 쓴 바이트 수를 센다.
4 // 이런 작은 클로저로 반복을 줄이는 건 편리하지만,
5 // 때로는 성능 페널티가 있을 수 있다.
6 printf := func(format string, args ...any) error {
7 m, err := fmt.Fprintf(w, format, args...)
8 n += int64(m)
9 return err
10 }
11 // HTTP 요청은 이렇게 생겼다:
12 // <METHOD> <PATH> <PROTOCOL/VERSION>
13 // <HEADER>: <VALUE>
14 // <HEADER>: <VALUE>
15 //
16 // <REQUEST BODY>
17
18 // 요청 라인 작성: 예 "GET /index.html HTTP/1.1"
19 if err := printf("%s %s HTTP/1.1\r\n", r.Method, r.Path); err != nil {
20 return n, err
21 }
22
23 // 헤더 작성. 헤더 정렬이나 중복 헤더 병합 등은 하지 않는다. 예시일 뿐.
24 for _, h := range r.Headers {
25 if err := printf("%s: %s\r\n", h.Key, h.Value); err != nil {
26 return n, err
27 }
28 }
29 printf("\r\n") // 헤더와 바디를 구분하는 빈 줄
30 err = printf("%s\r\n", r.Body) // 바디를 쓰고 개행으로 종료
31 return n, err
32}
Response도 거의 동일하다:
1func (resp *Response) WriteTo(w io.Writer) (n int64, err error) {
2 printf := func(format string, args ...any) error {
3 m, err := fmt.Fprintf(w, format, args...)
4 n += int64(m)
5 return err
6 }
7 if err := printf("HTTP/1.1 %d %s\r\n", resp.StatusCode, http.StatusText(resp.StatusCode)); err != nil {
8 return n, err
9 }
10 for _, h := range resp.Headers {
11 if err := printf("%s: %s\r\n", h.Key, h.Value); err != nil {
12 return n, err
13 }
14
15 }
16 if err := printf("\r\n%s\r\n", resp.Body); err != nil {
17 return n, err
18 }
19 return n, nil
20}
Go에는 표준 라이브러리 전반에서 쓰이는 여러 표준 인터페이스가 있다. io.Reader와 io.Writer는 이미 봤을 테지만, 더 많다. 표준 라이브러리의 많은 함수는 이런 인터페이스를 구현한 타입과 함께 쓰면 더 잘 동작한다. 예를 들어 io.Copy는 io.Reader에서 io.Writer로 복사하지만, src가 io.WriterTo를 구현하거나 dst가 io.ReaderFrom을 구현하면, 더 효율적일 수 있는 그 메서드를 대신 사용한다.
비슷하게, fmt.Stringer는 타입의 문자열 표현을 얻기 위해 쓰고, encoding.TextMarshaler는 네트워크나 디스크로 직렬화하기 위해 타입의 바이트 슬라이스 표현을 얻는 데 쓴다.
편의를 위해, 그리고 테스트를 더 쉽게 쓰기 위해 Request와 Response에 이 두 인터페이스를 구현하자.
필요한 건 WriteTo를 호출하고 결과를 반환하는 것뿐이다:
1var _, _ fmt.Stringer = (*Request)(nil), (*Response)(nil) // 컴파일 타임에 Request/Response가 fmt.Stringer를 구현하는지 체크
2var _, _ encoding.TextMarshaler = (*Request)(nil), (*Response)(nil)
3func (r *Request) String() string { b := new(strings.Builder); r.WriteTo(b); return b.String() }
4func (resp *Response) String() string { b := new(strings.Builder); resp.WriteTo(b); return b.String() }
5func (r *Request) MarshalText() ([]byte, error) { b := new(bytes.Buffer); r.WriteTo(b); return b.Bytes(), nil }
6func (resp *Response) MarshalText() ([]byte, error) { b := new(bytes.Buffer); resp.WriteTo(b); return b.Bytes(), nil }
마지막으로, 텍스트에서 HTTP 요청/응답을 파싱할 수 있으면 좋겠다. 쓰는 것보다 조금 더 복잡하지만, 지금까지 한 걸 생각하면 비교적 straightforward하다.
1// ParseRequest는 주어진 텍스트에서 HTTP 요청을 파싱한다.
2func ParseRequest(raw string) (r Request, err error) {
3 // 요청은 세 부분이다:
4 // 1. 요청 라인
5 // 2. 헤더
6 // 3. 바디(선택)
7 lines := splitLines(raw)
8
9 log.Println(lines)
10 if len(lines) < 3 {
11 return Request{}, fmt.Errorf("malformed request: should have at least 3 lines")
12 }
13 // 첫 줄은 특별하다.
14 first := strings.Fields(lines[0])
15 r.Method, r.Path = first[0], first[1]
16 if !strings.HasPrefix(r.Path, "/") {
17 return Request{}, fmt.Errorf("malformed request: path should start with /")
18 }
19 if !strings.Contains(first[2], "HTTP") {
20 return Request{}, fmt.Errorf("malformed request: first line should contain HTTP version")
21 }
22 var foundhost bool
23 var bodyStart int
24 // 그 다음은 빈 줄이 나올 때까지 헤더.
25 for i := 1; i < len(lines); i++ {
26 if lines[i] == "" { // 빈 줄
27 bodyStart = i + 1
28 break
29 }
30 key, val, ok := strings.Cut(lines[i], ": ")
31 if !ok {
32 return Request{}, fmt.Errorf("malformed request: header %q should be of form 'key: value'", lines[i])
33 }
34 if key == "Host" { // 특수 케이스: Host 헤더는 필수.
35 foundhost = true
36 }
37 key = AsTitle(key)
38
39 r.Headers = append(r.Headers, Header{key, val})
40 }
41 end := len(lines) - 1 // 일반 개행으로 바디를 재결합; 마지막 빈 줄은 스킵.
42 r.Body = strings.Join(lines[bodyStart:end], "\r\n")
43 if !foundhost {
44 return Request{}, fmt.Errorf("malformed request: missing Host header")
45 }
46 return r, nil
47}
48
49
50// ParseResponse는 주어진 HTTP/1.1 응답 문자열을 Response로 파싱한다. Response가 유효하지 않으면 error를 반환한다.
51// - 정수가 아님
52// - 유효하지 않은 상태 코드
53// - 상태 텍스트 누락
54// - 유효하지 않은 헤더
55// 멀티라인 헤더, 여러 값을 가진 헤더, HTML 인코딩 등은 제대로 처리하지 않는다.
56func ParseResponse(raw string) (resp *Response, err error) {
57 // 응답은 세 부분이다:
58 // 1. 응답 라인
59 // 2. 헤더
60 // 3. 바디(선택)
61 lines := splitLines(raw)
62 log.Println(lines)
63
64 // 첫 줄은 특별하다.
65 first := strings.SplitN(lines[0], " ", 3)
66 if !strings.Contains(first[0], "HTTP") {
67 return nil, fmt.Errorf("malformed response: first line should contain HTTP version")
68 }
69 resp = new(Response)
70 resp.StatusCode, err = strconv.Atoi(first[1])
71 if err != nil {
72 return nil, fmt.Errorf("malformed response: expected status code to be an integer, got %q", first[1])
73 }
74 if first[2] == "" || http.StatusText(resp.StatusCode) != first[2] {
75 log.Printf("missing or incorrect status text for status code %d: expected %q, but got %q", resp.StatusCode, http.StatusText(resp.StatusCode), first[2])
76 }
77 var bodyStart int
78 // 그 다음은 빈 줄이 나올 때까지 헤더.
79 for i := 1; i < len(lines); i++ {
80 log.Println(i, lines[i])
81 if lines[i] == "" { // 빈 줄
82 bodyStart = i + 1
83 break
84 }
85 key, val, ok := strings.Cut(lines[i], ": ")
86 if !ok {
87 return nil, fmt.Errorf("malformed response: header %q should be of form 'key: value'", lines[i])
88 }
89 key = AsTitle(key)
90 resp.Headers = append(resp.Headers, Header{key, val})
91 }
92 resp.Body = strings.TrimSpace(strings.Join(lines[bodyStart:], "\r\n")) // 일반 개행으로 바디를 재결합.
93 return resp, nil
94}
95// "\r\n" 시퀀스로 splitLines; 연속된 구분자는 축약되지 않는다.
96func splitLines(s string) []string {
97 if s == "" {
98 return nil
99 }
100 var lines []string
101 i := 0
102 for {
103 j := strings.Index(s[i:], "\r\n")
104 if j == -1 {
105 lines = append(lines, s[i:])
106 return lines
107 }
108 lines = append(lines, s[i:i+j]) // \r\n 전까지
109 i += j + 2 // \r\n 스킵
110 }
111}
이전처럼 요구사항을 이해했는지 확인하기 위해 간단한 테스트를 몇 개 써 보자.
지면 관계상 에러 케이스는 생략한다. 이 글은 이미 충분히 길다.
1func TestHTTPResponse(t *testing.T) {
2 for name, tt := range map[string]struct {
3 input string
4 want *Response
5 }{
6 "200 OK (no body)": {
7 input: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n",
8 want: &Response{
9 StatusCode: 200,
10 Headers: []Header{
11 {"Content-Length", "0"},
12 },
13 },
14 },
15 "404 Not Found (w/ body)": {
16 input: "HTTP/1.1 404 Not Found\r\nContent-Length: 11\r\n\r\nHello World\r\n",
17 want: &Response{
18 StatusCode: 404,
19 Headers: []Header{
20 {"Content-Length", "11"},
21 },
22 Body: "Hello World",
23 },
24 },
25 } {
26 t.Run(name, func(t *testing.T) {
27 got, err := ParseResponse(tt.input)
28 if err != nil {
29 t.Errorf("ParseResponse(%q) returned error: %v", tt.input, err)
30 }
31 if !reflect.DeepEqual(got, tt.want) {
32 t.Errorf("ParseResponse(%q) = %#+v, want %#+v", tt.input, got, tt.want)
33 }
34
35 if got2, err := ParseResponse(got.String()); err != nil {
36 t.Errorf("ParseResponse(%q) returned error: %v", got.String(), err)
37 } else if !reflect.DeepEqual(got2, got) {
38 t.Errorf("ParseResponse(%q) = %#+v, want %#+v", got.String(), got2, got)
39 }
40
41 })
42 }
43}
44
45func TestHTTPRequest(t *testing.T) {
46 for name, tt := range map[string]struct {
47 input string
48 want Request
49 }{
50 "GET (no body)": {
51 input: "GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n",
52 want: Request{
53 Method: "GET",
54 Path: "/",
55 Headers: []Header{
56 {"Host", "www.example.com"},
57 },
58 },
59 },
60 "POST (w/ body)": {
61 input: "POST / HTTP/1.1\r\nHost: www.example.com\r\nContent-Length: 11\r\n\r\nHello World\r\n",
62 want: Request{
63 Method: "POST",
64 Path: "/",
65 Headers: []Header{
66 {"Host", "www.example.com"},
67 {"Content-Length", "11"},
68 },
69 Body: "Hello World",
70 },
71 },
72 } {
73 t.Run(name, func(t *testing.T) {
74 got, err := ParseRequest(tt.input)
75 if err != nil {
76 t.Errorf("ParseRequest(%q) returned error: %v", tt.input, err)
77 }
78 if !reflect.DeepEqual(got, tt.want) {
79 t.Errorf("ParseRequest(%q) = %#+v, want %#+v", tt.input, got, tt.want)
80 }
81 // 요청을 문자열로 썼다가 다시 파싱했을 때 동일한 요청이 되는지 테스트
82 got2, err := ParseRequest(got.String())
83 if err != nil {
84 t.Errorf("ParseRequest(%q) returned error: %v", got.String(), err)
85 }
86 if !reflect.DeepEqual(got, got2) {
87 t.Errorf("ParseRequest(%q) = %+v, want %+v", got.String(), got2, got)
88 }
89
90 })
91 }
92}
테스트를 실행하면 통과하므로 준비됐다. 이를 통해 HTTP가 내부적으로 어떻게 동작하는지 꽤 좋은 감을 얻을 수 있을 것이다.
HTTP를 직접 파싱할 일은 거의 없다. 하지만 문제가 생겼을 때 실제로 어떻게 동작하는지 아는 것은 중요하다. 이 프로토콜의 상대적 단순함은, 현대 웹의 믿기 힘들 정도로 과도하게 설계된 복잡성과 비교하면 고개가 갸웃해질 정도다. 다음 글에서는 HTTP를 ‘제대로’ 다루는 방법으로 들어가며 표준 라이브러리의 net/http 패키지를 파고들 것이다.
이 글이 마음에 들었나? 훌륭한 소프트웨어를 만드는 데 도움이 필요하나? 아니면 클라우드 비용을 수십만 달러 아끼고 싶은가? 나를 고용하거나 컨설팅을 요청하라. 업무 문의는