GNPS (Go Network Programming School)의 내용을 정리한 글입니다.
Go 언어를 활용한 네트워크 프로그래밍 - 책으로 스터디를 진행합니다.

HTTP 기초 이해
HTTP는 www에서 사용되는 애플리케이션 계층의 프로토콜로, 클라이언트-서버 기반의 세션을 가지지 않는 프로토콜입니다. 웹상에서 통신하는 기반이 되는 프로토콜로, 하위 계층의 전송 프로토콜로는 TCP를 사용합니다.
URL
URL(Uniform Resource Locator - 통합 리소스 식별자)은 클라이언트가 웹 서버를 찾아 요청된 리소스를 식별하는 데 사용하는 일종의 주소입니다. 클라이언트는 URL을 웹 브라우저에 전송하고, 웹 서버는 URL에 해당하는 미디어 리소스를 응답합니다.
여기서 리소스란 이미지, 스타일 시트, HTML, 자바스크립트 파일 등을 의미합니다.
URL은 스키마, 권한정보, 경로, 쿼리 파라미터, 정보조각 등 총 5가지로 구분되지만, 일반적으로 인터넷상에서 사용하는 URL은 최소한 스키마와 호스트 이름을 포함합니다.
URL: https://google.com/
스키마: https
호스트 이름: google.com
경로: /(기본)
검색 쿼리가 포함된 URL 예시:
구글에서 스파이더맨을 검색하는 경우를 살펴보겠습니다.
URL: https://www.google.com/search?q=spiderman&newwindow=1...
스키마: https
호스트 이름: http://www.google.com
경로: /search
쿼리 파라미터: q=spiderman&newwindow=1...
물음표로 시작하는 쿼리 파라미터는 &(앰퍼샌드)로 구분된 파라미터로, 웹 서버에 요청하는 정보를 포함합니다.
URL 을 통한 클라이언트 리소스 요청
HTTP Request(http 요청)는 클라이언트가 서버로 특정한 리소스를 응답하도록 요청하는 메시지입니다. HTTP 요청은 메서드, 대상 리소스, 요청 헤더, 요청 본문으로 구성됩니다.
- 메서드: 서버에게 대상 리소스로 무엇을 하려는지에 대한 의도를 전달합니다. (GET/POST/PUT/DELETE 등)
- 리소스: 서버에게 요청하는 대상 리소스입니다.
- 요청 헤더: 전송 요청 시 보내는 데이터에 대한 메타데이터(데이터를 설명하는 데이터)입니다.
- 요청 본문: 서버에게 전달하는 데이터 페이로드입니다.
netcat을 사용한 HTTP 요청 예시
netcat을 사용하여 구글의 웹서버로 robots.txt 파일의 내용을 얻어오기 위한 간단한 GET 요청을 실행해 보겠습니다.
> nc google.com 80
GET /robots.txt HTTP/1.1
Host: google.com
# 비어있는 공백 줄
HTTP/1.1 301 Moved Permanently
Location: https://www.google.com/robots.txt
Cross-Origin-Resource-Policy: cross-origin
X-Content-Type-Options: nosniff
Server: sffe
Content-Length: 230
X-XSS-Protection: 0
Date: Mon, 10 Nov 2025 12:27:03 GMT
Expires: Mon, 10 Nov 2025 12:57:03 GMT
Cache-Control: public, max-age=1800
Content-Type: text/html; charset=UTF-8
Age: 1590
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="https://www.google.com/robots.txt">here</A>.
</BODY></HTML>
curl을 사용한 HTTP 요청
curl을 사용하면 더 직관적으로 요청을 보낼 수 있습니다.
> curl -X GET https://www.google.com/robots.txt -v
Note: Unnecessary use of -X or --request, GET is already inferred.
* Host www.google.com:443 was resolved.
* IPv6: (none)
* IPv4: 142.250.76.132
* Trying 142.250.76.132:443...
* Connected to www.google.com (142.250.76.132) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/cert.pem
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
* subject: CN=www.google.com
* start date: Oct 13 08:39:48 2025 GMT
...
...
서버에서는 상태 라인, 응답 헤더, 응답 본문을 구분하는 공백, 그리고 응답 본문을 전송합니다.
HTTP 메서드
Go의 스탠더드 라이브러리인 net/http 패키지를 이용하면 http 메서드와 url만 가지고 http 요청을 만들 수 있습니다. 따라서 많이 사용되는 메서드에 대해 일반적으로 사용되는 의도를 알고 있어야 URL을 사용하여 올바른 요청을 보낼 수 있습니다.
주요 HTTP 메서드
- GET: 해당 리소스에 대한 전송 요청으로, 서버는 요청을 받으면 해당하는 리소스를 클라이언트로 전달합니다. 해당 메서드를 통한 요청으로 서버의 리소스가 변경되거나 삭제되어서는 안 됩니다.
- HEAD: GET 메서드와 유사하지만 본문에 리소스를 포함하지 않습니다. 응답 코드와 필요한 다양한 메타데이터를 응답 헤더로 보내줍니다. 이를 통해 실제 리소스에 대한 정보를 미리 파악할 수 있습니다.
- POST: 웹 서버로 요청하는 요청 본문에 대상 리소스에 대한 데이터를 포함시켜 데이터를 업로드합니다. 예를 들어, 인스타그램 포스팅에 댓글을 다는 경우 인스타그램 포스팅에 대한 댓글 리소스가 대상이 되며, 본문 데이터에 댓글 내용을 담아 요청합니다. POST는 일반적으로 서버에 새로운 리소스를 생성합니다.
- PUT: POST와 유사하게 데이터 업로드의 의도를 담지만, 일반적으로 이미 존재하는 리소스에 대해서 특정한 값을 업데이트하거나 완전히 교체할 때 사용합니다. (POST도 업데이트 의도를 담을 때 사용할 수 있습니다.)
- PATCH: PUT과 유사하지만, 리소스의 일부분만 수정할 때 사용합니다. PUT은 리소스 전체를 교체하는 반면, PATCH는 부분적인 수정에 사용됩니다.
- DELETE: 서버에게 리소스를 제거하는 의도를 담고 있습니다. 예를 들어 블로그에 올린 글을 지우기 위해 삭제 버튼을 누르면 DELETE 메서드로 블로그 글 리소스에 대한 URL로 서버에 요청을 보냅니다.
- OPTIONS: 서버가 해당 리소스에 대해 어떤 메서드를 지원하는지 알 수 있습니다.
HTTP 메서드 사용 시 주의사항
메서드와 관련해서 리소스에 대한 메서드를 정의하는 것은 웹 서버를 개발하는 개발자입니다. 예를 들어 블로그 글 조회에 GET 메서드가 아닌 POST 메서드로 요청을 받을 수 있게 서버 코드를 작성할 수 있습니다. 하지만 사람들이 공통적으로 이해하고 있는 관행적인 사용 방법이 아닌 개발자가 마음대로 메서드를 사용하면 의도가 달라지게 되어 사용자가 생각하지 못한 동작을 할 수 있습니다.
따라서 개발자는 HTTP 메서드를 반드시 숙지하고, 리소스에 대한 의도를 논리적으로 사용할 수 있도록 정의해야 합니다.
서버 응답
서버 응답에는 항상 요청에 대한 상태를 알려주는 상태 코드를 포함합니다. 상태 코드는 크게 1xx, 2xx, 3xx, 4xx, 5xx로 백 번 대역부터 오백 번 대역까지입니다.
- 1xx: 클라이언트에게 방향을 제시하는 데 사용합니다 (일부 HTTP/1.1에서 사용)
- 2xx: 요청에 대한 성공적인 응답입니다
- 3xx: 클라이언트 측에서 추가로 무언가를 해야 합니다
- 4xx: 클라이언트 요청이 잘못되어 요청을 정상적으로 처리하지 못하고 에러가 발생했습니다
- 5xx: 서버 측의 문제로 요청을 정상적으로 처리하지 못하고 에러가 발생했습니다
IANA에서 공식적으로 HTTP 응답 코드를 관리하며, 개발자는 이런 응답 코드 또한 숙지하여 올바른 응답 코드를 코드에 적용할 수 있도록 해야 합니다.
웹 페이지 렌더링
웹 페이지는 여러 리소스들의 조합으로 구성됩니다. 이미지, 사운드, 레이아웃 정보, 서드파티 광고, 비디오 등 모든 리소스들을 조합하여 만듭니다. 각 리소스를 요청하는 것은 각각의 리소스에 대해 서버로 개별적인 요청을 보내야 합니다.
HTTP 버전별 특징
- HTTP/1.0: 각 요청마다 별도의 TCP 연결을 맺어 요청했습니다.
- HTTP/1.1: 동일한 웹서버로 발생하는 여러 HTTP 요청에 대해서 동일한 TCP에 연결해서 사용할 수 있도록 하여, 요청 및 연결에서 발생하는 오버헤드와 레이턴시를 줄였습니다.
- HTTP/2: HTTP/1.1의 오버헤드를 더욱 줄여 여러 개의 요청에 대해 동일한 TCP 연결을 사용하고, 서버가 클라이언트에게 푸시를 할 수 있는 등 확장된 기능을 제공합니다.
- HTTP/3: 다양한 기능을 지원하며 더 빠른 전송을 위해 UDP를 사용하여 구현되었습니다.
최초 웹 서버에 접속하는 경우 GET 요청을 통해 기본 리소스인 HTML을 받아갑니다. 해당 HTML은 페이지를 적절하게 렌더링 하기 위해 필요한 다양한 추가 리소스(Image, CSS, JavaScript 파일 등)를 포함할 것이며, 필요한 경우 동적 데이터 요청을 서버에 할 수도 있습니다.
google.com의 개발자 도구로 확인해 보면 수많은 요청들이 발생하고 있음을 알 수 있습니다.

