고 실전 #6 테스트와 배포 — httptest와 Docker
#5 미들웨어 까지로 — 작은 서비스의 골격은 다 봤습니다. 마지막 글은 그 위에 얹는 두 가지. 테스트와 배포.
Go가 백엔드에서 자주 채택되는 또 다른 이유 — 빌드 결과가 단일 정적 바이너리. 배포가 단순합니다.
httptest — 핸들러 단위 테스트
#
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHello(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
w := httptest.NewRecorder()
helloHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if w.Body.String() != "hello\n" {
t.Errorf("unexpected body: %q", w.Body.String())
}
}핵심 두 도구:
httptest.NewRequest— 가짜 요청 생성httptest.NewRecorder—ResponseWriter구현체, 응답을 메모리에
서버를 띄우지 않고 — 함수 호출만으로 핸들러를 테스트합니다. 빠르고 격리가 잘 됩니다.
라우터 통과 테스트 #
func TestGetUser(t *testing.T) {
mux := setupRoutes()
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d", w.Code)
}
}라우팅 + 미들웨어 + 핸들러까지 — 한 번에 검증. 보통 이 단위가 가장 가성비 좋습니다.
httptest.NewServer — 진짜 서버 띄워서 테스트
#
외부 클라이언트나 라이브러리를 — 실제 HTTP로 검증할 때 씁니다.
func TestE2E(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
resp, err := http.Get(srv.URL + "/hello")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// ...
}srv.URL — 임의 포트의 실제 URL. 외부 라이브러리(예: SDK가 자체 HTTP 클라이언트를 쓰는 경우) 검증에 어울립니다.
Table-driven 테스트 #
중급 #6의 핵심 패턴이 — 핸들러 테스트에서도 자연스럽습니다.
func TestValidateEmail(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"empty", "", true},
{"no @", "abc", true},
{"valid", "a@b.c", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateEmail(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("got err=%v, wantErr=%v", err, tc.wantErr)
}
})
}
}t.Run(name, ...)은 — 서브 테스트로 이름을 줘서 실패 위치가 명확. -run 플래그로 — 특정 케이스만 골라 실행 가능.
통합 테스트 — 실제 DB 연결 #
func TestUserRepo(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
db := mustOpenTestDB(t)
t.Cleanup(func() { db.Close() })
repo := NewUserRepo(db)
id := repo.Create(User{Name: "이도경"})
got := repo.Get(id)
if got.Name != "이도경" {
t.Errorf("got %q", got.Name)
}
}핵심:
testing.Short()+go test -short로 빠른 사이클에선 통합 테스트 스킵t.Cleanup—defer와 비슷하지만 서브테스트에서도 동작- 테스트용 별도 DB / 스키마 — 실 데이터를 건드리지 않게
testcontainers #
대안으로 — testcontainers-go로 테스트 시점에 컨테이너 띄우기. 격리 좋지만 시간이 더 듭니다.
Mock vs 실제 — 어느 쪽? #
type UserStore interface {
Get(id int) (User, error)
Save(u User) error
}
type fakeStore struct{ users map[int]User }
func (f *fakeStore) Get(id int) (User, error) { return f.users[id], nil }
func (f *fakeStore) Save(u User) error { f.users[u.ID] = u; return nil }가이드라인:
- 인터페이스 단위로 테스트 — 핸들러는 인터페이스만 받게 설계
- mock은 단순할수록 좋음 — 손으로 짠 fake가 고급 #7의 mockgen보다 읽기 좋은 경우도 많음
- 로직이 DB에 있다면 — mock으로는 의미 있는 테스트 안 됨, 실제 DB
빌드 — 단일 바이너리 #
go build -o app ./cmd/server
./app하나의 바이너리로 — Go 런타임이 안에 들어가 있어 별도 설치가 필요 없습니다.
정적 바이너리 (cgo 끄기) #
CGO_ENABLED=0 go build -o app ./cmd/server고급 #5의 cgo를 안 쓰면 — 글리브C의존도 없는 정적 바이너리. scratch 같은 빈 이미지에 그대로 들어갈 수 있습니다.
빌드 정보 적기 #
go build -ldflags="-X main.version=1.0.0 -X main.commit=$(git rev-parse HEAD)" ./cmd/servervar (
version = "dev"
commit = "unknown"
)/version 엔드포인트나 로그에 — 어떤 버전이 동작 중인지 항상 표시.
Docker — 멀티스테이지 빌드 #
# 1) 빌드 단계
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app ./cmd/server
# 2) 실행 이미지 — scratch 또는 distroless
FROM scratch
COPY --from=builder /app/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/app"]핵심:
- 멀티스테이지 — 빌드 환경과 실행 환경 분리. 최종 이미지에서 컴파일러나 소스 제거
- scratch — 완전히 빈 이미지. 정적 바이너리만 들어감 (~10 MB 이미지 가능)
- CA 인증서 — HTTPS 호출 위해 —
/etc/ssl/certs/만 복사
대안:
- distroless —
gcr.io/distroless/static이 — 검증된 작은 베이스 - alpine — shell이 필요할 때, 다만 musl의존이 cgo와 마찰
환경 변수 vs 플래그 #
작은 도구는 — flag 패키지로 명령행.
addr := flag.String("addr", ":8080", "listen address")
flag.Parse()서버는 — 환경 변수가 표준(Twelve-Factor App).
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}caarlos0/env 같은 패키지로 struct 태그 자동 매핑이 편합니다 (고급 #4 reflect 사례).
헬스체크와 readiness #
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /ready", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(); err != nil {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})/health— 프로세스 살아 있음 (kubelet liveness)/ready— 의존성(DB 등) 까지 OK (kubelet readiness)
쿠버네티스 같은 환경에서 — 롤링 업데이트가 안전하게 동작하는 데 필요.
운영 체크리스트 #
작은 서비스라도 운영에 올린다면:
- graceful shutdown (#1)
- 타임아웃 —
http.Server의 ReadTimeout, WriteTimeout, IdleTimeout - 요청 크기 제한 —
MaxBytesReader - panic 복구 미들웨어 (#5)
- 요청 ID + 구조화된 로그 (slog)
- 헬스/readiness 엔드포인트
- 메트릭 — Prometheus
/metrics(prometheus/client_golang) - 고루틴 누수 모니터링 — pprof 노출 (고급 #6)
- DB 풀 설정 명시 (#4)
- CGO 끄기 — 가능하면 정적 바이너리
- 버전 박힌 빌드 —
-ldflags - 헬스/메트릭 포트는 외부 노출 안 함
구조화 로그 — log/slog
#
Go 1.21+ 표준에 들어온 — 구조화 로그 라이브러리.
import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user signed in",
slog.String("user_id", "u1"),
slog.Int("count", 3),
)
// {"time":"...","level":"INFO","msg":"user signed in","user_id":"u1","count":3}JSON으로 — 로그 수집 시스템(ELK, CloudWatch, Datadog)이 자동 파싱. 이전엔 zap, zerolog 같은 외부에 의존했지만 — 이제 표준만으로도 충분한 경우가 많습니다.
시리즈 마무리 #
Go 실전 6 편을 돌아보면:
- 첫 HTTP 서버 — net/http, graceful shutdown
- 라우팅 — Go 1.22 ServeMux, chi
- JSON 입출력 — Encoder/Decoder, 검증
- DB 연동 — database/sql, 트랜잭션
- 미들웨어 — Handler 어댑터 체인
- 테스트와 배포 (이 글)
요약하자면 — Go의 표준 라이브러리만으로도 — 작은 서비스의 90% 가 깔끔하게 풀립니다. 외부 도구는 — 표준의 한계가 보일 때 하나씩 도입하면 충분합니다.
마무리 #
이번 글에서 정리한 내용:
httptest—NewRequest+NewRecorder로 핸들러 단위 테스트NewServer— 진짜 서버 띄워서 외부 라이브러리 검증- table-driven +
t.Run— 가독성 좋은 다중 케이스 testing.Short+t.Cleanup— 통합 테스트의 표준 도구- 손으로 짠 작은 fake가 — mockgen보다 명료할 때도 많음
- 단일 바이너리 +
CGO_ENABLED=0 - 멀티스테이지 Dockerfile —
scratch또는distroless - 헬스/readiness — 쿠버네티스 안전 배포에 필요
log/slog표준 구조화 로그- 운영 체크리스트는 — 시리즈 마지막 정착지
여기까지 — 고 기초 7편 → 중급 7편 → 고급 7편 → 실전 6편 (총 27 편). Go의 표준 도구만으로 작은 백엔드를 짜기까지의 길을 한 트랙으로 정리했습니다.