실제 환경의 Go 백엔드 테스트는 많은 사람들이 생각하는 것과 다르다
Source: Dev.to
Go 백엔드 테스트 스위트에서 본 관찰
나는 충분히 많은 Go 백엔드 테스트 스위트를 검토하면서 한 가지 패턴을 발견했다. 가장 많은 단위 테스트를 보유한 서비스가 종종 가장 많은 운영 사고를 겪는다. 이는 단위 테스트가 사고를 일으킨다는 뜻이 아니라, 단위 테스트를 작성하고 그걸로 끝낸 팀들이 실제로 문제가 되는 부분을 테스트하지 않았기 때문이다.
일반적인 프로덕션 버그
- “컨텍스트 데드라인이 백그라운드 goroutine으로 전파되지 않아 부하가 걸리면 메모리 누수가 발생했습니다.”
- “두 서비스가 정상 흐름에 대해 합의했지만, 오류 형태 계약이 6개월 전에 달라졌고, 이제 하나는
status.Code(codes.Unavailable)를 반환하고 다른 하나는codes.ResourceExhausted를 기대합니다.” - “재시도 로직에 레이스가 있습니다. 테스트 규모 트래픽에서는 동작하지만, 프로덕션에서 10배로 늘리면 이중 청구가 발생합니다.”
- “데이터베이스 마이그레이션은 SQLite(우리 테스트 DB)에서는 동작하지만, Postgres 15의 더 엄격한 플래너에서는 동작하지 않습니다.”
그러한 문제를 잡아내는 단위 테스트는 없습니다. 다른 형태의 테스트가 잡아냅니다.
테스트 분류 재고
tl;dr — “단위 테스트 vs 통합 테스트”라는 식으로 테스트를 구분하는 것을 그만두세요. 그것은 격리 수준(level‑of‑isolation) 축에 불과하며, 가장 흥미롭지 않은 축입니다. 실제 Go 프로덕션에서 중요한 축은 다음과 같습니다:
- 결정론적 동작 (제어된 시계, 시드된 난수)
- 동시성 정확성 (레이스 디텍터, 스트레스 테스트)
- 계약 충실도 (공유 스키마, 실제 다운스트림)
- 환경 충실도 (실제 DB, 실제 네트워크)
테스트 스위트를 위 축들을 중심으로 설계하세요; 그러면 커버리지는 자연스럽게 따라옵니다.
“단위 테스트는 하나의 함수를 테스트한다. 통합 테스트는 여러 개를 테스트한다. E2E 테스트는 전체 시스템을 테스트한다.”
이 구분은 주니어 엔지니어에게는 시작점이 될 수 있습니다. 하지만 프로덕션에서 Go 서비스가 메시지를 조용히 누락시킨 원인을 디버깅할 때는 더 이상 유용하지 않습니다. 격리 수준은 흥미로운 축이 아닙니다. 중요한 것은 다음과 같습니다:
중요한 축
- 결정론적 vs 비결정론적 동작. 동일한 입력이 매번 동일한 출력을 생성합니까?
- 동시성 정확성. 레이스 조건이 계속 잡히고 있습니까?
- 계약 충실도. 다운스트림에 대한 가정이 실제 동작과 일치합니까?
- 환경 충실도. 테스트 환경이 프로덕션 런타임을 충분히 재현하여 실제 버그를 잡을 수 있습니까?
테스트는 격리 축에서는 “단위”일 수 있지만 위 네 가지 중 두세 가지는 만족시킬 수 있습니다. 반대로 “통합” 테스트라 하더라도 네 가지 모두를 놓칠 수 있습니다.
불안정한 테스트
테스트를 천 번 실행해도 같은 결과가 나오지 않으면, 그 테스트는 불안정한 테스트이며, 불안정한 테스트는 테스트가 없는 것보다 더 나쁩니다 — 팀이 실패를 무시하도록 훈련시키기 때문입니다.
비결정성의 원천
Go 테스트 스위트에서 비결정성이 발생하는 세 가지 원천, 발생 빈도 순:
-
time.Now(),time.After(),time.Sleep()을 호출하거나 실시간 간격에 의존하는 모든 테스트는 지뢰밭이다. 개발자 노트북에서는 잘 동작하지만, GC가 작동한 느린 CI 러너에서는 실패한다.
해결책: 시계를 주입한다. 최소 시계 인터페이스:type Clock interface { Now() time.Time Sleep(d time.Duration) After(d time.Duration) } -
(원문은 첫 번째 원천만 명시적으로 나열하고 나머지 두 개는 순서 진술에 의해 암시된다.)
Test Taxonomy
Here’s the taxonomy I actually use when designing a test suite for a Go backend:
- Fast tests (seconds for the whole file): pure functions, algorithms, small state machines. Run on every save.
- Concurrency tests (seconds to a minute): anything with goroutines. Run with
-race. Run in PR. - Deterministic integration tests (single‑digit seconds per test): one module + fakes + fake clock. Fast enough to keep in the main test run.
- Real‑infra integration tests (seconds per test): one module + real DB / Kafka / Redis via Testcontainers. Run in PR, longer timeout.
- Contract tests (milliseconds): verify shared schemas with downstreams. Run on every schema change.
- Stress tests (minutes): high‑iteration, high‑concurrency, with
-race. Run nightly or on schedule. - End‑to‑end tests (minutes): real services, real network, against a staging environment. Run pre‑release.
What you’ll notice: “unit” and “integration” don’t appear as categories. That’s on purpose. The level of isolation is an implementation detail. The purpose of the test is the taxonomy.
실용적인 테스트 팁
t.Cleanup을defer보다 사용하세요. Cleanups는 LIFO 순서로 실행되며, 테스트 어디서든 추가할 수 있고, 테스트 패닉에서도 더 잘 살아남습니다.- 테이블 기반 테스트를 선호하세요. 슬라이스의 행으로 20개의 테스트를 작성하는 것이 거의 동일한 20개의 테스트 함수를 만드는 것보다 낫습니다.
- 설정 실패 시에는
t.Fatalf을 사용하고t.Errorf를 사용하지 마세요. 설정이 깨지면 테스트를 중단해야 하고, 어설션이 깨져도 더 많은 실패를 수집하도록 테스트를 계속 진행할 수 있습니다. - 복잡한 출력에는 골든 파일을 사용하세요. 생성된 SQL 쿼리, 직렬화된 이벤트, JSON 응답 등을 검증할 때, 골든 파일 비교가 긴 문자열 리터럴보다 가독성이 좋습니다.
- 느린 테스트는 빌드 태그가 있는 별도의
_test.go파일로 분리하세요.//go:build integration을 사용하면 해당 테스트를 명시적으로 실행할 수 있습니다.
커버리지 고려 사항
커버리지 수치는 거짓말을 합니다. 문제는 “테스트가 실행한 라인의 비율이 얼마인가”가 아니라, “위험한 동작 중 실제로 그 동작이 깨졌을 때 실패하는 테스트가 얼마나 커버하고 있는가” 입니다.
라인 커버리지가 95 %이고 레이스 테스트가 전혀 없으며 실제 DB 테스트도 없고 모의(mock) 중심의 통합 테스트만 있는 코드베이스는 깨지기 쉽습니다. 라인 커버리지가 60 %이고 CI에서 go test -race를 실행하며, DB에 Testcontainers를 사용하고, 모든 주요 동시 경로에 대해 스트레스 테스트를 수행하는 코드베이스는 그렇지 않습니다.
최종 권고
제가 권장하는 가장 큰 변화는: 테스트를 격리 수준으로 생각하는 것을 멈추고, 실제로 두려워하는 프로덕션 장애 모드 관점에서 생각하는 것입니다. 각 장애 모드를 테스트 형태에 매핑하십시오. 장애 모드에 대한 테스트 형태가 없으면 실제로 그 장애 모드를 커버한 것이 아니며, 단지 발생하지 않기를 바라는 것에 불과합니다.
프로덕션은 여러분이 기대하는 바에 대한 의견을 가지고 있습니다.
- Go’s Concurrency Is About Structure, Not Speed — 프로덕션 수준의 Go를 가능하게 하는 동시성 패턴들.
- Go Context in Distributed Systems: What Actually Works in Production — 제가 검토한 Go 서비스에서 가장 흔히 발견되는 테스트 격차.
- Why Your “Fail‑Fast” Strategy is Killing Your Distributed System — 테스트를 설계하지 않으면 검증하기 어려운 프로덕션 장애 모드.