고 실전 #6 테스트와 배포 — httptest와 Docker

5 분 소요

#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.NewRecorderResponseWriter 구현체, 응답을 메모리에

서버를 띄우지 않고 — 함수 호출만으로 핸들러를 테스트합니다. 빠르고 격리가 잘 됩니다.

라우터 통과 테스트 #

라우터까지 테스트
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 연결 #

setup/teardown
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.Cleanupdefer와 비슷하지만 서브테스트에서도 동작
  • 테스트용 별도 DB / 스키마 — 실 데이터를 건드리지 않게

testcontainers #

대안으로 — testcontainers-go로 테스트 시점에 컨테이너 띄우기. 격리 좋지만 시간이 더 듭니다.

Mock vs 실제 — 어느 쪽? #

mock 인터페이스
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/server
main.go
var (
	version = "dev"
	commit  = "unknown"
)

/version 엔드포인트나 로그에 — 어떤 버전이 동작 중인지 항상 표시.

Docker — 멀티스테이지 빌드 #

Dockerfile
# 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/만 복사

대안:

  • distrolessgcr.io/distroless/static이 — 검증된 작은 베이스
  • alpine — shell이 필요할 때, 다만 musl의존이 cgo와 마찰

환경 변수 vs 플래그 #

작은 도구는 — flag 패키지로 명령행.

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 #

health endpoints
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+ 표준에 들어온 — 구조화 로그 라이브러리.

slog
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 편을 돌아보면:

  1. 첫 HTTP 서버 — net/http, graceful shutdown
  2. 라우팅 — Go 1.22 ServeMux, chi
  3. JSON 입출력 — Encoder/Decoder, 검증
  4. DB 연동 — database/sql, 트랜잭션
  5. 미들웨어 — Handler 어댑터 체인
  6. 테스트와 배포 (이 글)

요약하자면 — Go의 표준 라이브러리만으로도 — 작은 서비스의 90% 가 깔끔하게 풀립니다. 외부 도구는 — 표준의 한계가 보일 때 하나씩 도입하면 충분합니다.

마무리 #

이번 글에서 정리한 내용:

  • httptestNewRequest + NewRecorder로 핸들러 단위 테스트
  • NewServer — 진짜 서버 띄워서 외부 라이브러리 검증
  • table-driven + t.Run — 가독성 좋은 다중 케이스
  • testing.Short + t.Cleanup — 통합 테스트의 표준 도구
  • 손으로 짠 작은 fake가 — mockgen보다 명료할 때도 많음
  • 단일 바이너리 + CGO_ENABLED=0
  • 멀티스테이지 Dockerfilescratch 또는 distroless
  • 헬스/readiness — 쿠버네티스 안전 배포에 필요
  • log/slog 표준 구조화 로그
  • 운영 체크리스트는 — 시리즈 마지막 정착지

여기까지 — 고 기초 7편 → 중급 7편 → 고급 7편 → 실전 6편 (총 27 편). Go의 표준 도구만으로 작은 백엔드를 짜기까지의 길을 한 트랙으로 정리했습니다.

X