표준 라이브러리와 net/http
Go 표준 라이브러리를 먼저 읽어야 하는 이유
Go를 실무에서 오래 쓰다 보면 “프레임워크를 먼저 고를 것인가, 표준 라이브러리로 충분한가”라는 질문을 자주 만난다. Go의 표준 라이브러리는 단순한 부속 도구 모음이 아니라, Go다운 API 설계의 기준에 가깝다. io.Reader, http.Handler, context.Context, json.Marshaler 같은 작은 인터페이스와 약속을 중심으로 패키지들이 연결되어 있기 때문이다.
다른 언어 생태계에서는 웹 서버, JSON 처리, 로깅, 테스트 HTTP 서버를 위해 초기에 여러 외부 패키지를 붙이는 일이 흔하다. Go에서도 외부 패키지는 당연히 필요하지만, 운영 코드의 뼈대는 표준 라이브러리만으로도 상당 부분 만들 수 있다. 특히 인프라·백엔드 도구처럼 배포 단위가 작고 장애 원인을 빠르게 좁혀야 하는 코드에서는 의존성이 적은 표준 패키지 조합이 유지보수 이점이 된다.
이 장에서는 표준 라이브러리 전체를 나열하지 않는다. 대신 자주 만나는 축을 중심으로 본다.
- I/O 추상화:
io.Reader,io.Writer,io.Copy - 데이터 인코딩:
encoding/json - 취소와 데드라인:
context - HTTP 서버·클라이언트:
net/http - 구조화 로깅과 테스트:
log/slog,net/http/httptest
Server · ServeMux · Handler → context
취소 · timeout · request scope → io
Reader · Writer · Copy → encoding/json
Marshal · Decoder · tags → slog · httptest
운영 로그와 테스트
핵심은 패키지 이름을 외우는 것이 아니라, 패키지 사이에 흐르는 공통 설계를 익히는 것이다. 대부분은 “작은 인터페이스를 받고, 호출자가 자원과 취소 정책을 명시하며, 에러를 값으로 돌려준다”는 방향으로 맞춰져 있다.
io.Reader와 io.Writer: 표준 라이브러리의 접착면
Go 표준 라이브러리에서 가장 중요한 인터페이스를 하나만 고르라면 io.Reader와 io.Writer를 먼저 봐야 한다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}작아 보이지만 이 두 인터페이스 덕분에 파일, 네트워크 연결, 압축 스트림, 메모리 버퍼, HTTP request/response body가 같은 방식으로 이어진다. 어떤 함수가 io.Reader를 받으면 그 함수는 “이 데이터가 파일에서 왔는지, HTTP body에서 왔는지, 테스트용 문자열인지”를 몰라도 된다.
func checksum(r io.Reader) (string, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return "", fmt.Errorf("copy input to hash: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}위 함수는 *os.File, strings.Reader, bytes.Buffer, http.Response.Body 모두에 쓸 수 있다. 이게 Go에서 “작은 인터페이스를 소비자 쪽에서 정의하라”는 말이 실용적으로 드러나는 지점이다.
io.Copy는 단순히 루프를 대신 써주는 함수가 아니다. 소스가 WriterTo를 구현하거나 대상이 ReaderFrom을 구현하면 그 최적화 경로를 사용한다. 호출자는 일반적인 인터페이스로 작성하지만, 구체 타입은 더 효율적인 복사를 제공할 수 있다. 표준 라이브러리의 추상화가 반드시 느린 추상화는 아니라는 좋은 예다.
다만 io 인터페이스가 감싼 하위 구현체가 모두 병렬 안전하다고 가정하면 안 된다. io 문서도 별도 보장이 없으면 병렬 실행 안전성을 추정하지 말라고 설명한다. 같은 Reader를 여러 고루틴이 동시에 읽는 코드는 구현체의 보장을 확인하거나 외부에서 동기화해야 한다.
encoding/json: 편리하지만 기본값을 알아야 한다
Go의 encoding/json은 구조체와 JSON을 빠르게 연결해준다.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
}
func writeUser(w http.ResponseWriter, user User) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(user)
}운영 API에서는 json.Marshal로 바이트 슬라이스를 만든 뒤 쓰는 방식보다 json.NewEncoder(w).Encode(...)처럼 스트림에 바로 쓰는 방식이 자연스러운 경우가 많다. 반대로 요청 body를 읽을 때는 io.ReadAll로 전체를 메모리에 올릴지, json.Decoder로 스트리밍 디코딩할지를 입력 크기와 정책에 맞춰 결정해야 한다.
encoding/json의 기본 동작에는 꼭 기억해야 할 부분이 있다.
| 동작 | 기본값 | 운영상 의미 |
|---|---|---|
| 구조체 필드 | exported field만 인코딩·디코딩 | 소문자 필드는 JSON에 나오지 않는다 |
| 알 수 없는 JSON 키 | 기본적으로 무시 | API 입력 검증이 느슨해질 수 있다 |
| 숫자 | interface{}로 받으면 float64가 되기 쉽다 | 큰 정수 정밀도 손실에 주의 |
| HTML 문자 | 기본적으로 <, >, & 등을 escape | HTML에 넣을 때 안전한 기본값이지만 순수 JSON API 출력 모양이 달라질 수 있다 |
| 중복 키 | 순서대로 처리되며 뒤 값이 대체 또는 병합될 수 있음 | 보안적으로 파서 차이를 이해해야 한다 |
엄격한 요청 검증이 필요하면 Decoder.DisallowUnknownFields()를 고려한다.
func decodeCreateUser(r *http.Request) (User, error) {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var user User
if err := dec.Decode(&user); err != nil {
return User{}, fmt.Errorf("decode user: %w", err)
}
return user, nil
}물론 DisallowUnknownFields가 모든 입력 검증을 대신하지는 않는다. 필수 필드가 비었는지, 문자열 형식이 맞는지, 배열 크기가 제한 안에 있는지는 별도 검증이 필요하다. JSON 패키지는 파싱과 매핑을 담당하고, 도메인 유효성은 애플리케이션이 책임져야 한다.
context: 요청의 수명과 취소를 코드에 드러내기
Go 서버에서는 HTTP 요청 하나가 들어오면 handler 고루틴이 생기고, 그 안에서 DB 호출이나 다른 HTTP 호출, 파일 작업, 추가 고루틴이 이어진다. 사용자가 연결을 끊었거나 upstream timeout이 끝났는데도 내부 작업이 계속 돌면 자원이 새고 장애 전파가 늦어진다.
context.Context는 이 문제를 다루는 표준 방식이다. 컨텍스트는 deadline, cancellation signal, request-scoped value를 API 경계와 고루틴 사이에 전달한다.
func loadProfile(ctx context.Context, id string) (Profile, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://profile.internal/users/"+url.PathEscape(id), nil)
if err != nil {
return Profile{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Profile{}, fmt.Errorf("call profile service: %w", err)
}
defer resp.Body.Close()
// decode response...
return Profile{}, nil
}컨텍스트 사용 규칙은 단순하지만 중요하다.
Context는 구조체에 저장하지 말고 필요한 함수의 첫 번째 인자로 전달한다.- 이름은 보통
ctx로 둔다. nilcontext를 넘기지 않는다. 아직 적절한 컨텍스트를 모르면context.TODO()를 쓴다.WithCancel,WithTimeout,WithDeadline이 돌려준cancel은defer cancel()로 호출해 타이머와 부모 참조를 정리한다.WithValue는 request-scoped 값에만 제한적으로 사용하고, 선택적 함수 인자 대용으로 쓰지 않는다.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
profile, err := loadProfile(ctx, userID)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, "profile unavailable", http.StatusBadGateway)
return
}r.Context()에서 파생한 컨텍스트를 쓰면 클라이언트 연결 종료, 서버 shutdown, handler timeout 같은 상위 취소 신호를 함께 받을 수 있다. 이것이 단순히 time.After를 곳곳에 넣는 것보다 낫다. 취소가 트리처럼 전파되기 때문이다.
net/http 서버: Handler가 전부를 연결한다
Go HTTP 서버의 중심 인터페이스는 매우 작다.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}함수가 이 형태를 만족하도록 감싸는 http.HandlerFunc 덕분에, handler 함수와 middleware가 같은 인터페이스로 연결된다.
func logging(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.InfoContext(r.Context(), "request handled",
"method", r.Method,
"path", r.URL.Path,
"elapsed", time.Since(start))
})
}Go 1.22 이후 표준 ServeMux는 method matching과 path wildcard를 지원한다. 예전에는 /users/처럼 prefix만 잡고 handler 안에서 method와 path segment를 직접 검사하는 코드가 많았다. 이제는 간단한 REST 스타일 경로는 표준 라우터로도 충분히 표현할 수 있다.
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "user id=%s\n", id)
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
// create user
})GET 패턴은 HEAD도 함께 매칭한다. 경로는 맞지만 method가 맞지 않으면 서버가 405 Method Not Allowed와 Allow header를 처리할 수 있다. 또한 /posts/latest와 /posts/{id}처럼 겹치는 패턴은 “더 구체적인 패턴”이 우선한다. 이 동작을 이해하면 작은 서비스에서 외부 라우터 없이도 명확한 HTTP API를 만들 수 있다.
운영 서버에서는 http.ListenAndServe를 바로 호출하는 예제에서 한 단계 더 나아가 http.Server를 명시적으로 구성하는 편이 안전하다.
srv := &http.Server{
Addr: ":8080",
Handler: logging(mux, logger),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "err", err)
}timeout은 장애가 난 뒤에야 중요성이 보이는 설정이다. 기본값에 기대면 느린 클라이언트나 끊어진 네트워크 상황에서 고루틴과 file descriptor가 오래 묶일 수 있다. 단, timeout은 엔드포인트 성격에 맞춰 잡아야 한다. 짧은 JSON API와 장시간 스트리밍 응답의 적절한 값은 다르다.
graceful shutdown: 서버를 그냥 죽이지 않기
컨테이너나 systemd 환경에서는 프로세스가 종료 신호를 받았을 때 이미 처리 중인 요청을 어느 정도 마무리하고, 새 요청은 받지 않는 방식이 필요하다. http.Server.Shutdown(ctx)는 listener를 닫고 idle connection을 닫은 뒤, active connection이 idle 상태가 될 때까지 기다린다. 주어진 context가 만료되면 shutdown도 중단된다.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "err", err)
}
}()중요한 점은 shutdown timeout과 request 내부 timeout이 서로 다른 책임을 가진다는 것이다. shutdown timeout은 “프로세스가 종료되기 전에 얼마까지 기다릴 것인가”이고, request context는 “이 요청의 하위 작업이 언제 중단되어야 하는가”다. 둘 중 하나만으로 모든 수명 관리를 대신하려 하면 애매한 장애가 생긴다.
net/http 클라이언트: 재사용과 timeout이 기본기다
서버 코드보다 더 자주 실수하는 부분이 HTTP 클라이언트다. http.Get은 간단한 예제에는 좋지만, 운영 코드에서는 보통 재사용 가능한 *http.Client를 명시적으로 둔다.
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
}Client와 Transport는 여러 고루틴에서 동시에 사용할 수 있고, 효율을 위해 재사용해야 한다. 매 요청마다 새 Transport를 만들면 connection pool을 버리는 셈이 되어 TLS handshake와 TCP 연결 비용이 늘고, 부하가 커질 때 소켓 사용도 불안정해질 수 있다.
요청 코드는 body 처리까지 포함해서 습관화해야 한다.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", endpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return fmt.Errorf("GET %s: status=%d body=%q", endpoint, resp.StatusCode, body)
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return fmt.Errorf("decode response: %w", err)
}net/http 문서에서 특히 강조하는 점은 response body를 호출자가 닫아야 한다는 것이다. err == nil이면 resp.Body는 non-nil이고 반드시 닫아야 한다. 또한 HTTP status가 500이라고 해서 client.Do가 error를 반환하는 것은 아니다. 네트워크 실패, 프로토콜 오류, redirect 정책 실패는 error가 되지만, 애플리케이션 상태 코드는 response로 직접 판단해야 한다.
연결 재사용을 위해서는 body를 EOF까지 읽고 닫는 편이 좋다. 큰 body를 모두 읽을 수 없는 에러 경로에서는 io.LimitReader로 일부만 읽어 로그에 남기고 닫는 식으로 비용을 제한한다.
slog: 로그를 문자열이 아니라 필드로 남기기
Go 1.21에 표준 라이브러리로 들어온 log/slog는 구조화 로깅을 제공한다. 운영 환경에서는 “문자열 한 줄”보다 key-value 필드가 있는 로그가 훨씬 다루기 쉽다.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
logger.InfoContext(ctx, "upstream request finished",
"service", "profile",
"status", resp.StatusCode,
"elapsed_ms", time.Since(start).Milliseconds())slog의 InfoContext, ErrorContext 같은 메서드는 context를 받는다. 기본 handler가 context 값을 자동으로 모두 로그에 넣는다는 뜻은 아니지만, handler 구현이나 tracing 연동에서 context를 활용할 수 있는 형태다. 로그 필드 이름은 초기에 정해 일관되게 쓰는 편이 좋다. user, user_id, uid가 뒤섞이면 검색과 집계가 어려워진다.
httptest: handler와 클라이언트를 실제처럼 검증하기
표준 라이브러리에는 HTTP 테스트 도구도 들어 있다. handler 단위 테스트에는 httptest.NewRecorder와 httptest.NewRequest가 유용하다.
func TestGetUser(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rr := httptest.NewRecorder()
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.ServeHTTP(rr, req)
resp := rr.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}외부 HTTP 클라이언트를 테스트해야 할 때는 httptest.NewServer가 더 자연스럽다. 실제 loopback 포트에 테스트 서버를 띄우기 때문에 request 생성, transport, status code, body close 같은 흐름을 더 현실적으로 검증할 수 있다.
func TestClientTimeout(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
}))
defer ts.Close()
client := &http.Client{Timeout: 50 * time.Millisecond}
_, err := client.Get(ts.URL)
if err == nil {
t.Fatal("expected timeout")
}
}테스트에서 표준 도구를 잘 쓰면 production 코드도 표준 인터페이스에 맞춰진다. handler가 http.Handler로 조립되고, 클라이언트가 *http.Client를 주입받고, 데이터가 io.Reader로 들어오면 테스트는 자연스럽게 쉬워진다.
표준 라이브러리로 설계할 때의 판단 기준
표준 라이브러리만 쓰는 것이 항상 정답은 아니다. 인증, 복잡한 라우팅, validation, OpenAPI 생성, metrics/tracing 통합처럼 팀의 요구가 뚜렷하면 검증된 외부 패키지가 더 낫다. 하지만 외부 패키지를 고를 때도 표준 라이브러리의 인터페이스와 수명 모델을 기준으로 보면 판단이 쉬워진다.
좋은 Go 패키지는 대체로 다음 특징을 가진다.
context.Context를 첫 번째 인자로 받아 취소와 timeout을 존중한다.io.Reader/io.Writer나http.Handler같은 표준 인터페이스와 잘 맞는다.- 호출자가
*http.Client, logger, clock 같은 의존성을 주입할 수 있다. - 에러를 문자열로 숨기지 않고
errors.Is/errors.As로 검사 가능한 형태를 보존한다. - 전역 상태보다 명시적 구성 값을 선호한다.
반대로 표준 인터페이스를 우회하고, 내부에서 timeout 없는 기본 클라이언트를 만들고, 요청 수명을 무시하는 패키지는 운영 환경에서 문제가 될 가능성이 높다.
정리
Go 표준 라이브러리는 “작고 조합 가능한 조각”으로 구성되어 있다. io.Reader와 io.Writer는 데이터 흐름을 추상화하고, context는 요청 수명을 전달하며, net/http는 Handler 하나로 서버·미들웨어·테스트를 묶는다. encoding/json, slog, httptest까지 함께 보면 작은 운영 서비스를 외부 프레임워크 없이도 충분히 만들 수 있다.
실무에서 중요한 습관은 세 가지다.
- 자원은 호출자가 닫는다:
resp.Body.Close(),defer cancel(), 파일 close를 놓치지 않는다. - timeout과 cancellation을 기본값으로 둔다: 무기한 대기는 운영 장애의 씨앗이다.
- 표준 인터페이스에 맞춰 설계한다: 그러면 테스트, 교체, 확장이 쉬워진다.
표준 라이브러리를 먼저 익히면 외부 프레임워크를 쓰더라도 기준점이 생긴다. “이 패키지가 Go의 기본 수명 관리와 인터페이스를 존중하는가”를 판단할 수 있기 때문이다.
References
- Go Documentation,
net/httppackage: https://pkg.go.dev/net/http - Go Documentation,
contextpackage: https://pkg.go.dev/context - Go Blog, “Go Concurrency Patterns: Context”: https://go.dev/blog/context
- Go Blog, “Routing Enhancements for Go 1.22”: https://go.dev/blog/routing-enhancements
- Go Documentation,
iopackage: https://pkg.go.dev/io - Go Documentation,
encoding/jsonpackage: https://pkg.go.dev/encoding/json - Go Documentation,
net/http/httptestpackage: https://pkg.go.dev/net/http/httptest - Go Documentation,
log/slogpackage: https://pkg.go.dev/log/slog - Effective Go: https://go.dev/doc/effective_go