Go에서 웹 리소스 가져오기
웹 브라우저가 아닌 이제 Go를 통해서 웹 리소스를 가져와보겠습니다. net/http 패키지를 이용해 웹서버와 통신이 가능합니다. HTML을 가져오더라도 화면에 렌더링 해주지는 않습니다.
대신 요청에 대한 응답으로 수집한 정보들을 원하는 형식으로 변환하거나 다른 웹서버와 API를 통해 상호작용하는 데 사용할 수 있습니다.
Go의 기본 HTTP 클라이언트 이용하기
net/http 패키지는 일회성으로 HTTP 요청을 할 수 있는 클라이언트를 포함합니다.
package httpbasic
import (
"net/http"
"testing"
"time"
)
func TestHeadTime(t *testing.T) {
resp, err := http.Head("https://www.time.gov/")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
now := time.Now().Round(time.Second)
date := resp.Header.Get("Date")
if date == "" {
t.Fatal("no Date header received from time.gov")
}
dt, err := time.Parse(time.RFC1123, date)
if err != nil {
t.Fatal(err)
}
t.Logf("time.gov: %s (skew %s)", dt, now.Sub(dt))
}
위 코드는 time.gov에서 현재 시간을 가져와 로컬 타임과 차이를 비교합니다.
이 코드에서 net/http 패키지의 Head 요청과 응답을 사용하는 방법을 확인해 볼 수 있습니다. https://www.time.gov/ 에서 기본적인 리소스를 조회하고, URL에서 지정된 스키마를 확인하고 HTTPS 프로토콜로 스키마를 요청합니다.
응답 본문을 읽거나 사용하지는 않지만 반드시 닫아줘야 하는데, 그 이유는 다음 섹션에서 확인해 보겠습니다.
응답 본문 닫기
HTTP/1.1은 클라이언트가 서버의 TCP 연결을 유지하여 여러 개의 HTTP 요청을 재사용하는 기능이 존재합니다. 이를 KeepAlive라고 합니다.
여기서 중요한 점은 이전 요청에 대한 응답으로부터 읽지 않은 바이트가 존재하면 TCP 세션을 재사용할 수 없다는 것입니다.
Go의 http 클라이언트에서 응답 본문을 닫아야 하는 이유는 응답 본문을 닫으면서 자동으로 모든 바이트를 다 읽어가기 때문에 TCP 세션을 재사용할 수 있게 합니다. 따라서 TCP 세션을 재사용하기 위해 응답 본문을 닫는 것은 중요합니다.
응답 본문 닫기의 잠재적 문제
Go HTTP 클라이언트가 소켓을 닫을 때 암묵적으로 응답 본문을 소비하는 것은 잠재적 문제를 가지고 있습니다. 예를 들어 GET 요청으로 서버에서 응답을 받았다고 가정해 보겠습니다. Content-Length 헤더를 읽었는데 콘텐츠가 너무 커서 응답 본문을 닫아버렸다고 해보겠습니다. 이런 경우 남아있는 바이트를 소비하기 위해 전체 콘텐츠를 다운로드하게 될 것입니다.
이런 경우 HEAD 요청으로 Content-Length의 값을 얻어오면 응답 본문에 읽지 않은 바이트가 존재하지 않고, 연결 종료 시에도 추가로 소비하는 오버헤드가 발생하지 않을 것입니다.
타임아웃과 취소 구현
Go의 기본 HTTP 클라이언트의 http.Get, http.Head, http.Post 헬퍼 함수에서 생성된 요청은 타임아웃되지 않습니다. 타임아웃이나 데드라인이 없는 요청은 문제가 발생하기 전까지는 아무런 문제가 없어 보이지만, 엄청난 문제를 내재하고 있습니다. 오작동하거나 악의적으로 작동하는 서비스로 인해서 코드가 에러를 발생하지 않고 무한정으로 블로킹될 수 있다는 의미입니다.
클라이언트가 무한정하게 블로킹되는 간단한 테스트 코드를 확인해 보겠습니다.
package httpbasic
import (
"net/http"
"net/http/httptest"
"testing"
)
func blockIndefinitely(w http.ResponseWriter, r *http.Request) {
select {}
}
func TestBlockIndefinitely(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(blockIndefinitely))
_, _ = http.Get(ts.URL)
t.Fatal("client did not indefinitely block")
}
net/http/httptest 패키지에는 http 테스트서버를 만들 수 있는 함수가 존재합니다. http 테스트서버는 수신한 요청의 URL로 무한정 블로킹하는 함수를 수행합니다. http.Get() 함수가 기본 http 클라이언트를 사용하기 때문에 해당 요청은 타임아웃되지 않아 테스트에서 타임아웃이 발생하여 실패 상태로 중지됩니다.
이런 문제를 해결하기 위해서는 데드라인 콘텍스트를 활용하여 연결 타임아웃을 사용할 수 있습니다.
// 위 코드에 이어서 작성
func TestBlockIndefinitelyWithTimeout(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(blockIndefinitely))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
return
}
_ = resp.Body.Close()
}
위 코드에서는 context를 포함하는 요청 객체를 생성했습니다. GET 요청의 요청 본문은 페이로드가 없기 때문에 본문은 nil로 할당합니다.
콘텍스트 타이머는 초기화 직후부터 바로 동작하기 때문에 응답 본문을 닫는 함수를 호출하는 데까지 5초 이내에 실행되어야 합니다.
context.WithTimeout() 함수를 호출하면 내부적으로 지정한 시간만큼의 타이머가 실행되고, 시간이 완료되면 context의 Done() 채널을 종료합니다. context.DeadlineExceeded 에러는 이때 반환되는 에러값입니다.
HTTP로 데이터 전송
웹 서버로 JSON 전송하기
웹 서버로 POST 요청과 함께 페이로드를 전송하는 것은 GET 요청을 보내는 것과 유사합니다. io.Reader 인터페이스를 구현한 객체라면 어느 것이든 페이로드로 사용할 수 있습니다. GET 요청은 URL로 요청을 보내면 되기 때문에 간단했지만, POST와 같이 페이로드를 함께 전달하는 요청의 경우 데이터를 준비하고 변환하는 과정을 거쳐야 하기 때문에 코드가 추가됩니다.
다음은 사용자 데이터를 웹 서버에 전송하는 테스트 코드입니다.
package httpbasic
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
type User struct {
First string
Last string
}
// post handler 함수
func handlePostUser(t *testing.T) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
defer func(r io.ReadCloser) {
// 서버에서는 명시적으로 요청 본문을 닫기전에 소비해야함
_, _ = io.Copy(io.Discard, r)
_ = r.Close()
}(r.Body)
if r.Method != http.MethodPost {
http.Error(w, "", http.StatusMethodNotAllowed)
return
}
var u User
err := json.NewDecoder(r.Body).Decode(&u)
if err != nil {
t.Error(err)
http.Error(w, "Decode Failed", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
}
}
func TestPostUser(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(handlePostUser(t)))
defer ts.Close()
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
// 잘못된 타입의 요청
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d; actual status %d", http.StatusMethodNotAllowed, resp.StatusCode)
}
buf := new(bytes.Buffer)
u := User{First: "Sehyeong", Last: "An"}
err = json.NewEncoder(buf).Encode(&u)
if err != nil {
t.Fatal(err)
}
// Content-Type 의 헤더값을 application/json 으로 설정
resp, err = http.Post(ts.URL, "application/json", buf)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("expected status %d; actual status %d", http.StatusAccepted, resp.StatusCode)
}
_ = resp.Body.Close()
}
위 코드에서는 잘못된 타입의 요청을 보내 MethodNotAllowed(405) 상태 코드가 나타나는지 확인한 후에 POST 요청을 통해 서버에 요청을 보냅니다.
JSON을 인코딩하고 디코딩하는 데 json.Encoder, json.Decoder를 사용합니다. 클라이언트에서 Go 구조체를 JSON으로 변경할 때는 Encoder를 사용하고, 서버에서 JSON으로 받은 payload를 Go 구조체로 매핑하는 데 Decoder를 사용합니다.
멀티파트 폼으로 첨부 파일 전송
JSON 형태로 구성된 데이터가 아닌 여러 미디어 형태의 데이터를 POST 요청으로 보내기 위해서는 mime/multipart 패키지를 이용할 수 있습니다.
// 앞선 코드...
func TestMultiPartPost(t *testing.T) {
reqBody := new(bytes.Buffer)
w := multipart.NewWriter(reqBody)
for k, v := range map[string]string{
"date": time.Now().Format(time.RFC3339),
"description": "Form values with attached files",
} {
err := w.WriteField(k, v)
if err != nil {
t.Fatal(err)
}
}
for i, file := range []string{
"./files/hello.txt",
"./files/goodbye.txt",
} {
filePart, err := w.CreateFormFile(fmt.Sprintf("file%d", i+1), filepath.Base(file))
if err != nil {
t.Fatal(err)
}
f, err := os.Open(file)
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(filePart, f)
_ = f.Close()
if err != nil {
t.Fatal(err)
}
}
err := w.Close()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://httpbin.org/post", reqBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() {
_ = resp.Body.Close()
}()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d; actual status %d", http.StatusOK, resp.StatusCode)
}
t.Logf("\n%s", b)
}
Go의 표준 라이브러리를 사용해 파일과 텍스트 데이터를 multipart/form-data 형식으로 POST 요청을 보내는 테스트 코드입니다.
먼저 multipart.NewWriter를 사용해서 multipart/form-data 형식의 데이터를 버퍼에 씁니다. 텍스트 폼 필드를 추가하는 w.WriteField() 메서드를 사용하여 텍스트 데이터를 추가합니다.
다음으로 w.CreateFormFile() 메서드를 사용하여 파일 데이터를 위한 새로운 멀티파트 요청을 생성합니다.
HTTP 클라이언트를 사용하여 POST 요청을 생성하고 POST 요청을 보냅니다. 이 과정에서 바운더리라는 문자열로 전송하고자 하는 데이터들을 다른 데이터들과 구별하여 여러 형태의 데이터를 전송할 수 있도록 Content-Type 헤더 값을 설정합니다.
'School > GNPS' 카테고리의 다른 글
| [GNPS] 챕터 11 TLS 통신 보안 (0) | 2025.11.15 |
|---|---|
| [GNPS] 챕터 9 HTTP 서비스 작성! (0) | 2025.11.12 |
| [GNPS] 챕터 7 유닉스 도메인 소켓! (0) | 2025.11.08 |
| [GNPS] 챕터 6 UDP 통신의 신뢰성 확보 (0) | 2025.11.07 |
| [GNPS] 챕터 5 신뢰성 없는 UDP 통신 (0) | 2025.11.06 |